mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-09 10:42:40 +08:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4c18f991f | ||
|
|
58c2cdd440 | ||
|
|
7d861ca5f7 | ||
|
|
52bac11760 | ||
|
|
c441d8776f | ||
|
|
45e0194465 | ||
|
|
540065f195 | ||
|
|
4f86e2da4d | ||
|
|
31d347d24f | ||
|
|
7a9a20509c | ||
|
|
373b6410c2 | ||
|
|
d6eb6e1605 | ||
|
|
1d66fb56c8 | ||
|
|
bb9589fa62 | ||
|
|
ab89451b2d | ||
|
|
3e1b75d81a | ||
|
|
1679b03d3a | ||
|
|
ab6562fc79 | ||
|
|
87770176b6 | ||
|
|
e7cf8dbdb8 | ||
|
|
e7eafdee97 | ||
|
|
051b49d3f6 | ||
|
|
b059b0eb44 | ||
|
|
59ad2cb622 | ||
|
|
6b2ada0b42 | ||
|
|
a727e77341 | ||
|
|
4638356a45 | ||
|
|
e51344b43e | ||
|
|
b7685db0e8 |
@@ -11,10 +11,11 @@ from domain.processors import api as processors
|
||||
from domain.share import api as share
|
||||
from domain.tasks import api as tasks
|
||||
from domain.ai import api as ai
|
||||
from domain.agent import api as agent
|
||||
from domain.virtual_fs import api as virtual_fs
|
||||
from domain.virtual_fs.mapping import s3_api, webdav_api
|
||||
from domain.virtual_fs.search import search_api
|
||||
from domain.audit import router as audit
|
||||
from domain.audit import api as audit
|
||||
|
||||
|
||||
def include_routers(app: FastAPI):
|
||||
@@ -30,9 +31,10 @@ def include_routers(app: FastAPI):
|
||||
app.include_router(backup.router)
|
||||
app.include_router(ai.router_vector_db)
|
||||
app.include_router(ai.router_ai)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(plugins.router)
|
||||
app.include_router(webdav_api.router)
|
||||
app.include_router(s3_api.router)
|
||||
app.include_router(offline_downloads.router)
|
||||
app.include_router(email.router)
|
||||
app.include_router(audit)
|
||||
app.include_router(audit.router)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from tortoise import Tortoise
|
||||
|
||||
from domain.adapters.registry import runtime_registry
|
||||
from domain.adapters import runtime_registry
|
||||
|
||||
TORTOISE_ORM = {
|
||||
"connections": {"default": "sqlite://data/db/db.sqlite3"},
|
||||
|
||||
7
domain/__init__.py
Normal file
7
domain/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
domain:业务域层
|
||||
|
||||
约定:跨包只从各子包 `__init__.py` 导入公开 API。
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -1 +1,24 @@
|
||||
from .providers import BaseAdapter
|
||||
from .registry import (
|
||||
RuntimeRegistry,
|
||||
discover_adapters,
|
||||
get_config_schema,
|
||||
get_config_schemas,
|
||||
normalize_adapter_type,
|
||||
runtime_registry,
|
||||
)
|
||||
from .service import AdapterService
|
||||
from .types import AdapterCreate, AdapterOut
|
||||
|
||||
__all__ = [
|
||||
"BaseAdapter",
|
||||
"RuntimeRegistry",
|
||||
"discover_adapters",
|
||||
"get_config_schema",
|
||||
"get_config_schemas",
|
||||
"normalize_adapter_type",
|
||||
"runtime_registry",
|
||||
"AdapterService",
|
||||
"AdapterCreate",
|
||||
"AdapterOut",
|
||||
]
|
||||
|
||||
@@ -4,10 +4,9 @@ from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from api.response import success
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.adapters.service import AdapterService
|
||||
from domain.adapters.types import AdapterCreate
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.auth.types import User
|
||||
from domain.auth import User, get_current_active_user
|
||||
from .service import AdapterService
|
||||
from .types import AdapterCreate
|
||||
|
||||
router = APIRouter(prefix="/api/adapters", tags=["adapters"])
|
||||
|
||||
|
||||
@@ -81,8 +81,9 @@ class AListApiAdapterBase:
|
||||
raise ValueError(f"{product_name} requires base_url http/https")
|
||||
self.username: str = str(cfg.get("username") or "")
|
||||
self.password: str = str(cfg.get("password") or "")
|
||||
if not self.username or not self.password:
|
||||
raise ValueError(f"{product_name} requires username and password")
|
||||
if (self.username and not self.password) or (self.password and not self.username):
|
||||
raise ValueError(f"{product_name} requires both username and password")
|
||||
self.use_auth: bool = bool(self.username and self.password)
|
||||
|
||||
self.timeout: float = float(cfg.get("timeout", 30))
|
||||
self.root_path: str = _normalize_fs_path(str(cfg.get("root") or "/"))
|
||||
@@ -98,6 +99,8 @@ class AListApiAdapterBase:
|
||||
return base
|
||||
|
||||
async def _ensure_token(self) -> str:
|
||||
if not self.use_auth:
|
||||
return ""
|
||||
if self._token:
|
||||
return self._token
|
||||
async with self._login_lock:
|
||||
@@ -137,12 +140,14 @@ class AListApiAdapterBase:
|
||||
) -> Any:
|
||||
token = await self._ensure_token()
|
||||
url = self.base_url + endpoint
|
||||
req_headers: Dict[str, str] = {"Authorization": token}
|
||||
req_headers: Dict[str, str] = {}
|
||||
if token:
|
||||
req_headers["Authorization"] = token
|
||||
if headers:
|
||||
req_headers.update(headers)
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
resp = await client.request(method, url, json=json, headers=req_headers, files=files)
|
||||
if resp.status_code == 401 and retry:
|
||||
if resp.status_code == 401 and retry and self.use_auth:
|
||||
self._token = None
|
||||
return await self._api_json(method, endpoint, json=json, headers=headers, retry=False, files=files)
|
||||
resp.raise_for_status()
|
||||
@@ -153,7 +158,7 @@ class AListApiAdapterBase:
|
||||
code = payload.get("code")
|
||||
if code in (0, 200):
|
||||
return payload.get("data")
|
||||
if code in (401, 403) and retry:
|
||||
if code in (401, 403) and retry and self.use_auth:
|
||||
self._token = None
|
||||
return await self._api_json(method, endpoint, json=json, headers=headers, retry=False, files=files)
|
||||
if code == 404:
|
||||
@@ -349,10 +354,9 @@ class AListApiAdapterBase:
|
||||
|
||||
async def _upload_file(self, full_path: str, file_path: Path) -> Any:
|
||||
token = await self._ensure_token()
|
||||
headers = {
|
||||
"Authorization": token,
|
||||
"File-Path": quote(full_path, safe="/"),
|
||||
}
|
||||
headers = {"File-Path": quote(full_path, safe="/")}
|
||||
if token:
|
||||
headers["Authorization"] = token
|
||||
with file_path.open("rb") as f:
|
||||
files = {"file": (file_path.name, f, "application/octet-stream")}
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
@@ -381,6 +385,30 @@ class AListApiAdapterBase:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def write_upload_file(self, root: str, rel: str, file_obj, filename: str | None, file_size: int | None = None, content_type: str | None = None):
|
||||
full_path = _join_fs_path(root, rel)
|
||||
token = await self._ensure_token()
|
||||
headers = {"File-Path": quote(full_path, safe="/")}
|
||||
if token:
|
||||
headers["Authorization"] = token
|
||||
name = filename or Path(rel).name or "file"
|
||||
mime = content_type or "application/octet-stream"
|
||||
files = {"file": (name, file_obj, mime)}
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
resp = await client.put(self.base_url + "/api/fs/form", headers=headers, files=files)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(502, detail=f"{self.product_name} upload: invalid response")
|
||||
code = payload.get("code")
|
||||
if code not in (0, 200):
|
||||
msg = payload.get("message") or payload.get("msg") or ""
|
||||
raise HTTPException(502, detail=f"{self.product_name} upload failed: {msg}")
|
||||
data = payload.get("data")
|
||||
if isinstance(data, dict) and file_size is not None and "size" not in data:
|
||||
data["size"] = file_size
|
||||
return data
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
full_path = _join_fs_path(root, rel)
|
||||
suffix = Path(rel).suffix
|
||||
@@ -479,8 +507,8 @@ ADAPTER_TYPES = {"alist": AListAdapter, "openlist": OpenListAdapter}
|
||||
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "base_url", "label": "基础地址", "type": "string", "required": True, "placeholder": "http://127.0.0.1:5244"},
|
||||
{"key": "username", "label": "用户名", "type": "string", "required": True},
|
||||
{"key": "password", "label": "密码", "type": "password", "required": True},
|
||||
{"key": "username", "label": "用户名", "type": "string", "required": False, "placeholder": "留空则匿名访问"},
|
||||
{"key": "password", "label": "密码", "type": "password", "required": False, "placeholder": "留空则匿名访问"},
|
||||
{"key": "root", "label": "根目录", "type": "string", "required": False, "default": "/"},
|
||||
{"key": "timeout", "label": "超时(秒)", "type": "number", "required": False, "default": 30},
|
||||
{"key": "enable_direct_download_307", "label": "启用 307 直链下载", "type": "boolean", "default": False},
|
||||
|
||||
@@ -250,6 +250,30 @@ class FoxelAdapter:
|
||||
return True
|
||||
raise HTTPException(502, detail="Foxel 写入失败")
|
||||
|
||||
async def write_upload_file(self, root: str, rel: str, file_obj, filename: str | None, file_size: int | None = None, content_type: str | None = None):
|
||||
rel = (rel or "").lstrip("/")
|
||||
full_path = _join_fs_path(root, rel)
|
||||
url = self.base_url + self._file_path(full_path)
|
||||
name = filename or Path(rel).name or "file"
|
||||
mime = content_type or "application/octet-stream"
|
||||
for attempt in range(2):
|
||||
try:
|
||||
if callable(getattr(file_obj, "seek", None)):
|
||||
file_obj.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
token = await self._ensure_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
files = {"file": (name, file_obj, mime)}
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
resp = await client.post(url, headers=headers, files=files)
|
||||
if resp.status_code == 401 and attempt == 0:
|
||||
self._token = None
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return {"size": file_size or 0}
|
||||
raise HTTPException(502, detail="Foxel 上传失败")
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
rel = (rel or "").lstrip("/")
|
||||
full_path = _join_fs_path(root, rel)
|
||||
|
||||
@@ -238,6 +238,39 @@ class FTPAdapter:
|
||||
|
||||
await asyncio.to_thread(_do_write)
|
||||
|
||||
async def write_upload_file(self, root: str, rel: str, file_obj, filename: str | None, file_size: int | None = None, content_type: str | None = None):
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _ensure_dirs(ftp: FTP, dir_path: str):
|
||||
parts = [p for p in dir_path.strip("/").split("/") if p]
|
||||
cur = "/"
|
||||
for p in parts:
|
||||
cur = _join_remote(cur, p)
|
||||
try:
|
||||
ftp.mkd(cur)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _do_upload():
|
||||
ftp = self._connect()
|
||||
try:
|
||||
parent = "/" if "/" not in path.strip("/") else path.rsplit("/", 1)[0]
|
||||
_ensure_dirs(ftp, parent)
|
||||
try:
|
||||
if callable(getattr(file_obj, "seek", None)):
|
||||
file_obj.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
ftp.storbinary("STOR " + path, file_obj)
|
||||
finally:
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.to_thread(_do_upload)
|
||||
return {"size": file_size or 0}
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
# KISS: 聚合后一次性写入
|
||||
buf = bytearray()
|
||||
|
||||
@@ -114,6 +114,32 @@ class LocalAdapter:
|
||||
if not pre_exists:
|
||||
await asyncio.to_thread(_apply_mode, fp, DEFAULT_FILE_MODE)
|
||||
|
||||
async def write_upload_file(self, root: str, rel: str, file_obj, filename: str | None, file_size: int | None = None, content_type: str | None = None):
|
||||
fp = _safe_join(root, rel)
|
||||
pre_exists = fp.exists()
|
||||
await asyncio.to_thread(os.makedirs, fp.parent, mode=DEFAULT_DIR_MODE, exist_ok=True)
|
||||
|
||||
def _copy():
|
||||
try:
|
||||
if callable(getattr(file_obj, "seek", None)):
|
||||
file_obj.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
with open(fp, "wb") as f:
|
||||
shutil.copyfileobj(file_obj, f)
|
||||
|
||||
await asyncio.to_thread(_copy)
|
||||
if not pre_exists:
|
||||
await asyncio.to_thread(_apply_mode, fp, DEFAULT_FILE_MODE)
|
||||
|
||||
size = file_size
|
||||
if size is None:
|
||||
try:
|
||||
size = fp.stat().st_size
|
||||
except Exception:
|
||||
size = 0
|
||||
return {"size": int(size or 0)}
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
fp = _safe_join(root, rel)
|
||||
pre_exists = fp.exists()
|
||||
|
||||
@@ -453,6 +453,159 @@ class QuarkAdapter:
|
||||
yield data
|
||||
return await self.write_file_stream(root, rel, gen())
|
||||
|
||||
async def write_upload_file(self, root: str, rel: str, file_obj, filename: str | None, file_size: int | None = None, content_type: str | None = None):
|
||||
if not rel or rel.endswith("/"):
|
||||
raise HTTPException(400, detail="Invalid file path")
|
||||
|
||||
parent = rel.rsplit("/", 1)[0] if "/" in rel else ""
|
||||
name = filename or rel.rsplit("/", 1)[-1]
|
||||
base_fid = root or self.root_fid
|
||||
parent_fid = await self._resolve_dir_fid_from(base_fid, parent)
|
||||
|
||||
md5 = hashlib.md5()
|
||||
sha1 = hashlib.sha1()
|
||||
total = 0
|
||||
try:
|
||||
if callable(getattr(file_obj, "seek", None)):
|
||||
file_obj.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
while True:
|
||||
chunk = file_obj.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
md5.update(chunk)
|
||||
sha1.update(chunk)
|
||||
|
||||
md5_hex = md5.hexdigest()
|
||||
sha1_hex = sha1.hexdigest()
|
||||
|
||||
# 预上传,拿到上传信息
|
||||
pre_resp = await self._upload_pre(name, total, parent_fid)
|
||||
pre_data = pre_resp.get("data", {})
|
||||
|
||||
# hash 秒传
|
||||
hash_body = {"md5": md5_hex, "sha1": sha1_hex, "task_id": pre_data.get("task_id")}
|
||||
hash_resp = await self._request("POST", "/file/update/hash", json=hash_body)
|
||||
if (hash_resp.get("data") or {}).get("finish") is True:
|
||||
self._invalidate_children_cache(parent_fid)
|
||||
return {"size": total}
|
||||
|
||||
# 分片上传
|
||||
part_size = int((pre_resp.get("metadata") or {}).get("part_size") or 0)
|
||||
if part_size <= 0:
|
||||
raise HTTPException(502, detail="Invalid part_size from Quark")
|
||||
|
||||
bucket = pre_data.get("bucket")
|
||||
obj_key = pre_data.get("obj_key")
|
||||
upload_id = pre_data.get("upload_id")
|
||||
upload_url = pre_data.get("upload_url")
|
||||
if not (bucket and obj_key and upload_id and upload_url):
|
||||
raise HTTPException(502, detail="Upload pre missing fields")
|
||||
|
||||
try:
|
||||
upload_host = upload_url.split("://", 1)[1]
|
||||
except Exception:
|
||||
upload_host = upload_url
|
||||
base_url = f"https://{bucket}.{upload_host}/{obj_key}"
|
||||
|
||||
try:
|
||||
if callable(getattr(file_obj, "seek", None)):
|
||||
file_obj.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
etags: List[str] = []
|
||||
oss_ua = "aliyun-sdk-js/6.6.1 Chrome 98.0.4758.80 on Windows 10 64-bit"
|
||||
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
|
||||
part_number = 1
|
||||
left = total
|
||||
while left > 0:
|
||||
sz = min(part_size, left)
|
||||
data_bytes = file_obj.read(sz)
|
||||
if len(data_bytes) != sz:
|
||||
raise IOError("Failed to read part bytes")
|
||||
now_str = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime())
|
||||
auth_meta = (
|
||||
"PUT\n\n"
|
||||
f"{self._guess_mime(name)}\n"
|
||||
f"{now_str}\n"
|
||||
f"x-oss-date:{now_str}\n"
|
||||
f"x-oss-user-agent:{oss_ua}\n"
|
||||
f"/{bucket}/{obj_key}?partNumber={part_number}&uploadId={upload_id}"
|
||||
)
|
||||
auth_req_body = {"auth_info": pre_data.get("auth_info"), "auth_meta": auth_meta, "task_id": pre_data.get("task_id")}
|
||||
auth_resp = await self._request("POST", "/file/upload/auth", json=auth_req_body)
|
||||
auth_key = (auth_resp.get("data") or {}).get("auth_key")
|
||||
if not auth_key:
|
||||
raise HTTPException(502, detail="upload/auth missing auth_key")
|
||||
|
||||
put_headers = {
|
||||
"Authorization": auth_key,
|
||||
"Content-Type": self._guess_mime(name),
|
||||
"Referer": REFERER + "/",
|
||||
"x-oss-date": now_str,
|
||||
"x-oss-user-agent": oss_ua,
|
||||
}
|
||||
put_url = f"{base_url}?partNumber={part_number}&uploadId={upload_id}"
|
||||
put_resp = await client.put(put_url, headers=put_headers, content=data_bytes)
|
||||
if put_resp.status_code != 200:
|
||||
raise HTTPException(502, detail=f"Upload part failed status={put_resp.status_code} text={put_resp.text}")
|
||||
etag = put_resp.headers.get("Etag", "")
|
||||
etags.append(etag)
|
||||
left -= sz
|
||||
part_number += 1
|
||||
|
||||
parts_xml = [f"<Part>\n<PartNumber>{i+1}</PartNumber>\n<ETag>{etags[i]}</ETag>\n</Part>\n" for i in range(len(etags))]
|
||||
body_xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<CompleteMultipartUpload>\n" + "".join(parts_xml) + "</CompleteMultipartUpload>"
|
||||
content_md5 = base64.b64encode(hashlib.md5(body_xml.encode("utf-8")).digest()).decode("ascii")
|
||||
callback = pre_data.get("callback") or {}
|
||||
try:
|
||||
import json as _json
|
||||
callback_b64 = base64.b64encode(_json.dumps(callback).encode("utf-8")).decode("ascii")
|
||||
except Exception:
|
||||
callback_b64 = ""
|
||||
|
||||
now_str = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime())
|
||||
auth_meta_commit = (
|
||||
"POST\n"
|
||||
f"{content_md5}\n"
|
||||
"application/xml\n"
|
||||
f"{now_str}\n"
|
||||
f"x-oss-callback:{callback_b64}\n"
|
||||
f"x-oss-date:{now_str}\n"
|
||||
f"x-oss-user-agent:{oss_ua}\n"
|
||||
f"/{bucket}/{obj_key}?uploadId={upload_id}"
|
||||
)
|
||||
auth_commit_resp = await self._request("POST", "/file/upload/auth", json={"auth_info": pre_data.get("auth_info"), "auth_meta": auth_meta_commit, "task_id": pre_data.get("task_id")})
|
||||
auth_key_commit = (auth_commit_resp.get("data") or {}).get("auth_key")
|
||||
if not auth_key_commit:
|
||||
raise HTTPException(502, detail="upload/auth(commit) missing auth_key")
|
||||
|
||||
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
|
||||
commit_headers = {
|
||||
"Authorization": auth_key_commit,
|
||||
"Content-MD5": content_md5,
|
||||
"Content-Type": "application/xml",
|
||||
"Referer": REFERER + "/",
|
||||
"x-oss-callback": callback_b64,
|
||||
"x-oss-date": now_str,
|
||||
"x-oss-user-agent": oss_ua,
|
||||
}
|
||||
commit_url = f"{base_url}?uploadId={upload_id}"
|
||||
r = await client.post(commit_url, headers=commit_headers, content=body_xml.encode("utf-8"))
|
||||
if r.status_code != 200:
|
||||
raise HTTPException(502, detail=f"Upload commit failed status={r.status_code} text={r.text}")
|
||||
|
||||
await self._request("POST", "/file/upload/finish", json={"obj_key": obj_key, "task_id": pre_data.get("task_id")})
|
||||
try:
|
||||
await asyncio.sleep(1.0)
|
||||
except Exception:
|
||||
pass
|
||||
self._invalidate_children_cache(parent_fid)
|
||||
return {"size": total}
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
if not rel or rel.endswith("/"):
|
||||
raise HTTPException(400, detail="Invalid file path")
|
||||
|
||||
@@ -157,6 +157,41 @@ class SFTPAdapter:
|
||||
|
||||
await asyncio.to_thread(_do_write)
|
||||
|
||||
async def write_upload_file(self, root: str, rel: str, file_obj, filename: str | None, file_size: int | None = None, content_type: str | None = None):
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _ensure_dirs(sftp: paramiko.SFTPClient, dir_path: str):
|
||||
parts = [p for p in dir_path.strip("/").split("/") if p]
|
||||
cur = "/"
|
||||
for p in parts:
|
||||
cur = _join_remote(cur, p)
|
||||
try:
|
||||
sftp.mkdir(cur)
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
def _do_upload():
|
||||
sftp = self._connect()
|
||||
try:
|
||||
parent = "/" if "/" not in path.strip("/") else path.rsplit("/", 1)[0]
|
||||
_ensure_dirs(sftp, parent)
|
||||
try:
|
||||
if callable(getattr(file_obj, "seek", None)):
|
||||
file_obj.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
with sftp.open(path, "wb") as f:
|
||||
import shutil
|
||||
shutil.copyfileobj(file_obj, f)
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.to_thread(_do_upload)
|
||||
return {"size": file_size or 0}
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
buf = bytearray()
|
||||
async for chunk in data_iter:
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
from typing import List, Dict, Tuple, AsyncIterator
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
from models import StorageAdapter
|
||||
from telethon import TelegramClient
|
||||
from telethon.crypto import AuthKey
|
||||
from telethon.sessions import StringSession
|
||||
from telethon.tl import types
|
||||
import socks
|
||||
|
||||
_SESSION_LOCKS: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _get_session_lock(session_string: str) -> asyncio.Lock:
|
||||
lock = _SESSION_LOCKS.get(session_string)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_SESSION_LOCKS[session_string] = lock
|
||||
return lock
|
||||
|
||||
|
||||
class _NamedFile:
|
||||
def __init__(self, file_obj, name: str):
|
||||
self._file = file_obj
|
||||
self.name = name
|
||||
|
||||
def read(self, *args, **kwargs):
|
||||
return self._file.read(*args, **kwargs)
|
||||
|
||||
def seek(self, *args, **kwargs):
|
||||
return self._file.seek(*args, **kwargs)
|
||||
|
||||
def tell(self):
|
||||
return self._file.tell()
|
||||
|
||||
def seekable(self):
|
||||
return self._file.seekable()
|
||||
|
||||
def close(self):
|
||||
return self._file.close()
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._file, name)
|
||||
|
||||
# 适配器类型标识
|
||||
ADAPTER_TYPE = "telegram"
|
||||
|
||||
@@ -54,9 +93,93 @@ 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")
|
||||
|
||||
@staticmethod
|
||||
def _parse_legacy_session_string(value: str) -> StringSession:
|
||||
"""
|
||||
兼容旧版 session_string 格式:
|
||||
- version(1B char) + base64(data)
|
||||
- data: dc_id(1B) + ip_len(2B) + ip(ASCII, ip_len bytes) + port(2B) + auth_key(256B)
|
||||
"""
|
||||
s = (value or "").strip()
|
||||
if not s:
|
||||
raise ValueError("session_string 为空")
|
||||
|
||||
body = s[1:] if s.startswith("1") else s
|
||||
raw = base64.urlsafe_b64decode(body)
|
||||
if len(raw) < 1 + 2 + 2 + 256:
|
||||
raise ValueError("legacy session 数据长度不足")
|
||||
|
||||
dc_id = raw[0]
|
||||
ip_len = struct.unpack(">H", raw[1:3])[0]
|
||||
expected_len = 1 + 2 + ip_len + 2 + 256
|
||||
if len(raw) != expected_len:
|
||||
raise ValueError("legacy session 数据长度不匹配")
|
||||
|
||||
ip_start = 3
|
||||
ip_end = ip_start + ip_len
|
||||
ip = raw[ip_start:ip_end].decode("utf-8")
|
||||
port = struct.unpack(">H", raw[ip_end : ip_end + 2])[0]
|
||||
key = raw[ip_end + 2 : ip_end + 2 + 256]
|
||||
|
||||
sess = StringSession()
|
||||
sess.set_dc(dc_id, ip, port)
|
||||
sess.auth_key = AuthKey(key)
|
||||
return sess
|
||||
|
||||
@staticmethod
|
||||
def _pick_photo_thumb(thumbs: list | None):
|
||||
if not thumbs:
|
||||
return None
|
||||
|
||||
cached = []
|
||||
others = []
|
||||
for t in thumbs:
|
||||
if isinstance(t, (types.PhotoCachedSize, types.PhotoStrippedSize)):
|
||||
cached.append(t)
|
||||
elif isinstance(t, (types.PhotoSize, types.PhotoSizeProgressive)):
|
||||
if not isinstance(t, types.PhotoSizeEmpty):
|
||||
others.append(t)
|
||||
|
||||
if cached:
|
||||
cached.sort(key=lambda x: len(getattr(x, "bytes", b"") or b""))
|
||||
return cached[-1]
|
||||
|
||||
if others:
|
||||
def _sz(x):
|
||||
if isinstance(x, types.PhotoSizeProgressive):
|
||||
return max(x.sizes or [0])
|
||||
return int(getattr(x, "size", 0) or 0)
|
||||
|
||||
others.sort(key=_sz)
|
||||
return others[-1]
|
||||
|
||||
return None
|
||||
|
||||
def _build_session(self) -> StringSession:
|
||||
s = (self.session_string or "").strip()
|
||||
if not s:
|
||||
raise ValueError("Telegram 适配器 session_string 为空")
|
||||
|
||||
try:
|
||||
return StringSession(s)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 少数工具可能去掉了 version 前缀,这里做一次兼容
|
||||
if not s.startswith("1"):
|
||||
try:
|
||||
return StringSession("1" + s)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return self._parse_legacy_session_string(s)
|
||||
except Exception as exc:
|
||||
raise ValueError("Telegram session_string 无效,请使用 Telethon StringSession 重新生成") from exc
|
||||
|
||||
def _get_client(self) -> TelegramClient:
|
||||
"""创建一个新的 TelegramClient 实例"""
|
||||
return TelegramClient(StringSession(self.session_string), self.api_id, self.api_hash, proxy=self.proxy)
|
||||
return TelegramClient(self._build_session(), self.api_id, self.api_hash, proxy=self.proxy)
|
||||
|
||||
def get_effective_root(self, sub_path: str | None) -> str:
|
||||
return ""
|
||||
@@ -164,7 +287,48 @@ class TelegramAdapter:
|
||||
|
||||
try:
|
||||
await client.connect()
|
||||
await client.send_file(self.chat_id, file_like, caption=file_like.name)
|
||||
sent = await client.send_file(self.chat_id, file_like, caption=file_like.name)
|
||||
message = sent[0] if isinstance(sent, list) and sent else sent
|
||||
actual_rel = rel
|
||||
if message:
|
||||
stored_name = file_like.name
|
||||
file_meta = getattr(message, "file", None)
|
||||
if file_meta and getattr(file_meta, "name", None):
|
||||
stored_name = file_meta.name
|
||||
if getattr(message, "id", None) is not None:
|
||||
actual_rel = f"{message.id}_{stored_name}"
|
||||
return {"rel": actual_rel, "size": len(data)}
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
async def write_upload_file(self, root: str, rel: str, file_obj, filename: str | None, file_size: int | None = None, content_type: str | None = None):
|
||||
client = self._get_client()
|
||||
name = filename or os.path.basename(rel) or "file"
|
||||
file_like = _NamedFile(file_obj, name)
|
||||
|
||||
try:
|
||||
await client.connect()
|
||||
sent = await client.send_file(
|
||||
self.chat_id,
|
||||
file_like,
|
||||
caption=file_like.name,
|
||||
file_size=file_size,
|
||||
mime_type=content_type,
|
||||
)
|
||||
message = sent[0] if isinstance(sent, list) and sent else sent
|
||||
actual_rel = rel
|
||||
size = file_size or 0
|
||||
if message:
|
||||
stored_name = file_like.name
|
||||
file_meta = getattr(message, "file", None)
|
||||
if file_meta and getattr(file_meta, "name", None):
|
||||
stored_name = file_meta.name
|
||||
if getattr(message, "id", None) is not None:
|
||||
actual_rel = f"{message.id}_{stored_name}"
|
||||
if file_meta and getattr(file_meta, "size", None):
|
||||
size = int(file_meta.size)
|
||||
return {"rel": actual_rel, "size": size}
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
@@ -174,8 +338,9 @@ class TelegramAdapter:
|
||||
client = self._get_client()
|
||||
filename = os.path.basename(rel) or "file"
|
||||
import tempfile
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_path = os.path.join(temp_dir, filename)
|
||||
suffix = os.path.splitext(filename)[1]
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tf:
|
||||
temp_path = tf.name
|
||||
|
||||
total_size = 0
|
||||
try:
|
||||
@@ -186,18 +351,62 @@ class TelegramAdapter:
|
||||
total_size += len(chunk)
|
||||
|
||||
await client.connect()
|
||||
await client.send_file(self.chat_id, temp_path, caption=filename)
|
||||
sent = await client.send_file(self.chat_id, temp_path, caption=filename)
|
||||
message = sent[0] if isinstance(sent, list) and sent else sent
|
||||
actual_rel = rel
|
||||
if message:
|
||||
stored_name = filename
|
||||
file_meta = getattr(message, "file", None)
|
||||
if file_meta and getattr(file_meta, "name", None):
|
||||
stored_name = file_meta.name
|
||||
if getattr(message, "id", None) is not None:
|
||||
actual_rel = f"{message.id}_{stored_name}"
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
return total_size
|
||||
return {"rel": actual_rel, "size": total_size}
|
||||
|
||||
async def mkdir(self, root: str, rel: str):
|
||||
raise NotImplementedError("Telegram 适配器不支持创建目录。")
|
||||
|
||||
async def get_thumbnail(self, root: str, rel: str, size: str = "medium"):
|
||||
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
|
||||
|
||||
doc = message.document or message.video
|
||||
thumbs = None
|
||||
if doc and getattr(doc, "thumbs", None):
|
||||
thumbs = list(doc.thumbs or [])
|
||||
elif message.photo and getattr(message.photo, "sizes", None):
|
||||
thumbs = list(message.photo.sizes or [])
|
||||
|
||||
thumb = self._pick_photo_thumb(thumbs)
|
||||
if not thumb:
|
||||
return None
|
||||
|
||||
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):
|
||||
"""删除一个文件 (即一条消息)"""
|
||||
try:
|
||||
@@ -236,6 +445,8 @@ class TelegramAdapter:
|
||||
raise HTTPException(status_code=400, detail=f"无效的文件路径格式: {rel}")
|
||||
|
||||
client = self._get_client()
|
||||
lock = _get_session_lock(self.session_string)
|
||||
await lock.acquire()
|
||||
|
||||
try:
|
||||
await client.connect()
|
||||
@@ -273,7 +484,6 @@ class TelegramAdapter:
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": mime_type,
|
||||
"Content-Length": str(file_size),
|
||||
}
|
||||
|
||||
if range_header:
|
||||
@@ -285,7 +495,6 @@ class TelegramAdapter:
|
||||
if start >= file_size or end >= file_size or start > end:
|
||||
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
|
||||
status = 206
|
||||
headers["Content-Length"] = str(end - start + 1)
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid Range header")
|
||||
@@ -304,18 +513,28 @@ class TelegramAdapter:
|
||||
if downloaded >= limit:
|
||||
break
|
||||
finally:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
try:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
return StreamingResponse(iterator(), status_code=status, headers=headers)
|
||||
|
||||
except HTTPException:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
lock.release()
|
||||
raise
|
||||
except FileNotFoundError as e:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
lock.release()
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
if client.is_connected():
|
||||
await client.disconnect()
|
||||
lock.release()
|
||||
raise HTTPException(status_code=500, detail=f"Streaming failed: {str(e)}")
|
||||
|
||||
async def stat_file(self, root: str, rel: str):
|
||||
|
||||
@@ -4,7 +4,7 @@ from importlib import import_module
|
||||
from typing import Callable, Dict
|
||||
|
||||
from models import StorageAdapter
|
||||
from domain.adapters.providers.base import BaseAdapter
|
||||
from .providers.base import BaseAdapter
|
||||
|
||||
AdapterFactory = Callable[[StorageAdapter], BaseAdapter]
|
||||
|
||||
@@ -21,7 +21,7 @@ def normalize_adapter_type(value: str | None) -> str | None:
|
||||
|
||||
def discover_adapters():
|
||||
"""扫描 domain.adapters.providers 包, 自动注册适配器类型、工厂与配置 schema。"""
|
||||
from domain.adapters import providers as adapters_pkg
|
||||
from . import providers as adapters_pkg
|
||||
|
||||
TYPE_MAP.clear()
|
||||
CONFIG_SCHEMAS.clear()
|
||||
|
||||
@@ -2,13 +2,13 @@ from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from domain.adapters.registry import (
|
||||
from domain.auth import User
|
||||
from .registry import (
|
||||
get_config_schemas,
|
||||
normalize_adapter_type,
|
||||
runtime_registry,
|
||||
)
|
||||
from domain.adapters.types import AdapterCreate, AdapterOut
|
||||
from domain.auth.types import User
|
||||
from .types import AdapterCreate, AdapterOut
|
||||
from models import StorageAdapter
|
||||
|
||||
|
||||
@@ -36,6 +36,11 @@ class AdapterService:
|
||||
missing.append(k)
|
||||
if missing:
|
||||
raise HTTPException(400, detail="缺少必填配置字段: " + ", ".join(missing))
|
||||
if adapter_type in ("alist", "openlist"):
|
||||
username = out.get("username")
|
||||
password = out.get("password")
|
||||
if (username and not password) or (password and not username):
|
||||
raise HTTPException(400, detail="用户名和密码必须同时填写或同时留空")
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
|
||||
9
domain/agent/__init__.py
Normal file
9
domain/agent/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .service import AgentService
|
||||
from .types import AgentChatContext, AgentChatRequest, PendingToolCall
|
||||
|
||||
__all__ = [
|
||||
"AgentService",
|
||||
"AgentChatContext",
|
||||
"AgentChatRequest",
|
||||
"PendingToolCall",
|
||||
]
|
||||
38
domain/agent/api.py
Normal file
38
domain/agent/api.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from api.response import success
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.auth import User, get_current_active_user
|
||||
from .service import AgentService
|
||||
from .types import AgentChatRequest
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/agent", tags=["agent"])
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
@audit(action=AuditAction.CREATE, description="Agent 对话", body_fields=["auto_execute"])
|
||||
async def chat(
|
||||
request: Request,
|
||||
payload: AgentChatRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
data = await AgentService.chat(payload, current_user)
|
||||
return success(data)
|
||||
|
||||
|
||||
@router.post("/chat/stream")
|
||||
@audit(action=AuditAction.CREATE, description="Agent 对话(SSE)", body_fields=["auto_execute"])
|
||||
async def chat_stream(
|
||||
request: Request,
|
||||
payload: AgentChatRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
return StreamingResponse(
|
||||
AgentService.chat_stream(payload, current_user),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
471
domain/agent/service.py
Normal file
471
domain/agent/service.py
Normal file
@@ -0,0 +1,471 @@
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from domain.ai import AIProviderService, MissingModelError, chat_completion, chat_completion_stream
|
||||
from domain.auth import User
|
||||
from .tools import get_tool, openai_tools, tool_result_to_content
|
||||
from .types import AgentChatRequest, PendingToolCall
|
||||
|
||||
|
||||
def _normalize_path(p: Optional[str]) -> Optional[str]:
|
||||
if not p:
|
||||
return None
|
||||
s = str(p).strip()
|
||||
if not s:
|
||||
return None
|
||||
s = s.replace("\\", "/")
|
||||
if not s.startswith("/"):
|
||||
s = "/" + s
|
||||
s = s.rstrip("/") or "/"
|
||||
return s
|
||||
|
||||
|
||||
def _build_system_prompt(current_path: Optional[str]) -> str:
|
||||
lines = [
|
||||
"你是 Foxel 的 AI 助手。",
|
||||
"你可以通过工具对文件/目录进行查询、读写、移动、复制、删除,以及运行处理器(processor)。",
|
||||
"",
|
||||
"可用工具:",
|
||||
"- time:获取服务器当前时间(精确到秒,英文星期),支持 year/month/day/hour/minute/second 偏移。",
|
||||
"- vfs_list_dir:浏览目录(列出 entries + pagination)。",
|
||||
"- vfs_stat:查看文件/目录信息。",
|
||||
"- vfs_read_text:读取文本文件内容(不支持二进制)。",
|
||||
"- vfs_search:搜索文件(vector/filename)。",
|
||||
"- vfs_write_text:写入文本文件内容(覆盖)。",
|
||||
"- vfs_mkdir:创建目录。",
|
||||
"- vfs_delete:删除文件或目录。",
|
||||
"- vfs_move:移动路径。",
|
||||
"- vfs_copy:复制路径。",
|
||||
"- vfs_rename:重命名路径。",
|
||||
"- processors_list:获取可用处理器列表(含 type/name/config_schema/produces_file/supports_directory)。",
|
||||
"- processors_run:运行处理器处理文件或目录(会返回 task_id 或 task_ids)。",
|
||||
"",
|
||||
"规则:",
|
||||
"1) 读操作(vfs_list_dir/vfs_stat/vfs_read_text/vfs_search)可直接调用工具。",
|
||||
"2) 写/改/删操作(vfs_write_text/vfs_mkdir/vfs_delete/vfs_move/vfs_copy/vfs_rename/processors_run)默认需要用户确认;只有在开启自动执行时才应直接执行。",
|
||||
"3) 用户未给出明确路径时先追问;若提供了“当前文件管理目录”,可以基于它把相对描述补全为绝对路径(以 / 开头)。",
|
||||
"4) 修改文件内容:先读取(vfs_read_text)→给出改动点→确认后再写入(vfs_write_text)。",
|
||||
"5) processors_run 返回任务 id 后,说明任务已提交,可在任务队列查看进度。",
|
||||
"6) 回答语言跟随用户;用户用英文则用英文,用户用中文则用中文。回答尽量简洁。",
|
||||
]
|
||||
if current_path:
|
||||
lines.append("")
|
||||
lines.append(f"当前文件管理目录:{current_path}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _ensure_tool_call_ids(message: Dict[str, Any]) -> Dict[str, Any]:
|
||||
tool_calls = message.get("tool_calls")
|
||||
if not isinstance(tool_calls, list):
|
||||
return message
|
||||
|
||||
changed = False
|
||||
for idx, call in enumerate(tool_calls):
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
call_id = call.get("id")
|
||||
if isinstance(call_id, str) and call_id.strip():
|
||||
continue
|
||||
call["id"] = f"call_{idx}"
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
message["tool_calls"] = tool_calls
|
||||
return message
|
||||
|
||||
|
||||
def _extract_pending(tool_call: Dict[str, Any], requires_confirmation: bool) -> PendingToolCall:
|
||||
call_id = str(tool_call.get("id") or "")
|
||||
fn = tool_call.get("function") or {}
|
||||
name = str((fn.get("name") if isinstance(fn, dict) else None) or "")
|
||||
raw_args = fn.get("arguments") if isinstance(fn, dict) else None
|
||||
arguments: Dict[str, Any] = {}
|
||||
if isinstance(raw_args, str) and raw_args.strip():
|
||||
try:
|
||||
parsed = json.loads(raw_args)
|
||||
if isinstance(parsed, dict):
|
||||
arguments = parsed
|
||||
except json.JSONDecodeError:
|
||||
arguments = {}
|
||||
return PendingToolCall(
|
||||
id=call_id,
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
requires_confirmation=requires_confirmation,
|
||||
)
|
||||
|
||||
|
||||
def _find_last_assistant_tool_calls(messages: List[Dict[str, Any]]) -> Tuple[int, Dict[str, Any]]:
|
||||
for idx in range(len(messages) - 1, -1, -1):
|
||||
msg = messages[idx]
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if isinstance(tool_calls, list) and tool_calls:
|
||||
return idx, msg
|
||||
raise HTTPException(status_code=400, detail="没有可确认的待执行操作")
|
||||
|
||||
|
||||
def _existing_tool_result_ids(messages: List[Dict[str, Any]]) -> set[str]:
|
||||
ids: set[str] = set()
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("role") != "tool":
|
||||
continue
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
if isinstance(tool_call_id, str) and tool_call_id.strip():
|
||||
ids.add(tool_call_id)
|
||||
return ids
|
||||
|
||||
|
||||
async def _choose_chat_ability() -> str:
|
||||
tools_model = await AIProviderService.get_default_model("tools")
|
||||
return "tools" if tools_model else "chat"
|
||||
|
||||
|
||||
def _sse(event: str, data: Any) -> bytes:
|
||||
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
||||
return f"event: {event}\ndata: {payload}\n\n".encode("utf-8")
|
||||
|
||||
|
||||
def _format_exc(exc: BaseException) -> str:
|
||||
text = str(exc)
|
||||
return text if text else exc.__class__.__name__
|
||||
|
||||
|
||||
class AgentService:
|
||||
@classmethod
|
||||
async def chat(cls, req: AgentChatRequest, user: Optional[User]) -> Dict[str, Any]:
|
||||
history: List[Dict[str, Any]] = list(req.messages or [])
|
||||
current_path = _normalize_path(req.context.current_path if req.context else None)
|
||||
|
||||
system_prompt = _build_system_prompt(current_path)
|
||||
internal_messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}] + history
|
||||
|
||||
new_messages: List[Dict[str, Any]] = []
|
||||
pending: List[PendingToolCall] = []
|
||||
|
||||
approved_ids = {i for i in (req.approved_tool_call_ids or []) if isinstance(i, str) and i.strip()}
|
||||
rejected_ids = {i for i in (req.rejected_tool_call_ids or []) if isinstance(i, str) and i.strip()}
|
||||
|
||||
if approved_ids or rejected_ids:
|
||||
_, last_call_msg = _find_last_assistant_tool_calls(internal_messages)
|
||||
last_call_msg = _ensure_tool_call_ids(last_call_msg)
|
||||
tool_calls = last_call_msg.get("tool_calls") or []
|
||||
call_map: Dict[str, Dict[str, Any]] = {
|
||||
str(c.get("id")): c
|
||||
for c in tool_calls
|
||||
if isinstance(c, dict) and isinstance(c.get("id"), str)
|
||||
}
|
||||
|
||||
existing_ids = _existing_tool_result_ids(internal_messages)
|
||||
for call_id in approved_ids | rejected_ids:
|
||||
if call_id in existing_ids:
|
||||
continue
|
||||
tool_call = call_map.get(call_id)
|
||||
if not tool_call:
|
||||
continue
|
||||
fn = tool_call.get("function") or {}
|
||||
name = fn.get("name") if isinstance(fn, dict) else None
|
||||
args_raw = fn.get("arguments") if isinstance(fn, dict) else None
|
||||
args: Dict[str, Any] = {}
|
||||
if isinstance(args_raw, str) and args_raw.strip():
|
||||
try:
|
||||
parsed = json.loads(args_raw)
|
||||
if isinstance(parsed, dict):
|
||||
args = parsed
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
|
||||
spec = get_tool(str(name or ""))
|
||||
if call_id in rejected_ids:
|
||||
content = tool_result_to_content({"canceled": True, "reason": "user_rejected"})
|
||||
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
|
||||
internal_messages.append(tool_msg)
|
||||
new_messages.append(tool_msg)
|
||||
continue
|
||||
|
||||
if not spec:
|
||||
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
|
||||
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
|
||||
internal_messages.append(tool_msg)
|
||||
new_messages.append(tool_msg)
|
||||
continue
|
||||
|
||||
try:
|
||||
result = await spec.handler(args)
|
||||
content = tool_result_to_content(result)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
content = tool_result_to_content({"error": str(exc)})
|
||||
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
|
||||
internal_messages.append(tool_msg)
|
||||
new_messages.append(tool_msg)
|
||||
|
||||
tools_schema = openai_tools()
|
||||
ability = await _choose_chat_ability()
|
||||
max_loops = 4
|
||||
|
||||
for _ in range(max_loops):
|
||||
try:
|
||||
assistant = await chat_completion(
|
||||
internal_messages,
|
||||
ability=ability,
|
||||
tools=tools_schema,
|
||||
tool_choice="auto",
|
||||
timeout=60.0,
|
||||
)
|
||||
except MissingModelError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"对话请求失败: {exc}") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"对话请求异常: {exc}") from exc
|
||||
|
||||
assistant = _ensure_tool_call_ids(assistant)
|
||||
internal_messages.append(assistant)
|
||||
new_messages.append(assistant)
|
||||
|
||||
tool_calls = assistant.get("tool_calls")
|
||||
if not isinstance(tool_calls, list) or not tool_calls:
|
||||
break
|
||||
|
||||
pending = []
|
||||
for call in tool_calls:
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
call_id = str(call.get("id") or "")
|
||||
fn = call.get("function") or {}
|
||||
name = fn.get("name") if isinstance(fn, dict) else None
|
||||
args_raw = fn.get("arguments") if isinstance(fn, dict) else None
|
||||
args: Dict[str, Any] = {}
|
||||
if isinstance(args_raw, str) and args_raw.strip():
|
||||
try:
|
||||
parsed = json.loads(args_raw)
|
||||
if isinstance(parsed, dict):
|
||||
args = parsed
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
|
||||
spec = get_tool(str(name or ""))
|
||||
if not spec:
|
||||
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
|
||||
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
|
||||
internal_messages.append(tool_msg)
|
||||
new_messages.append(tool_msg)
|
||||
continue
|
||||
|
||||
if spec.requires_confirmation and not req.auto_execute:
|
||||
pending.append(_extract_pending(call, True))
|
||||
continue
|
||||
|
||||
try:
|
||||
result = await spec.handler(args)
|
||||
content = tool_result_to_content(result)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
content = tool_result_to_content({"error": str(exc)})
|
||||
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
|
||||
internal_messages.append(tool_msg)
|
||||
new_messages.append(tool_msg)
|
||||
|
||||
if pending:
|
||||
break
|
||||
|
||||
payload: Dict[str, Any] = {"messages": new_messages}
|
||||
if pending:
|
||||
payload["pending_tool_calls"] = [p.model_dump() for p in pending]
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
async def chat_stream(cls, req: AgentChatRequest, user: Optional[User]):
|
||||
history: List[Dict[str, Any]] = list(req.messages or [])
|
||||
current_path = _normalize_path(req.context.current_path if req.context else None)
|
||||
|
||||
system_prompt = _build_system_prompt(current_path)
|
||||
internal_messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}] + history
|
||||
|
||||
new_messages: List[Dict[str, Any]] = []
|
||||
pending: List[PendingToolCall] = []
|
||||
|
||||
approved_ids = {i for i in (req.approved_tool_call_ids or []) if isinstance(i, str) and i.strip()}
|
||||
rejected_ids = {i for i in (req.rejected_tool_call_ids or []) if isinstance(i, str) and i.strip()}
|
||||
|
||||
try:
|
||||
if approved_ids or rejected_ids:
|
||||
_, last_call_msg = _find_last_assistant_tool_calls(internal_messages)
|
||||
last_call_msg = _ensure_tool_call_ids(last_call_msg)
|
||||
tool_calls = last_call_msg.get("tool_calls") or []
|
||||
call_map: Dict[str, Dict[str, Any]] = {
|
||||
str(c.get("id")): c
|
||||
for c in tool_calls
|
||||
if isinstance(c, dict) and isinstance(c.get("id"), str)
|
||||
}
|
||||
|
||||
existing_ids = _existing_tool_result_ids(internal_messages)
|
||||
for call_id in approved_ids | rejected_ids:
|
||||
if call_id in existing_ids:
|
||||
continue
|
||||
tool_call = call_map.get(call_id)
|
||||
if not tool_call:
|
||||
continue
|
||||
fn = tool_call.get("function") or {}
|
||||
name = fn.get("name") if isinstance(fn, dict) else None
|
||||
args_raw = fn.get("arguments") if isinstance(fn, dict) else None
|
||||
args: Dict[str, Any] = {}
|
||||
if isinstance(args_raw, str) and args_raw.strip():
|
||||
try:
|
||||
parsed = json.loads(args_raw)
|
||||
if isinstance(parsed, dict):
|
||||
args = parsed
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
|
||||
spec = get_tool(str(name or ""))
|
||||
if call_id in rejected_ids:
|
||||
content = tool_result_to_content({"canceled": True, "reason": "user_rejected"})
|
||||
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
|
||||
internal_messages.append(tool_msg)
|
||||
new_messages.append(tool_msg)
|
||||
yield _sse("tool_end", {"tool_call_id": call_id, "name": str(name or ""), "message": tool_msg})
|
||||
continue
|
||||
|
||||
if not spec:
|
||||
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
|
||||
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
|
||||
internal_messages.append(tool_msg)
|
||||
new_messages.append(tool_msg)
|
||||
yield _sse("tool_end", {"tool_call_id": call_id, "name": str(name or ""), "message": tool_msg})
|
||||
continue
|
||||
|
||||
yield _sse("tool_start", {"tool_call_id": call_id, "name": spec.name})
|
||||
try:
|
||||
result = await spec.handler(args)
|
||||
content = tool_result_to_content(result)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
content = tool_result_to_content({"error": str(exc)})
|
||||
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
|
||||
internal_messages.append(tool_msg)
|
||||
new_messages.append(tool_msg)
|
||||
yield _sse("tool_end", {"tool_call_id": call_id, "name": spec.name, "message": tool_msg})
|
||||
|
||||
tools_schema = openai_tools()
|
||||
ability = await _choose_chat_ability()
|
||||
max_loops = 4
|
||||
|
||||
for _ in range(max_loops):
|
||||
assistant_event_id = uuid.uuid4().hex
|
||||
yield _sse("assistant_start", {"id": assistant_event_id})
|
||||
|
||||
assistant_message: Dict[str, Any] | None = None
|
||||
try:
|
||||
async for event in chat_completion_stream(
|
||||
internal_messages,
|
||||
ability=ability,
|
||||
tools=tools_schema,
|
||||
tool_choice="auto",
|
||||
timeout=60.0,
|
||||
):
|
||||
if event.get("type") == "delta":
|
||||
delta = event.get("delta")
|
||||
if isinstance(delta, str) and delta:
|
||||
yield _sse("assistant_delta", {"id": assistant_event_id, "delta": delta})
|
||||
elif event.get("type") == "message":
|
||||
msg = event.get("message")
|
||||
if isinstance(msg, dict):
|
||||
assistant_message = msg
|
||||
except MissingModelError as exc:
|
||||
raise HTTPException(status_code=400, detail=_format_exc(exc)) from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"对话请求失败: {_format_exc(exc)}") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"对话请求异常: {_format_exc(exc)}") from exc
|
||||
|
||||
if not assistant_message:
|
||||
assistant_message = {"role": "assistant", "content": ""}
|
||||
|
||||
assistant_message = _ensure_tool_call_ids(assistant_message)
|
||||
internal_messages.append(assistant_message)
|
||||
new_messages.append(assistant_message)
|
||||
yield _sse("assistant_end", {"id": assistant_event_id, "message": assistant_message})
|
||||
|
||||
tool_calls = assistant_message.get("tool_calls")
|
||||
if not isinstance(tool_calls, list) or not tool_calls:
|
||||
break
|
||||
|
||||
pending = []
|
||||
for call in tool_calls:
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
call_id = str(call.get("id") or "")
|
||||
fn = call.get("function") or {}
|
||||
name = fn.get("name") if isinstance(fn, dict) else None
|
||||
args_raw = fn.get("arguments") if isinstance(fn, dict) else None
|
||||
args: Dict[str, Any] = {}
|
||||
if isinstance(args_raw, str) and args_raw.strip():
|
||||
try:
|
||||
parsed = json.loads(args_raw)
|
||||
if isinstance(parsed, dict):
|
||||
args = parsed
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
|
||||
spec = get_tool(str(name or ""))
|
||||
if not spec:
|
||||
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
|
||||
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
|
||||
internal_messages.append(tool_msg)
|
||||
new_messages.append(tool_msg)
|
||||
yield _sse("tool_end", {"tool_call_id": call_id, "name": str(name or ""), "message": tool_msg})
|
||||
continue
|
||||
|
||||
if spec.requires_confirmation and not req.auto_execute:
|
||||
pending.append(_extract_pending(call, True))
|
||||
continue
|
||||
|
||||
yield _sse("tool_start", {"tool_call_id": call_id, "name": spec.name})
|
||||
try:
|
||||
result = await spec.handler(args)
|
||||
content = tool_result_to_content(result)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
content = tool_result_to_content({"error": str(exc)})
|
||||
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
|
||||
internal_messages.append(tool_msg)
|
||||
new_messages.append(tool_msg)
|
||||
yield _sse("tool_end", {"tool_call_id": call_id, "name": spec.name, "message": tool_msg})
|
||||
|
||||
if pending:
|
||||
yield _sse("pending", {"pending_tool_calls": [p.model_dump() for p in pending]})
|
||||
break
|
||||
|
||||
payload: Dict[str, Any] = {"messages": new_messages}
|
||||
if pending:
|
||||
payload["pending_tool_calls"] = [p.model_dump() for p in pending]
|
||||
yield _sse("done", payload)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except HTTPException as exc:
|
||||
detail = exc.detail
|
||||
content = detail if isinstance(detail, str) else str(detail)
|
||||
if not content.strip():
|
||||
content = f"请求失败({exc.status_code})"
|
||||
new_messages.append({"role": "assistant", "content": content})
|
||||
payload: Dict[str, Any] = {"messages": new_messages}
|
||||
if pending:
|
||||
payload["pending_tool_calls"] = [p.model_dump() for p in pending]
|
||||
yield _sse("done", payload)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
new_messages.append({"role": "assistant", "content": f"服务端异常: {_format_exc(exc)}"})
|
||||
payload: Dict[str, Any] = {"messages": new_messages}
|
||||
if pending:
|
||||
payload["pending_tool_calls"] = [p.model_dump() for p in pending]
|
||||
yield _sse("done", payload)
|
||||
return
|
||||
623
domain/agent/tools.py
Normal file
623
domain/agent/tools.py
Normal file
@@ -0,0 +1,623 @@
|
||||
import calendar
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
from domain.processors import ProcessDirectoryRequest, ProcessRequest, ProcessorService
|
||||
from domain.virtual_fs import VirtualFSService
|
||||
from domain.virtual_fs.search import VirtualFSSearchService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolSpec:
|
||||
name: str
|
||||
description: str
|
||||
parameters: Dict[str, Any]
|
||||
requires_confirmation: bool
|
||||
handler: Callable[[Dict[str, Any]], Awaitable[Any]]
|
||||
|
||||
|
||||
def _parse_offset(args: Dict[str, Any], key: str) -> int:
|
||||
value = args.get(key)
|
||||
if value is None:
|
||||
return 0
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _add_months(dt: datetime, months: int) -> datetime:
|
||||
if months == 0:
|
||||
return dt
|
||||
total = dt.year * 12 + (dt.month - 1) + months
|
||||
year = total // 12
|
||||
month = total % 12 + 1
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
day = min(dt.day, last_day)
|
||||
return dt.replace(year=year, month=month, day=day)
|
||||
|
||||
|
||||
async def _time(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
now = datetime.now()
|
||||
year_offset = _parse_offset(args, "year")
|
||||
month_offset = _parse_offset(args, "month")
|
||||
day_offset = _parse_offset(args, "day")
|
||||
hour_offset = _parse_offset(args, "hour")
|
||||
minute_offset = _parse_offset(args, "minute")
|
||||
second_offset = _parse_offset(args, "second")
|
||||
|
||||
dt = _add_months(now, year_offset * 12 + month_offset)
|
||||
dt = dt + timedelta(days=day_offset, hours=hour_offset, minutes=minute_offset, seconds=second_offset)
|
||||
|
||||
weekday_names = [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
]
|
||||
weekday = weekday_names[dt.weekday()]
|
||||
dt_str = dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return {
|
||||
"ok": True,
|
||||
"summary": f"{dt_str} · {weekday}",
|
||||
"data": {
|
||||
"datetime": dt_str,
|
||||
"weekday": weekday,
|
||||
"offset": {
|
||||
"year": year_offset,
|
||||
"month": month_offset,
|
||||
"day": day_offset,
|
||||
"hour": hour_offset,
|
||||
"minute": minute_offset,
|
||||
"second": second_offset,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _processors_list(_: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"processors": ProcessorService.list_processors()}
|
||||
|
||||
|
||||
async def _processors_run(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
path = str(args.get("path") or "")
|
||||
processor_type = str(args.get("processor_type") or "")
|
||||
config = args.get("config")
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
|
||||
save_to = args.get("save_to")
|
||||
save_to = str(save_to) if isinstance(save_to, str) and save_to.strip() else None
|
||||
|
||||
max_depth = args.get("max_depth")
|
||||
max_depth_value: Optional[int] = None
|
||||
if max_depth is not None:
|
||||
try:
|
||||
max_depth_value = int(max_depth)
|
||||
except (TypeError, ValueError):
|
||||
max_depth_value = None
|
||||
|
||||
suffix = args.get("suffix")
|
||||
suffix_value = str(suffix) if isinstance(suffix, str) and suffix.strip() else None
|
||||
|
||||
overwrite_value = args.get("overwrite")
|
||||
overwrite = bool(overwrite_value) if overwrite_value is not None else None
|
||||
|
||||
is_dir = await VirtualFSService.path_is_directory(path)
|
||||
if is_dir and (max_depth_value is not None or suffix_value is not None):
|
||||
req = ProcessDirectoryRequest(
|
||||
path=path,
|
||||
processor_type=processor_type,
|
||||
config=config,
|
||||
overwrite=True if overwrite is None else overwrite,
|
||||
max_depth=max_depth_value,
|
||||
suffix=suffix_value,
|
||||
)
|
||||
result = await ProcessorService.process_directory(req)
|
||||
return {"mode": "directory", **result}
|
||||
|
||||
req = ProcessRequest(
|
||||
path=path,
|
||||
processor_type=processor_type,
|
||||
config=config,
|
||||
save_to=save_to,
|
||||
overwrite=False if overwrite is None else overwrite,
|
||||
)
|
||||
result = await ProcessorService.process_file(req)
|
||||
return {"mode": "file", **result}
|
||||
|
||||
|
||||
def _normalize_vfs_path(value: Any) -> str:
|
||||
s = str(value or "").strip().replace("\\", "/")
|
||||
if not s:
|
||||
return ""
|
||||
if not s.startswith("/"):
|
||||
s = "/" + s
|
||||
s = s.rstrip("/") or "/"
|
||||
return s
|
||||
|
||||
|
||||
def _require_vfs_path(value: Any, field: str) -> str:
|
||||
path = _normalize_vfs_path(value)
|
||||
if not path:
|
||||
raise ValueError(f"missing_{field}")
|
||||
return path
|
||||
|
||||
|
||||
async def _vfs_list_dir(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
path = _normalize_vfs_path(args.get("path") or "/") or "/"
|
||||
page = int(args.get("page") or 1)
|
||||
page_size = int(args.get("page_size") or 50)
|
||||
sort_by = str(args.get("sort_by") or "name")
|
||||
sort_order = str(args.get("sort_order") or "asc")
|
||||
return await VirtualFSService.list_directory(path, page, page_size, sort_by, sort_order)
|
||||
|
||||
|
||||
async def _vfs_stat(args: Dict[str, Any]) -> Any:
|
||||
path = _require_vfs_path(args.get("path"), "path")
|
||||
return await VirtualFSService.stat(path)
|
||||
|
||||
|
||||
async def _vfs_read_text(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
path = _require_vfs_path(args.get("path"), "path")
|
||||
encoding = str(args.get("encoding") or "utf-8")
|
||||
max_chars = int(args.get("max_chars") or 8000)
|
||||
|
||||
data = await VirtualFSService.read_file(path)
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
try:
|
||||
text = bytes(data).decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
return {"error": "binary_or_invalid_text", "path": path}
|
||||
elif isinstance(data, str):
|
||||
text = data
|
||||
else:
|
||||
text = str(data)
|
||||
|
||||
original_len = len(text)
|
||||
truncated = original_len > max_chars
|
||||
if truncated:
|
||||
text = text[:max_chars]
|
||||
return {
|
||||
"path": path,
|
||||
"encoding": encoding,
|
||||
"content": text,
|
||||
"truncated": truncated,
|
||||
"length": original_len,
|
||||
}
|
||||
|
||||
|
||||
async def _vfs_write_text(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
path = _require_vfs_path(args.get("path"), "path")
|
||||
if path == "/":
|
||||
raise ValueError("invalid_path")
|
||||
encoding = str(args.get("encoding") or "utf-8")
|
||||
content = str(args.get("content") or "")
|
||||
data = content.encode(encoding)
|
||||
await VirtualFSService.write_file(path, data)
|
||||
return {"written": True, "path": path, "encoding": encoding, "bytes": len(data)}
|
||||
|
||||
|
||||
async def _vfs_mkdir(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
path = _require_vfs_path(args.get("path"), "path")
|
||||
return await VirtualFSService.mkdir(path)
|
||||
|
||||
|
||||
async def _vfs_delete(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
path = _require_vfs_path(args.get("path"), "path")
|
||||
return await VirtualFSService.delete(path)
|
||||
|
||||
|
||||
async def _vfs_move(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
src = _require_vfs_path(args.get("src"), "src")
|
||||
dst = _require_vfs_path(args.get("dst"), "dst")
|
||||
if src == "/" or dst == "/":
|
||||
raise ValueError("invalid_path")
|
||||
overwrite = bool(args.get("overwrite") or False)
|
||||
return await VirtualFSService.move(src, dst, overwrite)
|
||||
|
||||
|
||||
async def _vfs_copy(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
src = _require_vfs_path(args.get("src"), "src")
|
||||
dst = _require_vfs_path(args.get("dst"), "dst")
|
||||
if src == "/" or dst == "/":
|
||||
raise ValueError("invalid_path")
|
||||
overwrite = bool(args.get("overwrite") or False)
|
||||
return await VirtualFSService.copy(src, dst, overwrite)
|
||||
|
||||
|
||||
async def _vfs_rename(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
src = _require_vfs_path(args.get("src"), "src")
|
||||
dst = _require_vfs_path(args.get("dst"), "dst")
|
||||
if src == "/" or dst == "/":
|
||||
raise ValueError("invalid_path")
|
||||
overwrite = bool(args.get("overwrite") or False)
|
||||
return await VirtualFSService.rename(src, dst, overwrite)
|
||||
|
||||
|
||||
async def _vfs_search(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
q = str(args.get("q") or "").strip()
|
||||
if not q:
|
||||
raise ValueError("missing_q")
|
||||
mode = str(args.get("mode") or "vector")
|
||||
top_k = int(args.get("top_k") or 10)
|
||||
page = int(args.get("page") or 1)
|
||||
page_size = int(args.get("page_size") or 10)
|
||||
return await VirtualFSSearchService.search(q, top_k, mode, page, page_size)
|
||||
|
||||
|
||||
TOOLS: Dict[str, ToolSpec] = {
|
||||
"time": ToolSpec(
|
||||
name="time",
|
||||
description=(
|
||||
"获取服务器当前时间(精确到秒,含英文星期)。"
|
||||
" 支持 year/month/day/hour/minute/second 偏移(可为负数)。"
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"year": {"type": "integer", "description": "年偏移(可为负数)"},
|
||||
"month": {"type": "integer", "description": "月偏移(可为负数)"},
|
||||
"day": {"type": "integer", "description": "日偏移(可为负数)"},
|
||||
"hour": {"type": "integer", "description": "时偏移(可为负数)"},
|
||||
"minute": {"type": "integer", "description": "分偏移(可为负数)"},
|
||||
"second": {"type": "integer", "description": "秒偏移(可为负数)"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
requires_confirmation=False,
|
||||
handler=_time,
|
||||
),
|
||||
"processors_list": ToolSpec(
|
||||
name="processors_list",
|
||||
description="获取可用处理器列表(type/name/config_schema 等)。",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
requires_confirmation=False,
|
||||
handler=_processors_list,
|
||||
),
|
||||
"processors_run": ToolSpec(
|
||||
name="processors_run",
|
||||
description=(
|
||||
"运行处理器处理文件或目录。"
|
||||
" 对目录可选 max_depth/suffix;对文件可选 overwrite/save_to。"
|
||||
" 返回任务 id(去任务队列查看进度)。"
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "文件或目录路径(绝对路径,如 /foo/bar)"},
|
||||
"processor_type": {"type": "string", "description": "处理器类型(例如 image_watermark)"},
|
||||
"config": {"type": "object", "description": "处理器配置,按 processors_list 返回的 config_schema 填写"},
|
||||
"overwrite": {"type": "boolean", "description": "是否覆盖原文件/目录内文件"},
|
||||
"save_to": {"type": "string", "description": "保存到指定路径(仅文件模式,且 overwrite=false 时使用)"},
|
||||
"max_depth": {"type": "integer", "description": "目录遍历深度(仅目录模式)"},
|
||||
"suffix": {"type": "string", "description": "目录批处理时的输出后缀(仅 produces_file 且 overwrite=false)"},
|
||||
},
|
||||
"required": ["path", "processor_type"],
|
||||
},
|
||||
requires_confirmation=True,
|
||||
handler=_processors_run,
|
||||
),
|
||||
"vfs_list_dir": ToolSpec(
|
||||
name="vfs_list_dir",
|
||||
description="浏览目录(列出 entries + pagination)。",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "目录路径(绝对路径,如 /foo/bar)"},
|
||||
"page": {"type": "integer", "description": "页码(从 1 开始)"},
|
||||
"page_size": {"type": "integer", "description": "每页条数"},
|
||||
"sort_by": {"type": "string", "description": "排序字段:name/size/mtime"},
|
||||
"sort_order": {"type": "string", "description": "排序顺序:asc/desc"},
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
requires_confirmation=False,
|
||||
handler=_vfs_list_dir,
|
||||
),
|
||||
"vfs_stat": ToolSpec(
|
||||
name="vfs_stat",
|
||||
description="查看文件/目录信息(size/mtime/is_dir/has_thumbnail/vector_index 等)。",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "路径(绝对路径,如 /foo/bar.txt)"},
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
requires_confirmation=False,
|
||||
handler=_vfs_stat,
|
||||
),
|
||||
"vfs_read_text": ToolSpec(
|
||||
name="vfs_read_text",
|
||||
description="读取文本文件内容(解码失败视为二进制,返回 error)。",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "文件路径(绝对路径,如 /foo/bar.md)"},
|
||||
"encoding": {"type": "string", "description": "文本编码(默认 utf-8)"},
|
||||
"max_chars": {"type": "integer", "description": "最多返回的字符数(默认 8000)"},
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
requires_confirmation=False,
|
||||
handler=_vfs_read_text,
|
||||
),
|
||||
"vfs_write_text": ToolSpec(
|
||||
name="vfs_write_text",
|
||||
description="写入文本文件内容(会覆盖目标文件)。",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "文件路径(绝对路径,如 /foo/bar.md)"},
|
||||
"content": {"type": "string", "description": "要写入的文本内容"},
|
||||
"encoding": {"type": "string", "description": "文本编码(默认 utf-8)"},
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
requires_confirmation=True,
|
||||
handler=_vfs_write_text,
|
||||
),
|
||||
"vfs_mkdir": ToolSpec(
|
||||
name="vfs_mkdir",
|
||||
description="创建目录。",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "目录路径(绝对路径,如 /foo/bar)"},
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
requires_confirmation=True,
|
||||
handler=_vfs_mkdir,
|
||||
),
|
||||
"vfs_delete": ToolSpec(
|
||||
name="vfs_delete",
|
||||
description="删除文件或目录(由底层适配器决定是否递归)。",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "路径(绝对路径,如 /foo/bar 或 /foo/bar.txt)"},
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
requires_confirmation=True,
|
||||
handler=_vfs_delete,
|
||||
),
|
||||
"vfs_move": ToolSpec(
|
||||
name="vfs_move",
|
||||
description="移动路径(可能进入任务队列)。",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"src": {"type": "string", "description": "源路径(绝对路径)"},
|
||||
"dst": {"type": "string", "description": "目标路径(绝对路径)"},
|
||||
"overwrite": {"type": "boolean", "description": "是否允许覆盖已存在目标(默认 false)"},
|
||||
},
|
||||
"required": ["src", "dst"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
requires_confirmation=True,
|
||||
handler=_vfs_move,
|
||||
),
|
||||
"vfs_copy": ToolSpec(
|
||||
name="vfs_copy",
|
||||
description="复制路径(可能进入任务队列)。",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"src": {"type": "string", "description": "源路径(绝对路径)"},
|
||||
"dst": {"type": "string", "description": "目标路径(绝对路径)"},
|
||||
"overwrite": {"type": "boolean", "description": "是否覆盖已存在目标(默认 false)"},
|
||||
},
|
||||
"required": ["src", "dst"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
requires_confirmation=True,
|
||||
handler=_vfs_copy,
|
||||
),
|
||||
"vfs_rename": ToolSpec(
|
||||
name="vfs_rename",
|
||||
description="重命名路径(本质是同目录 move)。",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"src": {"type": "string", "description": "源路径(绝对路径)"},
|
||||
"dst": {"type": "string", "description": "目标路径(绝对路径)"},
|
||||
"overwrite": {"type": "boolean", "description": "是否允许覆盖已存在目标(默认 false)"},
|
||||
},
|
||||
"required": ["src", "dst"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
requires_confirmation=True,
|
||||
handler=_vfs_rename,
|
||||
),
|
||||
"vfs_search": ToolSpec(
|
||||
name="vfs_search",
|
||||
description="搜索文件(mode=vector 或 filename)。",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"q": {"type": "string", "description": "搜索关键词"},
|
||||
"mode": {"type": "string", "description": "搜索模式:vector/filename(默认 vector)"},
|
||||
"top_k": {"type": "integer", "description": "返回数量(vector 模式使用,默认 10)"},
|
||||
"page": {"type": "integer", "description": "页码(filename 模式使用,默认 1)"},
|
||||
"page_size": {"type": "integer", "description": "分页大小(filename 模式使用,默认 10)"},
|
||||
},
|
||||
"required": ["q"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
requires_confirmation=False,
|
||||
handler=_vfs_search,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_tool(name: str) -> Optional[ToolSpec]:
|
||||
return TOOLS.get(name)
|
||||
|
||||
|
||||
def openai_tools() -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for spec in TOOLS.values():
|
||||
out.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": spec.name,
|
||||
"description": spec.description,
|
||||
"parameters": spec.parameters,
|
||||
},
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _stringify_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except TypeError:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _list_to_view_items(items: List[Any]) -> List[Any]:
|
||||
normalized: List[Any] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
normalized.append({str(k): _stringify_value(v) for k, v in item.items()})
|
||||
else:
|
||||
normalized.append(_stringify_value(item))
|
||||
return normalized
|
||||
|
||||
|
||||
def _dict_to_kv_items(data: Dict[str, Any]) -> List[Dict[str, str]]:
|
||||
return [{"key": str(k), "value": _stringify_value(v)} for k, v in data.items()]
|
||||
|
||||
|
||||
def _first_list_field(data: Dict[str, Any]) -> tuple[Optional[str], Optional[List[Any]]]:
|
||||
for key, value in data.items():
|
||||
if isinstance(value, list):
|
||||
return str(key), value
|
||||
return None, None
|
||||
|
||||
|
||||
def _build_view(data: Any) -> Dict[str, Any]:
|
||||
if data is None:
|
||||
return {"type": "kv", "items": []}
|
||||
if isinstance(data, str):
|
||||
return {"type": "text", "text": data}
|
||||
if isinstance(data, list):
|
||||
return {"type": "list", "items": _list_to_view_items(data)}
|
||||
if isinstance(data, dict):
|
||||
content = data.get("content")
|
||||
if isinstance(content, str):
|
||||
meta = {k: _stringify_value(v) for k, v in data.items() if k != "content"}
|
||||
view: Dict[str, Any] = {"type": "text", "text": content}
|
||||
if meta:
|
||||
view["meta"] = meta
|
||||
return view
|
||||
list_key, list_val = _first_list_field(data)
|
||||
if list_key and isinstance(list_val, list):
|
||||
meta = {k: _stringify_value(v) for k, v in data.items() if k != list_key}
|
||||
view = {"type": "list", "title": list_key, "items": _list_to_view_items(list_val)}
|
||||
if meta:
|
||||
view["meta"] = meta
|
||||
return view
|
||||
return {"type": "kv", "items": _dict_to_kv_items(data)}
|
||||
return {"type": "text", "text": _stringify_value(data)}
|
||||
|
||||
|
||||
def _build_summary(view: Dict[str, Any]) -> str:
|
||||
view_type = str(view.get("type") or "")
|
||||
if view_type == "text":
|
||||
text = view.get("text")
|
||||
size = len(text) if isinstance(text, str) else 0
|
||||
return f"chars: {size}" if size else "text"
|
||||
if view_type == "list":
|
||||
items = view.get("items")
|
||||
count = len(items) if isinstance(items, list) else 0
|
||||
title = str(view.get("title") or "items")
|
||||
return f"{title}: {count}"
|
||||
if view_type == "kv":
|
||||
items = view.get("items")
|
||||
count = len(items) if isinstance(items, list) else 0
|
||||
return f"fields: {count}"
|
||||
if view_type == "error":
|
||||
return str(view.get("message") or "error")
|
||||
return ""
|
||||
|
||||
|
||||
def _build_error_payload(code: str, message: str, detail: Any = None) -> Dict[str, Any]:
|
||||
summary = "Canceled" if code == "canceled" else message or "error"
|
||||
view = {"type": "error", "message": summary}
|
||||
payload: Dict[str, Any] = {
|
||||
"ok": False,
|
||||
"summary": summary,
|
||||
"view": view,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
if detail is not None:
|
||||
payload["error"]["detail"] = detail
|
||||
return payload
|
||||
|
||||
|
||||
def _normalize_tool_result(result: Any) -> Dict[str, Any]:
|
||||
if isinstance(result, dict) and "ok" in result:
|
||||
payload = dict(result)
|
||||
if payload.get("ok") is False:
|
||||
error = payload.get("error")
|
||||
message = _stringify_value(error.get("message") if isinstance(error, dict) else error)
|
||||
payload.setdefault("summary", message or "error")
|
||||
payload.setdefault("view", {"type": "error", "message": payload["summary"]})
|
||||
return payload
|
||||
data = payload.get("data")
|
||||
if payload.get("view") is None:
|
||||
payload["view"] = _build_view(data)
|
||||
if not payload.get("summary"):
|
||||
payload["summary"] = _build_summary(payload["view"])
|
||||
return payload
|
||||
|
||||
if isinstance(result, dict) and result.get("canceled"):
|
||||
reason = _stringify_value(result.get("reason") or "canceled")
|
||||
return _build_error_payload("canceled", reason, detail=result)
|
||||
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
error = result.get("error")
|
||||
message = _stringify_value(error.get("message") if isinstance(error, dict) else error)
|
||||
return _build_error_payload("error", message, detail=error)
|
||||
|
||||
view = _build_view(result)
|
||||
summary = _build_summary(view)
|
||||
return {"ok": True, "summary": summary, "view": view, "data": result}
|
||||
|
||||
|
||||
def tool_result_to_content(result: Any) -> str:
|
||||
payload = _normalize_tool_result(result)
|
||||
try:
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
except TypeError:
|
||||
return json.dumps({"ok": False, "summary": "error", "view": {"type": "error", "message": "error"}}, ensure_ascii=False)
|
||||
23
domain/agent/types.py
Normal file
23
domain/agent/types.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AgentChatContext(BaseModel):
|
||||
current_path: Optional[str] = None
|
||||
|
||||
|
||||
class AgentChatRequest(BaseModel):
|
||||
messages: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
auto_execute: bool = False
|
||||
approved_tool_call_ids: List[str] = Field(default_factory=list)
|
||||
rejected_tool_call_ids: List[str] = Field(default_factory=list)
|
||||
context: Optional[AgentChatContext] = None
|
||||
|
||||
|
||||
class PendingToolCall(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
arguments: Dict[str, Any] = Field(default_factory=dict)
|
||||
requires_confirmation: bool = True
|
||||
|
||||
@@ -1,28 +1,61 @@
|
||||
from .api import router_ai, router_vector_db
|
||||
from .inference import (
|
||||
MissingModelError,
|
||||
chat_completion,
|
||||
chat_completion_stream,
|
||||
describe_image_base64,
|
||||
get_text_embedding,
|
||||
provider_service,
|
||||
rerank_texts,
|
||||
)
|
||||
from .service import (
|
||||
AIProviderService,
|
||||
FILE_COLLECTION_NAME,
|
||||
VECTOR_COLLECTION_NAME,
|
||||
DEFAULT_VECTOR_DIMENSION,
|
||||
VectorDBConfigManager,
|
||||
VectorDBService,
|
||||
DEFAULT_VECTOR_DIMENSION,
|
||||
ABILITIES,
|
||||
normalize_capabilities,
|
||||
)
|
||||
from .types import (
|
||||
ABILITIES,
|
||||
AIDefaultsUpdate,
|
||||
AIModelCreate,
|
||||
AIModelUpdate,
|
||||
AIProviderCreate,
|
||||
AIProviderUpdate,
|
||||
VectorDBConfigPayload,
|
||||
normalize_capabilities,
|
||||
)
|
||||
from .vector_providers import (
|
||||
BaseVectorProvider,
|
||||
MilvusLiteProvider,
|
||||
MilvusServerProvider,
|
||||
QdrantProvider,
|
||||
get_provider_class,
|
||||
get_provider_entry,
|
||||
list_providers,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"router_ai",
|
||||
"router_vector_db",
|
||||
"MissingModelError",
|
||||
"chat_completion",
|
||||
"chat_completion_stream",
|
||||
"describe_image_base64",
|
||||
"get_text_embedding",
|
||||
"provider_service",
|
||||
"rerank_texts",
|
||||
"AIProviderService",
|
||||
"VectorDBService",
|
||||
"VectorDBConfigManager",
|
||||
"DEFAULT_VECTOR_DIMENSION",
|
||||
"VECTOR_COLLECTION_NAME",
|
||||
"FILE_COLLECTION_NAME",
|
||||
"BaseVectorProvider",
|
||||
"MilvusLiteProvider",
|
||||
"MilvusServerProvider",
|
||||
"QdrantProvider",
|
||||
"list_providers",
|
||||
"get_provider_entry",
|
||||
"get_provider_class",
|
||||
"ABILITIES",
|
||||
"normalize_capabilities",
|
||||
"AIDefaultsUpdate",
|
||||
|
||||
@@ -5,8 +5,9 @@ from fastapi import APIRouter, Depends, HTTPException, Path, Request
|
||||
|
||||
from api.response import success
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.ai.service import AIProviderService, VectorDBConfigManager, VectorDBService
|
||||
from domain.ai.types import (
|
||||
from domain.auth import User, get_current_active_user
|
||||
from .service import AIProviderService, VectorDBConfigManager, VectorDBService
|
||||
from .types import (
|
||||
AIDefaultsUpdate,
|
||||
AIModelCreate,
|
||||
AIModelUpdate,
|
||||
@@ -14,9 +15,7 @@ from domain.ai.types import (
|
||||
AIProviderUpdate,
|
||||
VectorDBConfigPayload,
|
||||
)
|
||||
from domain.ai.vector_providers import get_provider_class, get_provider_entry, list_providers
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.auth.types import User
|
||||
from .vector_providers import get_provider_class, get_provider_entry, list_providers
|
||||
|
||||
router_ai = APIRouter(prefix="/api/ai", tags=["ai"])
|
||||
router_vector_db = APIRouter(prefix="/api/vector-db", tags=["vector-db"])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import httpx
|
||||
from tortoise.exceptions import DoesNotExist
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from domain.config.service import ConfigService
|
||||
from domain.config import ConfigService
|
||||
from models.database import AIDefaultModel, AIModel, AIProvider
|
||||
|
||||
from .types import ABILITIES, normalize_capabilities
|
||||
@@ -140,7 +140,7 @@ def serialize_provider(provider: AIProvider) -> Dict[str, Any]:
|
||||
"provider_type": provider.provider_type,
|
||||
"api_format": provider.api_format,
|
||||
"base_url": provider.base_url,
|
||||
"api_key": provider.api_key,
|
||||
"has_api_key": bool(provider.api_key),
|
||||
"logo_url": provider.logo_url,
|
||||
"extra_config": provider.extra_config or {},
|
||||
"created_at": provider.created_at,
|
||||
|
||||
@@ -30,8 +30,8 @@ class AIProviderBase(BaseModel):
|
||||
@classmethod
|
||||
def normalize_format(cls, value: str) -> str:
|
||||
fmt = value.lower()
|
||||
if fmt not in {"openai", "gemini"}:
|
||||
raise ValueError("api_format must be 'openai' or 'gemini'")
|
||||
if fmt not in {"openai", "gemini", "anthropic", "ollama"}:
|
||||
raise ValueError("api_format must be 'openai', 'gemini', 'anthropic', or 'ollama'")
|
||||
return fmt
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ class AIProviderUpdate(BaseModel):
|
||||
if value is None:
|
||||
return value
|
||||
fmt = value.lower()
|
||||
if fmt not in {"openai", "gemini"}:
|
||||
raise ValueError("api_format must be 'openai' or 'gemini'")
|
||||
if fmt not in {"openai", "gemini", "anthropic", "ollama"}:
|
||||
raise ValueError("api_format must be 'openai', 'gemini', 'anthropic', or 'ollama'")
|
||||
return fmt
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from domain.audit.decorator import audit
|
||||
from domain.audit.types import AuditAction
|
||||
from domain.audit.api import router
|
||||
from .decorator import audit
|
||||
from .types import AuditAction
|
||||
|
||||
__all__ = ["audit", "AuditAction", "router"]
|
||||
__all__ = ["audit", "AuditAction"]
|
||||
|
||||
@@ -4,10 +4,9 @@ from typing import Annotated, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from api import response
|
||||
from domain.audit.service import AuditService
|
||||
from domain.audit.types import AuditAction
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.auth.types import User
|
||||
from domain.auth import User, get_current_active_user
|
||||
from .service import AuditService
|
||||
from .types import AuditAction
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_active_user)]
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ import jwt
|
||||
from fastapi import Request
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
from domain.audit.service import AuditService
|
||||
from domain.audit.types import AuditAction
|
||||
from domain.auth.service import ALGORITHM
|
||||
from domain.config.service import ConfigService
|
||||
from domain.auth import ALGORITHM
|
||||
from domain.config import ConfigService
|
||||
from models.database import UserAccount
|
||||
from .service import AuditService
|
||||
from .types import AuditAction
|
||||
|
||||
|
||||
def _extract_request(bound_args: Mapping[str, Any]) -> Request | None:
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from models.database import AuditLog
|
||||
|
||||
from domain.audit.types import AuditAction
|
||||
from .types import AuditAction
|
||||
|
||||
|
||||
class AuditService:
|
||||
|
||||
49
domain/auth/__init__.py
Normal file
49
domain/auth/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from .service import (
|
||||
ALGORITHM,
|
||||
AuthService,
|
||||
authenticate_user_db,
|
||||
create_access_token,
|
||||
get_current_active_user,
|
||||
get_current_user,
|
||||
get_password_hash,
|
||||
has_users,
|
||||
register_user,
|
||||
request_password_reset,
|
||||
reset_password_with_token,
|
||||
verify_password,
|
||||
verify_password_reset_token,
|
||||
)
|
||||
from .types import (
|
||||
PasswordResetConfirm,
|
||||
PasswordResetRequest,
|
||||
RegisterRequest,
|
||||
Token,
|
||||
TokenData,
|
||||
UpdateMeRequest,
|
||||
User,
|
||||
UserInDB,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ALGORITHM",
|
||||
"AuthService",
|
||||
"authenticate_user_db",
|
||||
"create_access_token",
|
||||
"get_current_active_user",
|
||||
"get_current_user",
|
||||
"get_password_hash",
|
||||
"has_users",
|
||||
"register_user",
|
||||
"request_password_reset",
|
||||
"reset_password_with_token",
|
||||
"verify_password",
|
||||
"verify_password_reset_token",
|
||||
"PasswordResetConfirm",
|
||||
"PasswordResetRequest",
|
||||
"RegisterRequest",
|
||||
"Token",
|
||||
"TokenData",
|
||||
"UpdateMeRequest",
|
||||
"User",
|
||||
"UserInDB",
|
||||
]
|
||||
@@ -5,8 +5,8 @@ from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from api.response import success
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.auth.service import AuthService, get_current_active_user
|
||||
from domain.auth.types import (
|
||||
from .service import AuthService, get_current_active_user
|
||||
from .types import (
|
||||
PasswordResetConfirm,
|
||||
PasswordResetRequest,
|
||||
RegisterRequest,
|
||||
|
||||
@@ -11,7 +11,9 @@ from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
from domain.auth.types import (
|
||||
from domain.config import ConfigService
|
||||
from models.database import UserAccount
|
||||
from .types import (
|
||||
PasswordResetConfirm,
|
||||
PasswordResetRequest,
|
||||
RegisterRequest,
|
||||
@@ -21,8 +23,6 @@ from domain.auth.types import (
|
||||
User,
|
||||
UserInDB,
|
||||
)
|
||||
from models.database import UserAccount
|
||||
from domain.config.service import ConfigService
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 365
|
||||
@@ -324,7 +324,7 @@ class AuthService:
|
||||
|
||||
@classmethod
|
||||
async def _send_password_reset_email(cls, user: UserAccount, token: str) -> None:
|
||||
from domain.email.service import EmailService
|
||||
from domain.email import EmailService
|
||||
|
||||
app_domain = await ConfigService.get("APP_DOMAIN", None)
|
||||
base_url = (app_domain or "http://localhost:5173").rstrip("/")
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
from .service import BackupService
|
||||
from .types import BackupData
|
||||
|
||||
__all__ = [
|
||||
"BackupService",
|
||||
"BackupData",
|
||||
]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Request, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.backup.service import BackupService
|
||||
from domain.auth import get_current_active_user
|
||||
from .service import BackupService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/backup",
|
||||
@@ -16,8 +16,10 @@ router = APIRouter(
|
||||
|
||||
@router.get("/export", summary="导出全站数据")
|
||||
@audit(action=AuditAction.DOWNLOAD, description="导出备份")
|
||||
async def export_backup(request: Request):
|
||||
data = await BackupService.export_data()
|
||||
async def export_backup(
|
||||
request: Request, sections: list[str] | None = Query(default=None)
|
||||
):
|
||||
data = await BackupService.export_data(sections=sections)
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
headers = {"Content-Disposition": f"attachment; filename=foxel_backup_{timestamp}.json"}
|
||||
return JSONResponse(content=data.model_dump(), headers=headers)
|
||||
@@ -25,6 +27,10 @@ async def export_backup(request: Request):
|
||||
|
||||
@router.post("/import", summary="导入数据")
|
||||
@audit(action=AuditAction.UPLOAD, description="导入备份")
|
||||
async def import_backup(request: Request, file: UploadFile = File(...)):
|
||||
await BackupService.import_from_bytes(file.filename, await file.read())
|
||||
async def import_backup(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
mode: str = Form("replace"),
|
||||
):
|
||||
await BackupService.import_from_bytes(file.filename, await file.read(), mode=mode)
|
||||
return {"message": "数据导入成功。"}
|
||||
|
||||
@@ -4,8 +4,8 @@ from datetime import datetime
|
||||
from fastapi import HTTPException
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from domain.backup.types import BackupData
|
||||
from domain.config.service import VERSION
|
||||
from domain.config import VERSION
|
||||
from .types import BackupData
|
||||
from models.database import (
|
||||
AIDefaultModel,
|
||||
AIModel,
|
||||
@@ -20,18 +20,64 @@ from models.database import (
|
||||
|
||||
|
||||
class BackupService:
|
||||
ALL_SECTIONS = (
|
||||
"storage_adapters",
|
||||
"user_accounts",
|
||||
"automation_tasks",
|
||||
"share_links",
|
||||
"configurations",
|
||||
"ai_providers",
|
||||
"ai_models",
|
||||
"ai_default_models",
|
||||
"plugins",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def export_data(cls) -> BackupData:
|
||||
async def export_data(cls, sections: list[str] | None = None) -> BackupData:
|
||||
sections = cls._normalize_sections(sections)
|
||||
section_set = set(sections)
|
||||
async with in_transaction():
|
||||
adapters = await StorageAdapter.all().values()
|
||||
users = await UserAccount.all().values()
|
||||
tasks = await AutomationTask.all().values()
|
||||
shares = await ShareLink.all().values()
|
||||
configs = await Configuration.all().values()
|
||||
providers = await AIProvider.all().values()
|
||||
models = await AIModel.all().values()
|
||||
default_models = await AIDefaultModel.all().values()
|
||||
plugins = await Plugin.all().values()
|
||||
adapters = (
|
||||
await StorageAdapter.all().values()
|
||||
if "storage_adapters" in section_set
|
||||
else []
|
||||
)
|
||||
users = (
|
||||
await UserAccount.all().values()
|
||||
if "user_accounts" in section_set
|
||||
else []
|
||||
)
|
||||
tasks = (
|
||||
await AutomationTask.all().values()
|
||||
if "automation_tasks" in section_set
|
||||
else []
|
||||
)
|
||||
shares = (
|
||||
await ShareLink.all().values()
|
||||
if "share_links" in section_set
|
||||
else []
|
||||
)
|
||||
configs = (
|
||||
await Configuration.all().values()
|
||||
if "configurations" in section_set
|
||||
else []
|
||||
)
|
||||
providers = (
|
||||
await AIProvider.all().values()
|
||||
if "ai_providers" in section_set
|
||||
else []
|
||||
)
|
||||
models = (
|
||||
await AIModel.all().values() if "ai_models" in section_set else []
|
||||
)
|
||||
default_models = (
|
||||
await AIDefaultModel.all().values()
|
||||
if "ai_default_models" in section_set
|
||||
else []
|
||||
)
|
||||
plugins = (
|
||||
await Plugin.all().values() if "plugins" in section_set else []
|
||||
)
|
||||
|
||||
share_links = cls._serialize_datetime_fields(
|
||||
shares, ["created_at", "expires_at"]
|
||||
@@ -51,6 +97,7 @@ class BackupService:
|
||||
|
||||
return BackupData(
|
||||
version=VERSION,
|
||||
sections=sections,
|
||||
storage_adapters=list(adapters),
|
||||
user_accounts=list(users),
|
||||
automation_tasks=list(tasks),
|
||||
@@ -63,106 +110,195 @@ class BackupService:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def import_from_bytes(cls, filename: str, content: bytes) -> None:
|
||||
async def import_from_bytes(
|
||||
cls, filename: str, content: bytes, mode: str = "replace"
|
||||
) -> None:
|
||||
if not filename.endswith(".json"):
|
||||
raise HTTPException(status_code=400, detail="无效的文件类型, 请上传 .json 文件")
|
||||
try:
|
||||
raw_data = json.loads(content)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="无法解析JSON文件")
|
||||
await cls.import_data(BackupData(**raw_data))
|
||||
await cls.import_data(BackupData(**raw_data), mode=mode)
|
||||
|
||||
@classmethod
|
||||
async def import_data(cls, payload: BackupData) -> None:
|
||||
async def import_data(cls, payload: BackupData, mode: str = "replace") -> None:
|
||||
sections = cls._normalize_sections(payload.sections)
|
||||
if mode not in {"replace", "merge"}:
|
||||
raise HTTPException(status_code=400, detail="无效的导入模式")
|
||||
|
||||
share_links = (
|
||||
cls._parse_datetime_fields(payload.share_links, ["created_at", "expires_at"])
|
||||
if payload.share_links
|
||||
else []
|
||||
)
|
||||
ai_providers = (
|
||||
cls._parse_datetime_fields(payload.ai_providers, ["created_at", "updated_at"])
|
||||
if payload.ai_providers
|
||||
else []
|
||||
)
|
||||
ai_models = (
|
||||
cls._parse_datetime_fields(payload.ai_models, ["created_at", "updated_at"])
|
||||
if payload.ai_models
|
||||
else []
|
||||
)
|
||||
ai_default_models = (
|
||||
cls._parse_datetime_fields(
|
||||
payload.ai_default_models, ["created_at", "updated_at"]
|
||||
)
|
||||
if payload.ai_default_models
|
||||
else []
|
||||
)
|
||||
plugins = (
|
||||
cls._parse_datetime_fields(payload.plugins, ["created_at", "updated_at"])
|
||||
if payload.plugins
|
||||
else []
|
||||
)
|
||||
|
||||
async with in_transaction() as conn:
|
||||
await ShareLink.all().using_db(conn).delete()
|
||||
await AutomationTask.all().using_db(conn).delete()
|
||||
await StorageAdapter.all().using_db(conn).delete()
|
||||
await UserAccount.all().using_db(conn).delete()
|
||||
await Configuration.all().using_db(conn).delete()
|
||||
await AIDefaultModel.all().using_db(conn).delete()
|
||||
await AIModel.all().using_db(conn).delete()
|
||||
await AIProvider.all().using_db(conn).delete()
|
||||
await Plugin.all().using_db(conn).delete()
|
||||
if mode == "replace":
|
||||
if "share_links" in sections:
|
||||
await ShareLink.all().using_db(conn).delete()
|
||||
if "automation_tasks" in sections:
|
||||
await AutomationTask.all().using_db(conn).delete()
|
||||
if "storage_adapters" in sections:
|
||||
await StorageAdapter.all().using_db(conn).delete()
|
||||
if "user_accounts" in sections:
|
||||
await UserAccount.all().using_db(conn).delete()
|
||||
if "configurations" in sections:
|
||||
await Configuration.all().using_db(conn).delete()
|
||||
if "ai_default_models" in sections:
|
||||
await AIDefaultModel.all().using_db(conn).delete()
|
||||
if "ai_models" in sections:
|
||||
await AIModel.all().using_db(conn).delete()
|
||||
if "ai_providers" in sections:
|
||||
await AIProvider.all().using_db(conn).delete()
|
||||
if "plugins" in sections:
|
||||
await Plugin.all().using_db(conn).delete()
|
||||
|
||||
if payload.configurations:
|
||||
await Configuration.bulk_create(
|
||||
[Configuration(**config) for config in payload.configurations],
|
||||
using_db=conn,
|
||||
)
|
||||
if "configurations" in sections and payload.configurations:
|
||||
if mode == "merge":
|
||||
await cls._merge_records(
|
||||
Configuration, payload.configurations, conn
|
||||
)
|
||||
else:
|
||||
await Configuration.bulk_create(
|
||||
[Configuration(**config) for config in payload.configurations],
|
||||
using_db=conn,
|
||||
)
|
||||
|
||||
if payload.user_accounts:
|
||||
await UserAccount.bulk_create(
|
||||
[UserAccount(**user) for user in payload.user_accounts],
|
||||
using_db=conn,
|
||||
)
|
||||
if "user_accounts" in sections and payload.user_accounts:
|
||||
if mode == "merge":
|
||||
await cls._merge_records(UserAccount, payload.user_accounts, conn)
|
||||
else:
|
||||
await UserAccount.bulk_create(
|
||||
[UserAccount(**user) for user in payload.user_accounts],
|
||||
using_db=conn,
|
||||
)
|
||||
|
||||
if payload.storage_adapters:
|
||||
await StorageAdapter.bulk_create(
|
||||
[StorageAdapter(**adapter) for adapter in payload.storage_adapters],
|
||||
using_db=conn,
|
||||
)
|
||||
if "storage_adapters" in sections and payload.storage_adapters:
|
||||
if mode == "merge":
|
||||
await cls._merge_records(
|
||||
StorageAdapter, payload.storage_adapters, conn
|
||||
)
|
||||
else:
|
||||
await StorageAdapter.bulk_create(
|
||||
[StorageAdapter(**adapter) for adapter in payload.storage_adapters],
|
||||
using_db=conn,
|
||||
)
|
||||
|
||||
if payload.automation_tasks:
|
||||
await AutomationTask.bulk_create(
|
||||
[AutomationTask(**task) for task in payload.automation_tasks],
|
||||
using_db=conn,
|
||||
)
|
||||
if "automation_tasks" in sections and payload.automation_tasks:
|
||||
if mode == "merge":
|
||||
await cls._merge_records(
|
||||
AutomationTask, payload.automation_tasks, conn
|
||||
)
|
||||
else:
|
||||
await AutomationTask.bulk_create(
|
||||
[AutomationTask(**task) for task in payload.automation_tasks],
|
||||
using_db=conn,
|
||||
)
|
||||
|
||||
if payload.share_links:
|
||||
await ShareLink.bulk_create(
|
||||
[
|
||||
ShareLink(**share)
|
||||
for share in cls._parse_datetime_fields(
|
||||
payload.share_links, ["created_at", "expires_at"]
|
||||
)
|
||||
],
|
||||
using_db=conn,
|
||||
)
|
||||
if "share_links" in sections and share_links:
|
||||
if mode == "merge":
|
||||
await cls._merge_records(ShareLink, share_links, conn)
|
||||
else:
|
||||
await ShareLink.bulk_create(
|
||||
[ShareLink(**share) for share in share_links],
|
||||
using_db=conn,
|
||||
)
|
||||
|
||||
if payload.ai_providers:
|
||||
await AIProvider.bulk_create(
|
||||
[
|
||||
AIProvider(**item)
|
||||
for item in cls._parse_datetime_fields(
|
||||
payload.ai_providers, ["created_at", "updated_at"]
|
||||
)
|
||||
],
|
||||
using_db=conn,
|
||||
)
|
||||
if "ai_providers" in sections and ai_providers:
|
||||
if mode == "merge":
|
||||
await cls._merge_records(AIProvider, ai_providers, conn)
|
||||
else:
|
||||
await AIProvider.bulk_create(
|
||||
[AIProvider(**item) for item in ai_providers],
|
||||
using_db=conn,
|
||||
)
|
||||
|
||||
if payload.ai_models:
|
||||
await AIModel.bulk_create(
|
||||
[
|
||||
AIModel(**item)
|
||||
for item in cls._parse_datetime_fields(
|
||||
payload.ai_models, ["created_at", "updated_at"]
|
||||
)
|
||||
],
|
||||
using_db=conn,
|
||||
)
|
||||
if "ai_models" in sections and ai_models:
|
||||
if mode == "merge":
|
||||
await cls._merge_records(AIModel, ai_models, conn)
|
||||
else:
|
||||
await AIModel.bulk_create(
|
||||
[AIModel(**item) for item in ai_models],
|
||||
using_db=conn,
|
||||
)
|
||||
|
||||
if payload.ai_default_models:
|
||||
await AIDefaultModel.bulk_create(
|
||||
[
|
||||
AIDefaultModel(**item)
|
||||
for item in cls._parse_datetime_fields(
|
||||
payload.ai_default_models, ["created_at", "updated_at"]
|
||||
)
|
||||
],
|
||||
using_db=conn,
|
||||
)
|
||||
if "ai_default_models" in sections and ai_default_models:
|
||||
if mode == "merge":
|
||||
await cls._merge_records(
|
||||
AIDefaultModel, ai_default_models, conn
|
||||
)
|
||||
else:
|
||||
await AIDefaultModel.bulk_create(
|
||||
[AIDefaultModel(**item) for item in ai_default_models],
|
||||
using_db=conn,
|
||||
)
|
||||
|
||||
if payload.plugins:
|
||||
await Plugin.bulk_create(
|
||||
[
|
||||
Plugin(**item)
|
||||
for item in cls._parse_datetime_fields(
|
||||
payload.plugins, ["created_at", "updated_at"]
|
||||
)
|
||||
],
|
||||
using_db=conn,
|
||||
)
|
||||
if "plugins" in sections and plugins:
|
||||
if mode == "merge":
|
||||
await cls._merge_records(Plugin, plugins, conn)
|
||||
else:
|
||||
await Plugin.bulk_create(
|
||||
[Plugin(**item) for item in plugins],
|
||||
using_db=conn,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _normalize_sections(cls, sections: list[str] | None) -> list[str]:
|
||||
if not sections:
|
||||
return list(cls.ALL_SECTIONS)
|
||||
normalized = [item for item in sections if item]
|
||||
invalid = [item for item in normalized if item not in cls.ALL_SECTIONS]
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"无效的备份分区: {', '.join(invalid)}"
|
||||
)
|
||||
result: list[str] = []
|
||||
seen = set()
|
||||
for item in normalized:
|
||||
if item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def _merge_records(model, records: list[dict], using_db) -> None:
|
||||
for record in records:
|
||||
data = dict(record)
|
||||
record_id = data.pop("id", None)
|
||||
if record_id is None:
|
||||
await model.create(using_db=using_db, **data)
|
||||
continue
|
||||
updated = (
|
||||
await model.filter(id=record_id)
|
||||
.using_db(using_db)
|
||||
.update(**data)
|
||||
)
|
||||
if updated == 0:
|
||||
await model.create(using_db=using_db, id=record_id, **data)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_datetime_fields(
|
||||
|
||||
@@ -5,6 +5,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
class BackupData(BaseModel):
|
||||
version: str | None = None
|
||||
sections: list[str] = Field(default_factory=list)
|
||||
storage_adapters: list[dict[str, Any]] = Field(default_factory=list)
|
||||
user_accounts: list[dict[str, Any]] = Field(default_factory=list)
|
||||
automation_tasks: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
10
domain/config/__init__.py
Normal file
10
domain/config/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from .service import ConfigService, VERSION
|
||||
from .types import ConfigItem, LatestVersionInfo, SystemStatus
|
||||
|
||||
__all__ = [
|
||||
"ConfigService",
|
||||
"VERSION",
|
||||
"ConfigItem",
|
||||
"LatestVersionInfo",
|
||||
"SystemStatus",
|
||||
]
|
||||
@@ -4,10 +4,9 @@ from fastapi import APIRouter, Depends, Form, Request
|
||||
|
||||
from api.response import success
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.auth.types import User
|
||||
from domain.config.service import ConfigService
|
||||
from domain.config.types import ConfigItem
|
||||
from domain.auth import User, get_current_active_user
|
||||
from .service import ConfigService
|
||||
from .types import ConfigItem
|
||||
|
||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from domain.config.types import LatestVersionInfo, SystemStatus
|
||||
from .types import LatestVersionInfo, SystemStatus
|
||||
from models.database import Configuration, UserAccount
|
||||
|
||||
load_dotenv(dotenv_path=".env")
|
||||
|
||||
VERSION = "v1.6.0"
|
||||
VERSION = "v1.7.4"
|
||||
|
||||
|
||||
class ConfigService:
|
||||
|
||||
20
domain/email/__init__.py
Normal file
20
domain/email/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from .service import EmailService, EmailTemplateRenderer
|
||||
from .types import (
|
||||
EmailConfig,
|
||||
EmailSecurity,
|
||||
EmailSendPayload,
|
||||
EmailTemplatePreviewPayload,
|
||||
EmailTemplateUpdate,
|
||||
EmailTestRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EmailService",
|
||||
"EmailTemplateRenderer",
|
||||
"EmailConfig",
|
||||
"EmailSecurity",
|
||||
"EmailSendPayload",
|
||||
"EmailTemplatePreviewPayload",
|
||||
"EmailTemplateUpdate",
|
||||
"EmailTestRequest",
|
||||
]
|
||||
@@ -2,10 +2,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from api.response import success
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.auth.types import User
|
||||
from domain.email.service import EmailService, EmailTemplateRenderer
|
||||
from domain.email.types import (
|
||||
from domain.auth import User, get_current_active_user
|
||||
from .service import EmailService, EmailTemplateRenderer
|
||||
from .types import (
|
||||
EmailTemplatePreviewPayload,
|
||||
EmailTemplateUpdate,
|
||||
EmailTestRequest,
|
||||
|
||||
@@ -7,8 +7,8 @@ from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from domain.config.service import ConfigService
|
||||
from domain.email.types import EmailConfig, EmailSecurity, EmailSendPayload
|
||||
from domain.config import ConfigService
|
||||
from .types import EmailConfig, EmailSecurity, EmailSendPayload
|
||||
|
||||
|
||||
class EmailTemplateRenderer:
|
||||
@@ -104,7 +104,7 @@ class EmailService:
|
||||
template: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
from domain.tasks.task_queue import TaskProgress, task_queue_service
|
||||
from domain.tasks import TaskProgress, task_queue_service
|
||||
|
||||
payload = EmailSendPayload(
|
||||
recipients=recipients,
|
||||
@@ -126,7 +126,7 @@ class EmailService:
|
||||
|
||||
@classmethod
|
||||
async def send_from_task(cls, task_id: str, data: Dict[str, Any]):
|
||||
from domain.tasks.task_queue import TaskProgress, task_queue_service
|
||||
from domain.tasks import TaskProgress, task_queue_service
|
||||
|
||||
payload = EmailSendPayload(**data)
|
||||
|
||||
|
||||
7
domain/offline_downloads/__init__.py
Normal file
7
domain/offline_downloads/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .service import OfflineDownloadService
|
||||
from .types import OfflineDownloadCreate
|
||||
|
||||
__all__ = [
|
||||
"OfflineDownloadService",
|
||||
"OfflineDownloadCreate",
|
||||
]
|
||||
@@ -4,10 +4,9 @@ from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from api.response import success
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.auth.types import User
|
||||
from domain.offline_downloads.service import OfflineDownloadService
|
||||
from domain.offline_downloads.types import OfflineDownloadCreate
|
||||
from domain.auth import User, get_current_active_user
|
||||
from .service import OfflineDownloadService
|
||||
from .types import OfflineDownloadCreate
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_active_user)]
|
||||
|
||||
|
||||
@@ -7,11 +7,10 @@ import aiofiles
|
||||
import aiohttp
|
||||
from fastapi import Depends, HTTPException
|
||||
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.auth.types import User
|
||||
from domain.offline_downloads.types import OfflineDownloadCreate
|
||||
from domain.virtual_fs.service import VirtualFSService
|
||||
from domain.tasks.task_queue import Task, TaskProgress, task_queue_service
|
||||
from domain.auth import User, get_current_active_user
|
||||
from domain.tasks import Task, TaskProgress, task_queue_service
|
||||
from domain.virtual_fs import VirtualFSService
|
||||
from .types import OfflineDownloadCreate
|
||||
|
||||
|
||||
class OfflineDownloadService:
|
||||
|
||||
@@ -4,9 +4,9 @@ Foxel 插件系统
|
||||
提供 .foxpkg 插件包的安装、管理和运行时加载功能。
|
||||
"""
|
||||
|
||||
from domain.plugins.loader import PluginLoader, PluginLoadError
|
||||
from domain.plugins.service import PluginService
|
||||
from domain.plugins.startup import init_plugins, load_installed_plugins
|
||||
from .loader import PluginLoadError, PluginLoader
|
||||
from .service import PluginService
|
||||
from .startup import init_plugins, load_installed_plugins
|
||||
|
||||
__all__ = [
|
||||
"PluginLoader",
|
||||
|
||||
@@ -8,8 +8,8 @@ from fastapi import APIRouter, File, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.plugins.service import PluginService
|
||||
from domain.plugins.types import (
|
||||
from .service import PluginService
|
||||
from .types import (
|
||||
PluginInstallResult,
|
||||
PluginOut,
|
||||
)
|
||||
@@ -67,10 +67,12 @@ async def delete_plugin(request: Request, key_or_id: str):
|
||||
async def get_bundle(request: Request, key_or_id: str):
|
||||
"""获取插件前端 bundle"""
|
||||
path = await PluginService.get_bundle_path(key_or_id)
|
||||
v = (request.query_params.get("v") or "").strip()
|
||||
cache_control = "public, max-age=31536000, immutable" if v else "no-cache"
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
headers={"Cache-Control": cache_control},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from domain.plugins.types import (
|
||||
from .types import (
|
||||
ManifestProcessorConfig,
|
||||
ManifestRouteConfig,
|
||||
PluginManifest,
|
||||
@@ -344,7 +344,7 @@ class PluginLoader:
|
||||
supported_exts = getattr(module, "SUPPORTED_EXTS", [])
|
||||
|
||||
# 注册到处理器注册表
|
||||
from domain.processors.registry import CONFIG_SCHEMAS, TYPE_MAP
|
||||
from domain.processors import CONFIG_SCHEMAS, TYPE_MAP
|
||||
|
||||
processor_type = processor_config.type
|
||||
TYPE_MAP[processor_type] = factory
|
||||
@@ -401,7 +401,7 @@ class PluginLoader:
|
||||
"""
|
||||
# 卸载处理器
|
||||
if manifest and manifest.backend and manifest.backend.processors:
|
||||
from domain.processors.registry import CONFIG_SCHEMAS, TYPE_MAP
|
||||
from domain.processors import CONFIG_SCHEMAS, TYPE_MAP
|
||||
|
||||
for proc_config in manifest.backend.processors:
|
||||
proc_type = proc_config.type
|
||||
|
||||
@@ -12,8 +12,8 @@ from typing import List, Optional, Union
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from domain.plugins.loader import PluginLoadError, PluginLoader
|
||||
from domain.plugins.types import (
|
||||
from .loader import PluginLoadError, PluginLoader
|
||||
from .types import (
|
||||
PluginInstallResult,
|
||||
PluginManifest,
|
||||
PluginOut,
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, List, Tuple
|
||||
|
||||
from domain.plugins.loader import PluginLoadError, PluginLoader
|
||||
from domain.plugins.types import PluginManifest
|
||||
from .loader import PluginLoadError, PluginLoader
|
||||
from .types import PluginManifest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import FastAPI
|
||||
@@ -113,4 +113,3 @@ async def init_plugins(app: "FastAPI") -> None:
|
||||
logger.warning(f" - {error}")
|
||||
else:
|
||||
logger.info(f"插件加载完成,共 {loaded_count} 个插件")
|
||||
|
||||
|
||||
35
domain/processors/__init__.py
Normal file
35
domain/processors/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from .base import BaseProcessor
|
||||
from .registry import (
|
||||
CONFIG_SCHEMAS,
|
||||
TYPE_MAP,
|
||||
get_config_schema,
|
||||
get_config_schemas,
|
||||
get_last_discovery_errors,
|
||||
get_module_path,
|
||||
reload_processors,
|
||||
)
|
||||
from .service import (
|
||||
ProcessorService,
|
||||
get_processor,
|
||||
list_processors,
|
||||
reload_processor_modules,
|
||||
)
|
||||
from .types import ProcessDirectoryRequest, ProcessRequest, UpdateSourceRequest
|
||||
|
||||
__all__ = [
|
||||
"BaseProcessor",
|
||||
"CONFIG_SCHEMAS",
|
||||
"TYPE_MAP",
|
||||
"get_config_schema",
|
||||
"get_config_schemas",
|
||||
"get_last_discovery_errors",
|
||||
"get_module_path",
|
||||
"reload_processors",
|
||||
"ProcessorService",
|
||||
"get_processor",
|
||||
"list_processors",
|
||||
"reload_processor_modules",
|
||||
"ProcessDirectoryRequest",
|
||||
"ProcessRequest",
|
||||
"UpdateSourceRequest",
|
||||
]
|
||||
@@ -4,10 +4,9 @@ from fastapi import APIRouter, Body, Depends, Request
|
||||
|
||||
from api.response import success
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.auth.types import User
|
||||
from domain.processors.service import ProcessorService
|
||||
from domain.processors.types import (
|
||||
from domain.auth import User, get_current_active_user
|
||||
from .service import ProcessorService
|
||||
from .types import (
|
||||
ProcessDirectoryRequest,
|
||||
ProcessRequest,
|
||||
UpdateSourceRequest,
|
||||
|
||||
@@ -8,12 +8,14 @@ from fastapi.responses import Response
|
||||
from PIL import Image
|
||||
|
||||
from ..base import BaseProcessor
|
||||
from domain.ai.inference import describe_image_base64, get_text_embedding, provider_service
|
||||
from domain.ai.service import (
|
||||
VectorDBService,
|
||||
from domain.ai import (
|
||||
DEFAULT_VECTOR_DIMENSION,
|
||||
VECTOR_COLLECTION_NAME,
|
||||
FILE_COLLECTION_NAME,
|
||||
VECTOR_COLLECTION_NAME,
|
||||
VectorDBService,
|
||||
describe_image_base64,
|
||||
get_text_embedding,
|
||||
provider_service,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from domain.processors.base import BaseProcessor
|
||||
from .base import BaseProcessor
|
||||
|
||||
ProcessorFactory = Callable[[], BaseProcessor]
|
||||
TYPE_MAP: Dict[str, ProcessorFactory] = {}
|
||||
@@ -16,7 +16,7 @@ LAST_DISCOVERY_ERRORS: list[str] = []
|
||||
|
||||
def discover_processors(force_reload: bool = False) -> list[str]:
|
||||
"""扫描并缓存可用的处理器模块。"""
|
||||
from domain.processors import builtin as processors_pkg
|
||||
from . import builtin as processors_pkg
|
||||
|
||||
TYPE_MAP.clear()
|
||||
CONFIG_SCHEMAS.clear()
|
||||
|
||||
@@ -3,20 +3,20 @@ from typing import List, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from domain.processors.registry import (
|
||||
from domain.tasks import task_queue_service
|
||||
from domain.virtual_fs import VirtualFSService
|
||||
from .registry import (
|
||||
get,
|
||||
get_config_schema,
|
||||
get_config_schemas,
|
||||
get_module_path,
|
||||
reload_processors,
|
||||
)
|
||||
from domain.processors.types import (
|
||||
from .types import (
|
||||
ProcessDirectoryRequest,
|
||||
ProcessRequest,
|
||||
UpdateSourceRequest,
|
||||
)
|
||||
from domain.virtual_fs.service import VirtualFSService
|
||||
from domain.tasks.task_queue import task_queue_service
|
||||
|
||||
|
||||
class ProcessorService:
|
||||
|
||||
1
domain/repositories/__init__.py
Normal file
1
domain/repositories/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__all__: list[str] = []
|
||||
10
domain/share/__init__.py
Normal file
10
domain/share/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from .service import ShareService
|
||||
from .types import ShareCreate, ShareInfo, ShareInfoWithPassword, SharePassword
|
||||
|
||||
__all__ = [
|
||||
"ShareService",
|
||||
"ShareCreate",
|
||||
"ShareInfo",
|
||||
"ShareInfoWithPassword",
|
||||
"SharePassword",
|
||||
]
|
||||
@@ -4,10 +4,9 @@ from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from api.response import success
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.auth.types import User
|
||||
from domain.share.service import ShareService
|
||||
from domain.share.types import (
|
||||
from domain.auth import User, get_current_active_user
|
||||
from .service import ShareService
|
||||
from .types import (
|
||||
ShareCreate,
|
||||
ShareInfo,
|
||||
ShareInfoWithPassword,
|
||||
|
||||
@@ -7,7 +7,7 @@ import bcrypt
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi.responses import Response
|
||||
|
||||
from domain.virtual_fs.service import VirtualFSService
|
||||
from domain.virtual_fs import VirtualFSService
|
||||
from models.database import ShareLink, UserAccount
|
||||
|
||||
|
||||
|
||||
26
domain/tasks/__init__.py
Normal file
26
domain/tasks/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from .service import TaskService
|
||||
from .scheduler import task_scheduler
|
||||
from .task_queue import Task, TaskProgress, TaskStatus, task_queue_service
|
||||
from .types import (
|
||||
AutomationTaskBase,
|
||||
AutomationTaskCreate,
|
||||
AutomationTaskRead,
|
||||
AutomationTaskUpdate,
|
||||
TaskQueueSettings,
|
||||
TaskQueueSettingsResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TaskService",
|
||||
"Task",
|
||||
"TaskProgress",
|
||||
"TaskStatus",
|
||||
"task_queue_service",
|
||||
"task_scheduler",
|
||||
"AutomationTaskBase",
|
||||
"AutomationTaskCreate",
|
||||
"AutomationTaskRead",
|
||||
"AutomationTaskUpdate",
|
||||
"TaskQueueSettings",
|
||||
"TaskQueueSettingsResponse",
|
||||
]
|
||||
@@ -2,9 +2,9 @@ from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from api.response import success
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.tasks.service import TaskService
|
||||
from domain.tasks.types import (
|
||||
from domain.auth import get_current_active_user
|
||||
from .service import TaskService
|
||||
from .types import (
|
||||
AutomationTaskCreate,
|
||||
AutomationTaskUpdate,
|
||||
TaskQueueSettings,
|
||||
@@ -59,8 +59,7 @@ async def get_task_status(task_id: str, request: Request, current_user: CurrentU
|
||||
body_fields=[
|
||||
"name",
|
||||
"event",
|
||||
"path_pattern",
|
||||
"filename_regex",
|
||||
"trigger_config",
|
||||
"processor_type",
|
||||
"processor_config",
|
||||
"enabled",
|
||||
@@ -93,8 +92,7 @@ async def list_tasks(request: Request, current_user: CurrentUser):
|
||||
body_fields=[
|
||||
"name",
|
||||
"event",
|
||||
"path_pattern",
|
||||
"filename_regex",
|
||||
"trigger_config",
|
||||
"processor_type",
|
||||
"processor_config",
|
||||
"enabled",
|
||||
|
||||
102
domain/tasks/scheduler.py
Normal file
102
domain/tasks/scheduler.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from croniter import croniter
|
||||
|
||||
from models.database import AutomationTask
|
||||
from .task_queue import task_queue_service
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronTaskItem:
|
||||
task_id: int
|
||||
processor_type: str
|
||||
path: str
|
||||
cron: croniter
|
||||
next_run: datetime
|
||||
|
||||
|
||||
class AutomationTaskScheduler:
|
||||
def __init__(self):
|
||||
self._items: list[CronTaskItem] = []
|
||||
self._worker: asyncio.Task | None = None
|
||||
self._reload_event = asyncio.Event()
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._worker and not self._worker.done():
|
||||
return
|
||||
self._stop_event.clear()
|
||||
await self._load_tasks()
|
||||
self._worker = asyncio.create_task(self._run_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._worker:
|
||||
return
|
||||
self._stop_event.set()
|
||||
self._reload_event.set()
|
||||
await self._worker
|
||||
self._worker = None
|
||||
|
||||
def refresh(self) -> None:
|
||||
if self._worker and not self._worker.done():
|
||||
self._reload_event.set()
|
||||
|
||||
async def _load_tasks(self) -> None:
|
||||
tasks = await AutomationTask.filter(event="cron", enabled=True)
|
||||
items: list[CronTaskItem] = []
|
||||
now = datetime.now()
|
||||
for task in tasks:
|
||||
trigger = task.trigger_config or {}
|
||||
if not isinstance(trigger, dict):
|
||||
continue
|
||||
cron_expr = trigger.get("cron_expr")
|
||||
path = trigger.get("path")
|
||||
if not cron_expr or not path:
|
||||
continue
|
||||
cron = self._build_cron(cron_expr, now)
|
||||
if not cron:
|
||||
continue
|
||||
next_run = cron.get_next(datetime)
|
||||
items.append(
|
||||
CronTaskItem(
|
||||
task_id=task.id,
|
||||
processor_type=task.processor_type,
|
||||
path=path,
|
||||
cron=cron,
|
||||
next_run=next_run,
|
||||
)
|
||||
)
|
||||
self._items = items
|
||||
|
||||
def _build_cron(self, expr: str, base_time: datetime) -> croniter | None:
|
||||
expr = str(expr or "").strip()
|
||||
if not expr:
|
||||
return None
|
||||
parts = [p for p in expr.split() if p]
|
||||
if len(parts) not in (5, 6):
|
||||
return None
|
||||
second_at_beginning = len(parts) == 6
|
||||
try:
|
||||
return croniter(expr, base_time, second_at_beginning=second_at_beginning)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
if self._reload_event.is_set():
|
||||
self._reload_event.clear()
|
||||
await self._load_tasks()
|
||||
now = datetime.now()
|
||||
for item in list(self._items):
|
||||
if item.next_run <= now:
|
||||
await task_queue_service.add_task(
|
||||
item.processor_type,
|
||||
{"task_id": item.task_id, "path": item.path},
|
||||
)
|
||||
item.next_run = item.cron.get_next(datetime)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
task_scheduler = AutomationTaskScheduler()
|
||||
@@ -3,17 +3,17 @@ from typing import Annotated, Any, Dict, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.auth.types import User
|
||||
from domain.config.service import ConfigService
|
||||
from domain.tasks.types import (
|
||||
from domain.auth import User, get_current_active_user
|
||||
from domain.config import ConfigService
|
||||
from .scheduler import task_scheduler
|
||||
from .task_queue import task_queue_service
|
||||
from .types import (
|
||||
AutomationTaskCreate,
|
||||
AutomationTaskUpdate,
|
||||
TaskQueueSettings,
|
||||
TaskQueueSettingsResponse,
|
||||
)
|
||||
from models.database import AutomationTask
|
||||
from domain.tasks.task_queue import task_queue_service
|
||||
|
||||
|
||||
class TaskService:
|
||||
@@ -47,6 +47,7 @@ class TaskService:
|
||||
@classmethod
|
||||
async def create_task(cls, payload: AutomationTaskCreate, user: Optional[User]) -> AutomationTask:
|
||||
task = await AutomationTask.create(**payload.model_dump())
|
||||
task_scheduler.refresh()
|
||||
return task
|
||||
|
||||
@classmethod
|
||||
@@ -70,6 +71,7 @@ class TaskService:
|
||||
for key, value in update_data.items():
|
||||
setattr(task, key, value)
|
||||
await task.save()
|
||||
task_scheduler.refresh()
|
||||
return task
|
||||
|
||||
@classmethod
|
||||
@@ -77,6 +79,7 @@ class TaskService:
|
||||
deleted_count = await AutomationTask.filter(id=task_id).delete()
|
||||
if not deleted_count:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
task_scheduler.refresh()
|
||||
|
||||
@classmethod
|
||||
async def trigger_tasks(cls, event: str, path: str):
|
||||
@@ -87,11 +90,16 @@ class TaskService:
|
||||
|
||||
@classmethod
|
||||
def match(cls, task: AutomationTask, path: str) -> bool:
|
||||
if task.path_pattern and not path.startswith(task.path_pattern):
|
||||
trigger_config = task.trigger_config or {}
|
||||
if not isinstance(trigger_config, dict):
|
||||
trigger_config = {}
|
||||
path_prefix = trigger_config.get("path_prefix")
|
||||
filename_regex = trigger_config.get("filename_regex")
|
||||
if path_prefix and not path.startswith(path_prefix):
|
||||
return False
|
||||
if task.filename_regex:
|
||||
if filename_regex:
|
||||
filename = path.split("/")[-1]
|
||||
if not re.match(task.filename_regex, filename):
|
||||
if not re.match(filename_regex, filename):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ class TaskQueueService:
|
||||
|
||||
try:
|
||||
# Local import to avoid circular dependency during module load.
|
||||
from domain.virtual_fs.service import VirtualFSService
|
||||
from domain.virtual_fs import VirtualFSService
|
||||
|
||||
if task.name == "process_file":
|
||||
params = task.task_info
|
||||
@@ -88,35 +88,30 @@ class TaskQueueService:
|
||||
task.result = result
|
||||
elif task.name == "automation_task" or self._is_processor_task(task.name):
|
||||
from models.database import AutomationTask
|
||||
from domain.processors.service import get_processor
|
||||
|
||||
params = task.task_info
|
||||
auto_task = await AutomationTask.get(id=params["task_id"])
|
||||
path = params["path"]
|
||||
|
||||
processor_type = auto_task.processor_type if task.name == "automation_task" else task.name
|
||||
processor = get_processor(processor_type)
|
||||
if not processor:
|
||||
raise ValueError(f"Processor {processor_type} not found for task {auto_task.id}")
|
||||
|
||||
if processor_type != auto_task.processor_type:
|
||||
processor_type = auto_task.processor_type
|
||||
processor = get_processor(processor_type)
|
||||
if not processor:
|
||||
raise ValueError(f"Processor {processor_type} not found for task {auto_task.id}")
|
||||
|
||||
requires_input_bytes = bool(getattr(processor, "requires_input_bytes", True))
|
||||
file_content = b""
|
||||
if requires_input_bytes:
|
||||
file_content = await VirtualFSService.read_file(path)
|
||||
result = await processor.process(file_content, path, auto_task.processor_config)
|
||||
|
||||
save_to = auto_task.processor_config.get("save_to")
|
||||
if save_to and getattr(processor, "produces_file", False):
|
||||
await VirtualFSService.write_file(save_to, result)
|
||||
processor_type = auto_task.processor_type
|
||||
config = auto_task.processor_config or {}
|
||||
save_to = config.get("save_to") if isinstance(config, dict) else None
|
||||
overwrite = bool(config.get("overwrite")) if isinstance(config, dict) else False
|
||||
try:
|
||||
if await VirtualFSService.path_is_directory(path):
|
||||
overwrite = True
|
||||
except Exception:
|
||||
pass
|
||||
await VirtualFSService.process_file(
|
||||
path=path,
|
||||
processor_type=processor_type,
|
||||
config=config if isinstance(config, dict) else {},
|
||||
save_to=save_to,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
task.result = "Automation task completed"
|
||||
elif task.name == "offline_http_download":
|
||||
from domain.offline_downloads.service import OfflineDownloadService
|
||||
from domain.offline_downloads import OfflineDownloadService
|
||||
|
||||
result_path = await OfflineDownloadService.run_http_download(task)
|
||||
task.result = {"path": result_path}
|
||||
@@ -124,12 +119,11 @@ class TaskQueueService:
|
||||
result = await VirtualFSService.run_cross_mount_transfer_task(task)
|
||||
task.result = result
|
||||
elif task.name == "send_email":
|
||||
from domain.email.service import EmailService
|
||||
from domain.email import EmailService
|
||||
await EmailService.send_from_task(task.id, task.task_info)
|
||||
task.result = "Email sent"
|
||||
else:
|
||||
raise ValueError(f"Unknown task name: {task.name}")
|
||||
|
||||
task.status = TaskStatus.SUCCESS
|
||||
|
||||
except Exception as e:
|
||||
@@ -141,7 +135,7 @@ class TaskQueueService:
|
||||
|
||||
def _is_processor_task(self, task_name: str) -> bool:
|
||||
try:
|
||||
from domain.processors.service import get_processor
|
||||
from domain.processors import get_processor
|
||||
|
||||
return get_processor(task_name) is not None
|
||||
except Exception:
|
||||
@@ -180,7 +174,7 @@ class TaskQueueService:
|
||||
|
||||
async def start_worker(self, concurrency: int | None = None):
|
||||
if concurrency is None:
|
||||
from domain.config.service import ConfigService
|
||||
from domain.config import ConfigService
|
||||
|
||||
stored_value = await ConfigService.get("TASK_QUEUE_CONCURRENCY", self._concurrency)
|
||||
try:
|
||||
|
||||
@@ -6,8 +6,7 @@ from pydantic import BaseModel, Field
|
||||
class AutomationTaskBase(BaseModel):
|
||||
name: str
|
||||
event: str
|
||||
path_pattern: Optional[str] = None
|
||||
filename_regex: Optional[str] = None
|
||||
trigger_config: Dict[str, Any] = {}
|
||||
processor_type: str
|
||||
processor_config: Dict[str, Any] = {}
|
||||
enabled: bool = True
|
||||
@@ -22,6 +21,7 @@ class AutomationTaskUpdate(AutomationTaskBase):
|
||||
event: Optional[str] = None
|
||||
processor_type: Optional[str] = None
|
||||
processor_config: Optional[Dict[str, Any]] = None
|
||||
trigger_config: Optional[Dict[str, Any]] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
|
||||
11
domain/virtual_fs/__init__.py
Normal file
11
domain/virtual_fs/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from .service import VirtualFSService
|
||||
from .types import DirListing, MkdirRequest, MoveRequest, SearchResultItem, VfsEntry
|
||||
|
||||
__all__ = [
|
||||
"VirtualFSService",
|
||||
"DirListing",
|
||||
"MkdirRequest",
|
||||
"MoveRequest",
|
||||
"SearchResultItem",
|
||||
"VfsEntry",
|
||||
]
|
||||
@@ -4,10 +4,9 @@ from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
|
||||
|
||||
from api.response import success
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.auth.types import User
|
||||
from domain.virtual_fs.service import VirtualFSService
|
||||
from domain.virtual_fs.types import MkdirRequest, MoveRequest
|
||||
from domain.auth import User, get_current_active_user
|
||||
from .service import VirtualFSService
|
||||
from .types import MkdirRequest, MoveRequest
|
||||
|
||||
router = APIRouter(prefix="/api/fs", tags=["virtual-fs"])
|
||||
|
||||
@@ -64,6 +63,16 @@ async def access_public_file(
|
||||
return await VirtualFSService.access_public_file(token, request.headers.get("Range"))
|
||||
|
||||
|
||||
@router.get("/public/{token}/{filename}")
|
||||
@audit(action=AuditAction.DOWNLOAD, description="访问临时链接文件")
|
||||
async def access_public_file_with_name(
|
||||
token: str,
|
||||
filename: str,
|
||||
request: Request,
|
||||
):
|
||||
return await VirtualFSService.access_public_file(token, request.headers.get("Range"))
|
||||
|
||||
|
||||
@router.get("/stat/{full_path:path}")
|
||||
@audit(action=AuditAction.READ, description="查看文件信息")
|
||||
async def get_file_stat(
|
||||
|
||||
@@ -4,13 +4,36 @@ from typing import Any, AsyncIterator, Union
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
from domain.tasks.service import TaskService
|
||||
from domain.virtual_fs.thumbnail import is_raw_filename, raw_bytes_to_jpeg
|
||||
from domain.tasks import TaskService
|
||||
from .thumbnail import is_raw_filename, raw_bytes_to_jpeg
|
||||
|
||||
from .listing import VirtualFSListingMixin
|
||||
|
||||
|
||||
class VirtualFSFileOpsMixin(VirtualFSListingMixin):
|
||||
@classmethod
|
||||
def _normalize_written_result(
|
||||
cls,
|
||||
original_path: str,
|
||||
adapter_model: Any,
|
||||
result: Any,
|
||||
size_hint: int,
|
||||
) -> tuple[str, int]:
|
||||
final_path = original_path
|
||||
size = size_hint
|
||||
if isinstance(result, dict):
|
||||
rel_override = result.get("rel")
|
||||
if isinstance(rel_override, str) and rel_override:
|
||||
final_path = cls._build_absolute_path(adapter_model.path, rel_override)
|
||||
else:
|
||||
path_override = result.get("path")
|
||||
if isinstance(path_override, str) and path_override:
|
||||
final_path = cls._normalize_path(path_override)
|
||||
size_val = result.get("size")
|
||||
if isinstance(size_val, int):
|
||||
size = size_val
|
||||
return final_path, size
|
||||
|
||||
@classmethod
|
||||
async def read_file(cls, path: str) -> Union[bytes, Any]:
|
||||
adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path)
|
||||
@@ -21,16 +44,18 @@ class VirtualFSFileOpsMixin(VirtualFSListingMixin):
|
||||
|
||||
@classmethod
|
||||
async def write_file(cls, path: str, data: bytes):
|
||||
adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path)
|
||||
adapter_instance, adapter_model, root, rel = await cls.resolve_adapter_and_rel(path)
|
||||
if rel.endswith("/"):
|
||||
raise HTTPException(400, detail="Invalid file path")
|
||||
write_func = await cls._ensure_method(adapter_instance, "write_file")
|
||||
await write_func(root, rel, data)
|
||||
await TaskService.trigger_tasks("file_written", path)
|
||||
result = await write_func(root, rel, data)
|
||||
final_path, size = cls._normalize_written_result(path, adapter_model, result, len(data))
|
||||
await TaskService.trigger_tasks("file_written", final_path)
|
||||
return {"path": final_path, "size": size}
|
||||
|
||||
@classmethod
|
||||
async def write_file_stream(cls, path: str, data_iter: AsyncIterator[bytes], overwrite: bool = True):
|
||||
adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path)
|
||||
adapter_instance, adapter_model, root, rel = await cls.resolve_adapter_and_rel(path)
|
||||
if rel.endswith("/"):
|
||||
raise HTTPException(400, detail="Invalid file path")
|
||||
exists_func = getattr(adapter_instance, "exists", None)
|
||||
@@ -46,18 +71,23 @@ class VirtualFSFileOpsMixin(VirtualFSListingMixin):
|
||||
size = 0
|
||||
stream_func = getattr(adapter_instance, "write_file_stream", None)
|
||||
if callable(stream_func):
|
||||
size = await stream_func(root, rel, data_iter)
|
||||
result = await stream_func(root, rel, data_iter)
|
||||
if isinstance(result, dict):
|
||||
size = int(result.get("size") or 0)
|
||||
else:
|
||||
size = int(result or 0)
|
||||
else:
|
||||
buf = bytearray()
|
||||
async for chunk in data_iter:
|
||||
if chunk:
|
||||
buf.extend(chunk)
|
||||
write_func = await cls._ensure_method(adapter_instance, "write_file")
|
||||
await write_func(root, rel, bytes(buf))
|
||||
result = await write_func(root, rel, bytes(buf))
|
||||
size = len(buf)
|
||||
|
||||
await TaskService.trigger_tasks("file_written", path)
|
||||
return size
|
||||
final_path, size = cls._normalize_written_result(path, adapter_model, result, size)
|
||||
await TaskService.trigger_tasks("file_written", final_path)
|
||||
return {"path": final_path, "size": size}
|
||||
|
||||
@classmethod
|
||||
async def make_dir(cls, path: str):
|
||||
|
||||
@@ -3,9 +3,9 @@ from typing import Any, Dict, List, Tuple
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.response import page
|
||||
from domain.adapters.registry import runtime_registry
|
||||
from domain.ai.service import VectorDBService, VECTOR_COLLECTION_NAME, FILE_COLLECTION_NAME
|
||||
from domain.virtual_fs.thumbnail import is_image_filename, is_video_filename
|
||||
from domain.adapters import runtime_registry
|
||||
from domain.ai import FILE_COLLECTION_NAME, VECTOR_COLLECTION_NAME, VectorDBService
|
||||
from .thumbnail import is_image_filename, is_video_filename
|
||||
from models import StorageAdapter
|
||||
|
||||
from .resolver import VirtualFSResolverMixin
|
||||
@@ -225,7 +225,10 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
|
||||
stat_func = getattr(adapter_instance, "stat_file", None)
|
||||
if not callable(stat_func):
|
||||
raise HTTPException(501, detail="Adapter does not implement stat_file")
|
||||
info = await stat_func(root, rel)
|
||||
try:
|
||||
info = await stat_func(root, rel)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(404, detail=str(exc))
|
||||
|
||||
if isinstance(info, dict):
|
||||
info.setdefault("path", path)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
__all__: list[str] = []
|
||||
|
||||
@@ -15,8 +15,8 @@ from fastapi import APIRouter, Request, Response
|
||||
from fastapi import HTTPException
|
||||
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.config.service import ConfigService
|
||||
from domain.virtual_fs.service import VirtualFSService
|
||||
from domain.config import ConfigService
|
||||
from domain.virtual_fs import VirtualFSService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/s3", tags=["s3"])
|
||||
|
||||
@@ -9,10 +9,9 @@ from fastapi import APIRouter, Request, Response, HTTPException, Depends
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.auth.service import AuthService
|
||||
from domain.auth.types import User, UserInDB
|
||||
from domain.virtual_fs.service import VirtualFSService
|
||||
from domain.config.service import ConfigService
|
||||
from domain.auth import AuthService, User, UserInDB
|
||||
from domain.config import ConfigService
|
||||
from domain.virtual_fs import VirtualFSService
|
||||
|
||||
|
||||
_WEBDAV_ENABLED_KEY = "WEBDAV_MAPPING_ENABLED"
|
||||
@@ -172,12 +171,32 @@ async def propfind(
|
||||
ctype = None if is_dir else (mimetypes.guess_type(name)[0] or "application/octet-stream")
|
||||
responses.append(_build_prop_response(full_path, name, is_dir, size, mtime, ctype))
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
st = None
|
||||
except HTTPException as e:
|
||||
if e.status_code != 404:
|
||||
raise
|
||||
st = None
|
||||
|
||||
if st is None:
|
||||
is_mount_root = False
|
||||
try:
|
||||
_, rel = await VirtualFSService.resolve_adapter_by_path(full_path)
|
||||
is_mount_root = rel == ""
|
||||
except HTTPException:
|
||||
is_mount_root = False
|
||||
|
||||
if not is_mount_root and full_path != "/":
|
||||
listing_probe = await VirtualFSService.list_virtual_dir(full_path, page_num=1, page_size=1)
|
||||
if not (listing_probe.get("items") or []):
|
||||
raise HTTPException(404, detail="Not found")
|
||||
|
||||
name = "/" if full_path == "/" else (full_path.rstrip("/").rsplit("/", 1)[-1] or "/")
|
||||
responses.append(_build_prop_response(full_path, name, True, None, 0, None))
|
||||
|
||||
if depth in ("1", "infinity"):
|
||||
try:
|
||||
listing = await VirtualFSService.list_virtual_dir(full_path, page_num=1, page_size=1000)
|
||||
for ent in listing["items"]:
|
||||
for ent in (listing.get("items") or []):
|
||||
is_dir = bool(ent.get("is_dir"))
|
||||
name = ent.get("name")
|
||||
child_path = full_path.rstrip("/") + "/" + name
|
||||
|
||||
@@ -16,7 +16,7 @@ class VirtualFSProcessingMixin(VirtualFSTransferMixin):
|
||||
save_to: str | None = None,
|
||||
overwrite: bool = False,
|
||||
) -> Any:
|
||||
from domain.processors.service import get_processor
|
||||
from domain.processors import get_processor
|
||||
|
||||
processor = get_processor(processor_type)
|
||||
if not processor:
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Tuple
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
from domain.adapters.registry import runtime_registry
|
||||
from domain.adapters import runtime_registry
|
||||
from models import StorageAdapter
|
||||
|
||||
from .common import VirtualFSCommonMixin
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import mimetypes
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
|
||||
from domain.config.service import ConfigService
|
||||
from domain.virtual_fs.thumbnail import (
|
||||
from domain.config import ConfigService
|
||||
from domain.tasks import TaskService
|
||||
from .thumbnail import (
|
||||
get_or_create_thumb,
|
||||
is_image_filename,
|
||||
is_raw_filename,
|
||||
@@ -112,12 +114,14 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin):
|
||||
async def create_temp_link(cls, full_path: str, expires_in: int):
|
||||
full_path = cls._normalize_path(full_path)
|
||||
token = await cls.generate_temp_link_token(full_path, expires_in=expires_in)
|
||||
filename = full_path.rstrip("/").split("/")[-1]
|
||||
filename_part = f"/{quote(filename, safe='')}" if filename else ""
|
||||
file_domain = await ConfigService.get("FILE_DOMAIN")
|
||||
if file_domain:
|
||||
file_domain = file_domain.rstrip("/")
|
||||
url = f"{file_domain}/api/fs/public/{token}"
|
||||
url = f"{file_domain}/api/fs/public/{token}{filename_part}"
|
||||
else:
|
||||
url = f"/api/fs/public/{token}"
|
||||
url = f"/api/fs/public/{token}{filename_part}"
|
||||
return {"token": token, "path": full_path, "url": url}
|
||||
|
||||
@classmethod
|
||||
@@ -128,12 +132,17 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin):
|
||||
raise exc
|
||||
|
||||
try:
|
||||
return await cls.stream_file(path, range_header)
|
||||
response = await cls.stream_file(path, range_header)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, detail="File not found via token")
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, detail=f"File access error: {exc}")
|
||||
|
||||
filename = path.rstrip("/").split("/")[-1]
|
||||
if filename and not response.headers.get("Content-Disposition"):
|
||||
response.headers["Content-Disposition"] = f"inline; filename*=UTF-8''{quote(filename, safe='')}"
|
||||
return response
|
||||
|
||||
@classmethod
|
||||
async def stat(cls, full_path: str):
|
||||
full_path = cls._normalize_path(full_path)
|
||||
@@ -142,8 +151,15 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin):
|
||||
@classmethod
|
||||
async def write_uploaded_file(cls, full_path: str, data: bytes):
|
||||
full_path = cls._normalize_path(full_path)
|
||||
await cls.write_file(full_path, data)
|
||||
return {"written": True, "path": full_path, "size": len(data)}
|
||||
result = await cls.write_file(full_path, data)
|
||||
path = full_path
|
||||
size = len(data)
|
||||
if isinstance(result, dict):
|
||||
path = result.get("path") or path
|
||||
size_val = result.get("size")
|
||||
if isinstance(size_val, int):
|
||||
size = size_val
|
||||
return {"written": True, "path": path, "size": size}
|
||||
|
||||
@classmethod
|
||||
async def mkdir(cls, path: str):
|
||||
@@ -201,7 +217,7 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin):
|
||||
full_path = cls._normalize_path(full_path)
|
||||
if full_path.endswith("/"):
|
||||
raise HTTPException(400, detail="Path must be a file")
|
||||
adapter, _m, root, rel = await cls.resolve_adapter_and_rel(full_path)
|
||||
adapter, adapter_model, root, rel = await cls.resolve_adapter_and_rel(full_path)
|
||||
exists_func = getattr(adapter, "exists", None)
|
||||
if not overwrite and callable(exists_func):
|
||||
try:
|
||||
@@ -212,6 +228,21 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
upload_func = getattr(adapter, "write_upload_file", None)
|
||||
if callable(upload_func):
|
||||
try:
|
||||
await file.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
size_hint = getattr(file, "size", None)
|
||||
if not isinstance(size_hint, int):
|
||||
size_hint = None
|
||||
filename = file.filename or (rel.rsplit("/", 1)[-1] if rel else "file")
|
||||
result = await upload_func(root, rel, file.file, filename, size_hint, file.content_type)
|
||||
final_path, size = cls._normalize_written_result(full_path, adapter_model, result, size_hint or 0)
|
||||
await TaskService.trigger_tasks("file_written", final_path)
|
||||
return {"uploaded": True, "path": final_path, "size": size, "overwrite": overwrite}
|
||||
|
||||
async def gen():
|
||||
while True:
|
||||
chunk = await file.read(chunk_size)
|
||||
@@ -219,8 +250,17 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin):
|
||||
break
|
||||
yield chunk
|
||||
|
||||
size = await cls.write_file_stream(full_path, gen(), overwrite=overwrite)
|
||||
return {"uploaded": True, "path": full_path, "size": size, "overwrite": overwrite}
|
||||
result = await cls.write_file_stream(full_path, gen(), 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):
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .search_service import VirtualFSSearchService
|
||||
|
||||
__all__ = ["VirtualFSSearchService"]
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from api.response import success
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.auth.types import User
|
||||
from domain.virtual_fs.search.search_service import VirtualFSSearchService
|
||||
from domain.auth import User, get_current_active_user
|
||||
from .search_service import VirtualFSSearchService
|
||||
|
||||
router = APIRouter(prefix="/api/fs/search", tags=["search"])
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from domain.virtual_fs.types import SearchResultItem
|
||||
from domain.ai.inference import get_text_embedding
|
||||
from domain.ai.service import VectorDBService, VECTOR_COLLECTION_NAME, FILE_COLLECTION_NAME
|
||||
from domain.ai import FILE_COLLECTION_NAME, VECTOR_COLLECTION_NAME, VectorDBService, get_text_embedding
|
||||
from ..types import SearchResultItem
|
||||
|
||||
|
||||
def _normalize_result(raw: Dict[str, Any], source: str, fallback_score: float = 0.0) -> SearchResultItem:
|
||||
|
||||
@@ -5,7 +5,7 @@ import time
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from domain.config.service import ConfigService
|
||||
from domain.config import ConfigService
|
||||
|
||||
from .processing import VirtualFSProcessingMixin
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ class VirtualFSTransferMixin(VirtualFSFileOpsMixin):
|
||||
"overwrite": overwrite,
|
||||
}
|
||||
|
||||
from domain.tasks.task_queue import task_queue_service
|
||||
from domain.tasks import task_queue_service
|
||||
|
||||
task = await task_queue_service.add_task("cross_mount_transfer", payload)
|
||||
return {
|
||||
@@ -286,7 +286,7 @@ class VirtualFSTransferMixin(VirtualFSFileOpsMixin):
|
||||
|
||||
@classmethod
|
||||
async def run_cross_mount_transfer_task(cls, task: "Task") -> Dict[str, Any]:
|
||||
from domain.tasks.task_queue import task_queue_service
|
||||
from domain.tasks import task_queue_service
|
||||
|
||||
params = task.task_info or {}
|
||||
operation = params.get("operation")
|
||||
|
||||
53
main.py
53
main.py
@@ -2,15 +2,16 @@ import os
|
||||
from pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from domain.config.service import ConfigService, VERSION
|
||||
from domain.adapters.registry import runtime_registry
|
||||
from domain.adapters import runtime_registry
|
||||
from domain.config import ConfigService, VERSION
|
||||
from db.session import close_db, init_db
|
||||
from api.routers import include_routers
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from middleware.exception_handler import (
|
||||
global_exception_handler,
|
||||
http_exception_handler,
|
||||
@@ -19,34 +20,45 @@ from middleware.exception_handler import (
|
||||
)
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from domain.tasks.task_queue import task_queue_service
|
||||
from domain.tasks import task_queue_service, task_scheduler
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class SPAStaticFiles(StaticFiles):
|
||||
async def get_response(self, path, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
if response.status_code == 404:
|
||||
return await super().get_response("index.html", scope)
|
||||
try:
|
||||
response = await super().get_response(path, scope)
|
||||
except StarletteHTTPException as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
if self._should_spa_fallback(scope):
|
||||
return FileResponse(INDEX_FILE)
|
||||
raise
|
||||
|
||||
if response.status_code == 404 and self._should_spa_fallback(scope):
|
||||
return FileResponse(INDEX_FILE)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _should_spa_fallback(scope) -> bool:
|
||||
return (
|
||||
scope.get("method") == "GET"
|
||||
and _request_accepts_html(scope)
|
||||
and not (scope.get("path") or "").startswith(SPA_EXCLUDE_PREFIXES)
|
||||
and INDEX_FILE.exists()
|
||||
)
|
||||
|
||||
|
||||
INDEX_FILE = Path("web/dist/index.html")
|
||||
SPA_EXCLUDE_PREFIXES = ("/api", "/docs", "/openapi.json", "/webdav", "/s3")
|
||||
|
||||
|
||||
async def spa_fallback_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
if (
|
||||
response.status_code == 404
|
||||
and request.method == "GET"
|
||||
and "text/html" in request.headers.get("accept", "")
|
||||
and not request.url.path.startswith(SPA_EXCLUDE_PREFIXES)
|
||||
and INDEX_FILE.exists()
|
||||
):
|
||||
return FileResponse(INDEX_FILE)
|
||||
return response
|
||||
def _request_accepts_html(scope) -> bool:
|
||||
for k, v in scope.get("headers") or []:
|
||||
if k == b"accept":
|
||||
return "text/html" in v.decode("latin-1")
|
||||
return False
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -59,8 +71,9 @@ async def lifespan(app: FastAPI):
|
||||
await task_queue_service.start_worker()
|
||||
|
||||
# 加载已安装的插件
|
||||
from domain.plugins.startup import init_plugins
|
||||
from domain.plugins import init_plugins
|
||||
await init_plugins(app)
|
||||
await task_scheduler.start()
|
||||
|
||||
# 在所有路由加载完成后,挂载静态文件服务(放在最后以避免覆盖 API 路由)
|
||||
app.mount("/", SPAStaticFiles(directory="web/dist", html=True, check_dir=False), name="static")
|
||||
@@ -68,6 +81,7 @@ async def lifespan(app: FastAPI):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await task_scheduler.stop()
|
||||
await task_queue_service.stop_worker()
|
||||
await close_db()
|
||||
|
||||
@@ -78,7 +92,6 @@ def create_app() -> FastAPI:
|
||||
description="A highly extensible private cloud storage solution for individuals and teams",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.middleware("http")(spa_fallback_middleware)
|
||||
include_routers(app)
|
||||
app.add_exception_handler(HTTPException, http_exception_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
|
||||
@@ -116,8 +116,7 @@ class AutomationTask(Model):
|
||||
name = fields.CharField(max_length=100)
|
||||
event = fields.CharField(max_length=50)
|
||||
|
||||
path_pattern = fields.CharField(max_length=1024, null=True)
|
||||
filename_regex = fields.CharField(max_length=255, null=True)
|
||||
trigger_config = fields.JSONField(null=True)
|
||||
|
||||
processor_type = fields.CharField(max_length=100)
|
||||
processor_config = fields.JSONField()
|
||||
|
||||
@@ -7,6 +7,7 @@ requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"aioboto3>=15.5.0",
|
||||
"bcrypt>=5.0.0",
|
||||
"croniter>=6.0.0",
|
||||
"fastapi>=0.127.0",
|
||||
"paramiko>=4.0.0",
|
||||
"pillow>=12.0.0",
|
||||
|
||||
@@ -13,8 +13,8 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from domain.auth.service import get_password_hash
|
||||
from domain.config.service import VERSION
|
||||
from domain.config import VERSION
|
||||
from domain.auth import get_password_hash
|
||||
|
||||
|
||||
def _project_root() -> Path:
|
||||
|
||||
21
uv.lock
generated
21
uv.lock
generated
@@ -318,6 +318,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "croniter"
|
||||
version = "6.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pytz" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/2f/44d1ae153a0e27be56be43465e5cb39b9650c781e001e7864389deb25090/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577", size = 64481, upload-time = "2024-12-17T17:17:47.32Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
@@ -418,6 +431,7 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aioboto3" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "croniter" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "paramiko" },
|
||||
{ name = "pillow" },
|
||||
@@ -437,6 +451,7 @@ dependencies = [
|
||||
requires-dist = [
|
||||
{ name = "aioboto3", specifier = ">=15.5.0" },
|
||||
{ name = "bcrypt", specifier = ">=5.0.0" },
|
||||
{ name = "croniter", specifier = ">=6.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.127.0" },
|
||||
{ name = "paramiko", specifier = ">=4.0.0" },
|
||||
{ name = "pillow", specifier = ">=12.0.0" },
|
||||
@@ -883,11 +898,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c9066137
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
1
web/public/icon/anthropic.svg
Normal file
1
web/public/icon/anthropic.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>
|
||||
|
After Width: | Height: | Size: 368 B |
1
web/public/icon/azure-color.svg
Normal file
1
web/public/icon/azure-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-fill-0)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-fill-1)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-0" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-1" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-2" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
web/public/icon/ollama.svg
Normal file
1
web/public/icon/ollama.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
1
web/public/icon/zai.svg
Normal file
1
web/public/icon/zai.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Z.ai</title><path d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"></path></svg>
|
||||
|
After Width: | Height: | Size: 319 B |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 3.7 KiB |
121
web/src/api/agent.ts
Normal file
121
web/src/api/agent.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import request, { API_BASE_URL } from './client';
|
||||
|
||||
export type AgentChatMessage = Record<string, any>;
|
||||
|
||||
export interface AgentChatContext {
|
||||
current_path?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentChatRequest {
|
||||
messages: AgentChatMessage[];
|
||||
auto_execute?: boolean;
|
||||
approved_tool_call_ids?: string[];
|
||||
rejected_tool_call_ids?: string[];
|
||||
context?: AgentChatContext;
|
||||
}
|
||||
|
||||
export interface PendingToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
requires_confirmation: boolean;
|
||||
}
|
||||
|
||||
export interface AgentChatResponse {
|
||||
messages: AgentChatMessage[];
|
||||
pending_tool_calls?: PendingToolCall[];
|
||||
}
|
||||
|
||||
export type AgentSseEvent =
|
||||
| { event: 'assistant_start'; data: { id: string } }
|
||||
| { event: 'assistant_delta'; data: { id: string; delta: string } }
|
||||
| { event: 'assistant_end'; data: { id: string; message: AgentChatMessage } }
|
||||
| { event: 'tool_start'; data: { tool_call_id: string; name: string } }
|
||||
| { event: 'tool_end'; data: { tool_call_id: string; name: string; message: AgentChatMessage } }
|
||||
| { event: 'pending'; data: { pending_tool_calls: PendingToolCall[] } }
|
||||
| { event: 'done'; data: AgentChatResponse };
|
||||
|
||||
export const agentApi = {
|
||||
chat: (payload: AgentChatRequest) =>
|
||||
request<AgentChatResponse>('/agent/chat', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
}),
|
||||
chatStream: async (
|
||||
payload: AgentChatRequest,
|
||||
onEvent: (evt: AgentSseEvent) => void,
|
||||
options?: { signal?: AbortSignal }
|
||||
) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
};
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const resp = await fetch(`${API_BASE_URL}/agent/chat/stream`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
let errMsg = resp.statusText;
|
||||
try {
|
||||
const data = await resp.json();
|
||||
if (Array.isArray((data as any)?.detail)) {
|
||||
errMsg = (data as any).detail.map((e: any) => e.msg || JSON.stringify(e)).join('; ');
|
||||
} else {
|
||||
errMsg = (typeof (data as any)?.detail === 'string') ? (data as any).detail : JSON.stringify(data);
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
errMsg = await resp.text();
|
||||
} catch { void 0; }
|
||||
}
|
||||
throw new Error(errMsg || `Request failed: ${resp.status}`);
|
||||
}
|
||||
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) throw new Error('Stream not supported');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
const flush = (raw: string) => {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trimStart());
|
||||
}
|
||||
}
|
||||
const dataStr = dataLines.join('\n').trim();
|
||||
if (!eventName || !dataStr) return;
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
onEvent({ event: eventName as any, data } as any);
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
while (true) {
|
||||
const idx = buffer.indexOf('\n\n');
|
||||
if (idx === -1) break;
|
||||
const chunk = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
if (chunk.trim()) flush(chunk);
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) flush(buffer);
|
||||
},
|
||||
};
|
||||
@@ -6,15 +6,16 @@ export interface AIProviderPayload {
|
||||
name: string;
|
||||
identifier: string;
|
||||
provider_type?: string | null;
|
||||
api_format: 'openai' | 'gemini';
|
||||
api_format: 'openai' | 'gemini' | 'anthropic' | 'ollama';
|
||||
base_url?: string | null;
|
||||
api_key?: string | null;
|
||||
logo_url?: string | null;
|
||||
extra_config?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AIProvider extends Omit<AIProviderPayload, 'extra_config'> {
|
||||
export interface AIProvider extends Omit<AIProviderPayload, 'extra_config' | 'api_key'> {
|
||||
id: number;
|
||||
has_api_key: boolean;
|
||||
extra_config: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import request from './client';
|
||||
|
||||
export const backupApi = {
|
||||
export: async () => {
|
||||
const response = await request('/backup/export', {
|
||||
export: async (sections?: string[]) => {
|
||||
const params = new URLSearchParams();
|
||||
(sections || []).forEach((section) => params.append('sections', section));
|
||||
const query = params.toString();
|
||||
const response = await request(`/backup/export${query ? `?${query}` : ''}`, {
|
||||
method: 'GET',
|
||||
rawResponse: true,
|
||||
}) as Response;
|
||||
@@ -27,12 +30,13 @@ export const backupApi = {
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
import: async (file: File) => {
|
||||
import: async (file: File, mode: 'replace' | 'merge' = 'replace') => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('mode', mode);
|
||||
return request('/backup/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
55
web/src/api/notices.ts
Normal file
55
web/src/api/notices.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export interface NoticeItem {
|
||||
id: number;
|
||||
title: string;
|
||||
contentMd: string;
|
||||
isPopup: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface GetNoticesResponse {
|
||||
items: NoticeItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
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> => {
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,8 +5,7 @@ export interface AutomationTask {
|
||||
id: number;
|
||||
name: string;
|
||||
event: string;
|
||||
path_pattern?: string;
|
||||
filename_regex?: string;
|
||||
trigger_config?: Record<string, any>;
|
||||
processor_type: string;
|
||||
processor_config: Record<string, any>;
|
||||
enabled: boolean;
|
||||
|
||||
@@ -7,10 +7,22 @@ export interface PluginAppHostProps extends AppComponentProps {
|
||||
}
|
||||
|
||||
function buildPluginFrameUrl(params: Record<string, string>): string {
|
||||
const qs = new URLSearchParams(params);
|
||||
const qs = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (typeof v !== 'string') return;
|
||||
const value = v.trim();
|
||||
if (!value) return;
|
||||
qs.set(k, value);
|
||||
});
|
||||
return `/plugin-frame.html?${qs.toString()}`;
|
||||
}
|
||||
|
||||
function getPluginStylePaths(plugin: PluginItem): string[] {
|
||||
const styles = (plugin.manifest as any)?.frontend?.styles as unknown;
|
||||
if (!Array.isArray(styles)) return [];
|
||||
return styles.filter((s) => typeof s === 'string' && s.trim().length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件宿主组件 - 文件打开模式
|
||||
* 使用 iframe 隔离渲染与样式,避免插件污染宿主 DOM/CSS。
|
||||
@@ -19,6 +31,7 @@ function buildPluginFrameUrl(params: Record<string, string>): string {
|
||||
export const PluginAppHost: React.FC<PluginAppHostProps> = ({
|
||||
plugin,
|
||||
filePath,
|
||||
entry,
|
||||
onRequestClose,
|
||||
}) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
@@ -29,10 +42,13 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({
|
||||
() =>
|
||||
buildPluginFrameUrl({
|
||||
pluginKey: plugin.key,
|
||||
pluginVersion: plugin.version || '',
|
||||
pluginStyles: JSON.stringify(getPluginStylePaths(plugin)),
|
||||
mode: 'file',
|
||||
filePath,
|
||||
entry: JSON.stringify(entry),
|
||||
}),
|
||||
[plugin.key, filePath]
|
||||
[plugin, filePath, entry]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,9 +94,11 @@ export const PluginAppOpenHost: React.FC<PluginAppOpenHostProps> = ({ plugin, on
|
||||
() =>
|
||||
buildPluginFrameUrl({
|
||||
pluginKey: plugin.key,
|
||||
pluginVersion: plugin.version || '',
|
||||
pluginStyles: JSON.stringify(getPluginStylePaths(plugin)),
|
||||
mode: 'app',
|
||||
}),
|
||||
[plugin.key]
|
||||
[plugin]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
891
web/src/components/AiAgentWidget.tsx
Normal file
891
web/src/components/AiAgentWidget.tsx
Normal file
@@ -0,0 +1,891 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Avatar, Button, Divider, Flex, Input, List, Modal, Space, Switch, Tag, Typography, message, theme } from 'antd';
|
||||
import { RobotOutlined, SendOutlined, DeleteOutlined, ToolOutlined, DownOutlined, UpOutlined, CodeOutlined, CopyOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import { agentApi, type AgentChatMessage, type PendingToolCall } from '../api/agent';
|
||||
import { useI18n } from '../i18n';
|
||||
import '../styles/ai-agent.css';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
function normalizePath(p?: string | null): string | null {
|
||||
if (!p) return null;
|
||||
const s = ('/' + p).replace(/\/+/, '/').replace(/\\/g, '/').replace(/\/+$/, '') || '/';
|
||||
return s;
|
||||
}
|
||||
|
||||
function extractTextContent(content: any): string {
|
||||
if (content == null) return '';
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
const parts: string[] = [];
|
||||
for (const item of content) {
|
||||
if (typeof item === 'string') {
|
||||
if (item.trim()) parts.push(item);
|
||||
continue;
|
||||
}
|
||||
const text = typeof item?.text === 'string' ? item.text : '';
|
||||
if (text.trim()) parts.push(text);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(content, null, 2);
|
||||
} catch {
|
||||
return String(content);
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseJson<T = any>(raw: string): T | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
try {
|
||||
return JSON.parse(s) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shortId(id: string, keep: number = 6): string {
|
||||
const s = String(id || '');
|
||||
if (s.length <= keep * 2 + 3) return s;
|
||||
return `${s.slice(0, keep)}…${s.slice(-keep)}`;
|
||||
}
|
||||
|
||||
function clampText(value: string, maxLen: number): string {
|
||||
if (value.length <= maxLen) return value;
|
||||
return `${value.slice(0, maxLen)}…`;
|
||||
}
|
||||
|
||||
function formatDisplayValue(value: any, maxLen: number = 120): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') return clampText(value, maxLen);
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
try {
|
||||
return clampText(JSON.stringify(value), maxLen);
|
||||
} catch {
|
||||
return clampText(String(value), maxLen);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: any): value is Record<string, any> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
type ToolPayload = {
|
||||
ok?: boolean;
|
||||
summary?: string;
|
||||
view?: {
|
||||
type?: string;
|
||||
title?: string;
|
||||
meta?: Record<string, any>;
|
||||
items?: any[];
|
||||
text?: string;
|
||||
message?: string;
|
||||
};
|
||||
data?: any;
|
||||
error?: any;
|
||||
};
|
||||
|
||||
function parseToolPayload(raw: string): ToolPayload | null {
|
||||
const parsed = tryParseJson<ToolPayload>(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
interface AiAgentWidgetProps {
|
||||
currentPath?: string | null;
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
}
|
||||
|
||||
const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenChange }: AiAgentWidgetProps) {
|
||||
const { t } = useI18n();
|
||||
const { token } = theme.useToken();
|
||||
const [autoExecute, setAutoExecute] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<AgentChatMessage[]>([]);
|
||||
const [pending, setPending] = useState<PendingToolCall[]>([]);
|
||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
|
||||
const [expandedRaw, setExpandedRaw] = useState<Record<string, boolean>>({});
|
||||
const [runningTools, setRunningTools] = useState<Record<string, string>>({});
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<TextAreaRef | null>(null);
|
||||
const streamControllerRef = useRef<AbortController | null>(null);
|
||||
const streamSeqRef = useRef(0);
|
||||
const baseMessagesRef = useRef<AgentChatMessage[]>([]);
|
||||
const assistantIndexRef = useRef<Record<string, number>>({});
|
||||
const toolNameByIdRef = useRef<Record<string, string>>({});
|
||||
|
||||
const effectivePath = useMemo(() => normalizePath(currentPath), [currentPath]);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = window.setTimeout(scrollToBottom, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [messages, open, pending, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || loading || pending.length > 0) return;
|
||||
const t = window.setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [open, loading, messages.length, pending.length]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toolCallsById = useMemo(() => {
|
||||
const map = new Map<string, { name: string; args: Record<string, any> }>();
|
||||
for (const msg of messages) {
|
||||
if (!msg || typeof msg !== 'object') continue;
|
||||
if (msg.role !== 'assistant') continue;
|
||||
const toolCalls = (msg as any).tool_calls;
|
||||
if (!Array.isArray(toolCalls)) continue;
|
||||
for (const call of toolCalls) {
|
||||
const id = typeof call?.id === 'string' ? call.id : '';
|
||||
const fn = call?.function;
|
||||
const name = typeof fn?.name === 'string' ? fn.name : '';
|
||||
const rawArgs = typeof fn?.arguments === 'string' ? fn.arguments : '';
|
||||
if (!id || !name) continue;
|
||||
const parsedArgs = tryParseJson<Record<string, any>>(rawArgs) || {};
|
||||
map.set(id, { name, args: parsedArgs });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
const runStream = useCallback(async (payload: Partial<Parameters<typeof agentApi.chat>[0]> & { messages: AgentChatMessage[] }) => {
|
||||
streamControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
streamControllerRef.current = controller;
|
||||
streamSeqRef.current += 1;
|
||||
const seq = streamSeqRef.current;
|
||||
|
||||
baseMessagesRef.current = payload.messages;
|
||||
assistantIndexRef.current = {};
|
||||
|
||||
setLoading(true);
|
||||
const approvedIds = payload.approved_tool_call_ids || [];
|
||||
if (Array.isArray(approvedIds) && approvedIds.length > 0) {
|
||||
const preRunning: Record<string, string> = {};
|
||||
approvedIds.forEach((id) => {
|
||||
if (typeof id === 'string' && id.trim()) preRunning[id] = '';
|
||||
});
|
||||
setRunningTools(preRunning);
|
||||
} else {
|
||||
setRunningTools({});
|
||||
}
|
||||
|
||||
try {
|
||||
await agentApi.chatStream(
|
||||
{
|
||||
messages: payload.messages,
|
||||
auto_execute: autoExecute,
|
||||
context: effectivePath ? { current_path: effectivePath } : undefined,
|
||||
approved_tool_call_ids: payload.approved_tool_call_ids,
|
||||
rejected_tool_call_ids: payload.rejected_tool_call_ids,
|
||||
},
|
||||
(evt) => {
|
||||
if (seq !== streamSeqRef.current) return;
|
||||
switch (evt.event) {
|
||||
case 'assistant_start': {
|
||||
const id = String((evt.data as any)?.id || '');
|
||||
if (!id) return;
|
||||
setMessages((prev) => {
|
||||
const idx = prev.length;
|
||||
assistantIndexRef.current[id] = idx;
|
||||
return [...prev, { role: 'assistant', content: '' }];
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'assistant_delta': {
|
||||
const id = String((evt.data as any)?.id || '');
|
||||
const delta = String((evt.data as any)?.delta || '');
|
||||
if (!id || !delta) return;
|
||||
setMessages((prev) => {
|
||||
const idx = assistantIndexRef.current[id];
|
||||
if (idx === undefined || idx < 0 || idx >= prev.length) return prev;
|
||||
const cur = prev[idx] as any;
|
||||
const curContent = typeof cur?.content === 'string' ? cur.content : extractTextContent(cur?.content);
|
||||
const next = prev.slice();
|
||||
next[idx] = { ...cur, content: (curContent || '') + delta };
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'assistant_end': {
|
||||
const id = String((evt.data as any)?.id || '');
|
||||
const msg = (evt.data as any)?.message;
|
||||
if (!id || !msg || typeof msg !== 'object') return;
|
||||
setMessages((prev) => {
|
||||
const idx = assistantIndexRef.current[id];
|
||||
if (idx === undefined || idx < 0 || idx >= prev.length) return prev;
|
||||
const next = prev.slice();
|
||||
next[idx] = msg;
|
||||
return next;
|
||||
});
|
||||
delete assistantIndexRef.current[id];
|
||||
return;
|
||||
}
|
||||
case 'tool_start': {
|
||||
const toolCallId = String((evt.data as any)?.tool_call_id || '');
|
||||
const name = String((evt.data as any)?.name || '');
|
||||
if (!toolCallId) return;
|
||||
if (name) toolNameByIdRef.current[toolCallId] = name;
|
||||
setRunningTools((prev) => ({ ...prev, [toolCallId]: name || prev[toolCallId] || '' }));
|
||||
return;
|
||||
}
|
||||
case 'tool_end': {
|
||||
const toolCallId = String((evt.data as any)?.tool_call_id || '');
|
||||
const name = String((evt.data as any)?.name || '');
|
||||
const msg = (evt.data as any)?.message;
|
||||
if (toolCallId && name) toolNameByIdRef.current[toolCallId] = name;
|
||||
if (toolCallId) {
|
||||
setRunningTools((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[toolCallId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
if (msg && typeof msg === 'object') {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'pending': {
|
||||
const items = Array.isArray((evt.data as any)?.pending_tool_calls) ? (evt.data as any).pending_tool_calls : [];
|
||||
setPending(items);
|
||||
return;
|
||||
}
|
||||
case 'done': {
|
||||
const base = baseMessagesRef.current || [];
|
||||
const newMessages = Array.isArray((evt.data as any)?.messages) ? (evt.data as any).messages : [];
|
||||
const nextPending = Array.isArray((evt.data as any)?.pending_tool_calls) ? (evt.data as any).pending_tool_calls : [];
|
||||
setMessages([...base, ...newMessages]);
|
||||
setPending(nextPending);
|
||||
setRunningTools({});
|
||||
assistantIndexRef.current = {};
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (controller.signal.aborted) return;
|
||||
message.error(err?.message || t('Operation failed'));
|
||||
} finally {
|
||||
if (seq === streamSeqRef.current) {
|
||||
setLoading(false);
|
||||
if (controller.signal.aborted) {
|
||||
setRunningTools({});
|
||||
assistantIndexRef.current = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [autoExecute, effectivePath, t]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
if (pending.length > 0) {
|
||||
message.warning(t('Please confirm pending actions first'));
|
||||
return;
|
||||
}
|
||||
const nextUserMsg: AgentChatMessage = { role: 'user', content: text };
|
||||
setInput('');
|
||||
const base = [...messages, nextUserMsg];
|
||||
setMessages(base);
|
||||
setPending([]);
|
||||
await runStream({ messages: base });
|
||||
}, [input, messages, pending.length, runStream, t]);
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
streamControllerRef.current?.abort();
|
||||
setMessages([]);
|
||||
setPending([]);
|
||||
setExpandedTools({});
|
||||
setExpandedRaw({});
|
||||
setRunningTools({});
|
||||
}, []);
|
||||
|
||||
const approveOne = useCallback(async (id: string) => {
|
||||
await runStream({ messages, approved_tool_call_ids: [id] });
|
||||
}, [messages, runStream]);
|
||||
|
||||
const rejectOne = useCallback(async (id: string) => {
|
||||
await runStream({ messages, rejected_tool_call_ids: [id] });
|
||||
}, [messages, runStream]);
|
||||
|
||||
const approveAll = useCallback(async () => {
|
||||
const ids = pending.map((p) => p.id).filter(Boolean);
|
||||
if (ids.length === 0) return;
|
||||
await runStream({ messages, approved_tool_call_ids: ids });
|
||||
}, [messages, pending, runStream]);
|
||||
|
||||
const rejectAll = useCallback(async () => {
|
||||
const ids = pending.map((p) => p.id).filter(Boolean);
|
||||
if (ids.length === 0) return;
|
||||
await runStream({ messages, rejected_tool_call_ids: ids });
|
||||
}, [messages, pending, runStream]);
|
||||
|
||||
const messageItems = useMemo(() => {
|
||||
return messages.filter((m) => {
|
||||
if (!m || typeof m !== 'object') return false;
|
||||
const role = typeof (m as any).role === 'string' ? String((m as any).role) : '';
|
||||
if (!role || role === 'system') return false;
|
||||
if (role === 'assistant') {
|
||||
const text = extractTextContent((m as any).content);
|
||||
return !!text.trim();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const runningToolEntries = useMemo(() => Object.entries(runningTools).filter(([id]) => !!id), [runningTools]);
|
||||
const runningToolCount = runningToolEntries.length;
|
||||
|
||||
const copyToClipboard = useCallback(async (raw: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(raw);
|
||||
message.success(t('Copied'));
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Operation failed'));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const renderToolResultSummary = useCallback((rawContent: string) => {
|
||||
const payload = parseToolPayload(rawContent);
|
||||
if (!payload) return '';
|
||||
const summary = typeof payload.summary === 'string' ? payload.summary.trim() : '';
|
||||
if (summary) return summary;
|
||||
|
||||
if (payload.ok === false) {
|
||||
const message = typeof payload.error?.message === 'string' ? payload.error.message : '';
|
||||
return message ? `${t('Error')}: ${message}` : t('Error');
|
||||
}
|
||||
|
||||
const view = payload.view || {};
|
||||
const viewType = typeof view.type === 'string' ? view.type : '';
|
||||
if (viewType === 'text') {
|
||||
const text = typeof view.text === 'string' ? view.text : '';
|
||||
return text ? `${text.length} ${t('chars')}` : '';
|
||||
}
|
||||
if (viewType === 'list') {
|
||||
const items = Array.isArray(view.items) ? view.items : [];
|
||||
return `${items.length} ${t('items')}`;
|
||||
}
|
||||
if (viewType === 'kv') {
|
||||
const items = Array.isArray(view.items) ? view.items : [];
|
||||
return `${items.length} ${t('items')}`;
|
||||
}
|
||||
return '';
|
||||
}, [t]);
|
||||
|
||||
const renderToolDetails = useCallback((toolKey: string, rawContent: string) => {
|
||||
const payload = parseToolPayload(rawContent);
|
||||
const view = payload?.view;
|
||||
const showRaw = !!expandedRaw[toolKey];
|
||||
const toggleRaw = () => setExpandedRaw((prev) => ({ ...prev, [toolKey]: !prev[toolKey] }));
|
||||
|
||||
const rawJson = (() => {
|
||||
if (!rawContent?.trim()) return '';
|
||||
const parsed = tryParseJson<any>(rawContent);
|
||||
if (!parsed) return rawContent;
|
||||
try {
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return rawContent;
|
||||
}
|
||||
})();
|
||||
|
||||
const header = (
|
||||
<Space size={10} wrap>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CodeOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); toggleRaw(); }}
|
||||
>
|
||||
{t('Raw JSON')}
|
||||
</Button>
|
||||
{showRaw && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); void copyToClipboard(rawJson); }}
|
||||
>
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
const viewType = typeof view?.type === 'string' ? view.type : '';
|
||||
const title = typeof view?.title === 'string' ? view.title : '';
|
||||
const metaEntries = isPlainObject(view?.meta) ? Object.entries(view!.meta) : [];
|
||||
|
||||
const renderMeta = () => {
|
||||
if (metaEntries.length === 0 && !title) return null;
|
||||
return (
|
||||
<>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
{title ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{title}</Text>
|
||||
) : null}
|
||||
{metaEntries.slice(0, 6).map(([key, value]) => (
|
||||
<Text key={key} type="secondary" style={{ fontSize: 12 }}>
|
||||
{key}: {formatDisplayValue(value, 180) || '-'}
|
||||
</Text>
|
||||
))}
|
||||
</Space>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (viewType === 'error') {
|
||||
const message = typeof view?.message === 'string'
|
||||
? view.message
|
||||
: (typeof payload?.error?.message === 'string' ? payload.error.message : t('Error'));
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{message || t('Error')}
|
||||
</Paragraph>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewType === 'text') {
|
||||
const text = typeof view?.text === 'string' ? view.text : '';
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
{renderMeta()}
|
||||
<pre className="fx-agent-pre" style={{ marginTop: metaEntries.length || title ? 0 : 10 }}>{text || ''}</pre>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewType === 'kv') {
|
||||
const items = Array.isArray(view?.items) ? view!.items : [];
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
{renderMeta()}
|
||||
<List
|
||||
size="small"
|
||||
dataSource={items}
|
||||
locale={{ emptyText: t('No results') }}
|
||||
renderItem={(item: any, idx) => {
|
||||
const key = typeof item?.key === 'string' ? item.key : (typeof item?.label === 'string' ? item.label : String(idx));
|
||||
const value = typeof item?.value === 'string' ? item.value : formatDisplayValue(item?.value, 200);
|
||||
return (
|
||||
<List.Item>
|
||||
<Space size={10} wrap>
|
||||
<Text code style={{ fontVariantNumeric: 'tabular-nums' }}>{key || '-'}</Text>
|
||||
<Text>{value || '-'}</Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewType === 'list') {
|
||||
const items = Array.isArray(view?.items) ? view!.items : [];
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
{renderMeta()}
|
||||
<List
|
||||
size="small"
|
||||
dataSource={items}
|
||||
locale={{ emptyText: t('No results') }}
|
||||
renderItem={(item: any) => {
|
||||
if (isPlainObject(item)) {
|
||||
const entries = Object.entries(item);
|
||||
const shown = entries.slice(0, 4);
|
||||
const extra = entries.length - shown.length;
|
||||
return (
|
||||
<List.Item>
|
||||
<Space size={10} wrap style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space size={10} wrap>
|
||||
{shown.map(([key, value]) => (
|
||||
<Text key={key}>
|
||||
<Text type="secondary">{key}</Text>: {formatDisplayValue(value, 160) || '-'}
|
||||
</Text>
|
||||
))}
|
||||
{extra > 0 ? <Text type="secondary">+{extra}</Text> : null}
|
||||
</Space>
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<List.Item>
|
||||
<Text>{formatDisplayValue(item, 200) || '-'}</Text>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
{showRaw ? (
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
) : (
|
||||
<Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{extractTextContent(payload ?? rawContent) || <Text type="secondary">{t('No content')}</Text>}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [copyToClipboard, expandedRaw, t]);
|
||||
|
||||
const renderToolArgsSummary = useCallback((args?: Record<string, any> | null) => {
|
||||
const entries = Object.entries(args || {})
|
||||
.filter(([, value]) => value != null && String(value).trim() !== '');
|
||||
if (entries.length === 0) return '';
|
||||
return entries.slice(0, 2)
|
||||
.map(([key, value]) => `${key}: ${formatDisplayValue(value, 60)}`)
|
||||
.join(' · ');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={(
|
||||
<Flex align="center" justify="space-between" gap={12} wrap>
|
||||
<Text strong>{t('AI Agent')}</Text>
|
||||
<Space align="center">
|
||||
<Text type="secondary">{t('Auto execute')}</Text>
|
||||
<Switch size="small" checked={autoExecute} onChange={setAutoExecute} />
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={clearChat}
|
||||
disabled={loading || messageItems.length === 0}
|
||||
>
|
||||
{t('Clear')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
)}
|
||||
open={open}
|
||||
onCancel={() => { streamControllerRef.current?.abort(); onOpenChange(false); }}
|
||||
width={720}
|
||||
centered
|
||||
closable={false}
|
||||
destroyOnHidden
|
||||
footer={null}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 8,
|
||||
background: token.colorBgContainer,
|
||||
height: '70vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex vertical gap={0} style={{ flex: 1, minHeight: 0 }} className="fx-agent-container">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="fx-agent-chat-scroll"
|
||||
>
|
||||
{messageItems.length === 0 ? (
|
||||
<div className="fx-agent-empty">
|
||||
<Avatar size={36} icon={<RobotOutlined />} style={{ background: token.colorPrimary }} />
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary">{t('Start a conversation')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fx-agent-messages">
|
||||
{messageItems.map((m, idx) => {
|
||||
const role = String((m as any).role);
|
||||
const isUser = role === 'user';
|
||||
const isTool = role === 'tool';
|
||||
const toolCallId = typeof (m as any).tool_call_id === 'string' ? String((m as any).tool_call_id) : '';
|
||||
const toolInfo = toolCallId ? toolCallsById.get(toolCallId) : null;
|
||||
const toolName = toolInfo?.name || (toolCallId ? toolNameByIdRef.current[toolCallId] : '') || '';
|
||||
const msgKey = toolCallId ? `tool:${toolCallId}` : `${role}:${idx}`;
|
||||
|
||||
if (isTool) {
|
||||
const rawContent = extractTextContent((m as any).content);
|
||||
const expanded = !!expandedTools[msgKey];
|
||||
const summary = rawContent ? renderToolResultSummary(rawContent) : '';
|
||||
return (
|
||||
<div key={msgKey} className="fx-agent-msg fx-agent-msg-tool">
|
||||
<div className="fx-agent-tool-block">
|
||||
<div className="fx-agent-tool-bar">
|
||||
<Space size={6} wrap className="fx-agent-tool-pills">
|
||||
<Tag className="fx-agent-pill" bordered={false} icon={<ToolOutlined />}>
|
||||
{t('MCP Tool')}
|
||||
</Tag>
|
||||
<Tag className="fx-agent-pill fx-agent-pill-strong" bordered={false} icon={<CodeOutlined />}>
|
||||
{toolName || t('Tool')}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={expanded ? <UpOutlined /> : <DownOutlined />}
|
||||
onClick={() => setExpandedTools((prev) => ({ ...prev, [msgKey]: !prev[msgKey] }))}
|
||||
>
|
||||
{expanded ? t('Collapse') : t('Expand')}
|
||||
</Button>
|
||||
</div>
|
||||
{summary ? (
|
||||
<div className="fx-agent-tool-summary-line">
|
||||
<Text type="secondary">{summary}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
{expanded && (
|
||||
<div className="fx-agent-tool-expanded">
|
||||
{toolInfo?.args && Object.keys(toolInfo.args).length > 0 && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('Arguments')}</Text>
|
||||
<pre className="fx-agent-pre fx-agent-pre-compact">
|
||||
{JSON.stringify(toolInfo.args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{renderToolDetails(msgKey, rawContent)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const text = extractTextContent((m as any).content);
|
||||
if (isUser) {
|
||||
return (
|
||||
<div key={msgKey} className="fx-agent-msg fx-agent-msg-user">
|
||||
<div className="fx-agent-user-block fx-agent-content">
|
||||
{text.trim() ? <div className="fx-agent-text">{text}</div> : <Text type="secondary">{t('No content')}</Text>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msgKey} className="fx-agent-msg fx-agent-msg-assistant">
|
||||
<div className="fx-agent-assistant-block fx-agent-content">
|
||||
{text.trim() ? (
|
||||
<div className="fx-agent-md">
|
||||
<ReactMarkdown>{text}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary">{t('No content')}</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{runningToolCount > 0 && (
|
||||
<div className="fx-agent-running">
|
||||
<LoadingOutlined spin />
|
||||
<Text type="secondary">{t('Calling tools')}</Text>
|
||||
<Space size={6} wrap>
|
||||
{runningToolEntries.slice(0, 2).map(([id, name]) => (
|
||||
<Tag key={id} bordered={false} color="blue">
|
||||
{(name || t('Tool'))} #{shortId(id, 4)}
|
||||
</Tag>
|
||||
))}
|
||||
{runningToolCount > 2 && (
|
||||
<Text type="secondary">+{runningToolCount - 2}</Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{pending.length > 0 && (
|
||||
<div className="fx-agent-pending-group">
|
||||
<div className="fx-agent-pending-head">
|
||||
<Space size={8} wrap>
|
||||
<Tag className="fx-agent-pill fx-agent-pill-warn" bordered={false}>
|
||||
{t('Pending actions')}
|
||||
</Tag>
|
||||
<Text type="secondary">{pending.length}</Text>
|
||||
</Space>
|
||||
<Space size={6}>
|
||||
<Button size="small" type="primary" onClick={approveAll} loading={loading}>
|
||||
{t('Execute all')}
|
||||
</Button>
|
||||
<Button size="small" onClick={rejectAll} disabled={loading}>
|
||||
{t('Cancel all')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="fx-agent-pending-list">
|
||||
{pending.map((p) => {
|
||||
const args = p.arguments || {};
|
||||
const key = `pending:${p.id}`;
|
||||
const expanded = !!expandedTools[key];
|
||||
const running = Object.prototype.hasOwnProperty.call(runningTools, p.id);
|
||||
const summary = renderToolArgsSummary(args);
|
||||
return (
|
||||
<div key={p.id} className="fx-agent-tool-block fx-agent-pending-item">
|
||||
<div className="fx-agent-tool-bar">
|
||||
<Space size={6} wrap className="fx-agent-tool-pills">
|
||||
<Tag className="fx-agent-pill" bordered={false} icon={<ToolOutlined />}>
|
||||
{t('MCP Tool')}
|
||||
</Tag>
|
||||
<Tag className="fx-agent-pill fx-agent-pill-strong" bordered={false} icon={<CodeOutlined />}>
|
||||
{p.name}
|
||||
</Tag>
|
||||
{running ? <LoadingOutlined spin style={{ color: token.colorPrimary }} /> : null}
|
||||
</Space>
|
||||
<Space size={6}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => void approveOne(p.id)}
|
||||
loading={loading && running}
|
||||
disabled={loading && !running}
|
||||
>
|
||||
{t('Execute')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => void rejectOne(p.id)}
|
||||
disabled={loading && !running}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={expanded ? <UpOutlined /> : <DownOutlined />}
|
||||
onClick={() => setExpandedTools((prev) => ({ ...prev, [key]: !prev[key] }))}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
{summary ? (
|
||||
<div className="fx-agent-tool-summary-line">
|
||||
<Text type="secondary">{summary}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
{expanded && (
|
||||
<div className="fx-agent-tool-expanded">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('Arguments')}</Text>
|
||||
<pre className="fx-agent-pre">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fx-agent-composer">
|
||||
<Flex vertical gap={8}>
|
||||
<Space wrap>
|
||||
{effectivePath && (
|
||||
<Tag bordered={false} color="blue">{t('Current')}: {effectivePath}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Input.TextArea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t('Type a message')}
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
autoFocus
|
||||
disabled={loading || pending.length > 0}
|
||||
variant="borderless"
|
||||
onPressEnter={(e) => {
|
||||
if (e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSend}
|
||||
loading={loading}
|
||||
disabled={loading || pending.length > 0 || !input.trim()}
|
||||
>
|
||||
{t('Send')}
|
||||
</Button>
|
||||
</div>
|
||||
</Flex>
|
||||
</div>
|
||||
</Flex>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default AiAgentWidget;
|
||||
184
web/src/components/NoticesModal.tsx
Normal file
184
web/src/components/NoticesModal.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, List, Typography, theme, Flex, Button, Empty, message, Divider, Spin } from 'antd';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { format } from 'date-fns';
|
||||
import { noticesApi, type NoticeItem } from '../api/notices';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export interface NoticesModalProps {
|
||||
open: boolean;
|
||||
version: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NoticesModal = memo(function NoticesModal({ open, version, onClose }: NoticesModalProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<NoticeItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
const selected = useMemo(() => items.find(i => i.id === selectedId) ?? null, [items, selectedId]);
|
||||
const hasMore = items.length < total;
|
||||
|
||||
const loadPage = async (targetPage: number, mode: 'replace' | 'append') => {
|
||||
if (mode === 'replace') setLoading(true);
|
||||
else setLoadingMore(true);
|
||||
try {
|
||||
const resp = await noticesApi.list({ version, page: targetPage });
|
||||
setPage(resp.page ?? targetPage);
|
||||
setTotal(resp.total ?? 0);
|
||||
setItems(prev => mode === 'replace' ? resp.items : [...prev, ...resp.items]);
|
||||
if (mode === 'replace') {
|
||||
setSelectedId(resp.items[0]?.id ?? null);
|
||||
} else {
|
||||
setSelectedId(prev => prev ?? resp.items[0]?.id ?? null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
message.error(e.message || t('Error'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setItems([]);
|
||||
setPage(1);
|
||||
setTotal(0);
|
||||
setSelectedId(null);
|
||||
loadPage(1, 'replace');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, version]);
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
try {
|
||||
return format(new Date(ts), 'yyyy-MM-dd HH:mm');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Notices')}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={980}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
height: '70vh',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex style={{ height: '70vh', minHeight: 0 }}>
|
||||
<div style={{
|
||||
width: 320,
|
||||
minWidth: 280,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
}}>
|
||||
<Typography.Text type="secondary">{t('Total')}: {total}</Typography.Text>
|
||||
<Typography.Text type="secondary">{items.length}/{total}</Typography.Text>
|
||||
</div>
|
||||
<List
|
||||
size="small"
|
||||
loading={loading && items.length === 0}
|
||||
dataSource={items}
|
||||
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
||||
renderItem={(item) => {
|
||||
const isSelected = item.id === selectedId;
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: isSelected ? 'rgba(22,119,255,0.08)' : undefined,
|
||||
borderInlineStart: isSelected ? `3px solid ${token.colorPrimary}` : '3px solid transparent',
|
||||
paddingInlineStart: 10,
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={<Typography.Text strong={isSelected}>{item.title}</Typography.Text>}
|
||||
description={<Typography.Text type="secondary">{formatTime(item.createdAt)}</Typography.Text>}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
padding: 12,
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}>
|
||||
<Button
|
||||
block
|
||||
loading={loadingMore}
|
||||
disabled={!hasMore}
|
||||
onClick={() => loadPage(page + 1, 'append')}
|
||||
>
|
||||
{t('Load more')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, padding: 16, overflow: 'auto' }}>
|
||||
{selected ? (
|
||||
<>
|
||||
<Typography.Title level={4} style={{ marginTop: 0, marginBottom: 6 }}>
|
||||
{selected.title}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">{formatTime(selected.createdAt)}</Typography.Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
{selected.contentMd?.trim() ? (
|
||||
<div style={{ color: token.colorText, lineHeight: 1.7 }}>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
||||
ul: ({ ...props }) => <ul style={{ paddingLeft: 20, marginBottom: 12 }} {...props} />,
|
||||
li: ({ ...props }) => <li style={{ marginBottom: 6 }} {...props} />,
|
||||
p: ({ ...props }) => <p style={{ marginBottom: 12 }} {...props} />,
|
||||
}}
|
||||
>
|
||||
{selected.contentMd}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description={t('No content')} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 80 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<Empty description={t('No notices')} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default NoticesModal;
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"Home": "Home",
|
||||
"File Manager": "File Manager",
|
||||
"New Folder": "New Folder",
|
||||
"New File": "New File",
|
||||
"Upload": "Upload",
|
||||
"Name": "Name",
|
||||
"Size": "Size",
|
||||
@@ -415,7 +416,7 @@
|
||||
"Custom Provider": "Custom Provider",
|
||||
"Custom Provider Description": "Bring your own endpoint compatible with OpenAI or Gemini formats.",
|
||||
"OpenAI Provider": "OpenAI",
|
||||
"OpenAI Provider Description": "Access GPT-4o, GPT-4.1, GPT-3.5 and more models from OpenAI.",
|
||||
"OpenAI Provider Description": "Access GPT-4o, GPT-4.1, GPT-5 and more models from OpenAI.",
|
||||
"Azure OpenAI Provider": "Azure OpenAI",
|
||||
"Azure OpenAI Provider Description": "Use OpenAI models deployed on Microsoft Azure.",
|
||||
"Google AI Provider": "Google AI",
|
||||
@@ -425,13 +426,15 @@
|
||||
"OpenRouter Provider": "OpenRouter",
|
||||
"OpenRouter Provider Description": "Connect to multiple AI providers through a single OpenAI-style endpoint.",
|
||||
"Anthropic Provider": "Anthropic",
|
||||
"Anthropic Provider Description": "Claude 3 family models exposed through the Claude API.",
|
||||
"Anthropic Provider Description": "Claude 4 family models exposed through the Claude API.",
|
||||
"Z.ai Provider": "Z.ai",
|
||||
"Z.ai Provider Description": "Z.ai models served via BigModel Open Platform (OpenAI-style).",
|
||||
"DeepSeek Provider": "DeepSeek",
|
||||
"DeepSeek Provider Description": "DeepSeek language models via OpenAI-compatible API.",
|
||||
"Grok Provider": "Grok (xAI)",
|
||||
"Grok Provider Description": "Grok models powered by xAI with OpenAI-style routes.",
|
||||
"Ollama Provider": "Ollama",
|
||||
"Ollama Provider Description": "Self-host and run models locally with Ollama's OpenAI bridge.",
|
||||
"Ollama Provider Description": "Self-host and run models locally with Ollama's native HTTP API.",
|
||||
"Voyage Provider": "Voyage AI",
|
||||
"Voyage Provider Description": "High-quality embeddings and rerankers from Voyage AI.",
|
||||
"Delete provider?": "Delete provider?",
|
||||
@@ -519,9 +522,12 @@
|
||||
"Trigger Event": "Trigger Event",
|
||||
"File Written": "File Written",
|
||||
"File Deleted": "File Deleted",
|
||||
"Scheduled": "Scheduled",
|
||||
"Matching Rules": "Matching Rules",
|
||||
"Path Prefix (optional)": "Path Prefix (optional)",
|
||||
"Filename Regex (optional)": "Filename Regex (optional)",
|
||||
"Schedule": "Schedule",
|
||||
"Cron Expression": "Cron Expression",
|
||||
"Action": "Action",
|
||||
"Current Task Queue": "Current Task Queue",
|
||||
"Params": "Params",
|
||||
@@ -531,6 +537,7 @@
|
||||
"This will delete all logs irreversibly.": "This will delete all logs irreversibly.",
|
||||
"Cleared {count} logs": "Cleared {count} logs",
|
||||
"Time": "Time",
|
||||
"Weekday": "Weekday",
|
||||
"Level": "Level",
|
||||
"Source": "Source",
|
||||
"Message": "Message",
|
||||
@@ -550,10 +557,24 @@
|
||||
"Export": "Export",
|
||||
"Import": "Import",
|
||||
"Export all data (adapters, users, tasks, shares) into a JSON file.": "Export all data (adapters, users, tasks, shares) into a JSON file.",
|
||||
"Export selected data into a JSON file.": "Export selected data into a JSON file.",
|
||||
"Keep your backup file safe.": "Keep your backup file safe.",
|
||||
"Select backup sections": "Select backup sections",
|
||||
"User Accounts": "User Accounts",
|
||||
"Share Links": "Share Links",
|
||||
"Configurations": "Configurations",
|
||||
"AI Providers": "AI Providers",
|
||||
"AI Models": "AI Models",
|
||||
"AI Default Models": "AI Default Models",
|
||||
"Plugin Data": "Plugins",
|
||||
"Export Backup": "Export Backup",
|
||||
"Restore data from a previously exported JSON file.": "Restore data from a previously exported JSON file.",
|
||||
"Warning: This will clear and overwrite existing data.": "Warning: This will clear and overwrite existing data.",
|
||||
"Import mode": "Import mode",
|
||||
"Merge (upsert by ID)": "Merge (upsert by ID)",
|
||||
"Replace (clear before import)": "Replace (clear before import)",
|
||||
"Warning: This will clear data in the backup sections before importing.": "Warning: This will clear data in the backup sections before importing.",
|
||||
"Warning: This will merge data in the backup sections and overwrite existing records with the same ID.": "Warning: This will merge data in the backup sections and overwrite existing records with the same ID.",
|
||||
"Choose File and Restore": "Choose File and Restore",
|
||||
"No files yet here": "No files yet here",
|
||||
"This folder is empty": "This folder is empty",
|
||||
@@ -690,5 +711,43 @@
|
||||
"App \"{key}\" not found.": "App \"{key}\" not found.",
|
||||
"Open with {app}": "Open with {app}",
|
||||
"Set as default for .{ext}": "Set as default for .{ext}",
|
||||
"Advanced tokens must be valid JSON": "Advanced tokens must be valid JSON"
|
||||
"AI Agent": "AI Agent",
|
||||
"Notices": "Notices",
|
||||
"No notices": "No notices",
|
||||
"Load more": "Load more",
|
||||
"Auto execute": "Auto execute",
|
||||
"Start a conversation": "Start a conversation",
|
||||
"No content": "No content",
|
||||
"Pending actions": "Pending actions",
|
||||
"Execute": "Execute",
|
||||
"Execute all": "Execute all",
|
||||
"Cancel all": "Cancel all",
|
||||
"Type a message": "Type a message",
|
||||
"Send": "Send",
|
||||
"Please confirm pending actions first": "Please confirm pending actions first",
|
||||
"You": "You",
|
||||
"Tool": "Tool",
|
||||
"MCP Tool": "MCP Tool",
|
||||
"Arguments": "Arguments",
|
||||
"Raw JSON": "Raw JSON",
|
||||
"Collapse": "Collapse",
|
||||
"Copied": "Copied",
|
||||
"Canceled": "Canceled",
|
||||
"Tasks submitted": "Tasks submitted",
|
||||
"Calling tools": "Calling tools",
|
||||
"Advanced tokens must be valid JSON": "Advanced tokens must be valid JSON",
|
||||
"Search": "Search",
|
||||
"Total": "Total",
|
||||
"Mode": "Mode",
|
||||
"Has more": "Has more",
|
||||
"Page": "Page",
|
||||
"results": "results",
|
||||
"chars": "chars",
|
||||
"Truncated": "Truncated",
|
||||
"Write": "Write",
|
||||
"Read": "Read",
|
||||
"Created": "Created",
|
||||
"Moved": "Moved",
|
||||
"Renamed": "Renamed",
|
||||
"Info": "Info"
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
"Home": "主页",
|
||||
"File Manager": "文件管理",
|
||||
"New Folder": "新建目录",
|
||||
"New File": "新建文件",
|
||||
"Upload": "上传",
|
||||
"Name": "名称",
|
||||
"Size": "大小",
|
||||
@@ -411,15 +412,23 @@
|
||||
"Added {count} models": "已添加 {count} 个模型",
|
||||
"Custom Provider": "自定义提供商",
|
||||
"Custom Provider Description": "自定义兼容 OpenAI 或 Gemini 标准的 API 端点。",
|
||||
"OpenAI Provider Description": "访问 OpenAI 的 GPT-4o、GPT-4.1、GPT-3.5 等模型。",
|
||||
"OpenAI Provider": "OpenAI",
|
||||
"OpenAI Provider Description": "访问 OpenAI 的 GPT-4o、GPT-4.1、GPT-5 等模型。",
|
||||
"Azure OpenAI Provider": "Azure OpenAI",
|
||||
"Azure OpenAI Provider Description": "使用托管在微软 Azure 上的 OpenAI 模型。",
|
||||
"Google AI Provider": "Google AI",
|
||||
"Google AI Provider Description": "Google AI 平台提供的 Gemini 系列模型。",
|
||||
"SiliconFlow Provider": "硅基流动",
|
||||
"SiliconFlow Provider Description": "硅基流动高性能推理平台,兼容 OpenAI 接口。",
|
||||
"OpenRouter Provider Description": "通过一个 OpenAI 风格入口接入多家 AI 提供商。",
|
||||
"Anthropic Provider Description": "通过 Claude API 使用 Claude 3 系列模型。",
|
||||
"Anthropic Provider": "Anthropic",
|
||||
"Anthropic Provider Description": "通过 Claude API 使用 Claude 4 系列模型。",
|
||||
"Z.ai Provider": "Z.ai",
|
||||
"Z.ai Provider Description": "通过智谱开放平台接入(OpenAI 风格接口)。",
|
||||
"DeepSeek Provider": "DeepSeek",
|
||||
"DeepSeek Provider Description": "DeepSeek 语言模型,支持 OpenAI 兼容接口。",
|
||||
"Grok Provider Description": "xAI 的 Grok 模型,提供 OpenAI 风格接口。",
|
||||
"Ollama Provider": "Ollama",
|
||||
"Ollama Provider Description": "使用 Ollama 在本地运行并管理大模型。",
|
||||
"Voyage Provider Description": "Voyage AI 提供的高质量嵌入与重排序模型。",
|
||||
"Delete provider?": "确认删除该提供商?",
|
||||
@@ -436,6 +445,9 @@
|
||||
"Add your first AI provider to get started": "添加第一个 AI 提供商开始配置",
|
||||
"Default Models Configuration": "默认模型配置",
|
||||
"Main Chat Model": "主对话模型",
|
||||
"Rerank Model": "重排序模型",
|
||||
"Voice Model": "语音模型",
|
||||
"Tools Model": "工具模型",
|
||||
"Primary assistant for conversations, reasoning, and tool calls.": "用于对话、推理与工具调用的核心模型。",
|
||||
"Handles multimodal perception such as image understanding.": "负责多模态感知与图像理解。",
|
||||
"Transforms content into dense vectors for search and retrieval.": "将内容向量化以驱动搜索与检索。",
|
||||
@@ -501,9 +513,12 @@
|
||||
"Trigger Event": "触发事件",
|
||||
"File Written": "文件写入",
|
||||
"File Deleted": "文件删除",
|
||||
"Scheduled": "定时任务",
|
||||
"Matching Rules": "匹配规则",
|
||||
"Path Prefix (optional)": "路径前缀 (可选)",
|
||||
"Filename Regex (optional)": "文件名正则 (可选)",
|
||||
"Schedule": "定时设置",
|
||||
"Cron Expression": "Cron 表达式",
|
||||
"Action": "执行动作",
|
||||
"Current Task Queue": "当前任务队列",
|
||||
"Params": "参数",
|
||||
@@ -513,6 +528,7 @@
|
||||
"This will delete all logs irreversibly.": "将删除全部日志且不可恢复",
|
||||
"Cleared {count} logs": "成功清理 {count} 条日志",
|
||||
"Time": "时间",
|
||||
"Weekday": "星期",
|
||||
"Level": "级别",
|
||||
"Source": "来源",
|
||||
"Message": "消息",
|
||||
@@ -532,10 +548,24 @@
|
||||
"Export": "导出",
|
||||
"Import": "恢复",
|
||||
"Export all data (adapters, users, tasks, shares) into a JSON file.": "点击按钮将所有数据(包括存储、用户、自动化任务和分享)导出为一个 JSON 文件。",
|
||||
"Export selected data into a JSON file.": "导出选中的数据为一个 JSON 文件。",
|
||||
"Keep your backup file safe.": "请妥善保管您的备份文件。",
|
||||
"Select backup sections": "选择备份内容",
|
||||
"User Accounts": "账号",
|
||||
"Share Links": "分享列表",
|
||||
"Configurations": "配置",
|
||||
"AI Providers": "AI 服务商",
|
||||
"AI Models": "AI 模型",
|
||||
"AI Default Models": "AI 默认模型",
|
||||
"Plugin Data": "插件",
|
||||
"Export Backup": "导出备份",
|
||||
"Restore data from a previously exported JSON file.": "从之前导出的JSON文件恢复数据。",
|
||||
"Warning: This will clear and overwrite existing data.": "警告:此操作将清除并覆盖现有数据。",
|
||||
"Import mode": "导入方式",
|
||||
"Merge (upsert by ID)": "增量+覆盖(按 ID)",
|
||||
"Replace (clear before import)": "清空后导入",
|
||||
"Warning: This will clear data in the backup sections before importing.": "警告:此操作会先清空备份中包含的分区数据,再导入。",
|
||||
"Warning: This will merge data in the backup sections and overwrite existing records with the same ID.": "警告:此操作会合并备份中包含的分区数据,并按 ID 覆盖已存在记录。",
|
||||
"Choose File and Restore": "选择文件并恢复",
|
||||
"No files yet here": "这里还没有任何文件",
|
||||
"This folder is empty": "此目录为空",
|
||||
@@ -635,7 +665,6 @@
|
||||
"Created (newest)": "创建时间(最新)",
|
||||
"Installed already": "已安装",
|
||||
"No results": "暂无结果",
|
||||
"Downloading": "下载中",
|
||||
"Download and Install": "下载并安装",
|
||||
"Loading apps": "加载应用中",
|
||||
"Failed to load apps": "加载应用失败",
|
||||
@@ -683,5 +712,43 @@
|
||||
"App \"{key}\" not found.": "应用 \"{key}\" 不存在。",
|
||||
"Open with {app}": "使用 {app} 打开",
|
||||
"Set as default for .{ext}": "设为该类型(.{ext})默认应用",
|
||||
"Advanced tokens must be valid JSON": "高级 Token 需为合法 JSON"
|
||||
"AI Agent": "AI 助手",
|
||||
"Notices": "公告",
|
||||
"No notices": "暂无公告",
|
||||
"Load more": "加载更多",
|
||||
"Auto execute": "自动执行",
|
||||
"Start a conversation": "开始对话",
|
||||
"No content": "无内容",
|
||||
"Pending actions": "待确认操作",
|
||||
"Execute": "执行",
|
||||
"Execute all": "全部执行",
|
||||
"Cancel all": "全部取消",
|
||||
"Type a message": "输入消息",
|
||||
"Send": "发送",
|
||||
"Please confirm pending actions first": "请先确认待执行操作",
|
||||
"You": "你",
|
||||
"Tool": "工具",
|
||||
"MCP Tool": "MCP 工具",
|
||||
"Arguments": "参数",
|
||||
"Raw JSON": "原始 JSON",
|
||||
"Collapse": "收起",
|
||||
"Copied": "已复制",
|
||||
"Canceled": "已取消",
|
||||
"Tasks submitted": "已提交任务",
|
||||
"Calling tools": "正在调用工具",
|
||||
"Advanced tokens must be valid JSON": "高级 Token 需为合法 JSON",
|
||||
"Search": "搜索",
|
||||
"Total": "总计",
|
||||
"Mode": "模式",
|
||||
"Has more": "更多",
|
||||
"Page": "页",
|
||||
"results": "条结果",
|
||||
"chars": "字符",
|
||||
"Truncated": "已截断",
|
||||
"Write": "写入",
|
||||
"Read": "读取",
|
||||
"Created": "已创建",
|
||||
"Moved": "已移动",
|
||||
"Renamed": "已重命名",
|
||||
"Info": "信息"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography } from 'antd';
|
||||
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip } from 'antd';
|
||||
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined } from '@ant-design/icons';
|
||||
import { memo, useState } from 'react';
|
||||
import SearchDialog from './SearchDialog.tsx';
|
||||
import { authApi } from '../api/auth.ts';
|
||||
@@ -8,21 +8,26 @@ import { useI18n } from '../i18n';
|
||||
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';
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
export interface TopHeaderProps {
|
||||
collapsed: boolean;
|
||||
onToggle(): void;
|
||||
onOpenAiAgent(): void;
|
||||
}
|
||||
|
||||
const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProps) {
|
||||
const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }: TopHeaderProps) {
|
||||
const { token } = theme.useToken();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const { user } = useAuth();
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [noticesOpen, setNoticesOpen] = useState(false);
|
||||
const status = useSystemStatus();
|
||||
|
||||
const handleLogout = () => {
|
||||
authApi.logout();
|
||||
@@ -50,6 +55,24 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
|
||||
</Button>
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<Flex style={{ marginLeft: 'auto' }} align="center" gap={12}>
|
||||
<Tooltip title={t('Notices')}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BellOutlined />}
|
||||
aria-label={t('Notices')}
|
||||
onClick={() => setNoticesOpen(true)}
|
||||
style={{ paddingInline: 8, height: 40 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('AI Agent')}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<RobotOutlined />}
|
||||
aria-label={t('AI Agent')}
|
||||
onClick={onOpenAiAgent}
|
||||
style={{ paddingInline: 8, height: 40 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<LanguageSwitcher />
|
||||
<Dropdown
|
||||
menu={{
|
||||
@@ -71,6 +94,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<ProfileModal open={profileOpen} onClose={() => setProfileOpen(false)} />
|
||||
<NoticesModal open={noticesOpen} onClose={() => setNoticesOpen(false)} version={status?.version || ''} />
|
||||
</Flex>
|
||||
</Header>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user