Compare commits

..

1 Commits

Author SHA1 Message Date
时雨
c157f1573b feat: add recent files backend APIs 2026-05-06 21:20:13 +08:00
54 changed files with 484 additions and 2751 deletions

View File

@@ -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"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -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):

View File

@@ -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)

View File

@@ -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]): ...

View File

@@ -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:
"""

View File

@@ -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)
# -----------------
# 上传(大文件分片)

View File

@@ -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
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:
downloaded = 0
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)

View File

@@ -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",

View File

@@ -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}")

View File

@@ -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}

View File

@@ -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

View File

@@ -19,7 +19,6 @@ PUBLIC_CONFIG_KEYS = [
"THEME_BORDER_RADIUS",
"THEME_CUSTOM_TOKENS",
"THEME_CUSTOM_CSS",
"DEFAULT_FILE_VIEW_MODE",
]

View File

@@ -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:

View File

@@ -1,3 +0,0 @@
from .service import NoticeService, notice_sync_service
__all__ = ["NoticeService", "notice_sync_service"]

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -1,9 +0,0 @@
from .service import VideoRoomService
from .types import VideoRoomCreate, VideoRoomInfo, VideoRoomState
__all__ = [
"VideoRoomService",
"VideoRoomCreate",
"VideoRoomInfo",
"VideoRoomState",
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
),
)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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,
},
}

View File

@@ -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()

View File

@@ -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) # 插件唯一标识

View File

@@ -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
View File

@@ -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"

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
},
};

View File

@@ -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) => {

View File

@@ -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;
},
};

View File

@@ -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 (

View File

@@ -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;

View File

@@ -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",

View File

@@ -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": "存储适配器"
}

View File

@@ -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>
);

View File

@@ -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)}

View File

@@ -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'),

View File

@@ -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'),

View File

@@ -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')}
/>

View File

@@ -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>
);
});

View File

@@ -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
};
}

View File

@@ -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),

View File

@@ -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;

View File

@@ -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>

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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();
});
}

View File

@@ -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;