merge: personal user filesystem minio integration

This commit is contained in:
2026-06-03 16:32:29 +08:00
56 changed files with 4780 additions and 115 deletions

View File

@ -36,6 +36,19 @@ from beaver.integrations.mcp import MCPConnectionManager
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
from beaver.services.cron_service import CronService, schedule_from_api
from beaver.services.skillhub_service import SkillHubService
from beaver.services.user_files import (
USER_FILE_ROOTS,
UserFileError,
UserFileNotFoundError,
UserFilePathError,
UserFileSizeError,
UserFileService,
)
from beaver.services.user_file_resolver import (
UserFileConfigurationError,
UserFileStorageResolver,
build_file_auth_context,
)
from beaver.skills.learning import SkillLearningWorker, SkillLearningWorkerConfig
from beaver.skills.catalog.utils import parse_frontmatter
@ -485,6 +498,28 @@ def create_app(
app.state.handoff_codes = {}
app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or "")
max_file_size = 50 * 1024 * 1024
max_user_file_upload_size = _int_env("BEAVER_USER_FILES_MAX_UPLOAD_BYTES", 5 * 1024 * 1024 * 1024)
user_file_upload_part_size = _int_env("BEAVER_USER_FILES_UPLOAD_PART_SIZE", 10 * 1024 * 1024)
def _user_file_resolver(request: Request, authorization: str | None) -> UserFileStorageResolver:
username = _require_web_user(app, authorization)
loaded = get_agent_service(request).create_loop().boot()
auth_context = build_file_auth_context(username=username, config=loaded.config)
return UserFileStorageResolver(config=loaded.config, workspace=loaded.workspace, auth_context=auth_context)
async def _user_file_service(request: Request, authorization: str | None) -> UserFileService:
return await _user_file_resolver(request, authorization).service()
def _user_file_http_error(exc: UserFileError) -> HTTPException:
if isinstance(exc, UserFileNotFoundError):
return HTTPException(status_code=404, detail=str(exc) or "File not found")
if isinstance(exc, UserFilePathError):
return HTTPException(status_code=400, detail=str(exc) or "Invalid path")
if isinstance(exc, UserFileSizeError):
return HTTPException(status_code=413, detail=str(exc) or "File too large")
if isinstance(exc, UserFileConfigurationError):
return HTTPException(status_code=503, detail=str(exc) or "User file storage is not configured")
return HTTPException(status_code=400, detail=str(exc) or "User file operation failed")
@app.get("/api/ping", response_model=WebStatusResponse)
async def ping(request: Request) -> WebStatusResponse:
@ -1279,6 +1314,101 @@ def create_app(
return {"ok": True}
raise HTTPException(status_code=404, detail="File not found")
@app.get("/api/user-files/status")
async def user_files_status(
request: Request,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
return (await _user_file_resolver(request, authorization).status()).to_dict()
@app.get("/api/user-files/browse")
async def browse_user_files(
request: Request,
path: str = "",
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
try:
return await (await _user_file_service(request, authorization)).browse(path)
except UserFileError as exc:
raise _user_file_http_error(exc) from exc
@app.get("/api/user-files/download")
async def download_user_file(
path: str,
request: Request,
authorization: str | None = Header(default=None),
) -> Response:
try:
content = await (await _user_file_service(request, authorization)).download(path)
except UserFileError as exc:
raise _user_file_http_error(exc) from exc
disposition = "inline" if content.content_type.startswith("image/") else "attachment"
return Response(
content=content.content,
media_type=content.content_type,
headers={"Content-Disposition": content_disposition(disposition, content.name)},
)
@app.get("/api/user-files/preview")
async def preview_user_file(
path: str,
request: Request,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
try:
return await (await _user_file_service(request, authorization)).preview(path)
except UserFileError as exc:
raise _user_file_http_error(exc) from exc
@app.post("/api/user-files/upload")
async def upload_user_file(
request: Request,
file: UploadFile = File(...),
path: str = Form("uploads"),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
file_size = getattr(file, "size", None)
if isinstance(file_size, int) and file_size > max_user_file_upload_size:
raise HTTPException(status_code=413, detail=f"File too large (max {_human_upload_size(max_user_file_upload_size)})")
try:
return await (await _user_file_service(request, authorization)).upload_stream(
path,
file.filename,
file.file,
content_type=file.content_type or "application/octet-stream",
max_bytes=max_user_file_upload_size,
part_size=user_file_upload_part_size,
)
except UserFileError as exc:
raise _user_file_http_error(exc) from exc
@app.delete("/api/user-files/delete")
async def delete_user_file(
path: str,
request: Request,
authorization: str | None = Header(default=None),
) -> dict[str, bool]:
try:
removed = await (await _user_file_service(request, authorization)).delete(path)
except UserFileError as exc:
raise _user_file_http_error(exc) from exc
if removed:
return {"ok": True}
raise HTTPException(status_code=404, detail="Path not found")
@app.post("/api/user-files/mkdir")
async def create_user_file_directory(
path: str,
request: Request,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
try:
return await (await _user_file_service(request, authorization)).mkdir(path)
except UserFileError as exc:
raise _user_file_http_error(exc) from exc
@app.get("/api/workspace/browse")
async def browse_workspace_dir(request: Request, path: str = "") -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
@ -3165,6 +3295,27 @@ def _handoff_replay_window_seconds() -> int:
return 15
def _int_env(name: str, default: int) -> int:
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
return default
return value if value > 0 else default
def _human_upload_size(size: int) -> str:
units = ("B", "KB", "MB", "GB", "TB")
value = float(size)
for unit in units:
if value < 1024 or unit == units[-1]:
return f"{value:.0f}{unit}" if unit == "B" else f"{value:.1f}{unit}"
value /= 1024
return f"{size}B"
def _prune_handoff_codes(app: FastAPI) -> None:
now = time.time()
replay_window = _handoff_replay_window_seconds()