mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-03 05:12:17 +08:00
Compare commits
1 Commits
main
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c157f1573b |
116
.github/workflows/release-drafter.yml
vendored
116
.github/workflows/release-drafter.yml
vendored
@@ -1,9 +1,6 @@
|
||||
name: Release Drafter
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -13,119 +10,8 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: drafter
|
||||
uses: release-drafter/release-drafter@v6
|
||||
- uses: release-drafter/release-drafter@v6
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Add direct commits
|
||||
if: steps.drafter.outputs.id != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.drafter.outputs.id }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
latest_tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' 2>/dev/null || true)"
|
||||
|
||||
if [ -n "$latest_tag" ]; then
|
||||
commits_json="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${latest_tag}...${HEAD_SHA}" --jq '.commits')"
|
||||
else
|
||||
commits_json="$(gh api "repos/${GITHUB_REPOSITORY}/commits?sha=${HEAD_SHA}&per_page=100")"
|
||||
fi
|
||||
|
||||
direct_commits="$(mktemp)"
|
||||
printf '%s\n' "$commits_json" \
|
||||
| jq -r '.[] | [.sha, (.commit.message | split("\n")[0]), (.author.login // .commit.author.name)] | @tsv' \
|
||||
> "$direct_commits"
|
||||
|
||||
features=()
|
||||
fixes=()
|
||||
refactors=()
|
||||
docs=()
|
||||
maintenance=()
|
||||
|
||||
while IFS=$'\t' read -r sha subject author; do
|
||||
if [[ -z "$sha" || "$subject" =~ ^Merge[[:space:]] ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
prs="$(gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"repos/${GITHUB_REPOSITORY}/commits/${sha}/pulls" \
|
||||
--jq 'length')"
|
||||
|
||||
if [ "$prs" -gt 0 ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
short_sha="${sha:0:7}"
|
||||
line="- ${short_sha} ${subject} @${author}"
|
||||
type="${subject%%:*}"
|
||||
type="${type%%(*}"
|
||||
type="${type%!}"
|
||||
|
||||
if [ "$type" = "feat" ]; then
|
||||
features+=("$line")
|
||||
elif [ "$type" = "fix" ]; then
|
||||
fixes+=("$line")
|
||||
elif [ "$type" = "refactor" ]; then
|
||||
refactors+=("$line")
|
||||
elif [ "$type" = "docs" ]; then
|
||||
docs+=("$line")
|
||||
elif [[ "$type" = "chore" || "$type" = "ci" || "$type" = "build" ]]; then
|
||||
maintenance+=("$line")
|
||||
fi
|
||||
done < "$direct_commits"
|
||||
|
||||
direct_notes="$(mktemp)"
|
||||
{
|
||||
echo "## Direct Commits"
|
||||
echo
|
||||
|
||||
if [ "${#features[@]}" -gt 0 ]; then
|
||||
echo "### 🚀 Features"
|
||||
printf '%s\n' "${features[@]}"
|
||||
echo
|
||||
fi
|
||||
|
||||
if [ "${#fixes[@]}" -gt 0 ]; then
|
||||
echo "### 🐛 Bug Fixes"
|
||||
printf '%s\n' "${fixes[@]}"
|
||||
echo
|
||||
fi
|
||||
|
||||
if [ "${#refactors[@]}" -gt 0 ]; then
|
||||
echo "### 📦 Code Refactoring"
|
||||
printf '%s\n' "${refactors[@]}"
|
||||
echo
|
||||
fi
|
||||
|
||||
if [ "${#docs[@]}" -gt 0 ]; then
|
||||
echo "### 📄 Documentation"
|
||||
printf '%s\n' "${docs[@]}"
|
||||
echo
|
||||
fi
|
||||
|
||||
if [ "${#maintenance[@]}" -gt 0 ]; then
|
||||
echo "### 🧰 Maintenance"
|
||||
printf '%s\n' "${maintenance[@]}"
|
||||
echo
|
||||
fi
|
||||
} > "$direct_notes"
|
||||
|
||||
if [ "$(wc -l < "$direct_notes")" -le 2 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
body="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --jq '.body')"
|
||||
body_without_direct="$(printf '%s\n' "$body" | sed '/^## Direct Commits$/,$d')"
|
||||
new_body="$(printf '%s\n\n%s\n' "$body_without_direct" "$(cat "$direct_notes")")"
|
||||
|
||||
gh api \
|
||||
--method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
|
||||
-f body="$new_body"
|
||||
|
||||
@@ -9,25 +9,7 @@ def success(data: Any = None, msg: str = "ok", code: int = 0):
|
||||
def page(items: list[Any], total: int, page: int, page_size: int):
|
||||
"""统一分页数据结构。"""
|
||||
pages = (total + page_size - 1) // page_size if page_size else 0
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size, "pages": pages, "pagination_mode": "paged"}
|
||||
|
||||
|
||||
def cursor_page(
|
||||
items: list[Any],
|
||||
page_size: int,
|
||||
*,
|
||||
cursor: str | None = None,
|
||||
next_cursor: str | None = None,
|
||||
):
|
||||
"""无总数游标分页结构。"""
|
||||
return {
|
||||
"items": items,
|
||||
"page_size": page_size,
|
||||
"pagination_mode": "cursor",
|
||||
"cursor": cursor,
|
||||
"next_cursor": next_cursor,
|
||||
"has_next": bool(next_cursor),
|
||||
}
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size, "pages": pages}
|
||||
|
||||
|
||||
def error(msg: str, code: int = 1, data: Optional[Any] = None):
|
||||
|
||||
@@ -6,12 +6,10 @@ from domain.backup import api as backup
|
||||
from domain.config import api as config
|
||||
from domain.email import api as email
|
||||
from domain.offline_downloads import api as offline_downloads
|
||||
from domain.notices import api as notices
|
||||
from domain.plugins import api as plugins
|
||||
from domain.processors import api as processors
|
||||
from domain.share import api as share
|
||||
from domain.tasks import api as tasks
|
||||
from domain.video_rooms import api as video_rooms
|
||||
from domain.ai import api as ai
|
||||
from domain.agent import api as agent
|
||||
from domain.virtual_fs import api as virtual_fs
|
||||
@@ -33,7 +31,6 @@ def include_routers(app: FastAPI):
|
||||
app.include_router(config.router)
|
||||
app.include_router(processors.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(video_rooms.router)
|
||||
app.include_router(share.router)
|
||||
app.include_router(share.public_router)
|
||||
app.include_router(backup.router)
|
||||
@@ -44,7 +41,6 @@ def include_routers(app: FastAPI):
|
||||
app.include_router(webdav_api.router)
|
||||
app.include_router(s3_api.router)
|
||||
app.include_router(offline_downloads.router)
|
||||
app.include_router(notices.router)
|
||||
app.include_router(email.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(permission.router)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import List, Dict, Protocol, runtime_checkable, Tuple, AsyncIterator, Any
|
||||
from typing import List, Dict, Protocol, runtime_checkable, Tuple, AsyncIterator
|
||||
from models import StorageAdapter
|
||||
|
||||
# 约定:任意新适配器模块需定义:
|
||||
@@ -9,7 +9,7 @@ from models import StorageAdapter
|
||||
@runtime_checkable
|
||||
class BaseAdapter(Protocol):
|
||||
record: StorageAdapter
|
||||
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", cursor: str | None = None) -> Tuple[List[Dict], int] | Dict[str, Any]: ...
|
||||
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]: ...
|
||||
async def read_file(self, root: str, rel: str) -> bytes: ...
|
||||
async def write_file(self, root: str, rel: str, data: bytes): ...
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]): ...
|
||||
|
||||
@@ -4,7 +4,6 @@ import httpx
|
||||
from fastapi.responses import StreamingResponse, Response
|
||||
from fastapi import HTTPException
|
||||
from models import StorageAdapter
|
||||
from api.response import cursor_page
|
||||
|
||||
MS_GRAPH_URL = "https://graph.microsoft.com/v1.0"
|
||||
MS_OAUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
|
||||
@@ -115,51 +114,65 @@ class OneDriveAdapter:
|
||||
"type": "dir" if is_dir else "file",
|
||||
}
|
||||
|
||||
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",
|
||||
cursor: str | None = None,
|
||||
):
|
||||
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]:
|
||||
"""
|
||||
列出目录内容。
|
||||
Graph API 不提供目录总数,使用 nextLink 游标分页。
|
||||
由于 Graph API 不支持基于偏移($skip)的分页,此方法将获取所有项目,
|
||||
:param root: 根路径 (在此适配器中未使用,通过配置的 root 确定)。
|
||||
:param rel: 相对路径。
|
||||
:param page_num: 页码。
|
||||
:param page_size: 每页大小。
|
||||
:param sort_by: 排序字段
|
||||
:param sort_order: 排序顺序
|
||||
:param cursor: Graph nextLink。
|
||||
:return: 游标分页结果。
|
||||
:return: 文件/目录列表和总数。
|
||||
"""
|
||||
if cursor:
|
||||
resp = await self._request("GET", full_url=cursor)
|
||||
else:
|
||||
api_path = self._get_api_path(rel)
|
||||
children_path = f"{api_path}:/children" if api_path else "/children"
|
||||
resp = await self._request("GET", api_path_segment=children_path, params={"$top": page_size})
|
||||
api_path = self._get_api_path(rel)
|
||||
children_path = f"{api_path}:/children" if api_path else "/children"
|
||||
all_items = []
|
||||
params = {"$top": 999}
|
||||
resp = await self._request("GET", api_path_segment=children_path, params=params)
|
||||
|
||||
if resp.status_code == 404:
|
||||
return cursor_page([], page_size, cursor=cursor)
|
||||
resp.raise_for_status()
|
||||
while True:
|
||||
if resp.status_code == 404 and not all_items:
|
||||
return [], 0
|
||||
resp.raise_for_status()
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
raise IOError(f"解析 Graph API 响应失败: {e}") from e
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
raise IOError(f"解析 Graph API 响应失败: {e}") from e
|
||||
|
||||
formatted_items = [self._format_item(item) for item in data.get("value", [])]
|
||||
return cursor_page(
|
||||
formatted_items,
|
||||
page_size,
|
||||
cursor=cursor,
|
||||
next_cursor=data.get("@odata.nextLink"),
|
||||
)
|
||||
all_items.extend(data.get("value", []))
|
||||
next_link = data.get("@odata.nextLink")
|
||||
|
||||
if not next_link:
|
||||
break
|
||||
|
||||
resp = await self._request("GET", full_url=next_link)
|
||||
|
||||
formatted_items = [self._format_item(item) for item in all_items]
|
||||
|
||||
# 排序
|
||||
reverse = sort_order.lower() == "desc"
|
||||
def get_sort_key(item):
|
||||
key = (not item["is_dir"],)
|
||||
sort_field = sort_by.lower()
|
||||
if sort_field == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif sort_field == "size":
|
||||
key += (item["size"],)
|
||||
elif sort_field == "mtime":
|
||||
key += (item["mtime"],)
|
||||
else:
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
formatted_items.sort(key=get_sort_key, reverse=reverse)
|
||||
|
||||
total_count = len(formatted_items)
|
||||
start_idx = (page_num - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
|
||||
return formatted_items[start_idx:end_idx], total_count
|
||||
|
||||
async def read_file(self, root: str, rel: str) -> bytes:
|
||||
"""
|
||||
|
||||
@@ -360,14 +360,25 @@ class QuarkAdapter:
|
||||
if tr:
|
||||
url = tr
|
||||
dl_headers = self._download_headers()
|
||||
file_size = int(it.get("size") or 0)
|
||||
|
||||
# 预获取大小/是否支持范围
|
||||
total_size: Optional[int] = None
|
||||
async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True) as client:
|
||||
try:
|
||||
head_resp = await client.head(url, headers=dl_headers)
|
||||
if head_resp.status_code == 200:
|
||||
cl = head_resp.headers.get("Content-Length")
|
||||
if cl and cl.isdigit():
|
||||
total_size = int(cl)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mime, _ = mimetypes.guess_type(rel)
|
||||
content_type = mime or "application/octet-stream"
|
||||
|
||||
# 解析 Range
|
||||
start = 0
|
||||
end: Optional[int] = file_size - 1 if file_size > 0 else None
|
||||
end: Optional[int] = None
|
||||
status_code = 200
|
||||
if range_header and range_header.startswith("bytes="):
|
||||
status_code = 206
|
||||
@@ -377,65 +388,35 @@ class QuarkAdapter:
|
||||
start = int(s)
|
||||
if e.strip():
|
||||
end = int(e)
|
||||
elif file_size > 0:
|
||||
end = file_size - 1
|
||||
if file_size > 0:
|
||||
if start >= file_size:
|
||||
raise HTTPException(416, detail="Requested Range Not Satisfiable")
|
||||
if end is None or end >= file_size:
|
||||
end = file_size - 1
|
||||
if start > end:
|
||||
raise HTTPException(416, detail="Requested Range Not Satisfiable")
|
||||
headers = dict(dl_headers)
|
||||
if status_code == 206:
|
||||
headers["Range"] = f"bytes={start}-" if end is None else f"bytes={start}-{end}"
|
||||
|
||||
client = httpx.AsyncClient(timeout=None, follow_redirects=True)
|
||||
req = client.build_request("GET", url, headers=headers)
|
||||
resp = await client.send(req, stream=True)
|
||||
if resp.status_code == 404:
|
||||
await resp.aclose()
|
||||
await client.aclose()
|
||||
raise FileNotFoundError(rel)
|
||||
if resp.status_code == 416:
|
||||
await resp.aclose()
|
||||
await client.aclose()
|
||||
if total_size is not None and end is None and status_code == 206:
|
||||
end = total_size - 1
|
||||
if end is not None and total_size is not None and end >= total_size:
|
||||
end = total_size - 1
|
||||
if total_size is not None and start >= total_size:
|
||||
raise HTTPException(416, detail="Requested Range Not Satisfiable")
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except Exception:
|
||||
await resp.aclose()
|
||||
await client.aclose()
|
||||
raise
|
||||
|
||||
resp_headers: Dict[str, str] = {
|
||||
"Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"),
|
||||
"Content-Type": resp.headers.get("Content-Type", content_type),
|
||||
}
|
||||
content_range = resp.headers.get("Content-Range")
|
||||
content_length = resp.headers.get("Content-Length")
|
||||
if content_range:
|
||||
resp_headers["Content-Range"] = content_range
|
||||
elif status_code == 206 and file_size > 0 and end is not None:
|
||||
resp_headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||
if content_length:
|
||||
resp_headers["Content-Length"] = content_length
|
||||
elif file_size > 0:
|
||||
if status_code == 206 and end is not None:
|
||||
resp_headers["Content-Length"] = str(end - start + 1)
|
||||
elif resp.status_code == 200:
|
||||
resp_headers["Content-Length"] = str(file_size)
|
||||
resp_headers: Dict[str, str] = {"Accept-Ranges": "bytes", "Content-Type": content_type}
|
||||
if status_code == 206 and total_size is not None and end is not None:
|
||||
resp_headers["Content-Range"] = f"bytes {start}-{end}/{total_size}"
|
||||
resp_headers["Content-Length"] = str(end - start + 1)
|
||||
elif total_size is not None:
|
||||
resp_headers["Content-Length"] = str(total_size)
|
||||
|
||||
async def iterator():
|
||||
try:
|
||||
async for chunk in resp.aiter_bytes():
|
||||
if chunk:
|
||||
yield chunk
|
||||
finally:
|
||||
await resp.aclose()
|
||||
await client.aclose()
|
||||
headers = dict(dl_headers)
|
||||
if status_code == 206 and end is not None:
|
||||
headers["Range"] = f"bytes={start}-{end}"
|
||||
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
|
||||
async with client.stream("GET", url, headers=headers) as resp:
|
||||
if resp.status_code in (404, 416):
|
||||
await resp.aclose()
|
||||
raise HTTPException(resp.status_code, detail="Upstream not available")
|
||||
async for chunk in resp.aiter_bytes():
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(iterator(), status_code=resp.status_code, headers=resp_headers, media_type=content_type)
|
||||
return StreamingResponse(iterator(), status_code=status_code, headers=resp_headers, media_type=content_type)
|
||||
|
||||
# -----------------
|
||||
# 上传(大文件分片)
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
from typing import List, Dict, Tuple, AsyncIterator, Optional
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from models import StorageAdapter
|
||||
from api.response import cursor_page
|
||||
from telethon import TelegramClient, errors, utils
|
||||
from telethon import TelegramClient, utils
|
||||
from telethon.crypto import AuthKey
|
||||
from telethon.sessions import StringSession
|
||||
from telethon.tl import types
|
||||
@@ -54,9 +51,6 @@ CONFIG_SCHEMA = [
|
||||
class TelegramAdapter:
|
||||
"""Telegram 存储适配器 (使用用户 Session)"""
|
||||
native_video_thumbnail_only = True
|
||||
_message_cache_ttl = 300
|
||||
_message_cache_limit = 200
|
||||
_download_chunk_size = 512 * 1024
|
||||
|
||||
def __init__(self, record: StorageAdapter):
|
||||
self.record = record
|
||||
@@ -89,12 +83,6 @@ class TelegramAdapter:
|
||||
if not all([self.api_id, self.api_hash, self.session_string, self.chat_id]):
|
||||
raise ValueError("Telegram 适配器需要 api_id, api_hash, session_string 和 chat_id")
|
||||
|
||||
self._client: TelegramClient | None = None
|
||||
self._client_lock = asyncio.Lock()
|
||||
self._download_lock = asyncio.Lock()
|
||||
self._active_stream_message_id: int | None = None
|
||||
self._message_cache: Dict[int, Tuple[float, object]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _parse_legacy_session_string(value: str) -> StringSession:
|
||||
"""
|
||||
@@ -196,80 +184,6 @@ class TelegramAdapter:
|
||||
"""创建一个新的 TelegramClient 实例"""
|
||||
return TelegramClient(self._build_session(), self.api_id, self.api_hash, proxy=self.proxy)
|
||||
|
||||
async def _get_connected_client(self) -> TelegramClient:
|
||||
async with self._client_lock:
|
||||
if self._client is None:
|
||||
self._client = self._get_client()
|
||||
if not self._client.is_connected():
|
||||
await self._client.connect()
|
||||
return self._client
|
||||
|
||||
async def _disconnect_shared_client(self):
|
||||
if self._client and self._client.is_connected():
|
||||
await self._client.disconnect()
|
||||
|
||||
def _clear_message_cache(self):
|
||||
self._message_cache.clear()
|
||||
|
||||
async def _get_cached_message(self, message_id: int):
|
||||
now = time.monotonic()
|
||||
cached = self._message_cache.get(message_id)
|
||||
if cached and cached[0] > now:
|
||||
return cached[1]
|
||||
|
||||
client = await self._get_connected_client()
|
||||
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||
if message:
|
||||
if len(self._message_cache) >= self._message_cache_limit:
|
||||
oldest_key = min(self._message_cache, key=lambda k: self._message_cache[k][0])
|
||||
self._message_cache.pop(oldest_key, None)
|
||||
self._message_cache[message_id] = (now + self._message_cache_ttl, message)
|
||||
else:
|
||||
self._message_cache.pop(message_id, None)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _get_message_media(message):
|
||||
return message.document or message.video or message.photo
|
||||
|
||||
@staticmethod
|
||||
def _flood_wait_http_exception(exc: errors.FloodWaitError):
|
||||
from fastapi import HTTPException
|
||||
|
||||
seconds = int(getattr(exc, "seconds", 0) or 0)
|
||||
if seconds > 0:
|
||||
return HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Telegram 请求过于频繁,请等待 {seconds} 秒后重试",
|
||||
headers={"Retry-After": str(seconds)},
|
||||
)
|
||||
return HTTPException(status_code=429, detail="Telegram 请求过于频繁,请稍后重试")
|
||||
|
||||
@staticmethod
|
||||
def _get_message_file_size(message, media) -> int:
|
||||
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 int(size or 0)
|
||||
|
||||
@staticmethod
|
||||
def _get_message_mime_type(message, media) -> str:
|
||||
file_meta = message.file
|
||||
if file_meta and getattr(file_meta, "mime_type", None):
|
||||
return file_meta.mime_type
|
||||
if hasattr(media, "mime_type") and media.mime_type:
|
||||
return media.mime_type
|
||||
if message.photo:
|
||||
return "image/jpeg"
|
||||
return "application/octet-stream"
|
||||
|
||||
@staticmethod
|
||||
def _parse_message_id(rel: str) -> int:
|
||||
try:
|
||||
@@ -281,144 +195,141 @@ class TelegramAdapter:
|
||||
def get_effective_root(self, sub_path: str | None) -> str:
|
||||
return ""
|
||||
|
||||
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",
|
||||
cursor: str | None = None,
|
||||
):
|
||||
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]:
|
||||
if rel:
|
||||
return cursor_page([], page_size, cursor=cursor)
|
||||
return [], 0
|
||||
|
||||
client = self._get_client()
|
||||
entries = []
|
||||
next_cursor = None
|
||||
try:
|
||||
await client.connect()
|
||||
offset_id = int(cursor) if cursor else 0
|
||||
batch_limit = min(max(page_size, 50), 200)
|
||||
while len(entries) < page_size:
|
||||
messages = await client.get_messages(self.chat_id, limit=batch_limit, offset_id=offset_id)
|
||||
if not messages:
|
||||
next_cursor = None
|
||||
break
|
||||
messages = await client.get_messages(self.chat_id, limit=200)
|
||||
for message in messages:
|
||||
if not message:
|
||||
continue
|
||||
|
||||
offset_id = messages[-1].id
|
||||
next_cursor = str(offset_id)
|
||||
for message in messages:
|
||||
if not message:
|
||||
continue
|
||||
media = message.document or message.video or message.photo
|
||||
if not media:
|
||||
continue
|
||||
|
||||
media = message.document or message.video or message.photo
|
||||
if not media:
|
||||
continue
|
||||
file_meta = message.file
|
||||
if not file_meta:
|
||||
continue
|
||||
|
||||
file_meta = message.file
|
||||
if not file_meta:
|
||||
continue
|
||||
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
|
||||
else:
|
||||
filename = f"unknown_{message.id}"
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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}",
|
||||
"is_dir": False,
|
||||
"size": size,
|
||||
"mtime": int(message.date.timestamp()),
|
||||
"type": "file",
|
||||
"has_thumbnail": False,
|
||||
})
|
||||
if len(entries) >= page_size:
|
||||
break
|
||||
entries.append({
|
||||
"name": f"{message.id}_{filename}",
|
||||
"is_dir": False,
|
||||
"size": size,
|
||||
"mtime": int(message.date.timestamp()),
|
||||
"type": "file",
|
||||
"has_thumbnail": self._message_has_thumbnail(message),
|
||||
})
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
return cursor_page(entries, page_size, cursor=cursor, next_cursor=next_cursor)
|
||||
# 排序
|
||||
reverse = sort_order.lower() == "desc"
|
||||
def get_sort_key(item):
|
||||
key = (not item["is_dir"],)
|
||||
sort_field = sort_by.lower()
|
||||
if sort_field == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif sort_field == "size":
|
||||
key += (item["size"],)
|
||||
elif sort_field == "mtime":
|
||||
key += (item["mtime"],)
|
||||
else:
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
entries.sort(key=get_sort_key, reverse=reverse)
|
||||
|
||||
total_count = len(entries)
|
||||
|
||||
# 分页
|
||||
start_idx = (page_num - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
page_entries = entries[start_idx:end_idx]
|
||||
|
||||
return page_entries, total_count
|
||||
|
||||
async def read_file(self, root: str, rel: str) -> bytes:
|
||||
message_id = self._parse_message_id(rel)
|
||||
|
||||
client = await self._get_connected_client()
|
||||
message = await self._get_cached_message(message_id)
|
||||
if not message or not self._get_message_media(message):
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
|
||||
client = self._get_client()
|
||||
try:
|
||||
async with self._download_lock:
|
||||
file_bytes = await client.download_media(message, file=bytes)
|
||||
return file_bytes
|
||||
except errors.FloodWaitError as exc:
|
||||
await self._disconnect_shared_client()
|
||||
raise self._flood_wait_http_exception(exc)
|
||||
await client.connect()
|
||||
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||
if not message or not (message.document or message.video or message.photo):
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
|
||||
file_bytes = await client.download_media(message, file=bytes)
|
||||
return file_bytes
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
async def read_file_range(self, root: str, rel: str, start: int, end: Optional[int] = None) -> bytes:
|
||||
from fastapi import HTTPException
|
||||
|
||||
message_id = self._parse_message_id(rel)
|
||||
client = await self._get_connected_client()
|
||||
message = await self._get_cached_message(message_id)
|
||||
if not message:
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
|
||||
media = self._get_message_media(message)
|
||||
if not media:
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
|
||||
file_size = self._get_message_file_size(message, media)
|
||||
if file_size > 0:
|
||||
if start >= file_size:
|
||||
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
|
||||
if end is None or end >= file_size:
|
||||
end = file_size - 1
|
||||
elif end is None:
|
||||
end = start
|
||||
|
||||
if end < start:
|
||||
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
|
||||
|
||||
limit = end - start + 1
|
||||
data = bytearray()
|
||||
client = self._get_client()
|
||||
try:
|
||||
async with self._download_lock:
|
||||
async for chunk in client.iter_download(
|
||||
media,
|
||||
offset=start,
|
||||
request_size=self._download_chunk_size,
|
||||
chunk_size=self._download_chunk_size,
|
||||
file_size=file_size or None,
|
||||
):
|
||||
if not chunk:
|
||||
continue
|
||||
need = limit - len(data)
|
||||
if need <= 0:
|
||||
break
|
||||
data.extend(chunk[:need])
|
||||
if len(data) >= limit:
|
||||
break
|
||||
await client.connect()
|
||||
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||
if not message:
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
|
||||
media = message.document or message.video or message.photo
|
||||
if not media:
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
|
||||
file_meta = message.file
|
||||
file_size = file_meta.size if file_meta and file_meta.size is not None else getattr(media, "size", 0) or 0
|
||||
if file_size > 0:
|
||||
if start >= file_size:
|
||||
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
|
||||
if end is None or end >= file_size:
|
||||
end = file_size - 1
|
||||
elif end is None:
|
||||
end = start
|
||||
|
||||
if end < start:
|
||||
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
|
||||
|
||||
limit = end - start + 1
|
||||
data = bytearray()
|
||||
async for chunk in client.iter_download(media, offset=start):
|
||||
if not chunk:
|
||||
continue
|
||||
need = limit - len(data)
|
||||
if need <= 0:
|
||||
break
|
||||
data.extend(chunk[:need])
|
||||
if len(data) >= limit:
|
||||
break
|
||||
return bytes(data)
|
||||
except errors.FloodWaitError as exc:
|
||||
await self._disconnect_shared_client()
|
||||
raise self._flood_wait_http_exception(exc)
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
async def write_file(self, root: str, rel: str, data: bytes):
|
||||
"""将字节数据作为文件上传"""
|
||||
@@ -438,7 +349,6 @@ class TelegramAdapter:
|
||||
stored_name = file_meta.name
|
||||
if getattr(message, "id", None) is not None:
|
||||
actual_rel = f"{message.id}_{stored_name}"
|
||||
self._clear_message_cache()
|
||||
return {"rel": actual_rel, "size": len(data)}
|
||||
finally:
|
||||
if client.is_connected():
|
||||
@@ -468,7 +378,6 @@ class TelegramAdapter:
|
||||
stored_name = file_meta.name
|
||||
if getattr(message, "id", None) is not None:
|
||||
actual_rel = f"{message.id}_{stored_name}"
|
||||
self._clear_message_cache()
|
||||
if file_meta and getattr(file_meta, "size", None):
|
||||
size = int(file_meta.size)
|
||||
return {"rel": actual_rel, "size": size}
|
||||
@@ -504,7 +413,6 @@ class TelegramAdapter:
|
||||
stored_name = file_meta.name
|
||||
if getattr(message, "id", None) is not None:
|
||||
actual_rel = f"{message.id}_{stored_name}"
|
||||
self._clear_message_cache()
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
@@ -517,7 +425,38 @@ class TelegramAdapter:
|
||||
raise NotImplementedError("Telegram 适配器不支持创建目录。")
|
||||
|
||||
async def get_thumbnail(self, root: str, rel: str, size: str = "medium"):
|
||||
return None
|
||||
try:
|
||||
message_id_str, _ = rel.split('_', 1)
|
||||
message_id = int(message_id_str)
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
client = self._get_client()
|
||||
try:
|
||||
await client.connect()
|
||||
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||
if not message:
|
||||
return None
|
||||
|
||||
thumb = self._pick_photo_thumb(self._get_message_thumbs(message))
|
||||
if not thumb:
|
||||
return None
|
||||
|
||||
embedded = getattr(thumb, "bytes", None)
|
||||
if embedded and isinstance(thumb, types.PhotoCachedSize):
|
||||
return bytes(embedded)
|
||||
if embedded and isinstance(thumb, types.PhotoStrippedSize):
|
||||
return utils.stripped_photo_to_jpg(bytes(embedded))
|
||||
|
||||
result = await client.download_media(message, bytes, thumb=thumb)
|
||||
if isinstance(result, (bytes, bytearray)):
|
||||
return bytes(result)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
async def delete(self, root: str, rel: str):
|
||||
"""删除一个文件 (即一条消息)"""
|
||||
@@ -533,12 +472,9 @@ class TelegramAdapter:
|
||||
result = await client.delete_messages(self.chat_id, [message_id])
|
||||
if not result or not result[0].pts:
|
||||
raise FileNotFoundError(f"在 {self.chat_id} 中删除消息 {message_id} 失败,可能消息不存在或无权限")
|
||||
self._message_cache.pop(message_id, None)
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
if self._client is client:
|
||||
self._client = None
|
||||
|
||||
async def move(self, root: str, src_rel: str, dst_rel: str):
|
||||
raise NotImplementedError("Telegram 适配器不支持移动。")
|
||||
@@ -558,17 +494,38 @@ class TelegramAdapter:
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=400, detail=f"无效的文件路径格式: {rel}")
|
||||
|
||||
client = self._get_client()
|
||||
|
||||
try:
|
||||
client = await self._get_connected_client()
|
||||
message = await self._get_cached_message(message_id)
|
||||
await client.connect()
|
||||
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||
if not message:
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
media = self._get_message_media(message)
|
||||
media = message.document or message.video or message.photo
|
||||
if not media:
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
|
||||
file_size = self._get_message_file_size(message, media)
|
||||
mime_type = self._get_message_mime_type(message, media)
|
||||
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
|
||||
@@ -581,6 +538,8 @@ class TelegramAdapter:
|
||||
|
||||
if file_size <= 0:
|
||||
headers["Content-Length"] = "0"
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
return StreamingResponse(iter(()), status_code=status, headers=headers)
|
||||
|
||||
if range_header:
|
||||
@@ -597,70 +556,37 @@ class TelegramAdapter:
|
||||
raise HTTPException(status_code=400, detail="Invalid Range header")
|
||||
|
||||
headers["Content-Length"] = str(end - start + 1)
|
||||
self._active_stream_message_id = message_id
|
||||
|
||||
async def iterator():
|
||||
downloaded = 0
|
||||
try:
|
||||
limit = end - start + 1
|
||||
if self._active_stream_message_id != message_id:
|
||||
return
|
||||
async with self._download_lock:
|
||||
async for chunk in client.iter_download(
|
||||
media,
|
||||
offset=start,
|
||||
request_size=self._download_chunk_size,
|
||||
chunk_size=self._download_chunk_size,
|
||||
file_size=file_size,
|
||||
):
|
||||
if self._active_stream_message_id != message_id:
|
||||
return
|
||||
if not chunk:
|
||||
continue
|
||||
remaining = limit - downloaded
|
||||
if remaining <= 0:
|
||||
break
|
||||
data = chunk[:remaining]
|
||||
downloaded += len(data)
|
||||
yield data
|
||||
if downloaded >= limit:
|
||||
break
|
||||
except errors.FloodWaitError as exc:
|
||||
await self._disconnect_shared_client()
|
||||
if downloaded == 0:
|
||||
raise self._flood_wait_http_exception(exc)
|
||||
seconds = int(getattr(exc, "seconds", 0) or 0)
|
||||
print(f"Telegram streaming stopped by FloodWait after partial response, wait={seconds}s")
|
||||
return
|
||||
except Exception:
|
||||
await self._disconnect_shared_client()
|
||||
raise
|
||||
downloaded = 0
|
||||
|
||||
agen = iterator()
|
||||
try:
|
||||
first_chunk = await agen.__anext__()
|
||||
except StopAsyncIteration:
|
||||
first_chunk = b""
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
async def response_iterator():
|
||||
try:
|
||||
if first_chunk:
|
||||
yield first_chunk
|
||||
async for chunk in agen:
|
||||
async for chunk in client.iter_download(media, offset=start):
|
||||
if downloaded + len(chunk) > limit:
|
||||
yield chunk[:limit - downloaded]
|
||||
break
|
||||
yield chunk
|
||||
downloaded += len(chunk)
|
||||
if downloaded >= limit:
|
||||
break
|
||||
finally:
|
||||
await agen.aclose()
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
return StreamingResponse(response_iterator(), status_code=status, headers=headers)
|
||||
return StreamingResponse(iterator(), status_code=status, headers=headers)
|
||||
|
||||
except HTTPException:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
raise
|
||||
except FileNotFoundError as e:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
await self._disconnect_shared_client()
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
raise HTTPException(status_code=500, detail=f"Streaming failed: {str(e)}")
|
||||
|
||||
async def stat_file(self, root: str, rel: str):
|
||||
@@ -670,21 +596,36 @@ class TelegramAdapter:
|
||||
except (ValueError, IndexError):
|
||||
raise FileNotFoundError(f"无效的文件路径格式: {rel}")
|
||||
|
||||
message = await self._get_cached_message(message_id)
|
||||
media = self._get_message_media(message) if message else None
|
||||
if not message or not media:
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
client = self._get_client()
|
||||
try:
|
||||
await client.connect()
|
||||
message = await client.get_messages(self.chat_id, ids=message_id)
|
||||
media = message.document or message.video or message.photo
|
||||
if not message or not media:
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
|
||||
size = self._get_message_file_size(message, media)
|
||||
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,
|
||||
"is_dir": False,
|
||||
"size": size,
|
||||
"mtime": int(message.date.timestamp()),
|
||||
"type": "file",
|
||||
"has_thumbnail": False,
|
||||
}
|
||||
return {
|
||||
"name": rel,
|
||||
"is_dir": False,
|
||||
"size": size,
|
||||
"mtime": int(message.date.timestamp()),
|
||||
"type": "file",
|
||||
"has_thumbnail": self._message_has_thumbnail(message),
|
||||
}
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
def ADAPTER_FACTORY(rec: StorageAdapter) -> TelegramAdapter:
|
||||
return TelegramAdapter(rec)
|
||||
|
||||
@@ -22,11 +22,8 @@ from .types import (
|
||||
AIModelUpdate,
|
||||
AIProviderCreate,
|
||||
AIProviderUpdate,
|
||||
OPENAI_PROTOCOL_CHAT_COMPLETIONS,
|
||||
OPENAI_PROTOCOL_RESPONSES,
|
||||
VectorDBConfigPayload,
|
||||
normalize_capabilities,
|
||||
normalize_openai_protocol,
|
||||
)
|
||||
from .vector_providers import (
|
||||
BaseVectorProvider,
|
||||
@@ -61,9 +58,6 @@ __all__ = [
|
||||
"get_provider_class",
|
||||
"ABILITIES",
|
||||
"normalize_capabilities",
|
||||
"normalize_openai_protocol",
|
||||
"OPENAI_PROTOCOL_CHAT_COMPLETIONS",
|
||||
"OPENAI_PROTOCOL_RESPONSES",
|
||||
"AIDefaultsUpdate",
|
||||
"AIModelCreate",
|
||||
"AIModelUpdate",
|
||||
|
||||
@@ -34,7 +34,7 @@ async def list_providers_endpoint(
|
||||
@audit(
|
||||
action=AuditAction.CREATE,
|
||||
description="创建 AI 提供商",
|
||||
body_fields=["name", "identifier", "provider_type", "api_format", "base_url", "logo_url", "extra_config"],
|
||||
body_fields=["name", "identifier", "provider_type", "api_format", "base_url", "logo_url"],
|
||||
redact_fields=["api_key"],
|
||||
)
|
||||
@router_ai.post("/providers")
|
||||
@@ -61,7 +61,7 @@ async def get_provider(
|
||||
@audit(
|
||||
action=AuditAction.UPDATE,
|
||||
description="更新 AI 提供商",
|
||||
body_fields=["name", "provider_type", "api_format", "base_url", "logo_url", "api_key", "extra_config"],
|
||||
body_fields=["name", "provider_type", "api_format", "base_url", "logo_url", "api_key"],
|
||||
redact_fields=["api_key"],
|
||||
)
|
||||
@router_ai.put("/providers/{provider_id}")
|
||||
|
||||
@@ -6,7 +6,6 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from models.database import AIModel, AIProvider
|
||||
from .service import AIProviderService
|
||||
from .types import OPENAI_PROTOCOL_RESPONSES, normalize_openai_protocol
|
||||
|
||||
|
||||
provider_service = AIProviderService
|
||||
@@ -228,248 +227,6 @@ def _is_azure_openai(provider: AIProvider) -> bool:
|
||||
return ".openai.azure.com" in base_url
|
||||
|
||||
|
||||
def _openai_protocol(provider: AIProvider) -> str:
|
||||
extra = provider.extra_config if isinstance(provider.extra_config, dict) else {}
|
||||
return normalize_openai_protocol(extra.get("openai_protocol"))
|
||||
|
||||
|
||||
def _content_to_text(content: Any) -> str:
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: List[str] = []
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
text = part.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "".join(parts)
|
||||
try:
|
||||
return json.dumps(content, ensure_ascii=False)
|
||||
except TypeError:
|
||||
return str(content)
|
||||
|
||||
|
||||
def _openai_content_to_responses_input(content: Any) -> Any:
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if not isinstance(content, list):
|
||||
return _content_to_text(content)
|
||||
|
||||
blocks: List[Dict[str, Any]] = []
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
part_type = part.get("type")
|
||||
if part_type in {"text", "input_text"} and isinstance(part.get("text"), str):
|
||||
blocks.append({"type": "input_text", "text": part["text"]})
|
||||
continue
|
||||
if part_type == "image_url":
|
||||
image_url = part.get("image_url")
|
||||
url = image_url.get("url") if isinstance(image_url, dict) else image_url
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
continue
|
||||
block: Dict[str, Any] = {"type": "input_image", "image_url": url}
|
||||
detail = image_url.get("detail") if isinstance(image_url, dict) else None
|
||||
if isinstance(detail, str) and detail.strip():
|
||||
block["detail"] = detail
|
||||
blocks.append(block)
|
||||
continue
|
||||
if part_type == "input_image" and isinstance(part.get("image_url"), str):
|
||||
block = {"type": "input_image", "image_url": part["image_url"]}
|
||||
detail = part.get("detail")
|
||||
if isinstance(detail, str) and detail.strip():
|
||||
block["detail"] = detail
|
||||
blocks.append(block)
|
||||
continue
|
||||
if part_type == "input_file" and isinstance(part.get("file_id") or part.get("filename"), str):
|
||||
blocks.append(dict(part))
|
||||
|
||||
return blocks or ""
|
||||
|
||||
|
||||
def _openai_tool_call_to_responses_item(call: Dict[str, Any], idx: int) -> Dict[str, Any] | None:
|
||||
fn = call.get("function")
|
||||
fn = fn if isinstance(fn, dict) else {}
|
||||
name = fn.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return None
|
||||
|
||||
raw_args = fn.get("arguments")
|
||||
if isinstance(raw_args, dict):
|
||||
args_text = json.dumps(raw_args, ensure_ascii=False)
|
||||
elif isinstance(raw_args, str):
|
||||
args_text = raw_args
|
||||
else:
|
||||
args_text = ""
|
||||
|
||||
return {
|
||||
"type": "function_call",
|
||||
"call_id": str(call.get("id") or f"call_{idx}"),
|
||||
"name": name,
|
||||
"arguments": args_text,
|
||||
}
|
||||
|
||||
|
||||
def _openai_messages_to_responses_input(messages: List[Dict[str, Any]]) -> Tuple[str, List[Dict[str, Any]]]:
|
||||
instructions: List[str] = []
|
||||
input_items: List[Dict[str, Any]] = []
|
||||
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
role = msg.get("role")
|
||||
if role in {"system", "developer"}:
|
||||
text = _content_to_text(msg.get("content")).strip()
|
||||
if text:
|
||||
instructions.append(text)
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
content = msg.get("content")
|
||||
output = content if isinstance(content, str) else _content_to_text(content)
|
||||
call_id = str(msg.get("tool_call_id") or "").strip()
|
||||
if call_id:
|
||||
input_items.append({
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": output,
|
||||
})
|
||||
elif output:
|
||||
input_items.append({"role": "user", "content": output})
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
input_items.append({
|
||||
"role": "user",
|
||||
"content": _openai_content_to_responses_input(msg.get("content")),
|
||||
})
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
text = _content_to_text(msg.get("content"))
|
||||
if text:
|
||||
input_items.append({"role": "assistant", "content": text})
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if isinstance(tool_calls, list):
|
||||
for idx, call in enumerate(tool_calls):
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
item = _openai_tool_call_to_responses_item(call, idx)
|
||||
if item:
|
||||
input_items.append(item)
|
||||
|
||||
if not input_items:
|
||||
input_items.append({"role": "user", "content": ""})
|
||||
|
||||
return "\n\n".join(instructions).strip(), input_items
|
||||
|
||||
|
||||
def _openai_tools_to_responses(tools: List[Dict[str, Any]] | None) -> List[Dict[str, Any]] | None:
|
||||
if not tools:
|
||||
return None
|
||||
out: List[Dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict) or tool.get("type") != "function":
|
||||
continue
|
||||
fn = tool.get("function")
|
||||
fn = fn if isinstance(fn, dict) else {}
|
||||
name = fn.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
entry: Dict[str, Any] = {
|
||||
"type": "function",
|
||||
"name": name,
|
||||
"description": fn.get("description") if isinstance(fn.get("description"), str) else "",
|
||||
"parameters": fn.get("parameters") if isinstance(fn.get("parameters"), dict) else {},
|
||||
}
|
||||
if isinstance(fn.get("strict"), bool):
|
||||
entry["strict"] = fn["strict"]
|
||||
out.append(entry)
|
||||
return out or None
|
||||
|
||||
|
||||
def _openai_tool_choice_to_responses(tool_choice: Any) -> Any | None:
|
||||
if tool_choice is None:
|
||||
return None
|
||||
if isinstance(tool_choice, str):
|
||||
return tool_choice
|
||||
if not isinstance(tool_choice, dict):
|
||||
return None
|
||||
if tool_choice.get("type") == "function":
|
||||
fn = tool_choice.get("function")
|
||||
name = fn.get("name") if isinstance(fn, dict) else tool_choice.get("name")
|
||||
if isinstance(name, str) and name.strip():
|
||||
return {"type": "function", "name": name}
|
||||
return None
|
||||
|
||||
|
||||
def _responses_function_call_to_openai(item: Dict[str, Any], idx: int) -> Dict[str, Any] | None:
|
||||
name = item.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return None
|
||||
raw_args = item.get("arguments")
|
||||
if isinstance(raw_args, dict):
|
||||
args_text = json.dumps(raw_args, ensure_ascii=False)
|
||||
elif isinstance(raw_args, str):
|
||||
args_text = raw_args
|
||||
else:
|
||||
args_text = ""
|
||||
return {
|
||||
"id": str(item.get("call_id") or item.get("id") or f"call_{idx}"),
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": args_text},
|
||||
}
|
||||
|
||||
|
||||
def _responses_content_to_text(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if not isinstance(content, list):
|
||||
return ""
|
||||
parts: List[str] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("type") in {"output_text", "text"} and isinstance(block.get("text"), str):
|
||||
parts.append(block["text"])
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _responses_body_to_openai_message(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
text_parts: List[str] = []
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
output = body.get("output")
|
||||
|
||||
if isinstance(output, list):
|
||||
for idx, item in enumerate(output):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
item_type = item.get("type")
|
||||
if item_type == "message":
|
||||
text = _responses_content_to_text(item.get("content"))
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
continue
|
||||
if item_type == "function_call":
|
||||
tool_call = _responses_function_call_to_openai(item, idx)
|
||||
if tool_call:
|
||||
tool_calls.append(tool_call)
|
||||
|
||||
if not text_parts and isinstance(body.get("output_text"), str):
|
||||
text_parts.append(body["output_text"])
|
||||
|
||||
message: Dict[str, Any] = {"role": "assistant", "content": "".join(text_parts)}
|
||||
if tool_calls:
|
||||
message["tool_calls"] = tool_calls
|
||||
return message
|
||||
|
||||
|
||||
def _gemini_endpoint(provider: AIProvider, path: str) -> str:
|
||||
base = (provider.base_url or "").rstrip("/")
|
||||
if not base:
|
||||
@@ -1051,12 +808,6 @@ async def _chat_stream_with_ollama(
|
||||
|
||||
|
||||
async def _describe_with_openai(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str:
|
||||
if _openai_protocol(provider) == OPENAI_PROTOCOL_RESPONSES:
|
||||
return await _describe_with_openai_responses(provider, model, base64_image, detail)
|
||||
return await _describe_with_openai_chat_completions(provider, model, base64_image, detail)
|
||||
|
||||
|
||||
async def _describe_with_openai_chat_completions(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str:
|
||||
url = _openai_endpoint(provider, "/chat/completions")
|
||||
payload = {
|
||||
"model": model.name,
|
||||
@@ -1083,32 +834,6 @@ async def _describe_with_openai_chat_completions(provider: AIProvider, model: AI
|
||||
return body["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
async def _describe_with_openai_responses(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str:
|
||||
url = _openai_endpoint(provider, "/responses")
|
||||
payload = {
|
||||
"model": model.name,
|
||||
"input": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": f"data:image/jpeg;base64,{base64_image}",
|
||||
"detail": detail,
|
||||
},
|
||||
{"type": "input_text", "text": "描述这个图片"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
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()
|
||||
message = _responses_body_to_openai_message(body if isinstance(body, dict) else {})
|
||||
return str(message.get("content") or "")
|
||||
|
||||
|
||||
async def _describe_with_anthropic(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str:
|
||||
url = _anthropic_endpoint(provider, "/messages")
|
||||
detail_text = f"描述这个图片,细节等级:{detail}"
|
||||
@@ -1355,37 +1080,6 @@ async def _chat_with_openai(
|
||||
tool_choice: Any | None,
|
||||
temperature: float | None,
|
||||
timeout: float,
|
||||
) -> Dict[str, Any]:
|
||||
if _openai_protocol(provider) == OPENAI_PROTOCOL_RESPONSES:
|
||||
return await _chat_with_openai_responses(
|
||||
provider,
|
||||
model,
|
||||
messages,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
temperature=temperature,
|
||||
timeout=timeout,
|
||||
)
|
||||
return await _chat_with_openai_chat_completions(
|
||||
provider,
|
||||
model,
|
||||
messages,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
temperature=temperature,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
async def _chat_with_openai_chat_completions(
|
||||
provider: AIProvider,
|
||||
model: AIModel,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: List[Dict[str, Any]] | None,
|
||||
tool_choice: Any | None,
|
||||
temperature: float | None,
|
||||
timeout: float,
|
||||
) -> Dict[str, Any]:
|
||||
url = _openai_endpoint(provider, "/chat/completions")
|
||||
payload: Dict[str, Any] = {
|
||||
@@ -1412,41 +1106,6 @@ async def _chat_with_openai_chat_completions(
|
||||
return message
|
||||
|
||||
|
||||
async def _chat_with_openai_responses(
|
||||
provider: AIProvider,
|
||||
model: AIModel,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: List[Dict[str, Any]] | None,
|
||||
tool_choice: Any | None,
|
||||
temperature: float | None,
|
||||
timeout: float,
|
||||
) -> Dict[str, Any]:
|
||||
url = _openai_endpoint(provider, "/responses")
|
||||
instructions, input_items = _openai_messages_to_responses_input(messages)
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model.name,
|
||||
"input": input_items,
|
||||
}
|
||||
if instructions:
|
||||
payload["instructions"] = instructions
|
||||
response_tools = _openai_tools_to_responses(tools)
|
||||
if response_tools:
|
||||
payload["tools"] = response_tools
|
||||
payload["tool_choice"] = _openai_tool_choice_to_responses(tool_choice) or "auto"
|
||||
if temperature is not None:
|
||||
payload["temperature"] = float(temperature)
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(url, headers=_openai_headers(provider), json=payload)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
|
||||
if not isinstance(body, dict):
|
||||
raise RuntimeError("Responses 接口返回格式异常")
|
||||
return _responses_body_to_openai_message(body)
|
||||
|
||||
|
||||
async def chat_completion_stream(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
@@ -1515,40 +1174,6 @@ async def _chat_stream_with_openai(
|
||||
tool_choice: Any | None,
|
||||
temperature: float | None,
|
||||
timeout: float,
|
||||
) -> AsyncIterator[Dict[str, Any]]:
|
||||
if _openai_protocol(provider) == OPENAI_PROTOCOL_RESPONSES:
|
||||
async for event in _chat_stream_with_openai_responses(
|
||||
provider,
|
||||
model,
|
||||
messages,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
temperature=temperature,
|
||||
timeout=timeout,
|
||||
):
|
||||
yield event
|
||||
return
|
||||
async for event in _chat_stream_with_openai_chat_completions(
|
||||
provider,
|
||||
model,
|
||||
messages,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
temperature=temperature,
|
||||
timeout=timeout,
|
||||
):
|
||||
yield event
|
||||
|
||||
|
||||
async def _chat_stream_with_openai_chat_completions(
|
||||
provider: AIProvider,
|
||||
model: AIModel,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: List[Dict[str, Any]] | None,
|
||||
tool_choice: Any | None,
|
||||
temperature: float | None,
|
||||
timeout: float,
|
||||
) -> AsyncIterator[Dict[str, Any]]:
|
||||
url = _openai_endpoint(provider, "/chat/completions")
|
||||
payload: Dict[str, Any] = {
|
||||
@@ -1648,100 +1273,3 @@ async def _chat_stream_with_openai_chat_completions(
|
||||
message["tool_calls"] = tool_calls
|
||||
|
||||
yield {"type": "message", "message": message, "finish_reason": finish_reason}
|
||||
|
||||
|
||||
async def _chat_stream_with_openai_responses(
|
||||
provider: AIProvider,
|
||||
model: AIModel,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: List[Dict[str, Any]] | None,
|
||||
tool_choice: Any | None,
|
||||
temperature: float | None,
|
||||
timeout: float,
|
||||
) -> AsyncIterator[Dict[str, Any]]:
|
||||
url = _openai_endpoint(provider, "/responses")
|
||||
instructions, input_items = _openai_messages_to_responses_input(messages)
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model.name,
|
||||
"input": input_items,
|
||||
"stream": True,
|
||||
}
|
||||
if instructions:
|
||||
payload["instructions"] = instructions
|
||||
response_tools = _openai_tools_to_responses(tools)
|
||||
if response_tools:
|
||||
payload["tools"] = response_tools
|
||||
payload["tool_choice"] = _openai_tool_choice_to_responses(tool_choice) or "auto"
|
||||
if temperature is not None:
|
||||
payload["temperature"] = float(temperature)
|
||||
|
||||
content_parts: List[str] = []
|
||||
output_items: Dict[int, Dict[str, Any]] = {}
|
||||
completed_body: Dict[str, Any] | None = None
|
||||
current_event: str | None = None
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("POST", url, headers=_openai_headers(provider), json=payload) as response:
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("event:"):
|
||||
current_event = line[6:].strip()
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if not data or data == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(chunk, dict):
|
||||
continue
|
||||
|
||||
event_type = chunk.get("type") or current_event
|
||||
if event_type == "response.output_text.delta":
|
||||
delta = chunk.get("delta")
|
||||
if isinstance(delta, str) and delta:
|
||||
content_parts.append(delta)
|
||||
yield {"type": "delta", "delta": delta}
|
||||
continue
|
||||
|
||||
if event_type in {"response.output_item.added", "response.output_item.done"}:
|
||||
item = chunk.get("item")
|
||||
idx = chunk.get("output_index")
|
||||
if isinstance(item, dict) and isinstance(idx, int):
|
||||
output_items[idx] = item
|
||||
continue
|
||||
|
||||
if event_type == "response.completed":
|
||||
response_body = chunk.get("response")
|
||||
if isinstance(response_body, dict):
|
||||
completed_body = response_body
|
||||
elif isinstance(chunk.get("output"), list):
|
||||
completed_body = chunk
|
||||
break
|
||||
|
||||
if event_type == "error":
|
||||
error = chunk.get("error") if isinstance(chunk.get("error"), dict) else chunk
|
||||
raise RuntimeError(str(error.get("message") if isinstance(error, dict) else error))
|
||||
|
||||
if completed_body is not None:
|
||||
message = _responses_body_to_openai_message(completed_body)
|
||||
if not message.get("content") and content_parts:
|
||||
message["content"] = "".join(content_parts)
|
||||
yield {"type": "message", "message": message, "finish_reason": None}
|
||||
return
|
||||
|
||||
if output_items:
|
||||
body = {"output": [output_items[idx] for idx in sorted(output_items.keys())]}
|
||||
message = _responses_body_to_openai_message(body)
|
||||
if not message.get("content") and content_parts:
|
||||
message["content"] = "".join(content_parts)
|
||||
yield {"type": "message", "message": message, "finish_reason": None}
|
||||
return
|
||||
|
||||
yield {"type": "message", "message": {"role": "assistant", "content": "".join(content_parts)}, "finish_reason": None}
|
||||
|
||||
@@ -3,19 +3,6 @@ from typing import Any, Dict, Iterable, List, Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
ABILITIES = ["chat", "vision", "embedding", "rerank", "voice", "tools"]
|
||||
OPENAI_PROTOCOL_CHAT_COMPLETIONS = "chat_completions"
|
||||
OPENAI_PROTOCOL_RESPONSES = "responses"
|
||||
OPENAI_PROTOCOLS = {OPENAI_PROTOCOL_CHAT_COMPLETIONS, OPENAI_PROTOCOL_RESPONSES}
|
||||
OPENAI_PROTOCOL_ALIASES = {
|
||||
"chat": OPENAI_PROTOCOL_CHAT_COMPLETIONS,
|
||||
"chat_completion": OPENAI_PROTOCOL_CHAT_COMPLETIONS,
|
||||
"chat_completions": OPENAI_PROTOCOL_CHAT_COMPLETIONS,
|
||||
"chat/completions": OPENAI_PROTOCOL_CHAT_COMPLETIONS,
|
||||
"/chat/completions": OPENAI_PROTOCOL_CHAT_COMPLETIONS,
|
||||
"response": OPENAI_PROTOCOL_RESPONSES,
|
||||
"responses": OPENAI_PROTOCOL_RESPONSES,
|
||||
"/responses": OPENAI_PROTOCOL_RESPONSES,
|
||||
}
|
||||
|
||||
|
||||
def normalize_capabilities(items: Optional[Iterable[str]]) -> List[str]:
|
||||
@@ -29,34 +16,6 @@ def normalize_capabilities(items: Optional[Iterable[str]]) -> List[str]:
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_openai_protocol(value: Any) -> str:
|
||||
if value is None:
|
||||
return OPENAI_PROTOCOL_CHAT_COMPLETIONS
|
||||
key = str(value).strip().lower().replace("-", "_").replace(".", "_")
|
||||
if not key:
|
||||
return OPENAI_PROTOCOL_CHAT_COMPLETIONS
|
||||
normalized = OPENAI_PROTOCOL_ALIASES.get(key)
|
||||
if normalized:
|
||||
return normalized
|
||||
normalized = OPENAI_PROTOCOL_ALIASES.get(key.replace("_", "/"))
|
||||
if normalized:
|
||||
return normalized
|
||||
if key in OPENAI_PROTOCOLS:
|
||||
return key
|
||||
raise ValueError("openai_protocol must be 'chat_completions' or 'responses'")
|
||||
|
||||
|
||||
def normalize_provider_extra_config(config: Optional[dict]) -> Optional[dict]:
|
||||
if config is None:
|
||||
return None
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError("extra_config must be an object")
|
||||
normalized = dict(config)
|
||||
if "openai_protocol" in normalized:
|
||||
normalized["openai_protocol"] = normalize_openai_protocol(normalized.get("openai_protocol"))
|
||||
return normalized
|
||||
|
||||
|
||||
class AIProviderBase(BaseModel):
|
||||
name: str
|
||||
identifier: str = Field(..., pattern=r"^[a-z0-9_\-\.]+$")
|
||||
@@ -75,11 +34,6 @@ class AIProviderBase(BaseModel):
|
||||
raise ValueError("api_format must be 'openai', 'gemini', 'anthropic', or 'ollama'")
|
||||
return fmt
|
||||
|
||||
@field_validator("extra_config")
|
||||
@classmethod
|
||||
def normalize_extra_config(cls, value: Optional[dict]) -> Optional[dict]:
|
||||
return normalize_provider_extra_config(value)
|
||||
|
||||
|
||||
class AIProviderCreate(AIProviderBase):
|
||||
pass
|
||||
@@ -104,11 +58,6 @@ class AIProviderUpdate(BaseModel):
|
||||
raise ValueError("api_format must be 'openai', 'gemini', 'anthropic', or 'ollama'")
|
||||
return fmt
|
||||
|
||||
@field_validator("extra_config")
|
||||
@classmethod
|
||||
def normalize_extra_config(cls, value: Optional[dict]) -> Optional[dict]:
|
||||
return normalize_provider_extra_config(value)
|
||||
|
||||
|
||||
class AIModelBase(BaseModel):
|
||||
name: str
|
||||
|
||||
@@ -19,7 +19,6 @@ PUBLIC_CONFIG_KEYS = [
|
||||
"THEME_BORDER_RADIUS",
|
||||
"THEME_CUSTOM_TOKENS",
|
||||
"THEME_CUSTOM_CSS",
|
||||
"DEFAULT_FILE_VIEW_MODE",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from models.database import Configuration, UserAccount
|
||||
|
||||
load_dotenv(dotenv_path=".env")
|
||||
|
||||
VERSION = "v2.2.2"
|
||||
VERSION = "v2.2.1"
|
||||
|
||||
|
||||
class ConfigService:
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .service import NoticeService, notice_sync_service
|
||||
|
||||
__all__ = ["NoticeService", "notice_sync_service"]
|
||||
@@ -1,36 +0,0 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from api.response import success
|
||||
from domain.auth import User, get_current_active_user
|
||||
|
||||
from .service import NoticeService
|
||||
|
||||
router = APIRouter(prefix="/api/notices", tags=["notices"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_notices(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
page: int = Query(1, ge=1),
|
||||
):
|
||||
data = await NoticeService.list_notices(page=page)
|
||||
return data.model_dump()
|
||||
|
||||
|
||||
@router.get("/popup")
|
||||
async def get_popup_notice(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
item = await NoticeService.get_popup_notice()
|
||||
return success(item.model_dump() if item else None)
|
||||
|
||||
|
||||
@router.post("/{notice_id}/dismiss")
|
||||
async def dismiss_popup_notice(
|
||||
notice_id: int,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
await NoticeService.dismiss_popup(notice_id)
|
||||
return success()
|
||||
@@ -1,177 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from domain.config import VERSION
|
||||
from models.database import Notice
|
||||
|
||||
from .types import NoticeItem, NoticeListResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REMOTE_NOTICES_URL = "https://foxel.cc/api/notices"
|
||||
SYNC_INTERVAL_SECONDS = 60 * 60 * 24
|
||||
PAGE_SIZE = 20
|
||||
|
||||
|
||||
def _normalize_version(version: str) -> str:
|
||||
return (version or "").strip().removeprefix("v").removeprefix("V")
|
||||
|
||||
|
||||
def _parse_remote_time(value: Any) -> datetime:
|
||||
if isinstance(value, (int, float)):
|
||||
timestamp = float(value)
|
||||
if timestamp > 10_000_000_000:
|
||||
timestamp = timestamp / 1000
|
||||
return datetime.fromtimestamp(timestamp, timezone.utc)
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return datetime.now(timezone.utc)
|
||||
try:
|
||||
if text.isdigit():
|
||||
return _parse_remote_time(int(text))
|
||||
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return datetime.now(timezone.utc)
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class NoticeService:
|
||||
@classmethod
|
||||
async def list_notices(cls, page: int = 1, page_size: int = PAGE_SIZE) -> NoticeListResponse:
|
||||
page = max(1, page)
|
||||
page_size = max(1, min(page_size, 100))
|
||||
query = Notice.all().order_by("-created_at", "-id")
|
||||
total = await query.count()
|
||||
notices = await query.offset((page - 1) * page_size).limit(page_size)
|
||||
return NoticeListResponse(
|
||||
items=[cls._to_item(item) for item in notices],
|
||||
page=page,
|
||||
pageSize=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_popup_notice(cls) -> NoticeItem | None:
|
||||
notice = await Notice.filter(is_popup=True, popup_dismissed=False).order_by("-created_at", "-id").first()
|
||||
if not notice:
|
||||
return None
|
||||
return cls._to_item(notice)
|
||||
|
||||
@classmethod
|
||||
async def dismiss_popup(cls, notice_id: int) -> None:
|
||||
await Notice.filter(id=notice_id).update(popup_dismissed=True, is_popup=False)
|
||||
|
||||
@classmethod
|
||||
async def sync_remote_notices(cls) -> None:
|
||||
items = await cls._fetch_remote_notices()
|
||||
if not items:
|
||||
return
|
||||
|
||||
popup_remote_ids: list[int] = []
|
||||
for raw in items:
|
||||
remote_id = raw.get("id")
|
||||
if remote_id is None:
|
||||
continue
|
||||
try:
|
||||
remote_id = int(remote_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
is_popup = bool(raw.get("isPopup"))
|
||||
if is_popup:
|
||||
popup_remote_ids.append(remote_id)
|
||||
|
||||
notice = await Notice.get_or_none(remote_id=remote_id)
|
||||
popup_dismissed = notice.popup_dismissed if notice else False
|
||||
await Notice.update_or_create(
|
||||
remote_id=remote_id,
|
||||
defaults={
|
||||
"title": str(raw.get("title") or "")[:255],
|
||||
"content_md": str(raw.get("contentMd") or ""),
|
||||
"is_popup": is_popup and not popup_dismissed,
|
||||
"created_at": _parse_remote_time(raw.get("createdAt")),
|
||||
},
|
||||
)
|
||||
|
||||
await cls._keep_only_latest_popup(popup_remote_ids)
|
||||
|
||||
@classmethod
|
||||
async def _keep_only_latest_popup(cls, popup_remote_ids: list[int]) -> None:
|
||||
latest = await Notice.filter(remote_id__in=popup_remote_ids, popup_dismissed=False).order_by(
|
||||
"-created_at", "-id"
|
||||
).first()
|
||||
if not latest:
|
||||
return
|
||||
await Notice.filter(is_popup=True).exclude(id=latest.id).update(is_popup=False)
|
||||
|
||||
@classmethod
|
||||
async def _fetch_remote_notices(cls) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||
while True:
|
||||
resp = await client.get(
|
||||
REMOTE_NOTICES_URL,
|
||||
params={"version": _normalize_version(VERSION), "page": page},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data.get("items") if isinstance(data, dict) else None
|
||||
if not isinstance(items, list):
|
||||
break
|
||||
results.extend(item for item in items if isinstance(item, dict))
|
||||
|
||||
total = data.get("total", len(results)) if isinstance(data, dict) else len(results)
|
||||
page_size = data.get("pageSize") or data.get("page_size") or len(items)
|
||||
if not items or len(results) >= int(total or 0) or page_size <= 0:
|
||||
break
|
||||
page += 1
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _to_item(notice: Notice) -> NoticeItem:
|
||||
return NoticeItem(
|
||||
id=notice.id,
|
||||
title=notice.title,
|
||||
contentMd=notice.content_md or "",
|
||||
isPopup=notice.is_popup and not notice.popup_dismissed,
|
||||
createdAt=int(notice.created_at.timestamp() * 1000),
|
||||
)
|
||||
|
||||
|
||||
class NoticeSyncService:
|
||||
def __init__(self):
|
||||
self._worker: asyncio.Task | None = None
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._worker and not self._worker.done():
|
||||
return
|
||||
self._stop_event.clear()
|
||||
self._worker = asyncio.create_task(self._run_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._worker:
|
||||
return
|
||||
self._stop_event.set()
|
||||
await self._worker
|
||||
self._worker = None
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
await NoticeService.sync_remote_notices()
|
||||
except Exception:
|
||||
logger.exception("Failed to sync notices")
|
||||
try:
|
||||
await asyncio.wait_for(self._stop_event.wait(), timeout=SYNC_INTERVAL_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
|
||||
notice_sync_service = NoticeSyncService()
|
||||
@@ -1,16 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class NoticeItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
contentMd: str
|
||||
isPopup: bool
|
||||
createdAt: int
|
||||
|
||||
|
||||
class NoticeListResponse(BaseModel):
|
||||
items: list[NoticeItem]
|
||||
page: int
|
||||
pageSize: int
|
||||
total: int
|
||||
@@ -1,9 +0,0 @@
|
||||
from .service import VideoRoomService
|
||||
from .types import VideoRoomCreate, VideoRoomInfo, VideoRoomState
|
||||
|
||||
__all__ = [
|
||||
"VideoRoomService",
|
||||
"VideoRoomCreate",
|
||||
"VideoRoomInfo",
|
||||
"VideoRoomState",
|
||||
]
|
||||
@@ -1,72 +0,0 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
|
||||
|
||||
from api.response import success
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.auth import User, get_current_active_user
|
||||
from domain.permission import require_path_permission
|
||||
from domain.permission.types import PathAction
|
||||
from models.database import UserAccount
|
||||
from .service import VideoRoomService
|
||||
from .types import VideoRoomCreate, VideoRoomInfo
|
||||
from .ws import video_room_ws_manager
|
||||
|
||||
router = APIRouter(prefix="/api/video-rooms", tags=["Video Rooms"])
|
||||
|
||||
|
||||
@router.post("", response_model=VideoRoomInfo)
|
||||
@audit(action=AuditAction.SHARE, description="创建视频房", body_fields=["name", "path"])
|
||||
@require_path_permission(PathAction.SHARE, "payload.path")
|
||||
async def create_video_room(
|
||||
request: Request,
|
||||
payload: VideoRoomCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
user_account = await UserAccount.get(id=current_user.id)
|
||||
room = await VideoRoomService.create_room(
|
||||
user=user_account,
|
||||
name=payload.name,
|
||||
path=payload.path,
|
||||
)
|
||||
return VideoRoomInfo.from_orm(room, VideoRoomService.get_effective_state(room))
|
||||
|
||||
|
||||
@router.get("/{token}", response_model=VideoRoomInfo)
|
||||
@audit(action=AuditAction.SHARE, description="获取视频房信息")
|
||||
async def get_video_room(request: Request, token: str):
|
||||
room = await VideoRoomService.get_room_by_token(token)
|
||||
return VideoRoomInfo.from_orm(room, VideoRoomService.get_effective_state(room))
|
||||
|
||||
|
||||
@router.get("/{token}/stream")
|
||||
@audit(action=AuditAction.DOWNLOAD, description="播放视频房文件")
|
||||
async def stream_video_room(token: str, request: Request):
|
||||
return await VideoRoomService.stream_room_file(token, request.headers.get("Range"))
|
||||
|
||||
|
||||
@router.websocket("/{token}/ws")
|
||||
async def video_room_ws(websocket: WebSocket, token: str):
|
||||
room = await VideoRoomService.get_room_by_token(token)
|
||||
await video_room_ws_manager.connect(token, websocket)
|
||||
try:
|
||||
state = VideoRoomService.get_effective_state(room)
|
||||
await websocket.send_json({"type": "state", "state": state.model_dump()})
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
if data.get("type") != "state":
|
||||
continue
|
||||
state = await VideoRoomService.update_state(
|
||||
room,
|
||||
current_time=float(data.get("current_time") or 0),
|
||||
paused=bool(data.get("paused")),
|
||||
)
|
||||
await video_room_ws_manager.broadcast(
|
||||
token,
|
||||
{"type": "state", "state": state.model_dump()},
|
||||
exclude=websocket,
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
video_room_ws_manager.disconnect(token, websocket)
|
||||
@@ -1,82 +0,0 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
from domain.virtual_fs import VirtualFSService
|
||||
from models.database import UserAccount, VideoRoom
|
||||
from .types import VideoRoomState
|
||||
|
||||
|
||||
VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".m4v", ".mov", ".mkv", ".avi", ".flv"}
|
||||
|
||||
|
||||
class VideoRoomService:
|
||||
@classmethod
|
||||
def _is_video_path(cls, path: str) -> bool:
|
||||
lower = path.lower()
|
||||
return any(lower.endswith(ext) for ext in VIDEO_EXTENSIONS)
|
||||
|
||||
@classmethod
|
||||
async def create_room(cls, user: UserAccount, name: str, path: str) -> VideoRoom:
|
||||
if not path or path == "/" or ".." in path.split("/"):
|
||||
raise HTTPException(status_code=400, detail="无效的视频路径")
|
||||
if not cls._is_video_path(path):
|
||||
raise HTTPException(status_code=400, detail="仅支持视频文件创建视频房")
|
||||
|
||||
stat = await VirtualFSService.stat_file(path)
|
||||
if stat.get("is_dir"):
|
||||
raise HTTPException(status_code=400, detail="目录不能创建视频房")
|
||||
|
||||
token = secrets.token_urlsafe(16)
|
||||
return await VideoRoom.create(
|
||||
token=token,
|
||||
name=name or path.rsplit("/", 1)[-1],
|
||||
path=path,
|
||||
user=user,
|
||||
state_updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_room_by_token(cls, token: str) -> VideoRoom:
|
||||
room = await VideoRoom.get_or_none(token=token).prefetch_related("user")
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail="视频房不存在")
|
||||
return room
|
||||
|
||||
@classmethod
|
||||
def get_effective_state(cls, room: VideoRoom) -> VideoRoomState:
|
||||
current_time = float(room.current_time or 0)
|
||||
updated_at = room.state_updated_at
|
||||
if not room.paused and updated_at:
|
||||
if updated_at.tzinfo is None:
|
||||
updated_at = updated_at.replace(tzinfo=timezone.utc)
|
||||
current_time += max(0, (datetime.now(timezone.utc) - updated_at).total_seconds())
|
||||
return VideoRoomState(
|
||||
current_time=max(0, current_time),
|
||||
paused=bool(room.paused),
|
||||
updated_at=updated_at.isoformat() if updated_at else None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def update_state(cls, room: VideoRoom, current_time: float, paused: bool) -> VideoRoomState:
|
||||
now = datetime.now(timezone.utc)
|
||||
room.current_time = max(0, float(current_time or 0))
|
||||
room.paused = bool(paused)
|
||||
room.state_updated_at = now
|
||||
await room.save(update_fields=["current_time", "paused", "state_updated_at"])
|
||||
return VideoRoomState(
|
||||
current_time=room.current_time,
|
||||
paused=room.paused,
|
||||
updated_at=now.isoformat(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def stream_room_file(cls, token: str, range_header: str | None) -> Response:
|
||||
room = await cls.get_room_by_token(token)
|
||||
response = await VirtualFSService.stream_file(room.path, range_header)
|
||||
filename = room.path.rsplit("/", 1)[-1]
|
||||
response.headers["Content-Disposition"] = f"inline; filename*=UTF-8''{quote(filename)}"
|
||||
return response
|
||||
@@ -1,39 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from models.database import VideoRoom
|
||||
|
||||
|
||||
class VideoRoomCreate(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
|
||||
|
||||
class VideoRoomState(BaseModel):
|
||||
current_time: float
|
||||
paused: bool
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class VideoRoomInfo(BaseModel):
|
||||
id: int
|
||||
token: str
|
||||
name: str
|
||||
path: str
|
||||
created_at: str
|
||||
state: VideoRoomState
|
||||
|
||||
@classmethod
|
||||
def from_orm(cls, obj: VideoRoom, state: VideoRoomState | None = None):
|
||||
return cls(
|
||||
id=obj.id,
|
||||
token=obj.token,
|
||||
name=obj.name,
|
||||
path=obj.path,
|
||||
created_at=obj.created_at.isoformat(),
|
||||
state=state
|
||||
or VideoRoomState(
|
||||
current_time=obj.current_time,
|
||||
paused=obj.paused,
|
||||
updated_at=obj.state_updated_at.isoformat() if obj.state_updated_at else None,
|
||||
),
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class VideoRoomWebSocketManager:
|
||||
def __init__(self):
|
||||
self.rooms: dict[str, set[WebSocket]] = {}
|
||||
|
||||
async def connect(self, token: str, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
self.rooms.setdefault(token, set()).add(websocket)
|
||||
|
||||
def disconnect(self, token: str, websocket: WebSocket):
|
||||
sockets = self.rooms.get(token)
|
||||
if not sockets:
|
||||
return
|
||||
sockets.discard(websocket)
|
||||
if not sockets:
|
||||
self.rooms.pop(token, None)
|
||||
|
||||
async def broadcast(self, token: str, message: dict, exclude: WebSocket | None = None):
|
||||
sockets = list(self.rooms.get(token, set()))
|
||||
for socket in sockets:
|
||||
if socket is exclude:
|
||||
continue
|
||||
try:
|
||||
await socket.send_json(message)
|
||||
except Exception:
|
||||
self.disconnect(token, socket)
|
||||
|
||||
|
||||
video_room_ws_manager = VideoRoomWebSocketManager()
|
||||
@@ -172,19 +172,6 @@ async def upload_stream(
|
||||
return success(result)
|
||||
|
||||
|
||||
@router.put("/upload-raw/{full_path:path}")
|
||||
@audit(action=AuditAction.UPLOAD, description="原始流上传文件")
|
||||
@require_path_permission(PathAction.WRITE, "full_path")
|
||||
async def upload_raw_stream(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
full_path: str,
|
||||
overwrite: bool = Query(True, description="是否覆盖已存在文件"),
|
||||
):
|
||||
result = await VirtualFSService.upload_raw_stream(full_path, request, overwrite)
|
||||
return success(result)
|
||||
|
||||
|
||||
@router.get("/{full_path:path}")
|
||||
@audit(action=AuditAction.READ, description="浏览目录")
|
||||
@require_path_permission(PathAction.READ, "full_path")
|
||||
@@ -196,10 +183,9 @@ async def browse_fs(
|
||||
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
|
||||
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
|
||||
sort_order: str = Query("asc", description="排序顺序: asc, desc"),
|
||||
cursor: str | None = Query(None, description="游标分页位置"),
|
||||
):
|
||||
data = await VirtualFSService.list_directory_with_permission(
|
||||
full_path, current_user.id, page_num, page_size, sort_by, sort_order, cursor
|
||||
full_path, current_user.id, page_num, page_size, sort_by, sort_order
|
||||
)
|
||||
return success(data)
|
||||
|
||||
@@ -225,10 +211,9 @@ async def root_listing(
|
||||
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
|
||||
sort_by: str = Query("name", description="按字段排序: name, size, mtime"),
|
||||
sort_order: str = Query("asc", description="排序顺序: asc, desc"),
|
||||
cursor: str | None = Query(None, description="游标分页位置"),
|
||||
):
|
||||
# 根目录不需要权限检查,但需要过滤无权限的子目录
|
||||
data = await VirtualFSService.list_directory_with_permission(
|
||||
"/", current_user.id, page_num, page_size, sort_by, sort_order, cursor
|
||||
"/", current_user.id, page_num, page_size, sort_by, sort_order
|
||||
)
|
||||
return success(data)
|
||||
|
||||
@@ -57,7 +57,6 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
|
||||
page_size: int = 50,
|
||||
sort_by: str = "name",
|
||||
sort_order: str = "asc",
|
||||
cursor: str | None = None,
|
||||
) -> Dict:
|
||||
norm = cls._normalize_path(path).rstrip("/") or "/"
|
||||
adapters = await StorageAdapter.filter(enabled=True)
|
||||
@@ -120,28 +119,12 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
|
||||
adapter_entries_for_merge: List[Dict] = []
|
||||
adapter_entries_page: List[Dict] | None = None
|
||||
adapter_total: int | None = None
|
||||
adapter_listing: Dict[str, Any] | None = None
|
||||
if adapter_model and adapter_instance:
|
||||
list_dir = getattr(adapter_instance, "list_dir", None)
|
||||
if callable(list_dir):
|
||||
try:
|
||||
parameters = inspect.signature(list_dir).parameters
|
||||
except (TypeError, ValueError):
|
||||
parameters = {}
|
||||
if "cursor" in parameters:
|
||||
raw_listing = await list_dir(
|
||||
effective_root, rel, page_num, page_size, sort_by, sort_order, cursor=cursor
|
||||
)
|
||||
else:
|
||||
raw_listing = await list_dir(
|
||||
effective_root, rel, page_num, page_size, sort_by, sort_order
|
||||
)
|
||||
if isinstance(raw_listing, dict):
|
||||
adapter_listing = raw_listing
|
||||
adapter_entries_page = raw_listing.get("items", [])
|
||||
adapter_total = raw_listing.get("total")
|
||||
else:
|
||||
adapter_entries_page, adapter_total = raw_listing
|
||||
adapter_entries_page, adapter_total = await list_dir(
|
||||
effective_root, rel, page_num, page_size, sort_by, sort_order
|
||||
)
|
||||
if rel:
|
||||
parent_rel = cls._parent_rel(rel)
|
||||
if rel:
|
||||
@@ -206,9 +189,6 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
|
||||
annotate_entry_list = adapter_entries_page or []
|
||||
for ent in annotate_entry_list:
|
||||
annotate_entry(ent)
|
||||
if adapter_listing and adapter_listing.get("pagination_mode") == "cursor":
|
||||
adapter_listing["items"] = annotate_entry_list
|
||||
return adapter_listing
|
||||
return page(adapter_entries_page, adapter_total, page_num, page_size)
|
||||
|
||||
@classmethod
|
||||
@@ -316,14 +296,13 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
|
||||
page_size: int = 50,
|
||||
sort_by: str = "name",
|
||||
sort_order: str = "asc",
|
||||
cursor: str | None = None,
|
||||
) -> Dict:
|
||||
"""
|
||||
带权限过滤的目录列表
|
||||
|
||||
过滤掉用户没有读取权限的条目
|
||||
"""
|
||||
result = await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order, cursor)
|
||||
result = await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order)
|
||||
items = result.get("items", [])
|
||||
if not items:
|
||||
return result
|
||||
|
||||
@@ -2,7 +2,7 @@ import mimetypes
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import HTTPException, Request, UploadFile
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
|
||||
from domain.config import ConfigService
|
||||
@@ -271,52 +271,19 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin):
|
||||
size = int(result or 0)
|
||||
return {"uploaded": True, "path": path, "size": size, "overwrite": overwrite}
|
||||
|
||||
@classmethod
|
||||
async def upload_raw_stream(cls, full_path: str, request: Request, overwrite: bool):
|
||||
full_path = cls._normalize_path(full_path)
|
||||
if full_path.endswith("/"):
|
||||
raise HTTPException(400, detail="Path must be a file")
|
||||
|
||||
result = await cls.write_file_stream(full_path, request.stream(), overwrite=overwrite)
|
||||
path = full_path
|
||||
size = 0
|
||||
if isinstance(result, dict):
|
||||
path = result.get("path") or path
|
||||
size_val = result.get("size")
|
||||
if isinstance(size_val, int):
|
||||
size = size_val
|
||||
else:
|
||||
size = int(result or 0)
|
||||
return {"uploaded": True, "path": path, "size": size, "overwrite": overwrite}
|
||||
|
||||
@classmethod
|
||||
async def list_directory(cls, full_path: str, page_num: int, page_size: int, sort_by: str, sort_order: str):
|
||||
full_path = cls._normalize_path(full_path)
|
||||
result = await cls.list_virtual_dir(full_path, page_num, page_size, sort_by, sort_order)
|
||||
pagination = {
|
||||
"mode": result.get("pagination_mode", "paged"),
|
||||
"page_size": result.get("page_size", page_size),
|
||||
}
|
||||
if pagination["mode"] == "cursor":
|
||||
pagination.update(
|
||||
{
|
||||
"cursor": result.get("cursor"),
|
||||
"next_cursor": result.get("next_cursor"),
|
||||
"has_next": bool(result.get("has_next")),
|
||||
}
|
||||
)
|
||||
else:
|
||||
pagination.update(
|
||||
{
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"pages": result["pages"],
|
||||
}
|
||||
)
|
||||
return {
|
||||
"path": full_path,
|
||||
"entries": result["items"],
|
||||
"pagination": pagination,
|
||||
"pagination": {
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
"pages": result["pages"],
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -26,10 +26,9 @@ class VirtualFSService(
|
||||
page_size: int = 50,
|
||||
sort_by: str = "name",
|
||||
sort_order: str = "asc",
|
||||
cursor: str | None = None,
|
||||
):
|
||||
"""列出目录内容"""
|
||||
return await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order, cursor)
|
||||
return await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order)
|
||||
|
||||
@classmethod
|
||||
async def list_directory_with_permission(
|
||||
@@ -40,35 +39,19 @@ class VirtualFSService(
|
||||
page_size: int = 50,
|
||||
sort_by: str = "name",
|
||||
sort_order: str = "asc",
|
||||
cursor: str | None = None,
|
||||
):
|
||||
"""列出目录内容(带权限过滤)"""
|
||||
full_path = cls._normalize_path(path).rstrip("/") or "/"
|
||||
result = await cls.list_virtual_dir_with_permission(
|
||||
full_path, user_id, page_num, page_size, sort_by, sort_order, cursor
|
||||
full_path, user_id, page_num, page_size, sort_by, sort_order
|
||||
)
|
||||
pagination = {
|
||||
"mode": result.get("pagination_mode", "paged") if isinstance(result, dict) else "paged",
|
||||
"page_size": result.get("page_size", page_size) if isinstance(result, dict) else page_size,
|
||||
}
|
||||
if pagination["mode"] == "cursor":
|
||||
pagination.update(
|
||||
{
|
||||
"cursor": result.get("cursor") if isinstance(result, dict) else cursor,
|
||||
"next_cursor": result.get("next_cursor") if isinstance(result, dict) else None,
|
||||
"has_next": bool(result.get("has_next")) if isinstance(result, dict) else False,
|
||||
}
|
||||
)
|
||||
else:
|
||||
pagination.update(
|
||||
{
|
||||
"total": result.get("total", 0) if isinstance(result, dict) else 0,
|
||||
"page": result.get("page", page_num) if isinstance(result, dict) else page_num,
|
||||
"pages": result.get("pages", 0) if isinstance(result, dict) else 0,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"path": full_path,
|
||||
"entries": result.get("items", []) if isinstance(result, dict) else [],
|
||||
"pagination": pagination,
|
||||
"pagination": {
|
||||
"total": result.get("total", 0) if isinstance(result, dict) else 0,
|
||||
"page": result.get("page", page_num) if isinstance(result, dict) else page_num,
|
||||
"page_size": result.get("page_size", page_size) if isinstance(result, dict) else page_size,
|
||||
"pages": result.get("pages", 0) if isinstance(result, dict) else 0,
|
||||
},
|
||||
}
|
||||
|
||||
3
main.py
3
main.py
@@ -23,7 +23,6 @@ import httpx
|
||||
from dotenv import load_dotenv
|
||||
from domain.tasks import task_queue_service, task_scheduler
|
||||
from domain.role.service import RoleService
|
||||
from domain.notices import notice_sync_service
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -78,7 +77,6 @@ async def lifespan(app: FastAPI):
|
||||
from domain.plugins import init_plugins
|
||||
await init_plugins(app)
|
||||
await task_scheduler.start()
|
||||
await notice_sync_service.start()
|
||||
|
||||
# 在所有路由加载完成后,挂载静态文件服务(放在最后以避免覆盖 API 路由)
|
||||
app.mount("/", SPAStaticFiles(directory="web/dist", html=True, check_dir=False), name="static")
|
||||
@@ -87,7 +85,6 @@ async def lifespan(app: FastAPI):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await notice_sync_service.stop()
|
||||
await task_scheduler.stop()
|
||||
await task_queue_service.stop_worker()
|
||||
await close_db()
|
||||
|
||||
@@ -234,23 +234,6 @@ class ShareLink(Model):
|
||||
table = "share_links"
|
||||
|
||||
|
||||
class VideoRoom(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
token = fields.CharField(max_length=100, unique=True, index=True)
|
||||
name = fields.CharField(max_length=255)
|
||||
path = fields.CharField(max_length=4096)
|
||||
user: fields.ForeignKeyRelation[UserAccount] = fields.ForeignKeyField(
|
||||
"models.UserAccount", related_name="video_rooms", on_delete=fields.CASCADE
|
||||
)
|
||||
current_time = fields.FloatField(default=0)
|
||||
paused = fields.BooleanField(default=True)
|
||||
state_updated_at = fields.DatetimeField(null=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
table = "video_rooms"
|
||||
|
||||
|
||||
class RecentFile(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
user: fields.ForeignKeyRelation[UserAccount] = fields.ForeignKeyField(
|
||||
@@ -264,20 +247,6 @@ class RecentFile(Model):
|
||||
unique_together = (("user", "path"),)
|
||||
|
||||
|
||||
class Notice(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
remote_id = fields.IntField(unique=True, index=True)
|
||||
title = fields.CharField(max_length=255)
|
||||
content_md = fields.TextField(null=True)
|
||||
is_popup = fields.BooleanField(default=False)
|
||||
popup_dismissed = fields.BooleanField(default=False)
|
||||
created_at = fields.DatetimeField()
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "notices"
|
||||
|
||||
|
||||
class Plugin(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
key = fields.CharField(max_length=100, unique=True) # 插件唯一标识
|
||||
|
||||
@@ -10,18 +10,17 @@ dependencies = [
|
||||
"croniter>=6.0.0",
|
||||
"fastapi>=0.127.0",
|
||||
"mcp>=1.26.0",
|
||||
"paramiko>=5.0.0",
|
||||
"paramiko>=4.0.0",
|
||||
"pillow>=12.2.0",
|
||||
"pydantic[email]>=2.12.5",
|
||||
"pyjwt>=2.10.1",
|
||||
"pymilvus[milvus-lite]>=2.6.5",
|
||||
"pysocks>=1.7.1",
|
||||
"python-dotenv>=1.2.2",
|
||||
"python-multipart>=0.0.27",
|
||||
"python-multipart>=0.0.26",
|
||||
"qdrant-client>=1.16.2",
|
||||
"setuptools<82",
|
||||
"telethon>=1.42.0",
|
||||
"tortoise-orm>=1.0.0",
|
||||
"uvicorn>=0.40.0",
|
||||
"websockets",
|
||||
]
|
||||
|
||||
136
uv.lock
generated
136
uv.lock
generated
@@ -63,7 +63,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.14.0"
|
||||
version = "3.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
@@ -74,49 +74,42 @@ dependencies = [
|
||||
{ name = "propcache" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/03/5f36ab196a88ba5e9648ae5643e6531e67a3a8c0e96f9c6510ff41540fec/aiohttp-3.14.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:363ef9e91014e7891679bfb2ac0a7c6ea93435dbbfd10ecf41b9f06fcf506c5f", size = 503330, upload-time = "2026-06-01T19:39:18.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ce/8b49ec2f30f68e02f314f4832186cd45e583360a5a386058be36855d23b6/aiohttp-3.14.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:884a4edbdad77be9d0ef36142c8b504351b170df0bf62b51e784fadabf311c42", size = 509822, upload-time = "2026-06-01T19:39:20.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/fe/6edbf5d39bf29322b6816365b17ed8ede4dace164a3aea1abcd30110eb78/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:70ea956f6cc4a37620966b56c2e205d88ca3e6d85ec063277e414b1035cddad3", size = 483329, upload-time = "2026-06-01T19:39:22.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/5a/fae531bdbc6456fb6241f46b7b81e4d8a0dd3fc09118a0055dc7141ac1ec/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:ea3b9806c89f61da22fddf1f12dd524fb368e5e28f1261fbdafe5c3cd8ce893b", size = 489502, upload-time = "2026-06-01T19:39:24.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f4/48a7b0414db7fed77a03d5dde34508c026afd83510ab6bca08c313855776/aiohttp-3.14.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a071be341c2bd9b0188e62d173509f024e0a35b1c342c53c50f8daaeda8c3bd8", size = 497357, upload-time = "2026-06-01T19:39:27.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/75/e85a13a370acc007fca5feb1fd1b88ac2d8426e6dadd625479b7cadd55a3/aiohttp-3.14.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:198cfe61bf253b19da1fb3e0fa122249dc4f14c12709493fed8054aa0411cc76", size = 750898, upload-time = "2026-06-01T19:39:29.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/e4/3d637f800c724eff0e2bed64df72557444482366fd0a35b0cec0e6968f6c/aiohttp-3.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc203d6ce6b9106d54e2a93f41dfdfebfbca2d99962ba503bfd3e5921a6549e", size = 506986, upload-time = "2026-06-01T19:39:31.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/df/35161f3598bf7501d2b2a805b41ab4f45a2e34150c421bcb4ef8c0d281a7/aiohttp-3.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e19d17ab02bf16832a2c8c0d55a486792c5b1645665652ee9531aebcc30cb72", size = 508033, upload-time = "2026-06-01T19:39:34.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/39/b36e5d3d31e850fb4691dd3e941684ac490a2559249f6fa634b6b0fdf020/aiohttp-3.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d925fba0c14d5b498a8028b0107beebdfd16c5d48d702ff54f879cb017aaaca3", size = 1746213, upload-time = "2026-06-01T19:39:36.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/28/24e1409e605a9aa5d84abe0e2acb365354b70ae56d40948101cabe3341ab/aiohttp-3.14.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d33e61021222ce7f9792bcac870d6f58d8adfceda33ab857b01264f4560f2c5f", size = 1705862, upload-time = "2026-06-01T19:39:38.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d0/e5eb3ff1daeaf644c7e36a957517672494122628e067c38b263fa04eda77/aiohttp-3.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:44eca38755d0105bb32f47d085f5dd449846a449e1245fc105889e3279dcf8e3", size = 1798909, upload-time = "2026-06-01T19:39:41.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ba/8943f906f0570342886ababb9a722a44e360f786a028c5e0b0e29e3f735b/aiohttp-3.14.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f13087e06f68fea4941c21a0c541c00553aa16e4f8fd7bbe2b198df761e964d6", size = 1868892, upload-time = "2026-06-01T19:39:43.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/05/27df32c844b2156e1675a8d8ec22d963e3c8ba469ed7ceb1863320c7b521/aiohttp-3.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff82be7f1ef73634cb77890a770743239bc3d487b848669be1c599889336dc0a", size = 1751659, upload-time = "2026-06-01T19:39:46.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/62/da182e5910ab912b2e88aa919b61a16046a37a95714a5795b02eb57b2d18/aiohttp-3.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a150c0875ac8fd87f1c398650841308a30d65facf7416b12dbdb9cfdcbe5a48c", size = 1578775, upload-time = "2026-06-01T19:39:48.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/e3/53c67097e8a5ce98625e91e3fa7f43c9c6940de680345d03b3509a72a078/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edc01ea4e1ec5a1649a28866262bf24195889ff7b27bdd947029a6086741de9b", size = 1710090, upload-time = "2026-06-01T19:39:51.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/55/0e2732ca598c7a4dfe8a775662376d0ca2977cb1030e48386d4da5d9a456/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:540632bf882ff8fc88f2e1697be0761578e89e0d79fb4a8a6d65dc5da7e729d4", size = 1715016, upload-time = "2026-06-01T19:39:53.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/96/f0b73730798c9ca525afc30b39f1f81bbe24e245d9654c54d3b39d63212d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:860a86bc2c80237f5dff52edcf427e10a8d8352271fd84845429a3e60199e02c", size = 1763810, upload-time = "2026-06-01T19:39:56.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/cc/11acb6c4518f448323405a7312b6f255d0f974a34373ad1db7633c4aadc8/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5cbd50e6a50d6b99283a826b18cbdebf65b0797689a7535cb0e9dd37be0f63c3", size = 1573064, upload-time = "2026-06-01T19:39:58.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/2d/28c31dde0a7dc98c0ee7d0da2ddcec3f7688c4fc131e5989e278d0c03c0a/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20144819e99db593e22bbd2f3f2691a5e149f879142d6b8670254708853ff4fb", size = 1775765, upload-time = "2026-06-01T19:40:01.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/69/155c4ef3aec96417d47024800472b33b16c5d8a665371dcd044c2afdf25d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:26b6d79aa54cb4ed50cc7d41ed14e99e0f1fc8e7c2d42f2e05b37aea897b2b52", size = 1733716, upload-time = "2026-06-01T19:40:03.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/44/6126116fd8a316b712bb615660b855c78466bb67ba1bb1742427eafcf7ac/aiohttp-3.14.0-cp314-cp314-win32.whl", hash = "sha256:106ed074a856f3e21d186b8579e2c8afb6da598e267cdaab01059e13db2fc44d", size = 453684, upload-time = "2026-06-01T19:40:06.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/d7/eff4c58a88c5cac5e38b55f44fb8a6d3929c3cbd77356e383e094d3220bd/aiohttp-3.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f770846edae8f00ecc57af825bce811f787f87a7dcf0e90d191790efe5b31f7", size = 481758, upload-time = "2026-06-01T19:40:08.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ed/17b5bd9fbcb46e688f02e572f517754a9a75831e7b54702f027761dc4fa5/aiohttp-3.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:acf1581c4f21ed4b80a2dded504d87b055a071a84d5737ea966435f768275ac6", size = 450557, upload-time = "2026-06-01T19:40:11.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/34/6180103ce9aabc8ebff3f7bb55a1228ffe60f61042823031d9692cb7b101/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6aa1a40f9cbb3da9f80714c5966b8946c21e6a2530d809b9498b33161e3c8733", size = 787878, upload-time = "2026-06-01T19:40:13.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/e9/08954a40e8b7baa3d8beadd2b074b186e9b1e9c8ddabc288678a6265de50/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b62af5a8cc96a194eaa01a9ed7b34a3ffa58d3d8daaa1a0d7a749353ad12d228", size = 524400, upload-time = "2026-06-01T19:40:15.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/6a/b5965a634ac4d5ba99a463314cf4ab214ca073fcdc38a15e0294273701fc/aiohttp-3.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6eb63b1417efaf7d1002a6ad034a40d44376afcc16508a57f8e74b49ad26a095", size = 527904, upload-time = "2026-06-01T19:40:18.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/b4/932bcdd850c354d9bcca30f360e475d7852e30413fbbd44b182782ed5432/aiohttp-3.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c20b9ad156a79eb97be5cf9e069eec01d2f0dc8472ffbd75299a8b2d4c2cbbde", size = 1912162, upload-time = "2026-06-01T19:40:20.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/85/ce79bab0310d2e3fd2d7bc7e44412abeff7c8338f8a21dd0f2f1714989e5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:40ae7b0642c25632c7eabc4a04754012691864d2a1b93becf7cddb76027b838a", size = 1778813, upload-time = "2026-06-01T19:40:23.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/54/ba62ac2d1bc87e010aad23751e383b8794e45d931df67677313a2da78823/aiohttp-3.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95f5217e76a046b9f228a101717ef8d42b1eb3d9d196d15202db5bf41df88936", size = 1899969, upload-time = "2026-06-01T19:40:26.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/82/7cc7907725d83a19f31551334061e1ab8e108b1d7ac52632a2a844a4acb5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a4a9f17e85b80878c176695c1998c790e83731d8271881e5d356488652a1f9e", size = 1991771, upload-time = "2026-06-01T19:40:29.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/1c/a57de71a4508c93a830b77c28af3d08cd97f606dedfc6b94275347744508/aiohttp-3.14.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:145262119b07d7f95abc1839add35ba2bfc84551d4b4660ca11542c0b215455b", size = 1868606, upload-time = "2026-06-01T19:40:31.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/ae/3839726cd49150a53ed340cc24ce5ba09d4c2117020ef9d45542bec5eb2f/aiohttp-3.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:49a33ded29b0b2fa7a367a02cf0fb89af602bb87542a16177ec8ce1c9c51d12a", size = 1665437, upload-time = "2026-06-01T19:40:35.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/1e/c237923232c7da7f0392ea25d89fc5e60c0e93f685f4ebca8e7bcdd5271c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cc736a9c9fc2bc4dd71fd404815741b6573df27c3f985948ec4076989ac57de", size = 1834090, upload-time = "2026-06-01T19:40:37.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/02/a5a7a2524f92d3911761b405a7c067c751891942144adc13e2ad79611e39/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4141a3e5342ee3053a9cab54d25b64ed28289c1041e4c54b3d99839314d90ce", size = 1816907, upload-time = "2026-06-01T19:40:40.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/76/a8b9f0d09234d516af9f2d7dd715557f33b5da3b0b56ead41d1170e86e3c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e30871b2d58996cb81aac52d2b1d15ac05257131ef0f90f18c2115a380fbfe7c", size = 1840382, upload-time = "2026-06-01T19:40:43.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/8e/140e715a0a4bbc211979ea30ec8396ad2ed5bf90ab87d8058fc4668b1923/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:667b881d083ccae3900ea5a241e17e5007ca78844c53ed389bb63d48f729d9c7", size = 1659497, upload-time = "2026-06-01T19:40:46.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/c7/7ba5de8af9650b9767b063c675427b8685f43fa7ce563673a7bc3af60f08/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b584dfe615d151e9b8f0a8ecb3aee6147f2927ec5b95ba25fe621f5377510928", size = 1870829, upload-time = "2026-06-01T19:40:49.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/bc/2aaab2f85cadb26ea59c091fa2b8e370d625154b5c14b478f1b489d07551/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6199707cc40e0e9cd39c36fbc97bec416c704e1d0ddce03412bb3b3e6a90ccd0", size = 1832281, upload-time = "2026-06-01T19:40:52.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/98/31b9ad9fbc01f0075ee7221002df5fd2d10b647f451ca5f30edc802d9dd6/aiohttp-3.14.0-cp314-cp314t-win32.whl", hash = "sha256:a8d93334d4961c9d566b1f046c81dee475b7c21eb730728d38237bfa70d1c8e6", size = 490597, upload-time = "2026-06-01T19:40:54.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/1f/299b21441c8de42ff70fddc7cfe65e92f810abcf740739a09b56f7835364/aiohttp-3.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d2ffe9b614f50f069068b3b52e73414e4107fc10b7efc939a76acff9251fdd2", size = 525789, upload-time = "2026-06-01T19:40:57.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/11/7f83fcba9ee05d4c54d61b3f8104da0d43a59adac44dd28effc0c9a10422/aiohttp-3.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7a3fc4358e65826c515350f199c210de747cf669998211b1ee6c2e46de364b24", size = 467399, upload-time = "2026-06-01T19:40:59.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -466,7 +459,6 @@ dependencies = [
|
||||
{ name = "telethon" },
|
||||
{ name = "tortoise-orm" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -476,20 +468,19 @@ requires-dist = [
|
||||
{ name = "croniter", specifier = ">=6.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.127.0" },
|
||||
{ name = "mcp", specifier = ">=1.26.0" },
|
||||
{ name = "paramiko", specifier = ">=5.0.0" },
|
||||
{ name = "paramiko", specifier = ">=4.0.0" },
|
||||
{ name = "pillow", specifier = ">=12.2.0" },
|
||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.12.5" },
|
||||
{ name = "pyjwt", specifier = ">=2.10.1" },
|
||||
{ name = "pymilvus", extras = ["milvus-lite"], specifier = ">=2.6.5" },
|
||||
{ name = "pysocks", specifier = ">=1.7.1" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.27" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.26" },
|
||||
{ name = "qdrant-client", specifier = ">=1.16.2" },
|
||||
{ name = "setuptools", specifier = "<82" },
|
||||
{ name = "telethon", specifier = ">=1.42.0" },
|
||||
{ name = "tortoise-orm", specifier = ">=1.0.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.40.0" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -638,11 +629,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.15"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -866,7 +857,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "paramiko"
|
||||
version = "5.0.0"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bcrypt" },
|
||||
@@ -874,9 +865,9 @@ dependencies = [
|
||||
{ name = "invoke" },
|
||||
{ name = "pynacl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1188,11 +1179,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.27"
|
||||
version = "0.0.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1418,11 +1409,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1438,33 +1429,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "1.17.3"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import request from './client';
|
||||
|
||||
export type AIAbility = 'chat' | 'vision' | 'embedding' | 'rerank' | 'voice' | 'tools';
|
||||
export type OpenAIProtocol = 'chat_completions' | 'responses';
|
||||
|
||||
export interface AIProviderPayload {
|
||||
name: string;
|
||||
|
||||
@@ -73,6 +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, type AdapterUsage } from './adapters';
|
||||
export { shareApi, type ShareInfo, type ShareInfoWithPassword } from './share';
|
||||
export { videoRoomsApi, type VideoRoomInfo, type VideoRoomState } from './videoRooms';
|
||||
export { offlineDownloadsApi, type OfflineDownloadTask, type OfflineDownloadCreate, type TaskProgress } from './offlineDownloads';
|
||||
export default request;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import request from './client';
|
||||
|
||||
export interface NoticeItem {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -16,17 +14,42 @@ export interface GetNoticesResponse {
|
||||
}
|
||||
|
||||
export interface GetNoticesParams {
|
||||
version: string;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
const FOXEL_CORE_BASE = 'https://foxel.cc';
|
||||
|
||||
function normalizeVersion(version: string) {
|
||||
return (version || '').trim().replace(/^v/i, '');
|
||||
}
|
||||
|
||||
function extractErrorMessage(data: any) {
|
||||
if (!data) return '';
|
||||
if (typeof data === 'string') return data;
|
||||
if (typeof data.detail === 'string') return data.detail;
|
||||
if (typeof data.code === 'string') return data.code;
|
||||
if (typeof data.message === 'string') return data.message;
|
||||
if (typeof data.msg === 'string') return data.msg;
|
||||
return '';
|
||||
}
|
||||
|
||||
export const noticesApi = {
|
||||
list: async (params: GetNoticesParams): Promise<GetNoticesResponse> => {
|
||||
return await request<GetNoticesResponse>(`/notices?page=${params.page ?? 1}`);
|
||||
},
|
||||
getPopup: async (): Promise<NoticeItem | null> => {
|
||||
return await request<NoticeItem | null>('/notices/popup');
|
||||
},
|
||||
dismiss: async (id: number): Promise<void> => {
|
||||
await request(`/notices/${id}/dismiss`, { method: 'POST' });
|
||||
const url = new URL('/api/notices', FOXEL_CORE_BASE);
|
||||
url.searchParams.set('version', normalizeVersion(params.version));
|
||||
url.searchParams.set('page', String(params.page ?? 1));
|
||||
|
||||
const resp = await fetch(url.href);
|
||||
if (!resp.ok) {
|
||||
let msg = resp.statusText || `Request failed: ${resp.status}`;
|
||||
try {
|
||||
const data = await resp.json();
|
||||
msg = extractErrorMessage(data) || msg;
|
||||
} catch { void 0; }
|
||||
throw new Error(msg);
|
||||
}
|
||||
return await resp.json();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -13,14 +13,10 @@ export interface DirListing {
|
||||
path: string;
|
||||
entries: VfsEntry[];
|
||||
pagination?: {
|
||||
mode?: 'paged' | 'cursor';
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total?: number;
|
||||
page?: number;
|
||||
pages?: number;
|
||||
cursor?: string | null;
|
||||
next_cursor?: string | null;
|
||||
has_next?: boolean;
|
||||
pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,7 +47,7 @@ export interface SearchResponse {
|
||||
}
|
||||
|
||||
export const vfsApi = {
|
||||
list: (path: string, page: number = 1, pageSize: number = 50, sortBy: string = 'name', sortOrder: string = 'asc', cursor?: string | null) => {
|
||||
list: (path: string, page: number = 1, pageSize: number = 50, sortBy: string = 'name', sortOrder: string = 'asc') => {
|
||||
const cleaned = path.replace(/\\/g, '/');
|
||||
const trimmed = cleaned === '/' ? '' : cleaned.replace(/^\/+/, '');
|
||||
const params = new URLSearchParams({
|
||||
@@ -60,7 +56,6 @@ export const vfsApi = {
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder
|
||||
});
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
return request<DirListing>(`/fs/${encodeURI(trimmed)}?${params}`);
|
||||
},
|
||||
readFile: async (path: string) => {
|
||||
@@ -100,40 +95,6 @@ export const vfsApi = {
|
||||
getTempLinkToken: (path: string, expiresIn: number = 3600) =>
|
||||
request<{token: string, path: string, url: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`),
|
||||
getTempPublicUrl: (token: string) => `${API_BASE_URL}/fs/public/${token}`,
|
||||
uploadRaw: (fullPath: string, file: File, overwrite: boolean = true, onProgress?: (loaded: number, total: number) => void) => {
|
||||
const enc = encodeURI(fullPath.replace(/^\/+/, ''));
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', `${API_BASE_URL}/fs/upload-raw/${enc}?overwrite=${overwrite}`);
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
if (ev.lengthComputable && onProgress) onProgress(ev.loaded, ev.total);
|
||||
};
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState === 4) {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
if (json.code === 0) return resolve(json.data);
|
||||
return reject(new Error(json.msg || json.message || 'Upload failed'));
|
||||
} catch {
|
||||
return reject(new Error('Invalid response'));
|
||||
}
|
||||
} else {
|
||||
let err = 'Upload failed';
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
err = json.detail || json.msg || json.message || err;
|
||||
} catch { void 0; }
|
||||
reject(new Error(err));
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.send(file);
|
||||
});
|
||||
},
|
||||
uploadStream: (fullPath: string, file: File, overwrite: boolean = true, onProgress?: (loaded: number, total: number) => void) => {
|
||||
const enc = encodeURI(fullPath.replace(/^\/+/, ''));
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import request, { API_BASE_URL } from './client';
|
||||
|
||||
export interface VideoRoomState {
|
||||
current_time: number;
|
||||
paused: boolean;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface VideoRoomInfo {
|
||||
id: number;
|
||||
token: string;
|
||||
name: string;
|
||||
path: string;
|
||||
created_at: string;
|
||||
state: VideoRoomState;
|
||||
}
|
||||
|
||||
export interface VideoRoomCreatePayload {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export const videoRoomsApi = {
|
||||
create: (payload: VideoRoomCreatePayload) => request<VideoRoomInfo>('/video-rooms', { method: 'POST', json: payload }),
|
||||
get: (token: string) => request<VideoRoomInfo>(`/video-rooms/${token}`),
|
||||
streamUrl: (token: string) => `${API_BASE_URL}/video-rooms/${token}/stream`,
|
||||
wsUrl: (token: string) => {
|
||||
const base = API_BASE_URL.startsWith('http')
|
||||
? API_BASE_URL
|
||||
: `${window.location.origin}${API_BASE_URL}`;
|
||||
const url = new URL(`${base}/video-rooms/${token}/ws`);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return url.href;
|
||||
},
|
||||
};
|
||||
@@ -24,16 +24,6 @@ function getPluginStylePaths(plugin: PluginItem): string[] {
|
||||
return styles.filter((s) => typeof s === 'string' && s.trim().length > 0);
|
||||
}
|
||||
|
||||
function unloadPluginFrame(iframe: HTMLIFrameElement | null) {
|
||||
if (!iframe) return;
|
||||
try {
|
||||
iframe.contentWindow?.postMessage({ type: 'foxel-plugin:unload' }, window.location.origin);
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
iframe.src = 'about:blank';
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件宿主组件 - 文件打开模式
|
||||
* 使用 iframe 隔离渲染与样式,避免插件污染宿主 DOM/CSS。
|
||||
@@ -76,10 +66,7 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({
|
||||
};
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => {
|
||||
window.removeEventListener('message', onMessage);
|
||||
unloadPluginFrame(iframeRef.current);
|
||||
};
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [plugin.key]);
|
||||
|
||||
return (
|
||||
@@ -131,10 +118,7 @@ export const PluginAppOpenHost: React.FC<PluginAppOpenHostProps> = ({ plugin, on
|
||||
};
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => {
|
||||
window.removeEventListener('message', onMessage);
|
||||
unloadPluginFrame(iframeRef.current);
|
||||
};
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [plugin.key]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,11 +7,11 @@ import { useI18n } from '../i18n';
|
||||
|
||||
export interface NoticesModalProps {
|
||||
open: boolean;
|
||||
version: string;
|
||||
onClose: () => void;
|
||||
initialNotice?: NoticeItem | null;
|
||||
}
|
||||
|
||||
const NoticesModal = memo(function NoticesModal({ open, onClose, initialNotice }: NoticesModalProps) {
|
||||
const NoticesModal = memo(function NoticesModal({ open, version, onClose }: NoticesModalProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<NoticeItem[]>([]);
|
||||
@@ -28,15 +28,12 @@ const NoticesModal = memo(function NoticesModal({ open, onClose, initialNotice }
|
||||
if (mode === 'replace') setLoading(true);
|
||||
else setLoadingMore(true);
|
||||
try {
|
||||
const resp = await noticesApi.list({ page: targetPage });
|
||||
const resp = await noticesApi.list({ version, page: targetPage });
|
||||
setPage(resp.page ?? targetPage);
|
||||
setTotal(resp.total ?? 0);
|
||||
const nextItems = mode === 'replace' && initialNotice && !resp.items.some(item => item.id === initialNotice.id)
|
||||
? [initialNotice, ...resp.items]
|
||||
: resp.items;
|
||||
setItems(prev => mode === 'replace' ? nextItems : [...prev, ...resp.items]);
|
||||
setItems(prev => mode === 'replace' ? resp.items : [...prev, ...resp.items]);
|
||||
if (mode === 'replace') {
|
||||
setSelectedId(initialNotice?.id ?? resp.items[0]?.id ?? null);
|
||||
setSelectedId(resp.items[0]?.id ?? null);
|
||||
} else {
|
||||
setSelectedId(prev => prev ?? resp.items[0]?.id ?? null);
|
||||
}
|
||||
@@ -58,7 +55,7 @@ const NoticesModal = memo(function NoticesModal({ open, onClose, initialNotice }
|
||||
setSelectedId(null);
|
||||
loadPage(1, 'replace');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialNotice?.id]);
|
||||
}, [open, version]);
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
try {
|
||||
@@ -184,3 +181,4 @@ const NoticesModal = memo(function NoticesModal({ open, onClose, initialNotice }
|
||||
});
|
||||
|
||||
export default NoticesModal;
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"English": "English",
|
||||
"Default Language": "Default Language",
|
||||
"Used when the user has not selected a language": "Used when the user has not selected a language",
|
||||
"Default File View Mode": "Default File View Mode",
|
||||
"Full Name": "Full Name",
|
||||
"Email": "Email",
|
||||
"Change Password": "Change Password",
|
||||
@@ -240,41 +239,6 @@
|
||||
"Type": "Type",
|
||||
"Folder": "Folder",
|
||||
"File": "File",
|
||||
"Image": "Image",
|
||||
"Video": "Video",
|
||||
"Create Video Room": "Create Video Room",
|
||||
"Video room created": "Video room created",
|
||||
"Share this room link with friends": "Share this room link with friends",
|
||||
"Video Room Link": "Video Room Link",
|
||||
"Video Room Name": "Video Room Name",
|
||||
"Video room load failed": "Failed to load video room",
|
||||
"Video room not found": "Video room not found",
|
||||
"Room synced": "Room synced",
|
||||
"Room disconnected": "Room disconnected",
|
||||
"Share room": "Share room",
|
||||
"Now watching": "Now watching",
|
||||
"Everyone follows the same playback": "Everyone follows the same playback",
|
||||
"Waiting for sync connection": "Waiting for sync connection",
|
||||
"Playback state is shared in this room": "Playback state is shared in this room",
|
||||
"Room status": "Room status",
|
||||
"Playback": "Playback",
|
||||
"Playing": "Playing",
|
||||
"Paused": "Paused",
|
||||
"Current position": "Current position",
|
||||
"Resync playback": "Resync playback",
|
||||
"Room link": "Room link",
|
||||
"Audio": "Audio",
|
||||
"PDF": "PDF",
|
||||
"Word": "Word",
|
||||
"Spreadsheet": "Spreadsheet",
|
||||
"Presentation": "Presentation",
|
||||
"Archive": "Archive",
|
||||
"Code": "Code",
|
||||
"Markdown": "Markdown",
|
||||
"Text": "Text",
|
||||
"Font": "Font",
|
||||
"Database": "Database",
|
||||
"Config": "Config",
|
||||
"Path": "Path",
|
||||
"Path copied to clipboard": "Path copied to clipboard",
|
||||
"Copy failed": "Copy failed",
|
||||
@@ -516,7 +480,6 @@
|
||||
"Enter identifier": "Enter identifier",
|
||||
"Only lowercase letters, numbers, dash, dot and underscore are allowed": "Only lowercase letters, numbers, dash, dot and underscore are allowed",
|
||||
"API Format": "API Format",
|
||||
"OpenAI Protocol": "OpenAI Protocol",
|
||||
"Base URL": "Base URL",
|
||||
"Enter base url": "Enter base URL",
|
||||
"Optional, can also be provided per request": "Optional, can also be provided per request",
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
"English": "English",
|
||||
"Default Language": "默认语言",
|
||||
"Used when the user has not selected a language": "用户未手动选择语言时使用",
|
||||
"Default File View Mode": "默认文件展示方式",
|
||||
"Full Name": "昵称",
|
||||
"Email": "邮箱",
|
||||
"Change Password": "修改密码",
|
||||
@@ -259,41 +258,6 @@
|
||||
"Type": "类型",
|
||||
"Folder": "文件夹",
|
||||
"File": "文件",
|
||||
"Image": "图片",
|
||||
"Video": "视频",
|
||||
"Create Video Room": "创建视频房",
|
||||
"Video room created": "视频房已创建",
|
||||
"Share this room link with friends": "把这个房间链接分享给好友",
|
||||
"Video Room Link": "视频房链接",
|
||||
"Video Room Name": "视频房名称",
|
||||
"Video room load failed": "加载视频房失败",
|
||||
"Video room not found": "视频房不存在",
|
||||
"Room synced": "房间同步中",
|
||||
"Room disconnected": "房间已断开",
|
||||
"Share room": "分享房间",
|
||||
"Now watching": "正在观看",
|
||||
"Everyone follows the same playback": "所有人同步播放",
|
||||
"Waiting for sync connection": "等待同步连接",
|
||||
"Playback state is shared in this room": "房间内会同步播放、暂停和进度",
|
||||
"Room status": "房间状态",
|
||||
"Playback": "播放状态",
|
||||
"Playing": "播放中",
|
||||
"Paused": "已暂停",
|
||||
"Current position": "当前进度",
|
||||
"Resync playback": "重新同步",
|
||||
"Room link": "房间链接",
|
||||
"Audio": "音频",
|
||||
"PDF": "PDF",
|
||||
"Word": "Word 文档",
|
||||
"Spreadsheet": "表格",
|
||||
"Presentation": "演示文稿",
|
||||
"Archive": "压缩包",
|
||||
"Code": "代码",
|
||||
"Markdown": "Markdown",
|
||||
"Text": "文本",
|
||||
"Font": "字体",
|
||||
"Database": "数据库",
|
||||
"Config": "配置",
|
||||
"Path": "路径",
|
||||
"Path copied to clipboard": "路径已复制到剪贴板",
|
||||
"Copy failed": "复制失败",
|
||||
@@ -515,7 +479,6 @@
|
||||
"Enter identifier": "请输入标识符",
|
||||
"Only lowercase letters, numbers, dash, dot and underscore are allowed": "仅允许小写字母、数字、连字符、点和下划线",
|
||||
"API Format": "API 格式",
|
||||
"OpenAI Protocol": "OpenAI 协议",
|
||||
"Base URL": "基础 URL",
|
||||
"Enter base url": "请输入基础 URL",
|
||||
"Optional, can also be provided per request": "可选,也可在请求时提供",
|
||||
@@ -812,6 +775,7 @@
|
||||
"Users": "用户",
|
||||
"Create User": "创建用户",
|
||||
"Create Role": "创建角色",
|
||||
"Edit": "编辑",
|
||||
"Submit": "提交",
|
||||
"Super Admin": "超级管理员",
|
||||
"Disabled": "已禁用",
|
||||
@@ -832,11 +796,14 @@
|
||||
"Is Regex": "正则表达式",
|
||||
"Priority": "优先级",
|
||||
"Higher value = higher priority": "数值越大优先级越高",
|
||||
"Permissions": "权限",
|
||||
"System Permissions": "系统权限",
|
||||
"Download and preview files": "下载和预览文件",
|
||||
"Upload and modify files": "上传和修改文件",
|
||||
"Delete files and folders": "删除文件和目录",
|
||||
"Create share links": "创建分享链接",
|
||||
"Share": "分享",
|
||||
"Delete": "删除",
|
||||
"permission.category.system": "系统",
|
||||
"permission.category.adapter": "存储适配器"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip, Modal, QRCode } from 'antd';
|
||||
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined, QrcodeOutlined } from '@ant-design/icons';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import SearchDialog from './SearchDialog.tsx';
|
||||
import { authApi } from '../api/auth.ts';
|
||||
import { useNavigate } from 'react-router';
|
||||
@@ -9,8 +9,8 @@ import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ProfileModal from '../components/ProfileModal';
|
||||
import NoticesModal from '../components/NoticesModal';
|
||||
import { useSystemStatus } from '../contexts/SystemContext';
|
||||
import useResponsive from '../hooks/useResponsive';
|
||||
import { noticesApi, type NoticeItem } from '../api/notices';
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
@@ -30,8 +30,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [clientAuthOpen, setClientAuthOpen] = useState(false);
|
||||
const [noticesOpen, setNoticesOpen] = useState(false);
|
||||
const [popupNotice, setPopupNotice] = useState<NoticeItem | null>(null);
|
||||
const [popupMode, setPopupMode] = useState(false);
|
||||
const status = useSystemStatus();
|
||||
const { isMobile } = useResponsive();
|
||||
const clientAuthPayload = useMemo(() => JSON.stringify({
|
||||
base_url: window.location.origin,
|
||||
@@ -45,35 +44,6 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
|
||||
|
||||
const openProfile = () => setProfileOpen(true);
|
||||
const openClientAuth = () => setClientAuthOpen(true);
|
||||
const openNotices = () => {
|
||||
setPopupMode(false);
|
||||
setNoticesOpen(true);
|
||||
};
|
||||
const closeNotices = async () => {
|
||||
const shouldDismiss = popupMode && popupNotice;
|
||||
setNoticesOpen(false);
|
||||
setPopupMode(false);
|
||||
if (shouldDismiss) {
|
||||
try {
|
||||
await noticesApi.dismiss(popupNotice.id);
|
||||
setPopupNotice(null);
|
||||
} catch { void 0; }
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!authToken) return;
|
||||
noticesApi.getPopup().then((notice) => {
|
||||
if (cancelled || !notice) return;
|
||||
setPopupNotice(notice);
|
||||
setPopupMode(true);
|
||||
setNoticesOpen(true);
|
||||
}).catch(() => void 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authToken]);
|
||||
|
||||
return (
|
||||
<Header
|
||||
@@ -114,7 +84,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
|
||||
type="text"
|
||||
icon={<BellOutlined />}
|
||||
aria-label={t('Notices')}
|
||||
onClick={openNotices}
|
||||
onClick={() => setNoticesOpen(true)}
|
||||
style={{ paddingInline: 8, height: 40 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -163,7 +133,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent,
|
||||
<QRCode value={clientAuthPayload} size={220} />
|
||||
</Flex>
|
||||
</Modal>
|
||||
<NoticesModal open={noticesOpen} onClose={closeNotices} initialNotice={popupMode ? popupNotice : null} />
|
||||
<NoticesModal open={noticesOpen} onClose={() => setNoticesOpen(false)} version={status?.version || ''} />
|
||||
</Flex>
|
||||
</Header>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { Button, Space, theme, Pagination } from 'antd';
|
||||
import { theme, Pagination } from 'antd';
|
||||
import { useFileExplorer } from './hooks/useFileExplorer';
|
||||
import { useFileSelection } from './hooks/useFileSelection';
|
||||
import { useFileActions } from './hooks/useFileActions.tsx';
|
||||
@@ -23,13 +23,11 @@ import { ProcessorModal } from './components/Modals/ProcessorModal';
|
||||
import UploadModal from './components/Modals/UploadModal';
|
||||
import { ShareModal } from './components/Modals/ShareModal';
|
||||
import { DirectLinkModal } from './components/Modals/DirectLinkModal';
|
||||
import { VideoRoomModal } from './components/Modals/VideoRoomModal';
|
||||
import { FileDetailModal } from './components/FileDetailModal';
|
||||
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
|
||||
import { SearchResultsView } from './components/SearchResultsView';
|
||||
import type { ViewMode } from './types';
|
||||
import { vfsApi, type VfsEntry } from '../../api/client';
|
||||
import { getPublicConfig } from '../../api/config';
|
||||
import { LoadingSkeleton } from './components/LoadingSkeleton';
|
||||
import useResponsive from '../../hooks/useResponsive';
|
||||
|
||||
@@ -44,7 +42,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const skeletonTimerRef = useRef<number | null>(null);
|
||||
|
||||
// --- Hooks ---
|
||||
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange, goCursorNext, goCursorPrev } = useFileExplorer(navKey);
|
||||
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
||||
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
|
||||
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, openContextMenuAt, closeContextMenus } = useContextMenu();
|
||||
@@ -59,7 +57,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
|
||||
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
|
||||
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
|
||||
const [videoRoomEntry, setVideoRoomEntry] = useState<VfsEntry | null>(null);
|
||||
const [detailData, setDetailData] = useState<Record<string, unknown> | { error: string } | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
|
||||
@@ -109,21 +106,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
|
||||
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
getPublicConfig()
|
||||
.then((cfg) => {
|
||||
if (!mounted || isMobile) return;
|
||||
setViewMode(cfg.DEFAULT_FILE_VIEW_MODE === 'list' ? 'list' : 'grid');
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted && !isMobile) setViewMode('grid');
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && viewMode !== 'grid') {
|
||||
setViewMode('grid');
|
||||
@@ -223,10 +205,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
}
|
||||
return joined.startsWith('/') ? joined : `/${joined}`;
|
||||
}, [entryBasePath]);
|
||||
const showFsPagination = !isSearching && pagination.mode === 'paged' && pagination.total > 0;
|
||||
const showCursorPagination = !isSearching && pagination.mode === 'cursor' && (pagination.cursorHistory.length > 0 || pagination.hasNext);
|
||||
const showFsPagination = !isSearching && pagination.total > 0;
|
||||
const shouldReserveBottomBar = showSearchPagination || showFsPagination;
|
||||
const shouldReserveAnyBottomBar = shouldReserveBottomBar || showCursorPagination;
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -302,7 +282,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
viewMode={viewMode}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
paginationMode={pagination.mode}
|
||||
isMobile={isMobile}
|
||||
onGoUp={goUp}
|
||||
onNavigate={navigateTo}
|
||||
@@ -330,7 +309,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
onChange={handleDirectoryInputChange}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', minHeight: 0, paddingBottom: shouldReserveAnyBottomBar ? '80px' : '0' }} onContextMenu={isMobile ? undefined : openBlankContextMenu}>
|
||||
<div style={{ flex: 1, overflow: 'auto', minHeight: 0, paddingBottom: shouldReserveBottomBar ? '80px' : '0' }} onContextMenu={isMobile ? undefined : openBlankContextMenu}>
|
||||
{isSearching ? (
|
||||
<SearchResultsView
|
||||
viewMode={viewMode}
|
||||
@@ -385,19 +364,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCursorPagination && (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
|
||||
<Space>
|
||||
<Button size="small" onClick={goCursorPrev} disabled={pagination.cursorHistory.length === 0 || loading}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={goCursorNext} disabled={!pagination.hasNext || loading}>
|
||||
下一页
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSearchPagination && (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
|
||||
<Pagination
|
||||
@@ -455,12 +421,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
open={!!directLinkEntry}
|
||||
onCancel={() => setDirectLinkEntry(null)}
|
||||
/>
|
||||
<VideoRoomModal
|
||||
entry={videoRoomEntry}
|
||||
path={entryBasePath}
|
||||
open={!!videoRoomEntry}
|
||||
onCancel={() => setVideoRoomEntry(null)}
|
||||
/>
|
||||
<ProcessorModal
|
||||
entry={processorHook.processorModal.entry}
|
||||
visible={processorHook.processorModal.visible}
|
||||
@@ -503,7 +463,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
onCreateFile={() => setCreatingFile(true)}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onShare={doShare}
|
||||
onCreateVideoRoom={setVideoRoomEntry}
|
||||
onGetDirectLink={doGetDirectLink}
|
||||
onMove={(entriesToMove) => setMovingEntries(entriesToMove)}
|
||||
onCopy={(entriesToCopy) => setCopyingEntries(entriesToCopy)}
|
||||
|
||||
@@ -8,8 +8,7 @@ import { useI18n } from '../../../i18n';
|
||||
import {
|
||||
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
|
||||
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined,
|
||||
ShareAltOutlined, LinkOutlined, CopyOutlined, SwapOutlined, FileAddOutlined,
|
||||
PlayCircleOutlined
|
||||
ShareAltOutlined, LinkOutlined, CopyOutlined, SwapOutlined, FileAddOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface ContextMenuProps {
|
||||
@@ -33,7 +32,6 @@ interface ContextMenuProps {
|
||||
onCreateFile: () => void;
|
||||
onCreateDir: () => void;
|
||||
onShare: (entries: VfsEntry[]) => void;
|
||||
onCreateVideoRoom: (entry: VfsEntry) => void;
|
||||
onGetDirectLink: (entry: VfsEntry) => void;
|
||||
onMove: (entries: VfsEntry[]) => void;
|
||||
onCopy: (entries: VfsEntry[]) => void;
|
||||
@@ -41,8 +39,6 @@ interface ContextMenuProps {
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
|
||||
const isVideoFile = (name: string) => /\.(mp4|webm|ogg|m4v|mov|mkv|avi|flv)$/i.test(name);
|
||||
|
||||
interface ActionMenuItem {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
@@ -142,13 +138,6 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
icon: <ShareAltOutlined />,
|
||||
onClick: () => actions.onShare(targetEntries),
|
||||
},
|
||||
{
|
||||
key: 'videoRoom',
|
||||
label: t('Create Video Room'),
|
||||
icon: <PlayCircleOutlined />,
|
||||
disabled: targetEntries.length !== 1 || targetEntries[0].is_dir || !isVideoFile(targetEntries[0].name),
|
||||
onClick: () => actions.onCreateVideoRoom(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'directLink',
|
||||
label: t('Get Direct Link'),
|
||||
|
||||
@@ -19,45 +19,6 @@ interface FileListViewProps {
|
||||
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
|
||||
}
|
||||
|
||||
const fileTypeGroups: Array<{ key: string; exts: string[] }> = [
|
||||
{ key: 'Image', exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'] },
|
||||
{ key: 'Video', exts: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v', '3gp'] },
|
||||
{ key: 'Audio', exts: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a'] },
|
||||
{ key: 'PDF', exts: ['pdf'] },
|
||||
{ key: 'Word', exts: ['doc', 'docx'] },
|
||||
{ key: 'Spreadsheet', exts: ['xls', 'xlsx', 'csv'] },
|
||||
{ key: 'Presentation', exts: ['ppt', 'pptx'] },
|
||||
{ key: 'Archive', exts: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'] },
|
||||
{ key: 'Code', exts: ['js', 'jsx', 'ts', 'tsx', 'vue', 'html', 'htm', 'css', 'scss', 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'py', 'java', 'cpp', 'cc', 'cxx', 'c', 'h', 'hpp', 'hxx', 'php', 'rb', 'go', 'rs', 'rust', 'swift', 'kt', 'scala', 'clj', 'cljs', 'cs', 'vb', 'fs', 'pl', 'pm', 'r', 'lua', 'dart', 'elm'] },
|
||||
{ key: 'Markdown', exts: ['md', 'markdown'] },
|
||||
{ key: 'Text', exts: ['txt', 'log', 'ini', 'cfg', 'conf', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'dockerfile', 'makefile', 'gradle', 'cmake', 'gitignore', 'gitattributes', 'editorconfig', 'prettierrc'] },
|
||||
{ key: 'Font', exts: ['ttf', 'otf', 'woff', 'woff2', 'eot'] },
|
||||
{ key: 'Database', exts: ['db', 'sqlite', 'sql'] },
|
||||
{ key: 'Config', exts: ['env', 'config', 'properties', 'toml'] },
|
||||
];
|
||||
|
||||
const formatFileSize = (size: number) => {
|
||||
if (!Number.isFinite(size) || size < 0) return '-';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let value = size;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
if (unitIndex === 0) return `${value} ${units[unitIndex]}`;
|
||||
return `${value.toFixed(2)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const getFileTypeLabel = (entry: VfsEntry, t: (key: string) => string) => {
|
||||
if (entry.type === 'mount') return t('Mount Point');
|
||||
if (entry.is_dir) return t('Folder');
|
||||
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
const group = fileTypeGroups.find(item => item.exts.includes(ext));
|
||||
return t(group?.key || 'File');
|
||||
};
|
||||
|
||||
export const FileListView: React.FC<FileListViewProps> = ({
|
||||
entries,
|
||||
selectedEntries,
|
||||
@@ -102,8 +63,7 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{ title: t('Type'), key: 'fileType', width: 110, render: (_: any, r: VfsEntry) => getFileTypeLabel(r, t) },
|
||||
{ title: t('Size'), dataIndex: 'size', width: 120, render: (v: number, r: VfsEntry) => r.is_dir ? '-' : formatFileSize(v) },
|
||||
{ title: t('Size'), dataIndex: 'size', width: 100, render: (v: number, r: VfsEntry) => r.is_dir ? '-' : v },
|
||||
{ title: t('Modified Time'), dataIndex: 'mtime', width: 160, render: (v: number) => v ? new Date(v * 1000).toLocaleString() : '-' },
|
||||
{
|
||||
title: t('Actions'),
|
||||
|
||||
@@ -12,7 +12,6 @@ interface HeaderProps {
|
||||
viewMode: ViewMode;
|
||||
sortBy: string;
|
||||
sortOrder: string;
|
||||
paginationMode?: 'paged' | 'cursor';
|
||||
isMobile?: boolean;
|
||||
onGoUp: () => void;
|
||||
onNavigate: (path: string) => void;
|
||||
@@ -31,7 +30,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
viewMode,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
paginationMode = 'paged',
|
||||
isMobile = false,
|
||||
onGoUp,
|
||||
onNavigate,
|
||||
@@ -84,7 +82,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
setEditingPath(false);
|
||||
setPathInputValue('');
|
||||
};
|
||||
const sortDisabled = paginationMode === 'cursor';
|
||||
|
||||
const renderBreadcrumb = () => {
|
||||
if (editingPath) {
|
||||
@@ -157,7 +154,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
{
|
||||
key: 'sort',
|
||||
label: t('Sort By') + `: ${t(sortBy === 'mtime' ? 'Modified Time' : sortBy === 'size' ? 'Size' : 'Name')}`,
|
||||
disabled: sortDisabled,
|
||||
children: [
|
||||
{ key: 'sort-name', label: t('Name'), onClick: () => onSortChange('name', sortOrder) },
|
||||
{ key: 'sort-size', label: t('Size'), onClick: () => onSortChange('size', sortOrder) },
|
||||
@@ -168,7 +164,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
key: 'sort-order',
|
||||
label: sortOrder === 'asc' ? t('Ascending') : t('Descending'),
|
||||
icon: sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />,
|
||||
disabled: sortDisabled,
|
||||
onClick: () => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc'),
|
||||
},
|
||||
];
|
||||
@@ -235,7 +230,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
<Select
|
||||
size="small"
|
||||
value={sortBy}
|
||||
disabled={sortDisabled}
|
||||
onChange={(val) => onSortChange(val, sortOrder)}
|
||||
style={{ width: 112 }}
|
||||
options={[
|
||||
@@ -246,7 +240,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={sortDisabled}
|
||||
icon={sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
onClick={() => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
/>
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Form, Input, message, Modal, Typography } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
import { videoRoomsApi, type VideoRoomInfo } from '../../../../api/videoRooms';
|
||||
import { useSystemStatus } from '../../../../contexts/SystemContext';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface VideoRoomModalProps {
|
||||
entry: VfsEntry | null;
|
||||
path: string;
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const VideoRoomModal = memo(function VideoRoomModal({ entry, path, open, onCancel }: VideoRoomModalProps) {
|
||||
const [form] = Form.useForm();
|
||||
const systemStatus = useSystemStatus();
|
||||
const { t } = useI18n();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createdRoom, setCreatedRoom] = useState<VideoRoomInfo | null>(null);
|
||||
|
||||
const defaultName = entry?.name || '';
|
||||
const roomUrl = useMemo(() => {
|
||||
if (!createdRoom) return '';
|
||||
const baseUrl = systemStatus?.app_domain || window.location.origin;
|
||||
return new URL(`/room/${createdRoom.token}`, baseUrl).href;
|
||||
}, [createdRoom, systemStatus?.app_domain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCreatedRoom(null);
|
||||
form.setFieldsValue({ name: defaultName });
|
||||
}, [defaultName, form, open]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!entry) return;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
const base = path === '/' ? '' : path;
|
||||
const fullPath = `${base}/${entry.name}`.replace(/\/{2,}/g, '/');
|
||||
const room = await videoRoomsApi.create({
|
||||
name: values.name || entry.name,
|
||||
path: fullPath,
|
||||
});
|
||||
setCreatedRoom(room);
|
||||
message.success(t('Video room created'));
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Create failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!roomUrl) return;
|
||||
navigator.clipboard.writeText(roomUrl);
|
||||
message.success(t('Copied to clipboard'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={createdRoom ? t('Video room created') : t('Create Video Room')}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={createdRoom ? onCancel : handleCreate}
|
||||
okText={createdRoom ? t('Done') : t('Create')}
|
||||
confirmLoading={loading}
|
||||
destroyOnHidden
|
||||
>
|
||||
{createdRoom ? (
|
||||
<div>
|
||||
<Typography.Paragraph>{t('Share this room link with friends')}</Typography.Paragraph>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t('Video Room Link')}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Input readOnly value={roomUrl} style={{ flex: 1 }} />
|
||||
<Button icon={<CopyOutlined />} onClick={handleCopy}>
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
) : (
|
||||
<Form form={form} layout="vertical" initialValues={{ name: defaultName }}>
|
||||
<Form.Item name="name" label={t('Video Room Name')} rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
@@ -7,14 +7,10 @@ type ExplorerSnapshot = {
|
||||
path: string;
|
||||
entries: VfsEntry[];
|
||||
pagination?: {
|
||||
mode?: 'paged' | 'cursor';
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total?: number;
|
||||
page?: number;
|
||||
pages?: number;
|
||||
cursor?: string | null;
|
||||
next_cursor?: string | null;
|
||||
has_next?: boolean;
|
||||
pages: number;
|
||||
};
|
||||
sortBy: string;
|
||||
sortOrder: string;
|
||||
@@ -34,11 +30,6 @@ export function useFileExplorer(navKey: string) {
|
||||
current: 1,
|
||||
pageSize: 50,
|
||||
total: 0,
|
||||
mode: 'paged' as 'paged' | 'cursor',
|
||||
cursor: null as string | null,
|
||||
nextCursor: null as string | null,
|
||||
cursorHistory: [] as (string | null)[],
|
||||
hasNext: false,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number, range: [number, number]) => `${total} ${'items'} ${range[0]}-${range[1]}`,
|
||||
@@ -47,29 +38,23 @@ export function useFileExplorer(navKey: string) {
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
|
||||
const load = useCallback(async (p: string, page: number = 1, pageSize: number = 50, sb = sortBy, so = sortOrder, cursor?: string | null, cursorHistory: (string | null)[] = []) => {
|
||||
const load = useCallback(async (p: string, page: number = 1, pageSize: number = 50, sb = sortBy, so = sortOrder) => {
|
||||
const canonical = p === '' ? '/' : (p.startsWith('/') ? p : '/' + p);
|
||||
setLoading(true);
|
||||
try {
|
||||
// Load entries and processor types concurrently
|
||||
const [res, processors] = await Promise.all([
|
||||
vfsApi.list(canonical === '/' ? '' : canonical, page, pageSize, sb, so, cursor),
|
||||
vfsApi.list(canonical === '/' ? '' : canonical, page, pageSize, sb, so),
|
||||
processorsApi.list()
|
||||
]);
|
||||
setEntries(res.entries);
|
||||
const resolvedPath = res.path || canonical;
|
||||
setPath(resolvedPath);
|
||||
const pageMode = res.pagination?.mode || 'paged';
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
mode: pageMode,
|
||||
current: res.pagination?.page || page,
|
||||
pageSize: res.pagination?.page_size || pageSize,
|
||||
total: res.pagination?.total || 0,
|
||||
cursor: res.pagination?.cursor || null,
|
||||
nextCursor: res.pagination?.next_cursor || null,
|
||||
hasNext: Boolean(res.pagination?.has_next),
|
||||
cursorHistory: pageMode === 'cursor' ? cursorHistory : [],
|
||||
current: res.pagination!.page,
|
||||
pageSize: res.pagination!.page_size,
|
||||
total: res.pagination!.total
|
||||
}));
|
||||
setProcessorTypes(processors);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -109,31 +94,8 @@ export function useFileExplorer(navKey: string) {
|
||||
load(path, page, pageSize, sortBy, sortOrder);
|
||||
};
|
||||
|
||||
const goCursorNext = () => {
|
||||
if (!pagination.nextCursor) return;
|
||||
load(path, 1, pagination.pageSize, sortBy, sortOrder, pagination.nextCursor, [
|
||||
...pagination.cursorHistory,
|
||||
pagination.cursor,
|
||||
]);
|
||||
};
|
||||
|
||||
const goCursorPrev = () => {
|
||||
if (pagination.cursorHistory.length === 0) return;
|
||||
const nextHistory = pagination.cursorHistory.slice(0, -1);
|
||||
const prevCursor = pagination.cursorHistory[pagination.cursorHistory.length - 1];
|
||||
load(path, 1, pagination.pageSize, sortBy, sortOrder, prevCursor, nextHistory);
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
load(
|
||||
path,
|
||||
pagination.current,
|
||||
pagination.pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
pagination.mode === 'cursor' ? pagination.cursor : null,
|
||||
pagination.mode === 'cursor' ? pagination.cursorHistory : [],
|
||||
);
|
||||
load(path, pagination.current, pagination.pageSize, sortBy, sortOrder);
|
||||
}
|
||||
|
||||
const handleSortChange = (sb: string, so: string) => {
|
||||
@@ -155,8 +117,6 @@ export function useFileExplorer(navKey: string) {
|
||||
goUp,
|
||||
handlePaginationChange,
|
||||
refresh,
|
||||
handleSortChange,
|
||||
goCursorNext,
|
||||
goCursorPrev,
|
||||
handleSortChange
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,8 +43,6 @@ interface RawUploadDirectory {
|
||||
|
||||
type RawUploadItem = RawUploadFile | RawUploadDirectory;
|
||||
|
||||
const MAX_UPLOAD_CONCURRENCY = 3;
|
||||
|
||||
const generateId = (() => {
|
||||
const cryptoApi = typeof crypto !== 'undefined' ? crypto : undefined;
|
||||
return () => {
|
||||
@@ -459,15 +457,15 @@ export function useUploader(path: string, onUploadComplete: () => void) {
|
||||
}
|
||||
}, [ensureDirectory, updateFile, t]);
|
||||
|
||||
const prepareFileTask = useCallback(async (task: UploadFile): Promise<{ task: UploadFile; overwrite: boolean } | null> => {
|
||||
const processFileTask = useCallback(async (task: UploadFile) => {
|
||||
if (!task.file) {
|
||||
updateFile(task.id, { status: 'error', error: t('Missing file content') });
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (skipAllRef.current) {
|
||||
updateFile(task.id, { status: 'skipped', progress: 0 });
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldOverwrite = overwriteAllRef.current;
|
||||
@@ -477,28 +475,19 @@ export function useUploader(path: string, onUploadComplete: () => void) {
|
||||
const decision = await awaitConflictDecision(task);
|
||||
if (decision === 'skip') {
|
||||
updateFile(task.id, { status: 'skipped', progress: 0 });
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
shouldOverwrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
setConflict(null);
|
||||
const parentDir = task.targetPath.replace(/\/[^/]+$/, '') || '/';
|
||||
await ensureDirectoryTree(parentDir);
|
||||
updateFile(task.id, { status: 'pending', progress: 0, loadedBytes: 0 });
|
||||
return { task, overwrite: shouldOverwrite };
|
||||
}, [ensureDirectoryTree, awaitConflictDecision, updateFile, t]);
|
||||
|
||||
const uploadPreparedFile = useCallback(async (task: UploadFile, shouldOverwrite: boolean) => {
|
||||
if (!task.file) {
|
||||
updateFile(task.id, { status: 'error', error: t('Missing file content') });
|
||||
return;
|
||||
}
|
||||
|
||||
updateFile(task.id, { status: 'uploading', progress: 0, loadedBytes: 0 });
|
||||
|
||||
const parentDir = task.targetPath.replace(/\/[^/]+$/, '') || '/';
|
||||
try {
|
||||
const uploadResult = await vfsApi.uploadRaw(task.targetPath, task.file, shouldOverwrite, (loaded, total) => {
|
||||
await ensureDirectoryTree(parentDir);
|
||||
const uploadResult = await vfsApi.uploadStream(task.targetPath, task.file, shouldOverwrite, (loaded, total) => {
|
||||
mutateFiles((prev) => prev.map((f) => {
|
||||
if (f.id !== task.id) return f;
|
||||
const effectiveTotal = total > 0 ? total : f.size;
|
||||
@@ -517,34 +506,22 @@ export function useUploader(path: string, onUploadComplete: () => void) {
|
||||
const finalSize = typeof uploadResult?.size === 'number' && uploadResult.size > 0
|
||||
? uploadResult.size
|
||||
: task.size;
|
||||
const link = await vfsApi.getTempLinkToken(actualPath, 60 * 60 * 24 * 365 * 10);
|
||||
const permanentLink = vfsApi.getTempPublicUrl(link.token);
|
||||
updateFile(task.id, {
|
||||
status: 'success',
|
||||
progress: 100,
|
||||
loadedBytes: finalSize,
|
||||
size: finalSize,
|
||||
targetPath: actualPath,
|
||||
permanentLink,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err.message : t('Upload failed');
|
||||
updateFile(task.id, { status: 'error', error, progress: 0 });
|
||||
message.error(`${task.relativePath}: ${error}`);
|
||||
}
|
||||
}, [mutateFiles, updateFile, t]);
|
||||
|
||||
const uploadPreparedFiles = useCallback(async (preparedFiles: Array<{ task: UploadFile; overwrite: boolean }>) => {
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.min(MAX_UPLOAD_CONCURRENCY, preparedFiles.length);
|
||||
|
||||
const runWorker = async () => {
|
||||
while (nextIndex < preparedFiles.length) {
|
||||
const current = preparedFiles[nextIndex];
|
||||
nextIndex += 1;
|
||||
await uploadPreparedFile(current.task, current.overwrite);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
||||
}, [uploadPreparedFile]);
|
||||
}, [ensureDirectoryTree, awaitConflictDecision, mutateFiles, updateFile, t]);
|
||||
|
||||
const startUpload = useCallback(async () => {
|
||||
if (isUploadingRef.current) return;
|
||||
@@ -553,7 +530,6 @@ export function useUploader(path: string, onUploadComplete: () => void) {
|
||||
isUploadingRef.current = true;
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const preparedFiles: Array<{ task: UploadFile; overwrite: boolean }> = [];
|
||||
for (const task of filesRef.current) {
|
||||
if (task.status !== 'pending' && task.status !== 'waiting') {
|
||||
continue;
|
||||
@@ -561,17 +537,15 @@ export function useUploader(path: string, onUploadComplete: () => void) {
|
||||
if (task.type === 'directory') {
|
||||
await processDirectoryTask(task);
|
||||
} else {
|
||||
const prepared = await prepareFileTask(task);
|
||||
if (prepared) preparedFiles.push(prepared);
|
||||
await processFileTask(task);
|
||||
}
|
||||
}
|
||||
await uploadPreparedFiles(preparedFiles);
|
||||
onUploadComplete();
|
||||
} finally {
|
||||
isUploadingRef.current = false;
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [onUploadComplete, processDirectoryTask, prepareFileTask, uploadPreparedFiles]);
|
||||
}, [onUploadComplete, processDirectoryTask, processFileTask]);
|
||||
|
||||
const totalFileBytes = useMemo(
|
||||
() => files.reduce((acc, f) => acc + (f.type === 'file' ? f.size : 0), 0),
|
||||
|
||||
@@ -43,30 +43,6 @@ const THEME_KEYS = {
|
||||
CSS: 'THEME_CUSTOM_CSS',
|
||||
};
|
||||
|
||||
const CONFIG_DEFAULTS: Record<string, string> = {
|
||||
...Object.fromEntries(APP_CONFIG_KEYS.map(({ key, default: def }) => [key, def ?? ''])),
|
||||
APP_DEFAULT_LANGUAGE: 'zh',
|
||||
AUTH_ALLOW_REGISTER: 'false',
|
||||
AUTH_DEFAULT_REGISTER_ROLE_ID: '',
|
||||
DEFAULT_FILE_VIEW_MODE: 'grid',
|
||||
[THEME_KEYS.MODE]: 'light',
|
||||
[THEME_KEYS.PRIMARY]: '#111111',
|
||||
[THEME_KEYS.RADIUS]: '10',
|
||||
[THEME_KEYS.TOKENS]: '',
|
||||
[THEME_KEYS.CSS]: '',
|
||||
WEBDAV_MAPPING_ENABLED: '1',
|
||||
S3_MAPPING_ENABLED: '1',
|
||||
S3_MAPPING_BUCKET: 'foxel',
|
||||
S3_MAPPING_REGION: '',
|
||||
S3_MAPPING_BASE_PATH: '/',
|
||||
S3_MAPPING_ACCESS_KEY: '',
|
||||
S3_MAPPING_SECRET_KEY: '',
|
||||
EMAIL_CONFIG: '',
|
||||
EMAIL_PASSWORD_RESET_TEMPLATE: '',
|
||||
};
|
||||
|
||||
const stringifyConfigValue = (value: unknown) => String(value ?? '');
|
||||
|
||||
export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSettingsPageProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [config, setConfigState] = useState<Record<string, string> | null>(null);
|
||||
@@ -93,21 +69,16 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
||||
const handleSave = async (values: Record<string, unknown>): Promise<boolean> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const currentConfig = config ?? {};
|
||||
const changedValues = Object.fromEntries(
|
||||
Object.entries(values)
|
||||
.map(([key, value]) => [key, stringifyConfigValue(value)] as const)
|
||||
.filter(([key, value]) => value !== (currentConfig[key] ?? CONFIG_DEFAULTS[key] ?? '')),
|
||||
) as Record<string, string>;
|
||||
|
||||
for (const [key, value] of Object.entries(changedValues)) {
|
||||
await setConfig(key, value);
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await setConfig(key, String(value ?? ''));
|
||||
}
|
||||
|
||||
message.success(t('Saved successfully'));
|
||||
setConfigState((prev) => ({ ...(prev ?? {}), ...changedValues }));
|
||||
const stringValues = Object.fromEntries(
|
||||
Object.entries(values).map(([key, value]) => [key, String(value ?? '')]),
|
||||
) as Record<string, string>;
|
||||
setConfigState((prev) => ({ ...(prev ?? {}), ...stringValues }));
|
||||
// trigger theme refresh if related keys changed
|
||||
if (Object.keys(changedValues).some(k => Object.values(THEME_KEYS).includes(k))) {
|
||||
if (Object.keys(values).some(k => Object.values(THEME_KEYS).includes(k))) {
|
||||
await refreshTheme();
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -48,7 +48,6 @@ import type {
|
||||
AIModelPayload,
|
||||
AIProvider,
|
||||
AIProviderPayload,
|
||||
OpenAIProtocol,
|
||||
} from '../../../api/aiProviders';
|
||||
import {
|
||||
createModel,
|
||||
@@ -91,11 +90,6 @@ interface ProviderTemplate {
|
||||
}
|
||||
|
||||
const abilityOrder: AIAbility[] = ['chat', 'vision', 'embedding', 'rerank', 'voice', 'tools'];
|
||||
const defaultOpenAIProtocol: OpenAIProtocol = 'chat_completions';
|
||||
|
||||
function normalizeOpenAIProtocol(value: unknown): OpenAIProtocol {
|
||||
return value === 'responses' ? 'responses' : defaultOpenAIProtocol;
|
||||
}
|
||||
|
||||
const abilityInfo: Record<AIAbility, { icon: ReactNode; label: string; color: string; description: string }> = {
|
||||
chat: {
|
||||
@@ -248,7 +242,6 @@ type AIProviderFormValues = {
|
||||
name?: string;
|
||||
identifier?: string;
|
||||
api_format: AIProviderPayload['api_format'];
|
||||
openai_protocol?: OpenAIProtocol;
|
||||
base_url?: string;
|
||||
api_key?: string;
|
||||
logo_url?: string;
|
||||
@@ -282,19 +275,12 @@ export default function AiSettingsTab() {
|
||||
const [addingRemoteModels, setAddingRemoteModels] = useState<boolean>(false);
|
||||
const [modelModalTab, setModelModalTab] = useState<'remote' | 'manual'>('remote');
|
||||
const [remoteSearchKeyword, setRemoteSearchKeyword] = useState<string>('');
|
||||
const providerApiFormat = Form.useWatch('api_format', providerForm);
|
||||
const capabilitiesValue = Form.useWatch('capabilities', modelForm);
|
||||
const showEmbeddingDimensions = useMemo(() => {
|
||||
const capabilities = Array.isArray(capabilitiesValue) ? capabilitiesValue : [];
|
||||
return capabilities.includes('embedding') || capabilities.includes('rerank');
|
||||
}, [capabilitiesValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (providerApiFormat === 'openai' && !providerForm.getFieldValue('openai_protocol')) {
|
||||
providerForm.setFieldValue('openai_protocol', defaultOpenAIProtocol);
|
||||
}
|
||||
}, [providerApiFormat, providerForm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showEmbeddingDimensions) {
|
||||
modelForm.setFieldsValue({ embedding_dimensions: null });
|
||||
@@ -352,7 +338,6 @@ export default function AiSettingsTab() {
|
||||
name: existing.name,
|
||||
identifier: existing.identifier,
|
||||
api_format: existing.api_format,
|
||||
openai_protocol: normalizeOpenAIProtocol(existing.extra_config?.openai_protocol),
|
||||
base_url: existing.base_url ?? undefined,
|
||||
api_key: '',
|
||||
logo_url: existing.logo_url ?? undefined,
|
||||
@@ -360,7 +345,7 @@ export default function AiSettingsTab() {
|
||||
});
|
||||
} else {
|
||||
providerForm.resetFields();
|
||||
providerForm.setFieldsValue({ api_format: 'openai', openai_protocol: defaultOpenAIProtocol });
|
||||
providerForm.setFieldsValue({ api_format: 'openai' });
|
||||
setSelectedTemplate(null);
|
||||
setProviderModal({ open: true, step: 1 });
|
||||
}
|
||||
@@ -379,7 +364,6 @@ export default function AiSettingsTab() {
|
||||
name: t(template.nameKey),
|
||||
identifier: template.identifier,
|
||||
api_format: template.api_format,
|
||||
openai_protocol: template.api_format === 'openai' ? defaultOpenAIProtocol : undefined,
|
||||
base_url: template.base_url ?? '',
|
||||
api_key: '',
|
||||
logo_url: template.logo_url ?? '',
|
||||
@@ -391,7 +375,7 @@ export default function AiSettingsTab() {
|
||||
setProviderModal((prev) => ({ ...prev, step: 1, editing: undefined }));
|
||||
setSelectedTemplate(null);
|
||||
providerForm.resetFields();
|
||||
providerForm.setFieldsValue({ api_format: 'openai', openai_protocol: defaultOpenAIProtocol });
|
||||
providerForm.setFieldsValue({ api_format: 'openai' });
|
||||
};
|
||||
|
||||
const handleSubmitProvider = async () => {
|
||||
@@ -400,12 +384,6 @@ export default function AiSettingsTab() {
|
||||
const trimmedApiKey = values.api_key?.trim();
|
||||
const trimmedLogoUrl = values.logo_url?.trim();
|
||||
const trimmedProviderType = values.provider_type?.trim();
|
||||
const extraConfig = { ...(providerModal.editing?.extra_config ?? {}) };
|
||||
if (values.api_format === 'openai') {
|
||||
extraConfig.openai_protocol = normalizeOpenAIProtocol(values.openai_protocol);
|
||||
} else {
|
||||
delete extraConfig.openai_protocol;
|
||||
}
|
||||
const payload: AIProviderPayload = {
|
||||
name: (values.name || '').trim(),
|
||||
identifier: (values.identifier || '').trim(),
|
||||
@@ -413,7 +391,6 @@ export default function AiSettingsTab() {
|
||||
base_url: trimmedBaseUrl ? trimmedBaseUrl : null,
|
||||
logo_url: trimmedLogoUrl ? trimmedLogoUrl : null,
|
||||
provider_type: trimmedProviderType ? trimmedProviderType : null,
|
||||
extra_config: Object.keys(extraConfig).length ? extraConfig : null,
|
||||
};
|
||||
if (trimmedApiKey) {
|
||||
payload.api_key = trimmedApiKey;
|
||||
@@ -1140,30 +1117,16 @@ export default function AiSettingsTab() {
|
||||
label={t('API Format')}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select
|
||||
disabled={!allowFormatChange}
|
||||
options={[
|
||||
{ value: 'openai', label: 'OpenAI Compatible' },
|
||||
{ value: 'gemini', label: 'Gemini Compatible' },
|
||||
{ value: 'anthropic', label: 'Anthropic Native' },
|
||||
{ value: 'ollama', label: 'Ollama Native' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{providerApiFormat === 'openai' ? (
|
||||
<Form.Item
|
||||
name="openai_protocol"
|
||||
label={t('OpenAI Protocol')}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'chat_completions', label: 'Chat Completions' },
|
||||
{ value: 'responses', label: 'Responses' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Select
|
||||
disabled={!allowFormatChange}
|
||||
options={[
|
||||
{ value: 'openai', label: 'OpenAI Compatible' },
|
||||
{ value: 'gemini', label: 'Gemini Compatible' },
|
||||
{ value: 'anthropic', label: 'Anthropic Native' },
|
||||
{ value: 'ollama', label: 'Ollama Native' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="base_url" label={t('Base URL')} rules={[{ required: true, message: t('Enter base url') }]}>
|
||||
<Input placeholder="https://" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -54,7 +54,6 @@ export default function AppSettingsTab({
|
||||
return {
|
||||
...Object.fromEntries(configKeys.map(({ key, default: def }) => [key, config[key] ?? def ?? ''])),
|
||||
APP_DEFAULT_LANGUAGE: normalizeLang(config.APP_DEFAULT_LANGUAGE, 'zh'),
|
||||
DEFAULT_FILE_VIEW_MODE: config.DEFAULT_FILE_VIEW_MODE === 'list' ? 'list' : 'grid',
|
||||
AUTH_ALLOW_REGISTER: allowRegister,
|
||||
AUTH_DEFAULT_REGISTER_ROLE_ID: Number.isFinite(roleId) ? roleId : undefined,
|
||||
};
|
||||
@@ -71,7 +70,6 @@ export default function AppSettingsTab({
|
||||
}
|
||||
const defaultLanguage = normalizeLang(vals.APP_DEFAULT_LANGUAGE, 'zh');
|
||||
payload.APP_DEFAULT_LANGUAGE = defaultLanguage;
|
||||
payload.DEFAULT_FILE_VIEW_MODE = vals.DEFAULT_FILE_VIEW_MODE === 'list' ? 'list' : 'grid';
|
||||
const allow = !!vals.AUTH_ALLOW_REGISTER;
|
||||
payload.AUTH_ALLOW_REGISTER = allow ? 'true' : 'false';
|
||||
if (allow) {
|
||||
@@ -105,19 +103,6 @@ export default function AppSettingsTab({
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="DEFAULT_FILE_VIEW_MODE"
|
||||
label={t('Default File View Mode')}
|
||||
>
|
||||
<Select
|
||||
size="large"
|
||||
options={[
|
||||
{ value: 'grid', label: t('Grid') },
|
||||
{ value: 'list', label: t('List') },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider titlePlacement="left">{t('Registration Settings')}</Divider>
|
||||
|
||||
<Alert
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
.video-room-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: #0b0f12;
|
||||
}
|
||||
|
||||
.video-room-page--center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.video-room-page--center .ant-empty-description {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.video-room-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
max-width: 1720px;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.video-room-header__left {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.video-room-header__right {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.video-room-title-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.video-room-title.ant-typography {
|
||||
max-width: min(980px, 70vw);
|
||||
margin: 0 0 4px;
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-room-file-name {
|
||||
max-width: min(720px, 58vw);
|
||||
}
|
||||
|
||||
.video-room-status-tag.ant-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.video-room-shell {
|
||||
max-width: 1720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.video-room-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.video-room-stage {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-room-player {
|
||||
width: 100%;
|
||||
height: min(72vh, 760px);
|
||||
min-height: 480px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-room-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.video-room-note .anticon {
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.video-room-page {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.video-room-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.video-room-header__right {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.video-room-title.ant-typography,
|
||||
.video-room-file-name {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.video-room-player {
|
||||
height: 56vh;
|
||||
min-height: 280px;
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { Button, Empty, Space, Spin, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
DisconnectOutlined,
|
||||
LinkOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import Artplayer from 'artplayer';
|
||||
import { videoRoomsApi, type VideoRoomInfo, type VideoRoomState } from '../api/videoRooms';
|
||||
import { useI18n } from '../i18n';
|
||||
import './VideoRoomPage.css';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const SYNC_THRESHOLD = 1.2;
|
||||
|
||||
function getSyncedTime(state: VideoRoomState) {
|
||||
const baseTime = Math.max(0, Number(state.current_time) || 0);
|
||||
if (state.paused || !state.updated_at) return baseTime;
|
||||
|
||||
const updatedAt = Date.parse(state.updated_at);
|
||||
if (Number.isNaN(updatedAt)) return baseTime;
|
||||
|
||||
return baseTime + Math.max(0, (Date.now() - updatedAt) / 1000);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const safeSeconds = Math.max(0, Math.floor(seconds || 0));
|
||||
const hours = Math.floor(safeSeconds / 3600);
|
||||
const minutes = Math.floor((safeSeconds % 3600) / 60);
|
||||
const secs = safeSeconds % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getFileName(path: string) {
|
||||
return path.split('/').filter(Boolean).pop() || path;
|
||||
}
|
||||
|
||||
const VideoRoomPage = memo(function VideoRoomPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { t } = useI18n();
|
||||
const artRef = useRef<HTMLDivElement | null>(null);
|
||||
const artInstance = useRef<Artplayer | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const applyingRemoteRef = useRef(false);
|
||||
const sendTimerRef = useRef<number | null>(null);
|
||||
const liveStateRef = useRef<VideoRoomState | null>(null);
|
||||
const [room, setRoom] = useState<VideoRoomInfo | null>(null);
|
||||
const [liveState, setLiveState] = useState<VideoRoomState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
if (!token) return;
|
||||
|
||||
videoRoomsApi.get(token)
|
||||
.then((data) => {
|
||||
if (!mounted) return;
|
||||
setRoom(data);
|
||||
setLiveState(data.state);
|
||||
liveStateRef.current = data.state;
|
||||
})
|
||||
.catch((e: any) => {
|
||||
if (!mounted) return;
|
||||
setError(e.message || t('Video room load failed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [t, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !room || !artRef.current) return;
|
||||
|
||||
const updateLocalState = () => {
|
||||
const art = artInstance.current;
|
||||
if (!art || applyingRemoteRef.current) return null;
|
||||
const video = art.video;
|
||||
const state: VideoRoomState = {
|
||||
current_time: video.currentTime || 0,
|
||||
paused: video.paused,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
liveStateRef.current = state;
|
||||
setLiveState(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const sendState = () => {
|
||||
const ws = wsRef.current;
|
||||
const state = updateLocalState();
|
||||
if (!state || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload = {
|
||||
type: 'state',
|
||||
current_time: state.current_time,
|
||||
paused: state.paused,
|
||||
};
|
||||
ws.send(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
const sendStateSoon = () => {
|
||||
if (sendTimerRef.current !== null) {
|
||||
window.clearTimeout(sendTimerRef.current);
|
||||
}
|
||||
sendTimerRef.current = window.setTimeout(() => {
|
||||
sendTimerRef.current = null;
|
||||
sendState();
|
||||
}, 120);
|
||||
};
|
||||
|
||||
const applyState = (state: VideoRoomState) => {
|
||||
const art = artInstance.current;
|
||||
liveStateRef.current = state;
|
||||
setLiveState(state);
|
||||
if (!art) return;
|
||||
const video = art.video;
|
||||
const targetTime = getSyncedTime(state);
|
||||
applyingRemoteRef.current = true;
|
||||
if (Math.abs((video.currentTime || 0) - targetTime) > SYNC_THRESHOLD) {
|
||||
video.currentTime = targetTime;
|
||||
}
|
||||
if (state.paused && !video.paused) {
|
||||
void video.pause();
|
||||
}
|
||||
if (!state.paused && video.paused) {
|
||||
void video.play().catch(() => undefined);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
applyingRemoteRef.current = false;
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const applyLatestState = () => {
|
||||
applyState(liveStateRef.current || room.state);
|
||||
};
|
||||
|
||||
const art = new Artplayer({
|
||||
container: artRef.current,
|
||||
url: videoRoomsApi.streamUrl(token),
|
||||
autoplay: false,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
pip: true,
|
||||
setting: true,
|
||||
playbackRate: true,
|
||||
});
|
||||
artInstance.current = art;
|
||||
|
||||
art.on('ready', applyLatestState);
|
||||
art.video.addEventListener('loadedmetadata', applyLatestState);
|
||||
art.on('play', sendStateSoon);
|
||||
art.on('pause', sendStateSoon);
|
||||
art.on('seek', sendStateSoon);
|
||||
|
||||
const ws = new WebSocket(videoRoomsApi.wsUrl(token));
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onclose = () => setConnected(false);
|
||||
ws.onerror = () => setConnected(false);
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data?.type === 'state' && data.state) {
|
||||
applyState(data.state);
|
||||
}
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (sendTimerRef.current !== null) {
|
||||
window.clearTimeout(sendTimerRef.current);
|
||||
sendTimerRef.current = null;
|
||||
}
|
||||
art.video.removeEventListener('loadedmetadata', applyLatestState);
|
||||
ws.close();
|
||||
art.destroy();
|
||||
wsRef.current = null;
|
||||
artInstance.current = null;
|
||||
};
|
||||
}, [room, token]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
message.success(t('Copied to clipboard'));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="video-room-page video-room-page--center">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !room) {
|
||||
return (
|
||||
<div className="video-room-page video-room-page--center">
|
||||
<Empty description={error || t('Video room not found')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileName = getFileName(room.path);
|
||||
const state = liveState || room.state;
|
||||
|
||||
return (
|
||||
<div className="video-room-page">
|
||||
<header className="video-room-header">
|
||||
<div className="video-room-header__left">
|
||||
<div className="video-room-title-block">
|
||||
<Title level={3} className="video-room-title">{room.name}</Title>
|
||||
<Space size={8} wrap>
|
||||
<Text type="secondary" ellipsis className="video-room-file-name">{fileName}</Text>
|
||||
<Text type="secondary">{formatTime(state.current_time)}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-room-header__right">
|
||||
<Tag
|
||||
className="video-room-status-tag"
|
||||
color={connected ? 'success' : 'error'}
|
||||
icon={connected ? <CheckCircleFilled /> : <DisconnectOutlined />}
|
||||
>
|
||||
{connected ? t('Room synced') : t('Room disconnected')}
|
||||
</Tag>
|
||||
<Tooltip title={t('Copy Link')}>
|
||||
<Button icon={<LinkOutlined />} onClick={handleCopy}>
|
||||
{t('Share room')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="video-room-shell">
|
||||
<section className="video-room-main">
|
||||
<div className="video-room-stage">
|
||||
<div ref={artRef} className="video-room-player" />
|
||||
</div>
|
||||
<div className="video-room-note">
|
||||
<CheckCircleFilled />
|
||||
<span>{t('Playback state is shared in this room')}</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default VideoRoomPage;
|
||||
@@ -364,27 +364,12 @@ async function main() {
|
||||
|
||||
await mountError();
|
||||
|
||||
const runCleanup = () => {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
try {
|
||||
cleanup?.();
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
cleanup = null;
|
||||
};
|
||||
|
||||
window.addEventListener('message', (ev) => {
|
||||
if (ev.origin !== window.location.origin) return;
|
||||
if (ev.source !== window.parent) return;
|
||||
const data = ev.data as any;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (data.type !== 'foxel-plugin:unload') return;
|
||||
runCleanup();
|
||||
root.innerHTML = '';
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
runCleanup();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import LoginPage from '../pages/LoginPage.tsx';
|
||||
import RegisterPage from '../pages/RegisterPage.tsx';
|
||||
import SetupPage from '../pages/SetupPage.tsx';
|
||||
import PublicSharePage from '../pages/PublicSharePage';
|
||||
import VideoRoomPage from '../pages/VideoRoomPage';
|
||||
import ForgotPasswordPage from '../pages/ForgotPasswordPage';
|
||||
import ResetPasswordPage from '../pages/ResetPasswordPage';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -17,7 +16,6 @@ export const routes: RouteObject[] = [
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/register', element: <RegisterPage /> },
|
||||
{ path: '/share/:token', element: <PublicSharePage /> },
|
||||
{ path: '/room/:token', element: <VideoRoomPage /> },
|
||||
{ path: '/setup', element: <SetupPage /> },
|
||||
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
||||
{ path: '/reset-password', element: <ResetPasswordPage /> },
|
||||
@@ -28,7 +26,7 @@ function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
const location = useLocation();
|
||||
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password'];
|
||||
const isPublic = publicPaths.some((p) => location.pathname.startsWith(p));
|
||||
if (!isAuthenticated && !location.pathname.startsWith('/share/') && !location.pathname.startsWith('/room/') && !isPublic) {
|
||||
if (!isAuthenticated && !location.pathname.startsWith('/share/') && !isPublic) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return children;
|
||||
|
||||
Reference in New Issue
Block a user