From f99c76ec106f9d47a909393e463197687eec4070 Mon Sep 17 00:00:00 2001 From: Ruochen Pan Date: Fri, 18 Sep 2026 17:55:30 +0800 Subject: [PATCH] fix(dashboard): bound upload memory and add resumable chunked uploads (#10123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(dashboard): consolidate upload saves into streaming helper Five copies of the same upload-save logic existed: three file wrappers (UploadFileAdapter, RequestUploadFile, PluginUploadFile) plus one _save_upload() in each of BackupService and SkillsService. The backup variant fell back to a full-body read() for raw FastAPI UploadFile, buffering entire uploads in memory. Introduce save_upload_stream() as the single streaming implementation (1 MiB blocks, optional max_bytes with in-loop abort and partial-file cleanup). The wrappers keep their public signatures and delegate to it; skills/chat/config save paths call save() directly with max_bytes, replacing their post-save size checks. * fix(dashboard): bound backup upload size and harden chunked flow Backup routes passed raw FastAPI UploadFile into the service layer, hitting the full-body read() fallback: a single authenticated request with an oversized chunk or whole-file upload could OOM-kill the process (verified with a 512 MiB memory-limited container, exit 137). - Wrap uploads in UploadFileAdapter so all saves stream to disk - Enforce CHUNK_SIZE (1 MiB) per chunk and 128 MiB for whole-file uploads, aborting mid-stream with partial-file cleanup - Cap chunked backup totals at 8 GiB and point larger backups at manual restore via the data directory - Verify the merged backup size against the declared total * fix(dashboard): enforce MAX_CONTENT_LENGTH with per-route overrides The 128 MiB MAX_CONTENT_LENGTH value was written into the compat-layer config dict but nothing read it, so the FastAPI/hypercorn path had no request body limit at all. Add a middleware that rejects oversized bodies with 413 before auth or body parsing, using MAX_CONTENT_LENGTH as the default with per-route overrides for legitimate large uploads (attachments 512 MiB, plugin config files 500 MiB, knowledge bases 512 MiB) and a tight 2 MiB cap on the backup chunk endpoint. Requests without a Content-Length header pass through and remain bounded by per-endpoint max_bytes at save time. * refactor(dashboard): extract generic chunked upload session service Pull the chunk session machinery out of BackupService into a reusable ChunkedUploadService owning session lifecycle, chunk storage, streaming assembly and expiry cleanup, in preparation for webchat attachment chunked uploads. Sessions are now bound to an owner (the authenticated username) and a purpose; chunk/complete/abort calls from another owner fail with the same "not found or expired" error as unknown sessions, so upload IDs cannot be probed or consumed cross-user. BackupService keeps business concerns (zip validation, size caps, manifest marking) and delegates the rest; API shapes and frontend flows are unchanged. * feat(dashboard): add resumable upload status endpoint Expose per-session progress (received chunk indexes, totals, expiry) so clients can resume an interrupted chunked upload by sending only the missing chunks instead of starting over. Abuse considerations baked in: the lookup inherits owner binding and answers "not found or expired" for both unknown sessions and sessions owned by someone else (no probing), the response carries no filename (minimal disclosure), and the query is strictly read-only — it never touches last_activity, so polling it cannot anchor a session and its on-disk chunks alive past expiry. * fix(dashboard): bound file signature memory with chunked hashing getFileSignature() read the entire file into browser memory via arrayBuffer() before computing SHA-256, spiking renderer memory by the full attachment size (up to the 512 MiB upload cap). The signature is only used for in-session staging dedup and never leaves the browser, so it does not need to be the canonical whole-file SHA-256. Hash 8 MiB blocks individually and digest the concatenated block digests instead; the prefix changes to sha256m: to mark the algorithm change (old signatures vanish with the page session, so no compatibility concern). * feat(dashboard): resumable chunked upload UI for backup import Extract the chunk scheduling logic from BackupDialog into a reusable useChunkedUpload composable (5-way concurrency, per-chunk silent retries, progress accounting) and add resume support on top of the new upload status endpoint: a failed upload now offers a "Resume Upload" action that queries received chunk indexes and sends only the missing ones. Expired sessions transparently re-initialize and start over; a failed complete() drops the poisoned session by design. Wires the new /backups/upload/status endpoint through the OpenAPI spec and regenerated client, and adds the resumeUpload label to all four locales. * feat(dashboard): chunked upload endpoints for webchat attachments Mirror the backup chunked-upload flow for chat attachments so large files (>128 MiB request-body default, up to the 512 MiB attachment cap) can be uploaded in slices: POST /api/v1/files/upload/{init, chunk,complete,abort,status}, owner-bound sessions, and a per-route body limit on the chunk endpoint. ChatService reuses the generic ChunkedUploadService with a new meta passthrough carrying the client content_type through the session; completion merges outside the chunk dir and feeds the result through save_uploaded_file() unchanged via a move-based LocalUploadFile adapter, keeping MIME sniffing, type classification and attachment insertion in exactly one place. Also repairs two pre-existing test stubs that predated the max_bytes-aware save() contract and were already failing on this branch. * feat(dashboard): chunked upload with resume for webchat attachments Route staged files >= 32 MiB through the new chunked upload endpoints via useChunkedUpload; smaller files keep the single POST path so paste and quick picks stay low-latency. The content_type travels through the init payload so server-side attachment classification is unchanged. A failed chunked upload is kept as an error chip in the input area with a retry action that resumes from the last received chunk (and transparently restarts when the session expired) plus a discard action that aborts the session. Failed entries hold their uploader in a shallowRef so Vue's deep ref unwrapping cannot strip the composable's internal refs, and their signatures stay reserved to block duplicate staging while a failed entry exists. * docs: regenerate openapi artifacts for file upload endpoints * fix(dashboard): harden chunk save and request body limits Address review findings on the upload-hardening branch: - save_chunk() now writes to a uniquely named temp file, verifies the save() byte count against the expected chunk length, and publishes via atomic rename. A failed or concurrent same-index retry can no longer truncate and delete an already-received chunk (previously left received_chunks claiming data that assemble() then failed to find), and short writes are rejected instead of published. - Requests with multipart bodies but no Content-Length are rejected with 411: form parsing spools large bodies to disk before any per-file size check can run. This is a documented stopgap — other lengthless bodies still pass and are bounded only at save time; closing that fully requires counting bytes as they arrive. - Whole-file upload overrides gain 1 MiB of multipart framing slack so a file exactly at the size cap is no longer rejected with 413. The body-limit decision is extracted into a pure function for direct unit tests; the assemble-time size recheck is now covered by an out-of-band corruption test since chunk-level verification rejects short writes earlier. * fix(dashboard): drain upload pool and pin session on resume A failed chunk made runPool() reject immediately while the remaining in-flight requests kept writing; a resume could then race them on the same chunk, stale retries read the mutable uploadId (potentially writing into a re-initialized session), and late completions kept counting progress (150% after resume). The pool now stops scheduling on first failure but drains issued requests via allSettled before surfacing the error, each request pins the uploadId captured at run start, and a run-generation counter (bumped on start/resume/cancel) blocks stale completions from the progress accounting. * fix(dashboard): close 411 bypass and temp-file residue on publish Address the second round of review findings: - The 411 media-type check used a raw startswith(), but the form parser strips surrounding whitespace, so a leading-space Content-Type was let through without a Content-Length while Starlette still parsed the body as multipart. Detection now uses the same parse_options_header the parser uses. - A failing os.replace() during chunk publish left the uniquely named temp file behind; it is now covered by the same cleanup discipline as the save path. Backup direct-upload routes deliberately get no framing slack: they are legacy paths with no remaining callers, and the pre-existing exact-limit 413 edge is answered by chunked upload. * fix(dashboard): abort sessions created after a cancel Cancelling while initUpload was still in flight cleared local state before an upload id existed; when the delayed init returned, the run flipped from idle to error and the freshly created server session was never aborted. handleFailure now lets cancel win over any late resolution and aborts the session id captured at that point. * fix(dashboard): check cancel flag after completeUpload resolves Cancelling while completeUpload was in flight let the late success response flip the uploader from idle to done and hand the result to the caller, which proceeded to the precheck. completeSession() now re-checks the cancel flag after the response arrives and routes the late success through the cancelled path instead. * fix(dashboard): gate the 411 rule to body-capable methods The 411 stopgap rejected GET /api/v1/logs/live: the SSE log client sends a bogus "Content-Type: multipart/form-data" header on a GET (copy-paste relic), and a GET with no Content-Length matched the multipart-without-length rule. Body-less methods never trigger form parsing, so no disk-spool risk exists for them — the 411 now applies only to POST/PUT/PATCH. The junk header on the SSE client is removed as well; the other two SSE consumers never had it. * feat(dashboard): show in-progress chips for chunked attachments Chunked uploads gave no feedback while in flight: the staged chip only appeared after completion, leaving multi-minute uploads silent. Track in-flight uploads alongside the failed ones and render progress chips (filename, percent, thin progress bar) with a cancel action in the input area; on success the chip hands off to the staged attachment, on failure to the resumable error chip. * fix(dashboard): type active upload chips upfront and align progress bar The in-progress chip used a generic cloud icon even though the file type is known locally before the upload starts; it now derives icon/color/label from the filename via attachmentPresentation, same as the staged chip. The progress bar's left edge is aligned with the filename text instead of the card padding. * fix(dashboard): keep active-chip progress bar inside the card v-progress-linear ships width:100%, which wins over the right offset in absolute positioning; the bar ran 52px past the card's right edge and was clipped by overflow:hidden. Give it width:auto so the left/ right offsets determine the width. * fix(dashboard): rework active-chip progress track and cancel button Positioning a Vuetify component root directly proved fragile — the progress bar drifted to the card's top edge. Wrap it in a plain track div that owns the absolute positioning and let the component fill it. Replace the corner tonal badge cancel with an inline text-variant icon button after the percent label, and drop the padding reserved for the badge. * fix(dashboard): enforce session expiry and atomic save semantics Address the third review round (sourcery-ai findings): - get_session() now actually enforces the idle expiry it advertises in its "not found or expired" error; expired sessions stay abortable for cleanup, and backup upload_init starts the janitor just like chat's does, so chunk dirs cannot linger indefinitely. - cleanup_session() only unregisters a session after rmtree() succeeds, letting the janitor retry on transient filesystem errors instead of permanently leaking the chunk directory. - save_upload_stream() writes to a uniquely named temp sibling and atomically renames on success, so a failed or cancelled overwrite can no longer delete a pre-existing destination (e.g. re-uploading over an existing attachment), and all callers inherit the behavior. The write()-return-value finding is a non-issue: BufferedWriter writes fully or raises, and the chunk path already verifies byte counts. * fix(dashboard): guard failed-upload retry against double clicks retryFailedUpload() ran resume() without any in-flight marker, so a second click started a concurrent resume on the same session; the loser hit "not found or expired" and left a failed chip next to the successfully staged attachment. The entry now moves to activeUploads while resuming, which blocks the second click and shows live progress, and returns to failedUploads only if the resume errors. --- astrbot/api/web.py | 25 +- astrbot/core/utils/upload.py | 76 ++++ astrbot/dashboard/api/backups.py | 69 +++- astrbot/dashboard/api/files.py | 94 ++++- astrbot/dashboard/api/multipart.py | 20 +- astrbot/dashboard/asgi_runtime.py | 20 +- astrbot/dashboard/schemas.py | 10 + astrbot/dashboard/server.py | 111 ++++++ astrbot/dashboard/services/backup_service.py | 240 +++++------- astrbot/dashboard/services/chat_service.py | 153 +++++++- .../services/chunked_upload_service.py | 327 ++++++++++++++++ astrbot/dashboard/services/config_service.py | 7 +- astrbot/dashboard/services/skills_service.py | 21 +- .../src/api/generated/openapi-v1/sdk.gen.ts | 67 +++- .../src/api/generated/openapi-v1/types.gen.ts | 64 +++ dashboard/src/api/v1.ts | 23 ++ dashboard/src/components/chat/Chat.vue | 15 + dashboard/src/components/chat/ChatInput.vue | 125 +++++- .../src/components/shared/BackupDialog.vue | 216 ++++------- dashboard/src/composables/useChunkedUpload.ts | 264 +++++++++++++ dashboard/src/composables/useMediaHandling.ts | 165 +++++++- .../i18n/locales/en-US/features/settings.json | 1 + .../i18n/locales/ja-JP/features/settings.json | 1 + .../i18n/locales/ru-RU/features/settings.json | 1 + .../i18n/locales/zh-CN/features/settings.json | 1 + dashboard/src/stores/common.js | 1 - docs/en/dev/openapi-scopes.md | 5 + docs/public/openapi.json | 183 +++++++++ docs/zh/dev/openapi-scopes.md | 5 + openspec/openapi-v1.yaml | 130 +++++++ tests/test_backup.py | 365 ++++++++++++++++++ tests/test_chat_route.py | 7 +- tests/test_upload_utils.py | 82 ++++ tests/unit/test_body_limit.py | 111 ++++++ tests/unit/test_chat_chunked_upload.py | 207 ++++++++++ .../unit/test_webchat_upload_image_format.py | 2 +- 36 files changed, 2803 insertions(+), 411 deletions(-) create mode 100644 astrbot/core/utils/upload.py create mode 100644 astrbot/dashboard/services/chunked_upload_service.py create mode 100644 dashboard/src/composables/useChunkedUpload.ts create mode 100644 tests/test_upload_utils.py create mode 100644 tests/unit/test_body_limit.py create mode 100644 tests/unit/test_chat_chunked_upload.py diff --git a/astrbot/api/web.py b/astrbot/api/web.py index 6fb46e69d..9ce80b8e2 100644 --- a/astrbot/api/web.py +++ b/astrbot/api/web.py @@ -12,6 +12,8 @@ from starlette.datastructures import Headers from starlette.datastructures import UploadFile as StarletteUploadFile from starlette.responses import StreamingResponse +from astrbot.core.utils.upload import save_upload_stream + ValueT = TypeVar("ValueT") DefaultT = TypeVar("DefaultT") ConvertedT = TypeVar("ConvertedT") @@ -109,23 +111,22 @@ class PluginUploadFile: except (TypeError, ValueError): return None - async def save(self, destination: str | Path) -> None: + async def save( + self, destination: str | Path, *, max_bytes: int | None = None + ) -> int: """Save the uploaded file to disk. Args: destination: Destination file path. + max_bytes: Optional hard limit; the partial file is removed and + UploadTooLargeError is raised when the upload exceeds it. + + Returns: + Number of bytes written. """ - path = Path(destination) - try: - await self._upload_file.seek(0) - except Exception: - pass - with path.open("wb") as output: - while True: - chunk = await self._upload_file.read(1024 * 1024) - if not chunk: - break - output.write(chunk) + return await save_upload_stream( + self._upload_file, destination, max_bytes=max_bytes + ) async def read(self, size: int = -1) -> bytes: """Read bytes from the uploaded file. diff --git a/astrbot/core/utils/upload.py b/astrbot/core/utils/upload.py new file mode 100644 index 000000000..1eb2f33f2 --- /dev/null +++ b/astrbot/core/utils/upload.py @@ -0,0 +1,76 @@ +"""Shared streaming save for uploaded files. + +Internal helper used by the dashboard/plugin upload wrappers so that every +upload endpoint writes request files to disk with bounded memory. Not part of +the plugin-facing API surface. +""" + +from __future__ import annotations + +import asyncio +import os +import uuid +from pathlib import Path +from typing import Any + +COPY_BLOCK_SIZE = 1024 * 1024 + + +class UploadTooLargeError(ValueError): + """Raised when an upload exceeds the allowed size while saving.""" + + def __init__(self, max_bytes: int) -> None: + super().__init__(f"Uploaded file exceeds the {max_bytes} bytes limit") + self.max_bytes = max_bytes + + +async def save_upload_stream( + upload: Any, + dest: str | Path, + *, + max_bytes: int | None = None, +) -> int: + """Stream an uploaded file to disk in 1 MiB blocks. + + The payload lands in a uniquely named sibling temp file and is + atomically renamed to ``dest`` only after the complete stream succeeds, + so a failed or cancelled save never destroys a pre-existing + destination (e.g. re-uploading over an existing attachment). + + Args: + upload: Upload object exposing async ``read(size)``; ``seek(0)`` is + attempted best-effort before copying. + dest: Destination file path. + max_bytes: Optional hard limit. When the upload exceeds it, the + partial temp file is removed and UploadTooLargeError is raised. + + Returns: + Number of bytes written. + + Raises: + UploadTooLargeError: The upload exceeded ``max_bytes``. + """ + path = Path(dest) + temp_path = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp") + try: + await upload.seek(0) + except Exception: + pass + + written = 0 + try: + with temp_path.open("wb") as output: + while True: + block = await upload.read(COPY_BLOCK_SIZE) + if not block: + break + written += len(block) + if max_bytes is not None and written > max_bytes: + raise UploadTooLargeError(max_bytes) + output.write(block) + await asyncio.to_thread(os.replace, temp_path, path) + except BaseException: + # Only ever remove our own temp file, never the destination. + temp_path.unlink(missing_ok=True) + raise + return written diff --git a/astrbot/dashboard/api/backups.py b/astrbot/dashboard/api/backups.py index c0d9f3efb..bd1ec026c 100644 --- a/astrbot/dashboard/api/backups.py +++ b/astrbot/dashboard/api/backups.py @@ -18,6 +18,7 @@ from astrbot.dashboard.services.backup_service import ( ) from .auth import AuthContext, require_dashboard_user, require_scope +from .multipart import UploadFileAdapter router = APIRouter(tags=["Backups"]) legacy_router = APIRouter( @@ -151,7 +152,10 @@ async def upload_backup( _auth: AuthContext = Depends(require_system_scope), service: BackupService = Depends(get_service), ): - return await _run(lambda: service.upload_backup(file), prefix="上传备份文件失败") + return await _run( + lambda: service.upload_backup(UploadFileAdapter(file)), + prefix="上传备份文件失败", + ) @legacy_router.post("/upload") @@ -160,17 +164,20 @@ async def upload_dashboard_backup( _username: str = Depends(require_dashboard_user), service: BackupService = Depends(get_service), ): - return await _run(lambda: service.upload_backup(file), prefix="上传备份文件失败") + return await _run( + lambda: service.upload_backup(UploadFileAdapter(file)), + prefix="上传备份文件失败", + ) @router.post("/backups/upload/init") async def init_backup_upload( payload: BackupUploadInitRequest, - _auth: AuthContext = Depends(require_system_scope), + auth: AuthContext = Depends(require_system_scope), service: BackupService = Depends(get_service), ): return await _run( - lambda: service.upload_init(_model_dict(payload)), + lambda: service.upload_init(_model_dict(payload), owner=auth.username), prefix="初始化分片上传失败", ) @@ -178,11 +185,11 @@ async def init_backup_upload( @legacy_router.post("/upload/init") async def init_dashboard_backup_upload( payload: BackupUploadInitRequest, - _username: str = Depends(require_dashboard_user), + username: str = Depends(require_dashboard_user), service: BackupService = Depends(get_service), ): return await _run( - lambda: service.upload_init(_model_dict(payload)), + lambda: service.upload_init(_model_dict(payload), owner=username), prefix="初始化分片上传失败", ) @@ -192,14 +199,15 @@ async def upload_backup_chunk( upload_id: str = Form(...), chunk_index: str = Form(...), chunk: UploadFile = File(...), - _auth: AuthContext = Depends(require_system_scope), + auth: AuthContext = Depends(require_system_scope), service: BackupService = Depends(get_service), ): return await _run( lambda: service.upload_chunk( upload_id=upload_id, chunk_index_str=chunk_index, - chunk_file=chunk, + chunk_file=UploadFileAdapter(chunk), + owner=auth.username, ), prefix="上传分片失败", ) @@ -210,14 +218,15 @@ async def upload_dashboard_backup_chunk( upload_id: str = Form(...), chunk_index: str = Form(...), chunk: UploadFile = File(...), - _username: str = Depends(require_dashboard_user), + username: str = Depends(require_dashboard_user), service: BackupService = Depends(get_service), ): return await _run( lambda: service.upload_chunk( upload_id=upload_id, chunk_index_str=chunk_index, - chunk_file=chunk, + chunk_file=UploadFileAdapter(chunk), + owner=username, ), prefix="上传分片失败", ) @@ -226,11 +235,11 @@ async def upload_dashboard_backup_chunk( @router.post("/backups/upload/complete") async def complete_backup_upload( payload: BackupUploadSessionRequest, - _auth: AuthContext = Depends(require_system_scope), + auth: AuthContext = Depends(require_system_scope), service: BackupService = Depends(get_service), ): return await _run( - lambda: service.upload_complete(_model_dict(payload)), + lambda: service.upload_complete(_model_dict(payload), owner=auth.username), prefix="完成分片上传失败", ) @@ -238,11 +247,11 @@ async def complete_backup_upload( @legacy_router.post("/upload/complete") async def complete_dashboard_backup_upload( payload: BackupUploadSessionRequest, - _username: str = Depends(require_dashboard_user), + username: str = Depends(require_dashboard_user), service: BackupService = Depends(get_service), ): return await _run( - lambda: service.upload_complete(_model_dict(payload)), + lambda: service.upload_complete(_model_dict(payload), owner=username), prefix="完成分片上传失败", ) @@ -250,11 +259,11 @@ async def complete_dashboard_backup_upload( @router.post("/backups/upload/abort") async def abort_backup_upload( payload: BackupUploadSessionRequest, - _auth: AuthContext = Depends(require_system_scope), + auth: AuthContext = Depends(require_system_scope), service: BackupService = Depends(get_service), ): return await _run( - lambda: service.upload_abort(_model_dict(payload)), + lambda: service.upload_abort(_model_dict(payload), owner=auth.username), prefix="取消上传失败", ) @@ -262,15 +271,39 @@ async def abort_backup_upload( @legacy_router.post("/upload/abort") async def abort_dashboard_backup_upload( payload: BackupUploadSessionRequest, - _username: str = Depends(require_dashboard_user), + username: str = Depends(require_dashboard_user), service: BackupService = Depends(get_service), ): return await _run( - lambda: service.upload_abort(_model_dict(payload)), + lambda: service.upload_abort(_model_dict(payload), owner=username), prefix="取消上传失败", ) +@router.post("/backups/upload/status") +async def status_backup_upload( + payload: BackupUploadSessionRequest, + auth: AuthContext = Depends(require_system_scope), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.upload_status(_model_dict(payload), owner=auth.username), + prefix="查询上传状态失败", + ) + + +@legacy_router.post("/upload/status") +async def status_dashboard_backup_upload( + payload: BackupUploadSessionRequest, + username: str = Depends(require_dashboard_user), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.upload_status(_model_dict(payload), owner=username), + prefix="查询上传状态失败", + ) + + @router.get("/backups/tasks/{task_id}") async def get_backup_progress( task_id: str, diff --git a/astrbot/dashboard/api/files.py b/astrbot/dashboard/api/files.py index 407ee3c59..e1dfa7890 100644 --- a/astrbot/dashboard/api/files.py +++ b/astrbot/dashboard/api/files.py @@ -1,10 +1,20 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile +from fastapi import ( + APIRouter, + Depends, + File, + Form, + HTTPException, + Query, + Request, + UploadFile, +) from fastapi.responses import FileResponse from astrbot.dashboard.async_utils import run_maybe_async from astrbot.dashboard.responses import error, ok +from astrbot.dashboard.schemas import ChatUploadInitRequest, ChatUploadSessionRequest from astrbot.dashboard.services.chat_service import ChatService, ChatServiceError from astrbot.dashboard.services.file_service import FileService, FileServiceError @@ -49,15 +59,19 @@ async def _run_file(operation, *, error_message: str = "File access error"): return error(error_message) -async def _upload_file(file: UploadFile, service: ChatService): - result = await _run_file( - lambda: service.save_uploaded_file(UploadFileAdapter(file)) - ) +async def _run_chat_upload(operation): + result = await _run_file(operation) if isinstance(result, dict) and result.get("status") == "error": return result return ok(result) +async def _upload_file(file: UploadFile, service: ChatService): + return await _run_chat_upload( + lambda: service.save_uploaded_file(UploadFileAdapter(file)) + ) + + @router.get("/files/tokens/{file_token}") async def get_token_file( file_token: str, @@ -75,6 +89,76 @@ async def upload_file( return await _upload_file(file, service) +@router.post("/files/upload/init") +async def init_file_upload( + payload: ChatUploadInitRequest, + auth: AuthContext = Depends(require_file_scope), + service: ChatService = Depends(get_chat_service), +): + return await _run_chat_upload( + lambda: service.upload_init( + payload.model_dump(exclude_none=True), owner=auth.username + ) + ) + + +@router.post("/files/upload/chunk") +async def upload_file_chunk( + upload_id: str = Form(...), + chunk_index: str = Form(...), + chunk: UploadFile = File(...), + auth: AuthContext = Depends(require_file_scope), + service: ChatService = Depends(get_chat_service), +): + return await _run_chat_upload( + lambda: service.upload_chunk( + upload_id=upload_id, + chunk_index_str=chunk_index, + chunk_file=UploadFileAdapter(chunk), + owner=auth.username, + ) + ) + + +@router.post("/files/upload/complete") +async def complete_file_upload( + payload: ChatUploadSessionRequest, + auth: AuthContext = Depends(require_file_scope), + service: ChatService = Depends(get_chat_service), +): + return await _run_chat_upload( + lambda: service.upload_complete( + payload.model_dump(exclude_none=True), owner=auth.username + ) + ) + + +@router.post("/files/upload/abort") +async def abort_file_upload( + payload: ChatUploadSessionRequest, + auth: AuthContext = Depends(require_file_scope), + service: ChatService = Depends(get_chat_service), +): + return await _run_chat_upload( + lambda: service.upload_abort( + payload.model_dump(exclude_none=True), owner=auth.username + ) + ) + + +@router.post("/files/upload/status") +async def status_file_upload( + payload: ChatUploadSessionRequest, + auth: AuthContext = Depends(require_file_scope), + service: ChatService = Depends(get_chat_service), +): + return await _run_chat_upload( + lambda: service.upload_status( + payload.model_dump(exclude_none=True), owner=auth.username + ) + ) + + @router.get("/files/content") async def get_file_by_name( filename: str | None = Query(default=None), diff --git a/astrbot/dashboard/api/multipart.py b/astrbot/dashboard/api/multipart.py index 4979ddfb9..e563cf823 100644 --- a/astrbot/dashboard/api/multipart.py +++ b/astrbot/dashboard/api/multipart.py @@ -7,6 +7,8 @@ from typing import Any from fastapi import Request from starlette.datastructures import UploadFile as StarletteUploadFile +from astrbot.core.utils.upload import save_upload_stream + class UploadFileAdapter: def __init__(self, upload_file: StarletteUploadFile) -> None: @@ -23,18 +25,12 @@ class UploadFileAdapter: except (TypeError, ValueError): return None - async def save(self, destination: str | Path) -> None: - path = Path(destination) - try: - await self._upload_file.seek(0) - except Exception: - pass - with path.open("wb") as output: - while True: - chunk = await self._upload_file.read(1024 * 1024) - if not chunk: - break - output.write(chunk) + async def save( + self, destination: str | Path, *, max_bytes: int | None = None + ) -> int: + return await save_upload_stream( + self._upload_file, destination, max_bytes=max_bytes + ) class MultiDict: diff --git a/astrbot/dashboard/asgi_runtime.py b/astrbot/dashboard/asgi_runtime.py index 7a46b4527..2dbcb3472 100644 --- a/astrbot/dashboard/asgi_runtime.py +++ b/astrbot/dashboard/asgi_runtime.py @@ -15,6 +15,8 @@ from fastapi.responses import FileResponse, JSONResponse, Response from starlette.datastructures import UploadFile as StarletteUploadFile from starlette.responses import StreamingResponse +from astrbot.core.utils.upload import save_upload_stream + _request_var: contextvars.ContextVar[DashboardRequest] = contextvars.ContextVar( "dashboard_request" ) @@ -99,18 +101,12 @@ class RequestUploadFile: except (TypeError, ValueError): return None - async def save(self, destination: str | Path) -> None: - path = Path(destination) - try: - await self._upload_file.seek(0) - except Exception: - pass - with path.open("wb") as output: - while True: - chunk = await self._upload_file.read(1024 * 1024) - if not chunk: - break - output.write(chunk) + async def save( + self, destination: str | Path, *, max_bytes: int | None = None + ) -> int: + return await save_upload_stream( + self._upload_file, destination, max_bytes=max_bytes + ) def __getattr__(self, key: str): return getattr(self._upload_file, key) diff --git a/astrbot/dashboard/schemas.py b/astrbot/dashboard/schemas.py index 3bf99863d..d54a4649e 100644 --- a/astrbot/dashboard/schemas.py +++ b/astrbot/dashboard/schemas.py @@ -70,6 +70,16 @@ class BackupUploadSessionRequest(OpenModel): upload_id: str | None = None +class ChatUploadInitRequest(OpenModel): + filename: str | None = None + total_size: int | None = None + content_type: str | None = None + + +class ChatUploadSessionRequest(OpenModel): + upload_id: str | None = None + + class BackupImportRequest(OpenModel): confirmed: bool | None = None diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 01388ddd3..35a0cce6f 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -27,15 +27,105 @@ from astrbot.dashboard.asgi_runtime import ( FastAPIAppAdapter, ) from astrbot.dashboard.responses import error +from astrbot.dashboard.services.backup_service import CHUNK_SIZE +from astrbot.dashboard.services.chat_service import MAX_UPLOAD_FILE_SIZE_BYTES +from astrbot.dashboard.services.config_service import MAX_FILE_BYTES from .api.app import create_dashboard_asgi_app from .plugin_page_auth import PluginPageAuth from .services.auth_service import DASHBOARD_JWT_COOKIE_NAME +try: # Mirror starlette.requests so media type detection matches the parser. + from python_multipart.multipart import parse_options_header +except ImportError: # pragma: no cover + try: + from multipart.multipart import parse_options_header + except ImportError: + parse_options_header = None + if os.name == "nt": # Windows 的 mimetypes 会把 .svg 映射成非标准的 image/svg,这里强制覆盖为标准类型 mimetypes.add_type("image/svg+xml", ".svg", strict=True) +# Multipart framing (boundaries, part headers) rides on top of the file +# payload, so whole-file upload routes get slack beyond the file size limit; +# otherwise a file exactly at the limit would be rejected with 413. +_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +# Per-route request body limits overriding the default MAX_CONTENT_LENGTH. +# More specific prefixes must come first. Routes not listed here fall back +# to the default; requests without a Content-Length header pass through and +# are bounded by the per-endpoint max_bytes checks at save time. +_BODY_LIMIT_OVERRIDES: tuple[tuple[str, int], ...] = ( + ("/api/v1/backups/upload/chunk", CHUNK_SIZE * 2), + ("/api/backup/upload/chunk", CHUNK_SIZE * 2), + ("/api/v1/files/upload/chunk", CHUNK_SIZE * 2), + ("/api/v1/files", MAX_UPLOAD_FILE_SIZE_BYTES + _MULTIPART_OVERHEAD_BYTES), + ( + "/api/chat/post_file", + MAX_UPLOAD_FILE_SIZE_BYTES + _MULTIPART_OVERHEAD_BYTES, + ), + ("/api/v1/plugins/config-files", MAX_FILE_BYTES + _MULTIPART_OVERHEAD_BYTES), + ( + "/api/v1/knowledge-bases/", + MAX_UPLOAD_FILE_SIZE_BYTES + _MULTIPART_OVERHEAD_BYTES, + ), +) + + +# Methods with request-body semantics; the 411 stopgap only applies to +# these, since form parsing (and its disk spooling) cannot trigger +# for body-less methods like GET. +_BODY_METHODS = frozenset({"POST", "PUT", "PATCH"}) + + +def _check_body_limit( + path: str, + content_length: int | None, + content_type: str, + *, + method: str = "POST", + default_limit: int, +) -> tuple[int, str] | None: + """Decide whether an /api request body must be rejected up front. + + Returns: + A (status_code, message) rejection, or None to pass through. + + Note: + The 411 rule is a stopgap scoped to multipart uploads on methods + with body semantics: their form parsing spools large bodies to + disk before any per-file size check can run, so they must declare + a length that can be bounded before parsing. Body-less methods + (GET etc.) never trigger form parsing and pass even with a bogus + multipart Content-Type header. Other lengthless bodies are still + bounded only at save time; closing that gap fully requires + counting bytes as they arrive, which is out of scope here. + """ + if not path.startswith("/api"): + return None + if content_length is None: + if method not in _BODY_METHODS: + return None + # Identify the media type by the same rule the form parser uses: + # parse_options_header strips surrounding whitespace, so a leading- + # space Content-Type that startswith() would miss is still caught. + media_type = ( + parse_options_header(content_type)[0] if parse_options_header else b"" + ) + if media_type == b"multipart/form-data": + return 411, "Content-Length header is required for uploads" + return None + limit = default_limit + for prefix, route_limit in _BODY_LIMIT_OVERRIDES: + if path.startswith(prefix): + limit = route_limit + break + if content_length > limit: + return 413, f"Request body exceeds the {limit} bytes limit" + return None + + _RATE_LIMITED_ENDPOINTS: frozenset = frozenset( { "/api/config/astrbot/update", @@ -210,6 +300,27 @@ class AstrBotDashboard: return auth_response return await call_next(request_) + @self.asgi_app.middleware("http") + async def dashboard_body_limit_middleware(request_, call_next): + # Registered after the auth middleware so it runs outermost and + # can reject oversized bodies before any parsing happens. + raw_length = request_.headers.get("content-length") + try: + content_length = int(raw_length) if raw_length else None + except ValueError: + content_length = None + rejection = _check_body_limit( + request_.url.path, + content_length, + request_.headers.get("content-type", ""), + method=request_.method, + default_limit=self.app.config["MAX_CONTENT_LENGTH"], + ) + if rejection is not None: + status_code, message = rejection + return JSONResponse(error(message), status_code=status_code) + return await call_next(request_) + self.shutdown_event = shutdown_event async def auth_middleware(self, current_request: Request): diff --git a/astrbot/dashboard/services/backup_service.py b/astrbot/dashboard/services/backup_service.py index fd5353dab..291e5d408 100644 --- a/astrbot/dashboard/services/backup_service.py +++ b/astrbot/dashboard/services/backup_service.py @@ -2,11 +2,8 @@ from __future__ import annotations import asyncio import json -import math import os import re -import shutil -import time import traceback import uuid import zipfile @@ -26,9 +23,18 @@ from astrbot.core.utils.astrbot_path import ( get_astrbot_backups_path, get_astrbot_data_path, ) +from astrbot.core.utils.upload import UploadTooLargeError +from astrbot.dashboard.services.chunked_upload_service import ( + ChunkedUploadError, + ChunkedUploadService, +) CHUNK_SIZE = 1024 * 1024 -UPLOAD_EXPIRE_SECONDS = 3600 +# Hard caps against disk exhaustion: a backup is never legitimately larger +# than this, and the whole-file endpoint is only for small backups (large +# ones must use the chunked flow). +MAX_BACKUP_TOTAL_BYTES = 8 * 1024 * 1024 * 1024 +MAX_DIRECT_UPLOAD_BYTES = 128 * 1024 * 1024 class BackupServiceError(Exception): @@ -72,30 +78,12 @@ class BackupService: self.chunks_dir = os.path.join(self.backup_dir, ".chunks") self.backup_tasks: dict[str, dict] = {} self.backup_progress: dict[str, dict] = {} - self.upload_sessions: dict[str, dict] = {} - self._cleanup_task: asyncio.Task | None = None + self.chunked_uploads = ChunkedUploadService(self.chunks_dir) @staticmethod def _payload(data: object) -> dict[str, Any]: return data if isinstance(data, dict) else {} - @staticmethod - async def _save_upload(file: Any, target_path: str) -> None: - if hasattr(file, "save"): - result = file.save(target_path) - if hasattr(result, "__await__"): - await result - return - - if hasattr(file, "read"): - data = file.read() - if hasattr(data, "__await__"): - data = await data - Path(target_path).write_bytes(data) - return - - raise BackupServiceError("无效的上传文件") - @staticmethod def _validate_backup_filename(filename: str | None, *, missing: str) -> str: if not filename: @@ -176,45 +164,10 @@ class BackupService: return _callback def ensure_cleanup_task_started(self) -> None: - if self._cleanup_task is None or self._cleanup_task.done(): - try: - self._cleanup_task = asyncio.create_task( - self._cleanup_expired_uploads() - ) - except RuntimeError: - pass - - async def _cleanup_expired_uploads(self) -> None: - while True: - try: - await asyncio.sleep(300) - current_time = time.time() - expired_sessions = [] - - for upload_id, session in self.upload_sessions.items(): - last_activity = session.get("last_activity", session["created_at"]) - if current_time - last_activity > UPLOAD_EXPIRE_SECONDS: - expired_sessions.append(upload_id) - - for upload_id in expired_sessions: - await self.cleanup_upload_session(upload_id) - logger.info(f"清理过期的上传会话: {upload_id}") - - except asyncio.CancelledError: - break - except Exception as exc: - logger.error(f"清理过期上传会话失败: {exc}") + self.chunked_uploads.ensure_cleanup_task_started() async def cleanup_upload_session(self, upload_id: str) -> None: - if upload_id in self.upload_sessions: - session = self.upload_sessions[upload_id] - chunk_dir = session.get("chunk_dir") - if chunk_dir and os.path.exists(chunk_dir): - try: - shutil.rmtree(chunk_dir) - except Exception as exc: - logger.warning(f"清理分片目录失败: {exc}") - del self.upload_sessions[upload_id] + await self.chunked_uploads.cleanup_session(upload_id) def get_backup_manifest(self, zip_path: str) -> dict | None: try: @@ -315,7 +268,13 @@ class BackupService: Path(self.backup_dir).mkdir(parents=True, exist_ok=True) zip_path = os.path.join(self.backup_dir, unique_filename) - await self._save_upload(file, zip_path) + try: + await file.save(zip_path, max_bytes=MAX_DIRECT_UPLOAD_BYTES) + except UploadTooLargeError as exc: + raise BackupServiceError( + f"Backup file exceeds the size limit ({MAX_DIRECT_UPLOAD_BYTES // (1024**2)} MB); " + "use chunked upload instead." + ) from exc logger.info( f"上传的备份文件已保存: {unique_filename} (原始名称: {file.filename})" @@ -326,7 +285,7 @@ class BackupService: "size": os.path.getsize(zip_path), } - def upload_init(self, data: object) -> dict: + def upload_init(self, data: object, *, owner: str = "") -> dict: payload = self._payload(data) filename = payload.get("filename") total_size = payload.get("total_size", 0) @@ -337,35 +296,30 @@ class BackupService: raise BackupServiceError("请上传 ZIP 格式的备份文件") if total_size <= 0: raise BackupServiceError("无效的文件大小") + if total_size > MAX_BACKUP_TOTAL_BYTES: + raise BackupServiceError( + f"Backup file exceeds the size limit ({MAX_BACKUP_TOTAL_BYTES // (1024**3)} GB). " + "You can copy it into the backups folder of the data directory " + "via FTP/SFTP and restore it from the backup list." + ) - total_chunks = math.ceil(total_size / CHUNK_SIZE) - upload_id = str(uuid.uuid4()) - chunk_dir = os.path.join(self.chunks_dir, upload_id) - Path(chunk_dir).mkdir(parents=True, exist_ok=True) - - safe_filename = secure_filename(filename) - unique_filename = generate_unique_filename(safe_filename) - current_time = time.time() - self.upload_sessions[upload_id] = { - "filename": unique_filename, - "original_filename": filename, - "total_size": total_size, - "total_chunks": total_chunks, - "received_chunks": set(), - "created_at": current_time, - "last_activity": current_time, - "chunk_dir": chunk_dir, - } - - logger.info( - f"初始化分片上传: upload_id={upload_id}, " - f"filename={unique_filename}, total_chunks={total_chunks}" - ) + unique_filename = generate_unique_filename(secure_filename(filename)) + self.chunked_uploads.ensure_cleanup_task_started() + try: + session = self.chunked_uploads.init_session( + owner=owner, + purpose="backup", + filename=unique_filename, + original_filename=filename, + total_size=total_size, + ) + except ChunkedUploadError as exc: + raise BackupServiceError(str(exc)) from exc return { - "upload_id": upload_id, - "chunk_size": CHUNK_SIZE, - "total_chunks": total_chunks, + "upload_id": session.id, + "chunk_size": session.chunk_size, + "total_chunks": session.total_chunks, "filename": unique_filename, } @@ -375,6 +329,7 @@ class BackupService: upload_id: str | None, chunk_index_str: str | None, chunk_file: Any | None, + owner: str = "", ) -> dict: if not upload_id or chunk_index_str is None: raise BackupServiceError("缺少必要参数") @@ -386,29 +341,13 @@ class BackupService: if not chunk_file: raise BackupServiceError("缺少分片数据") - if upload_id not in self.upload_sessions: - raise BackupServiceError("上传会话不存在或已过期") - session = self.upload_sessions[upload_id] - if chunk_index < 0 or chunk_index >= session["total_chunks"]: - raise BackupServiceError("分片索引超出范围") - - chunk_path = os.path.join(session["chunk_dir"], f"{chunk_index}.part") - await self._save_upload(chunk_file, chunk_path) - session["received_chunks"].add(chunk_index) - session["last_activity"] = time.time() - - received_count = len(session["received_chunks"]) - total_chunks = session["total_chunks"] - logger.debug( - f"接收分片: upload_id={upload_id}, chunk={chunk_index + 1}/{total_chunks}" - ) - - return { - "received": received_count, - "total": total_chunks, - "chunk_index": chunk_index, - } + try: + return await self.chunked_uploads.save_chunk( + upload_id, chunk_index, chunk_file, owner=owner + ) + except ChunkedUploadError as exc: + raise BackupServiceError(str(exc)) from exc def mark_backup_as_uploaded(self, zip_path: str) -> None: try: @@ -428,67 +367,62 @@ class BackupService: except Exception as exc: logger.warning(f"标记备份来源失败: {exc}") - async def upload_complete(self, data: object) -> dict: + async def upload_complete(self, data: object, *, owner: str = "") -> dict: payload = self._payload(data) upload_id = payload.get("upload_id") if not upload_id: raise BackupServiceError("缺少 upload_id 参数") - if upload_id not in self.upload_sessions: - raise BackupServiceError("上传会话不存在或已过期") - - session = self.upload_sessions[upload_id] - received = session["received_chunks"] - total = session["total_chunks"] - - if len(received) != total: - missing = set(range(total)) - received - raise BackupServiceError(f"分片不完整,缺少: {sorted(missing)[:10]}...") - - chunk_dir = session["chunk_dir"] - filename = session["filename"] - - Path(self.backup_dir).mkdir(parents=True, exist_ok=True) - output_path = os.path.join(self.backup_dir, filename) try: - with open(output_path, "wb") as outfile: - for i in range(total): - chunk_path = os.path.join(chunk_dir, f"{i}.part") - with open(chunk_path, "rb") as chunk_file: - while True: - data_block = chunk_file.read(8192) - if not data_block: - break - outfile.write(data_block) + session = self.chunked_uploads.get_session(upload_id, owner=owner) + Path(self.backup_dir).mkdir(parents=True, exist_ok=True) + output_path = os.path.join(self.backup_dir, session.filename) + file_size = await self.chunked_uploads.assemble( + upload_id, output_path, owner=owner + ) + except ChunkedUploadError as exc: + raise BackupServiceError(str(exc)) from exc - file_size = os.path.getsize(output_path) - self.mark_backup_as_uploaded(output_path) - logger.info(f"分片上传完成: {filename}, size={file_size}, chunks={total}") - await self.cleanup_upload_session(upload_id) + self.mark_backup_as_uploaded(output_path) + logger.info( + f"分片上传完成: {session.filename}, size={file_size}, " + f"chunks={session.total_chunks}" + ) - return { - "filename": filename, - "original_filename": session["original_filename"], - "size": file_size, - } - except Exception: - if os.path.exists(output_path): - os.remove(output_path) - raise + return { + "filename": session.filename, + "original_filename": session.original_filename, + "size": file_size, + } - async def upload_abort(self, data: object) -> tuple[dict | None, str | None]: + async def upload_abort( + self, data: object, *, owner: str = "" + ) -> tuple[dict | None, str | None]: payload = self._payload(data) upload_id = payload.get("upload_id") if not upload_id: raise BackupServiceError("缺少 upload_id 参数") - if upload_id in self.upload_sessions: - await self.cleanup_upload_session(upload_id) - logger.info(f"取消分片上传: {upload_id}") + try: + if await self.chunked_uploads.abort(upload_id, owner=owner): + logger.info(f"取消分片上传: {upload_id}") + except ChunkedUploadError as exc: + raise BackupServiceError(str(exc)) from exc return None, "上传已取消" + def upload_status(self, data: object, *, owner: str = "") -> dict: + payload = self._payload(data) + upload_id = payload.get("upload_id") + if not upload_id: + raise BackupServiceError("缺少 upload_id 参数") + + try: + return self.chunked_uploads.session_status(upload_id, owner=owner) + except ChunkedUploadError as exc: + raise BackupServiceError(str(exc)) from exc + def check_backup(self, data: object) -> dict: payload = self._payload(data) filename = self._validate_backup_filename( diff --git a/astrbot/dashboard/services/chat_service.py b/astrbot/dashboard/services/chat_service.py index f23bb236d..4fd0c16de 100644 --- a/astrbot/dashboard/services/chat_service.py +++ b/astrbot/dashboard/services/chat_service.py @@ -33,6 +33,11 @@ from astrbot.core.utils.media_utils import ( MEDIA_MIME_EXTENSIONS, detect_image_mime_type_async, ) +from astrbot.core.utils.upload import UploadTooLargeError +from astrbot.dashboard.services.chunked_upload_service import ( + ChunkedUploadError, + ChunkedUploadService, +) SSE_HEARTBEAT = ": heartbeat\n\n" CHAT_RUN_SUBSCRIBER_QUEUE_SIZE = 256 @@ -58,6 +63,30 @@ def sanitize_upload_filename(filename: str | None) -> str: return name +class LocalUploadFile: + """Adapt a merged local file to the upload contract save_uploaded_file() consumes. + + Mirrors UploadFileAdapter (api/multipart.py) but sources bytes from a + local path; save() moves the file so the chunked-upload merge result + lands in the attachments directory without a second full copy. + """ + + def __init__(self, path: Path, filename: str, content_type: str | None) -> None: + self._path = path + self.filename = filename + self.content_type = content_type + self.headers: dict = {} + self.content_length = path.stat().st_size + + async def save( + self, destination: str | Path, *, max_bytes: int | None = None + ) -> int: + if max_bytes is not None and self.content_length > max_bytes: + raise UploadTooLargeError(max_bytes) + await asyncio.to_thread(os.replace, self._path, destination) + return self.content_length + + def normalize_reasoning_message_parts( message_parts: list[dict] | None, reasoning: str = "", @@ -521,6 +550,10 @@ class ChatService: self.chat_runs: dict[str, ChatRunState] = {} self.chat_runs_by_session: dict[str, set[str]] = {} + self.chunked_uploads = ChunkedUploadService( + os.path.join(get_astrbot_data_path(), "webchat", ".chunks") + ) + async def build_user_message_parts(self, message: str | list) -> list[dict]: return await build_webchat_message_parts( message, @@ -620,12 +653,12 @@ class ChatService: if not file_path.is_relative_to(attachments_dir): raise ChatServiceError("Invalid filename") - await file.save(str(file_path)) - if file_path.stat().st_size > MAX_UPLOAD_FILE_SIZE_BYTES: - file_path.unlink(missing_ok=True) + try: + await file.save(str(file_path), max_bytes=MAX_UPLOAD_FILE_SIZE_BYTES) + except UploadTooLargeError as exc: raise ChatServiceError( f"File too large (limit {MAX_UPLOAD_FILE_SIZE_MB} MB)" - ) + ) from exc if attach_type == "image": detected_mime_type = await detect_image_mime_type_async( file_path, @@ -663,6 +696,118 @@ class ChatService: raise ChatServiceError("Missing key: file") return await self.save_uploaded_file(files["file"]) + def upload_init(self, data: object, *, owner: str = "") -> dict: + payload = data if isinstance(data, dict) else {} + original_filename = str(payload.get("filename") or "") + total_size = payload.get("total_size", 0) + + if not original_filename: + raise ChatServiceError("Missing key: filename") + if not isinstance(total_size, int) or total_size <= 0: + raise ChatServiceError("Invalid file size") + if total_size > MAX_UPLOAD_FILE_SIZE_BYTES: + raise ChatServiceError( + f"File too large (limit {MAX_UPLOAD_FILE_SIZE_MB} MB)" + ) + + self.chunked_uploads.ensure_cleanup_task_started() + try: + session = self.chunked_uploads.init_session( + owner=owner, + purpose="chat_attachment", + filename=sanitize_upload_filename(original_filename), + original_filename=original_filename, + total_size=total_size, + meta={ + "content_type": payload.get("content_type") + or "application/octet-stream" + }, + ) + except ChunkedUploadError as exc: + raise ChatServiceError(str(exc)) from exc + + return { + "upload_id": session.id, + "chunk_size": session.chunk_size, + "total_chunks": session.total_chunks, + } + + async def upload_chunk( + self, + *, + upload_id: str | None, + chunk_index_str: str | None, + chunk_file: Any | None, + owner: str = "", + ) -> dict: + if not upload_id or chunk_index_str is None or not chunk_file: + raise ChatServiceError("Missing required parameters") + + try: + chunk_index = int(chunk_index_str) + except ValueError as exc: + raise ChatServiceError("Invalid chunk_index") from exc + + try: + return await self.chunked_uploads.save_chunk( + upload_id, chunk_index, chunk_file, owner=owner + ) + except ChunkedUploadError as exc: + raise ChatServiceError(str(exc)) from exc + + async def upload_complete(self, data: object, *, owner: str = "") -> dict: + payload = data if isinstance(data, dict) else {} + upload_id = payload.get("upload_id") + if not upload_id: + raise ChatServiceError("Missing key: upload_id") + + try: + session = self.chunked_uploads.get_session(upload_id, owner=owner) + # Merge outside the chunk dir: assemble() removes that dir on success. + merged_path = self.chunked_uploads.chunks_root / f"{upload_id}.merged" + await self.chunked_uploads.assemble(upload_id, merged_path, owner=owner) + except ChunkedUploadError as exc: + raise ChatServiceError(str(exc)) from exc + + try: + upload = LocalUploadFile( + merged_path, + session.filename, + session.meta.get("content_type"), + ) + result = await self.save_uploaded_file(upload) + finally: + merged_path.unlink(missing_ok=True) + + logger.info( + f"Chunked attachment upload completed: {session.filename}, " + f"size={session.total_size}, chunks={session.total_chunks}" + ) + return result + + async def upload_abort(self, data: object, *, owner: str = "") -> None: + payload = data if isinstance(data, dict) else {} + upload_id = payload.get("upload_id") + if not upload_id: + return + + try: + if await self.chunked_uploads.abort(upload_id, owner=owner): + logger.info(f"Aborted chunked attachment upload: {upload_id}") + except ChunkedUploadError as exc: + raise ChatServiceError(str(exc)) from exc + + def upload_status(self, data: object, *, owner: str = "") -> dict: + payload = data if isinstance(data, dict) else {} + upload_id = payload.get("upload_id") + if not upload_id: + raise ChatServiceError("Missing key: upload_id") + + try: + return self.chunked_uploads.session_status(upload_id, owner=owner) + except ChunkedUploadError as exc: + raise ChatServiceError(str(exc)) from exc + async def delete_threads_by_ids(self, thread_ids: list[str], creator: str) -> None: for thread_id in thread_ids: unified_msg_origin = build_thread_unified_msg_origin(creator, thread_id) diff --git a/astrbot/dashboard/services/chunked_upload_service.py b/astrbot/dashboard/services/chunked_upload_service.py new file mode 100644 index 000000000..4b8075d79 --- /dev/null +++ b/astrbot/dashboard/services/chunked_upload_service.py @@ -0,0 +1,327 @@ +"""Generic chunked-upload session management. + +Business services keep ownership of file validation, final naming and +post-merge handling; this service owns the session lifecycle, chunk storage, +streaming assembly and expiry cleanup. Every session is bound to an owner +(the authenticated username) and a purpose (the consuming business), so a +session created for one business cannot be consumed by another caller or +another upload flow. +""" + +from __future__ import annotations + +import asyncio +import math +import os +import shutil +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from astrbot.core import logger +from astrbot.core.utils.upload import UploadTooLargeError + +DEFAULT_CHUNK_SIZE = 1024 * 1024 +DEFAULT_EXPIRE_SECONDS = 3600 +CLEANUP_INTERVAL_SECONDS = 300 + + +class ChunkedUploadError(Exception): + pass + + +@dataclass +class UploadSession: + id: str + owner: str + purpose: str + filename: str + original_filename: str + total_size: int + total_chunks: int + chunk_size: int + chunk_dir: Path + meta: dict = field(default_factory=dict) + received_chunks: set[int] = field(default_factory=set) + created_at: float = 0.0 + last_activity: float = 0.0 + + +class ChunkedUploadService: + """Manage chunked upload sessions on disk with bounded memory.""" + + def __init__( + self, + chunks_root: str | Path, + *, + chunk_size: int = DEFAULT_CHUNK_SIZE, + expire_seconds: int = DEFAULT_EXPIRE_SECONDS, + ) -> None: + self.chunks_root = Path(chunks_root) + self.chunk_size = chunk_size + self.expire_seconds = expire_seconds + self.sessions: dict[str, UploadSession] = {} + self._cleanup_task: asyncio.Task | None = None + + def init_session( + self, + *, + owner: str, + purpose: str, + filename: str, + original_filename: str, + total_size: int, + meta: dict | None = None, + ) -> UploadSession: + """Create a session and its chunk directory. + + Args: + owner: Authenticated username the session is bound to. + purpose: Consuming business identifier (e.g. "backup"). + filename: Sanitized final file name chosen by the business. + original_filename: Name as reported by the client. + total_size: Declared total size in bytes; the business validates + any upper bound before calling, so only sanity is checked here. + meta: Opaque business metadata carried with the session (e.g. the + client-reported content type) and read back at assembly time. + + Returns: + The created session. + + Raises: + ChunkedUploadError: The declared size is not positive. + """ + if total_size <= 0: + raise ChunkedUploadError("Invalid file size") + + upload_id = str(uuid.uuid4()) + chunk_dir = self.chunks_root / upload_id + chunk_dir.mkdir(parents=True, exist_ok=True) + now = time.time() + session = UploadSession( + id=upload_id, + owner=owner, + purpose=purpose, + filename=filename, + original_filename=original_filename, + total_size=total_size, + total_chunks=math.ceil(total_size / self.chunk_size), + chunk_size=self.chunk_size, + chunk_dir=chunk_dir, + meta=meta or {}, + created_at=now, + last_activity=now, + ) + self.sessions[upload_id] = session + logger.info( + f"Chunked upload session started: id={upload_id}, owner={owner}, " + f"purpose={purpose}, file={filename}, chunks={session.total_chunks}" + ) + return session + + def get_session(self, upload_id: str, *, owner: str | None = None) -> UploadSession: + """Look up a session, optionally enforcing its owner. + + Missing sessions, expired sessions and owner mismatches raise the + same error so callers cannot probe which sessions exist. The janitor + reaps expired chunk directories; this check keeps expired sessions + from staying usable in the meantime. + + Raises: + ChunkedUploadError: Session unknown, expired or owned by someone else. + """ + session = self.sessions.get(upload_id) + if ( + session is None + or (owner is not None and session.owner != owner) + or time.time() - session.last_activity > self.expire_seconds + ): + raise ChunkedUploadError("Upload session not found or expired") + return session + + async def save_chunk( + self, + upload_id: str, + chunk_index: int, + file: Any, + *, + owner: str | None = None, + ) -> dict: + """Persist one chunk; repeats and out-of-order arrivals are idempotent. + + Args: + file: Upload object exposing the adapter ``save()`` contract. + + Returns: + Progress dict with received/total/chunk_index. + + Raises: + ChunkedUploadError: Unknown session, bad index or oversized chunk. + """ + session = self.get_session(upload_id, owner=owner) + if chunk_index < 0 or chunk_index >= session.total_chunks: + raise ChunkedUploadError("Chunk index out of range") + + # Save to a uniquely named temp file and publish it atomically: + # a failed or concurrent retry for the same index must not clobber + # the chunk that was already received. + chunk_path = session.chunk_dir / f"{chunk_index}.part" + temp_path = session.chunk_dir / f"{chunk_index}.{uuid.uuid4().hex}.tmp" + try: + written = await file.save(temp_path, max_bytes=session.chunk_size) + except BaseException as exc: + temp_path.unlink(missing_ok=True) + if isinstance(exc, UploadTooLargeError): + raise ChunkedUploadError("Chunk exceeds the size limit") from exc + raise + + # The save contract returns the byte count; a short or absent write + # means the chunk is unusable and must not be published. + expected = min( + session.total_size - chunk_index * session.chunk_size, + session.chunk_size, + ) + if written != expected: + temp_path.unlink(missing_ok=True) + raise ChunkedUploadError( + f"Chunk size mismatch: got {written} bytes, expected {expected}" + ) + + try: + await asyncio.to_thread(os.replace, temp_path, chunk_path) + except BaseException: + temp_path.unlink(missing_ok=True) + raise + session.received_chunks.add(chunk_index) + session.last_activity = time.time() + + logger.debug( + f"Chunk received: id={upload_id}, " + f"chunk={chunk_index + 1}/{session.total_chunks}" + ) + return { + "received": len(session.received_chunks), + "total": session.total_chunks, + "chunk_index": chunk_index, + } + + def session_status(self, upload_id: str, *, owner: str | None = None) -> dict: + """Return resumable progress for a session. + + Read-only on purpose: it must not refresh ``last_activity``, or + polling this endpoint would anchor a session (and its on-disk + chunks) alive forever, defeating the expiry mechanism. + + Raises: + ChunkedUploadError: Session unknown, expired or owned by someone else. + """ + session = self.get_session(upload_id, owner=owner) + remaining = self.expire_seconds - (time.time() - session.last_activity) + return { + "received_chunks": sorted(session.received_chunks), + "total_chunks": session.total_chunks, + "chunk_size": session.chunk_size, + "expires_in": max(0, int(remaining)), + } + + async def assemble( + self, + upload_id: str, + dest: str | Path, + *, + owner: str | None = None, + ) -> int: + """Merge all chunks into dest, verify the size and clean up. + + Returns: + Size in bytes of the merged file. + + Raises: + ChunkedUploadError: Chunks missing or merged size mismatch. + """ + session = self.get_session(upload_id, owner=owner) + if len(session.received_chunks) != session.total_chunks: + missing = sorted(set(range(session.total_chunks)) - session.received_chunks) + raise ChunkedUploadError(f"Chunks incomplete, missing: {missing[:10]}...") + + dest_path = Path(dest) + try: + with dest_path.open("wb") as outfile: + for i in range(session.total_chunks): + with (session.chunk_dir / f"{i}.part").open("rb") as part: + shutil.copyfileobj(part, outfile) + size = dest_path.stat().st_size + if size != session.total_size: + raise ChunkedUploadError( + f"Merged size ({size}) does not match the declared size " + f"({session.total_size})" + ) + except BaseException: + dest_path.unlink(missing_ok=True) + raise + + await self.cleanup_session(upload_id) + return size + + async def abort(self, upload_id: str, *, owner: str | None = None) -> bool: + """Abort and clean up a session. Unknown sessions abort silently. + + Works on expired sessions too — cleanup is the point — but never on + sessions owned by someone else. + + Returns: + True when a session existed and was removed. + + Raises: + ChunkedUploadError: The session exists but belongs to another owner. + """ + session = self.sessions.get(upload_id) + if session is None: + return False + if owner is not None and session.owner != owner: + raise ChunkedUploadError("Upload session not found or expired") + await self.cleanup_session(upload_id) + return True + + async def cleanup_session(self, upload_id: str) -> None: + session = self.sessions.get(upload_id) + if session is None: + return + if session.chunk_dir.exists(): + try: + shutil.rmtree(session.chunk_dir) + except Exception as exc: + logger.warning(f"Failed to remove chunk dir {session.chunk_dir}: {exc}") + # Keep the session registered so the janitor can retry the + # leftover directory instead of forgetting it permanently. + return + self.sessions.pop(upload_id, None) + + def ensure_cleanup_task_started(self) -> None: + if self._cleanup_task is None or self._cleanup_task.done(): + try: + self._cleanup_task = asyncio.create_task( + self._cleanup_expired_sessions() + ) + except RuntimeError: + pass + + async def _cleanup_expired_sessions(self) -> None: + while True: + try: + await asyncio.sleep(CLEANUP_INTERVAL_SECONDS) + now = time.time() + expired = [ + upload_id + for upload_id, session in self.sessions.items() + if now - session.last_activity > self.expire_seconds + ] + for upload_id in expired: + await self.cleanup_session(upload_id) + logger.info(f"Cleaned up expired upload session: {upload_id}") + except asyncio.CancelledError: + break + except Exception as exc: + logger.error(f"Failed to clean up expired upload sessions: {exc}") diff --git a/astrbot/dashboard/services/config_service.py b/astrbot/dashboard/services/config_service.py index 47d813de0..6667cea6a 100644 --- a/astrbot/dashboard/services/config_service.py +++ b/astrbot/dashboard/services/config_service.py @@ -36,6 +36,7 @@ from astrbot.core.utils.totp import ( set_pending_totp_secret, verify_configured_2fa_code, ) +from astrbot.core.utils.upload import UploadTooLargeError from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config from astrbot.dashboard.async_utils import run_maybe_async from astrbot.dashboard.responses import ApiError @@ -1238,9 +1239,9 @@ class ConfigFileService: continue save_path.parent.mkdir(parents=True, exist_ok=True) - await file.save(str(save_path)) - if save_path.is_file() and save_path.stat().st_size > MAX_FILE_BYTES: - save_path.unlink() + try: + await file.save(str(save_path), max_bytes=MAX_FILE_BYTES) + except UploadTooLargeError: errors.append(f"File too large: {filename}") continue uploaded.append(rel_path) diff --git a/astrbot/dashboard/services/skills_service.py b/astrbot/dashboard/services/skills_service.py index 3483373c4..75c21a3a0 100644 --- a/astrbot/dashboard/services/skills_service.py +++ b/astrbot/dashboard/services/skills_service.py @@ -103,23 +103,6 @@ class SkillsService: "You are not permitted to do this operation in demo mode" ) - @staticmethod - async def _save_upload(file: Any, target_path: str) -> None: - if hasattr(file, "save"): - maybe_awaitable = file.save(target_path) - if hasattr(maybe_awaitable, "__await__"): - await maybe_awaitable - return - - if hasattr(file, "read"): - data = file.read() - if hasattr(data, "__await__"): - data = await data - Path(target_path).write_bytes(data) - return - - raise SkillsServiceError("Invalid upload file") - def resolve_local_skill_dir(self, name: str) -> Path: skill_name = str(name or "").strip() if not skill_name: @@ -308,7 +291,7 @@ class SkillsService: temp_path = _next_available_temp_path(temp_dir, filename) try: - await self._save_upload(file, temp_path) + await file.save(temp_path) try: skill_name = skill_mgr.install_skill_from_zip( temp_path, @@ -365,7 +348,7 @@ class SkillsService: continue temp_path = _next_available_temp_path(temp_dir, filename) - await self._save_upload(file, temp_path) + await file.save(temp_path) try: skill_name = skill_mgr.install_skill_from_zip( diff --git a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts index 973b1971b..d45f4421e 100644 --- a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import { createClient, createConfig, type OptionsLegacyParser, formDataBodySerializer } from '@hey-api/client-axios'; -import type { LoginData, LoginError, LoginResponse, LogoutError, LogoutResponse, GetAuthSetupStatusError, GetAuthSetupStatusResponse, SetupAuthData, SetupAuthError, SetupAuthResponse, SetupTotpData, SetupTotpError, SetupTotpResponse, RecoverTotpError, RecoverTotpResponse, UpdateAuthAccountData, UpdateAuthAccountError, UpdateAuthAccountResponse, ListApiKeysError, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyError, CreateApiKeyResponse, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyResponse, DeleteApiKeyData, DeleteApiKeyError, DeleteApiKeyResponse, GetSystemConfigSchemaError, GetSystemConfigSchemaResponse, GetSystemConfigError, GetSystemConfigResponse, UpdateSystemConfigData, UpdateSystemConfigError, UpdateSystemConfigResponse, GetSystemConfigRuntimeError, GetSystemConfigRuntimeResponse, GetConfigProfileSchemaError, GetConfigProfileSchemaResponse, ListConfigProfilesError, ListConfigProfilesResponse, CreateConfigProfileData, CreateConfigProfileError, CreateConfigProfileResponse, GetConfigProfileData, GetConfigProfileError, GetConfigProfileResponse, UpdateConfigProfileContentData, UpdateConfigProfileContentError, UpdateConfigProfileContentResponse, RenameConfigProfileData, RenameConfigProfileError, RenameConfigProfileResponse, DeleteConfigProfileData, DeleteConfigProfileError, DeleteConfigProfileResponse, ListConfigRoutesError, ListConfigRoutesResponse, ReplaceConfigRoutesData, ReplaceConfigRoutesError, ReplaceConfigRoutesResponse, UpsertConfigRouteData, UpsertConfigRouteError, UpsertConfigRouteResponse, DeleteConfigRouteData, DeleteConfigRouteError, DeleteConfigRouteResponse, ListBotTypesError, ListBotTypesResponse, RegisterBotTypeData, RegisterBotTypeError, RegisterBotTypeResponse, ListBotsData, ListBotsError, ListBotsResponse, CreateBotData, CreateBotError, CreateBotResponse, ListBotStatsError, ListBotStatsResponse, GetBotByIdData, GetBotByIdError, GetBotByIdResponse, UpdateBotByIdData, UpdateBotByIdError, UpdateBotByIdResponse, DeleteBotByIdData, DeleteBotByIdError, DeleteBotByIdResponse, SetBotEnabledByIdData, SetBotEnabledByIdError, SetBotEnabledByIdResponse, TestBotByIdData, TestBotByIdError, TestBotByIdResponse, GetBotData, GetBotError, GetBotResponse, UpdateBotData, UpdateBotError, UpdateBotResponse, DeleteBotData, DeleteBotError, DeleteBotResponse, SetBotEnabledData, SetBotEnabledError, SetBotEnabledResponse, TestBotData, TestBotError, TestBotResponse, GetProviderSchemaError, GetProviderSchemaResponse, ListProviderSourcesError, ListProviderSourcesResponse, CreateProviderSourceData, CreateProviderSourceError, CreateProviderSourceResponse, GetProviderSourceByIdData, GetProviderSourceByIdError, GetProviderSourceByIdResponse, UpsertProviderSourceByIdData, UpsertProviderSourceByIdError, UpsertProviderSourceByIdResponse, DeleteProviderSourceByIdData, DeleteProviderSourceByIdError, DeleteProviderSourceByIdResponse, ListProviderSourceModelsByIdData, ListProviderSourceModelsByIdError, ListProviderSourceModelsByIdResponse, ListProvidersBySourceIdData, ListProvidersBySourceIdError, ListProvidersBySourceIdResponse, CreateProviderInSourceByIdData, CreateProviderInSourceByIdError, CreateProviderInSourceByIdResponse, GetProviderSourceData, GetProviderSourceError, GetProviderSourceResponse, UpsertProviderSourceData, UpsertProviderSourceError, UpsertProviderSourceResponse, DeleteProviderSourceData, DeleteProviderSourceError, DeleteProviderSourceResponse, ListProviderSourceModelsData, ListProviderSourceModelsError, ListProviderSourceModelsResponse, ListProvidersBySourceData, ListProvidersBySourceError, ListProvidersBySourceResponse, CreateProviderInSourceData, CreateProviderInSourceError, CreateProviderInSourceResponse, ListProvidersData, ListProvidersError, ListProvidersResponse, CreateProviderData, CreateProviderError, CreateProviderResponse, GetProviderByIdData, GetProviderByIdError, GetProviderByIdResponse, UpdateProviderByIdData, UpdateProviderByIdError, UpdateProviderByIdResponse, DeleteProviderByIdData, DeleteProviderByIdError, DeleteProviderByIdResponse, SetProviderEnabledByIdData, SetProviderEnabledByIdError, SetProviderEnabledByIdResponse, TestProviderByIdData, TestProviderByIdError, TestProviderByIdResponse, GetProviderEmbeddingDimensionByIdData, GetProviderEmbeddingDimensionByIdError, GetProviderEmbeddingDimensionByIdResponse, GetProviderData, GetProviderError, GetProviderResponse, UpdateProviderData, UpdateProviderError, UpdateProviderResponse, DeleteProviderData, DeleteProviderError, DeleteProviderResponse, SetProviderEnabledData, SetProviderEnabledError, SetProviderEnabledResponse, TestProviderData, TestProviderError, TestProviderResponse, GetProviderEmbeddingDimensionData, GetProviderEmbeddingDimensionError, GetProviderEmbeddingDimensionResponse, SendChatMessageData, SendChatMessageError, SendChatMessageResponse, OpenChatWebSocketData, OpenLiveChatWebSocketData, OpenUnifiedChatWebSocketData, ListChatSessionsData, ListChatSessionsError, ListChatSessionsResponse, CreateChatSessionData, CreateChatSessionError, CreateChatSessionResponse, BatchDeleteChatSessionsData, BatchDeleteChatSessionsError, BatchDeleteChatSessionsResponse, GetChatSessionData, GetChatSessionError, GetChatSessionResponse, UpdateChatSessionData, UpdateChatSessionError, UpdateChatSessionResponse, DeleteChatSessionData, DeleteChatSessionError, DeleteChatSessionResponse, StopChatSessionData, StopChatSessionError, StopChatSessionResponse, ResumeChatRunData, ResumeChatRunError, ResumeChatRunResponse, UpdateChatMessageData, UpdateChatMessageError, UpdateChatMessageResponse, RegenerateChatMessageData, RegenerateChatMessageError, RegenerateChatMessageResponse, ListChatConfigsError, ListChatConfigsResponse, CreateChatThreadData, CreateChatThreadError, CreateChatThreadResponse, GetChatThreadData, GetChatThreadError, GetChatThreadResponse, DeleteChatThreadData, DeleteChatThreadError, DeleteChatThreadResponse, SendChatThreadMessageData, SendChatThreadMessageError, SendChatThreadMessageResponse, ListChatProjectsError, ListChatProjectsResponse, CreateChatProjectData, CreateChatProjectError, CreateChatProjectResponse, GetChatProjectData, GetChatProjectError, GetChatProjectResponse, UpdateChatProjectData, UpdateChatProjectError, UpdateChatProjectResponse, DeleteChatProjectData, DeleteChatProjectError, DeleteChatProjectResponse, ListChatProjectSessionsData, ListChatProjectSessionsError, ListChatProjectSessionsResponse, ListChatProjectWorkspaceFilesData, ListChatProjectWorkspaceFilesError, ListChatProjectWorkspaceFilesResponse, GetChatProjectWorkspaceFileData, GetChatProjectWorkspaceFileError, GetChatProjectWorkspaceFileResponse, DownloadChatProjectWorkspaceFileData, DownloadChatProjectWorkspaceFileError, DownloadChatProjectWorkspaceFileResponse, AddChatProjectSessionData, AddChatProjectSessionError, AddChatProjectSessionResponse, RemoveChatProjectSessionData, RemoveChatProjectSessionError, RemoveChatProjectSessionResponse, SendImMessageData, SendImMessageError, SendImMessageResponse, ListImBotsError, ListImBotsResponse, UploadFileData, UploadFileError, UploadFileResponse, UploadOpenApiFileData, UploadOpenApiFileError, UploadOpenApiFileResponse, DownloadOpenApiFileData, DownloadOpenApiFileError, DownloadOpenApiFileResponse, GetFileByNameData, GetFileByNameError, GetFileByNameResponse, GetTokenFileData, GetTokenFileError, GetTokenFileResponse, GetAttachmentData, GetAttachmentError, GetAttachmentResponse, DeleteAttachmentData, DeleteAttachmentError, DeleteAttachmentResponse, DownloadAttachmentData, DownloadAttachmentError, DownloadAttachmentResponse, ListPluginsData, ListPluginsError, ListPluginsResponse, GetPluginByIdData, GetPluginByIdError, GetPluginByIdResponse, UninstallPluginByIdData, UninstallPluginByIdError, UninstallPluginByIdResponse, GetPluginConfigByIdData, GetPluginConfigByIdError, GetPluginConfigByIdResponse, UpdatePluginConfigByIdData, UpdatePluginConfigByIdError, UpdatePluginConfigByIdResponse, GetPluginConfigSchemaByIdData, GetPluginConfigSchemaByIdError, GetPluginConfigSchemaByIdResponse, ListPluginConfigFilesByIdData, ListPluginConfigFilesByIdError, ListPluginConfigFilesByIdResponse, UploadPluginConfigFilesByIdData, UploadPluginConfigFilesByIdError, UploadPluginConfigFilesByIdResponse, DeletePluginConfigFileByIdData, DeletePluginConfigFileByIdError, DeletePluginConfigFileByIdResponse, GetPluginReadmeByIdData, GetPluginReadmeByIdError, GetPluginReadmeByIdResponse, GetPluginChangelogByIdData, GetPluginChangelogByIdError, GetPluginChangelogByIdResponse, ReloadPluginByIdData, ReloadPluginByIdError, ReloadPluginByIdResponse, SetPluginEnabledByIdData, SetPluginEnabledByIdError, SetPluginEnabledByIdResponse, ListPluginPagesByIdData, ListPluginPagesByIdError, ListPluginPagesByIdResponse, GetPluginPageByIdData, GetPluginPageByIdError, GetPluginPageByIdResponse, GetPluginPageAssetByIdData, GetPluginPageAssetByIdError, GetPluginPageAssetByIdResponse, GetPluginData, GetPluginError, GetPluginResponse, UninstallPluginData, UninstallPluginError, UninstallPluginResponse, GetPluginConfigData, GetPluginConfigError, GetPluginConfigResponse, UpdatePluginConfigData, UpdatePluginConfigError, UpdatePluginConfigResponse, UpdatePluginLogLevelData, UpdatePluginLogLevelError, UpdatePluginLogLevelResponse, GetPluginConfigSchemaData, GetPluginConfigSchemaError, GetPluginConfigSchemaResponse, ListPluginConfigFilesData, ListPluginConfigFilesError, ListPluginConfigFilesResponse, UploadPluginConfigFilesData, UploadPluginConfigFilesError, UploadPluginConfigFilesResponse, DeletePluginConfigFileData, DeletePluginConfigFileError, DeletePluginConfigFileResponse, GetPluginReadmeData, GetPluginReadmeError, GetPluginReadmeResponse, GetPluginChangelogData, GetPluginChangelogError, GetPluginChangelogResponse, ReloadPluginData, ReloadPluginError, ReloadPluginResponse, BindPluginSourceData, BindPluginSourceError, BindPluginSourceResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ValidatePluginRepoData, ValidatePluginRepoError, ValidatePluginRepoResponse, ListFailedPluginsError, ListFailedPluginsResponse, UninstallFailedPluginData, UninstallFailedPluginError, UninstallFailedPluginResponse, ReloadFailedPluginData, ReloadFailedPluginError, ReloadFailedPluginResponse, InstallPluginFromGithubData, InstallPluginFromGithubError, InstallPluginFromGithubResponse, InstallPluginFromGitData, InstallPluginFromGitError, InstallPluginFromGitResponse, InstallPluginFromUrlData, InstallPluginFromUrlError, InstallPluginFromUrlResponse, InstallPluginFromUploadData, InstallPluginFromUploadError, InstallPluginFromUploadResponse, ListPluginMarketData, ListPluginMarketError, ListPluginMarketResponse, ListPluginMarketCategoriesError, ListPluginMarketCategoriesResponse, ListPluginSourcesError, ListPluginSourcesResponse, CreatePluginSourceData, CreatePluginSourceError, CreatePluginSourceResponse, ReplacePluginSourcesData, ReplacePluginSourcesError, ReplacePluginSourcesResponse, DeletePluginSourceData, DeletePluginSourceError, DeletePluginSourceResponse, DeletePluginSourceByIdData, DeletePluginSourceByIdError, DeletePluginSourceByIdResponse, ListPluginPagesData, ListPluginPagesError, ListPluginPagesResponse, GetPluginPageData, GetPluginPageError, GetPluginPageResponse, GetPluginPageAssetData, GetPluginPageAssetError, GetPluginPageAssetResponse, GetPluginPageBridgeSdkError, GetPluginPageBridgeSdkResponse, GetPluginExtensionRouteData, GetPluginExtensionRouteError, GetPluginExtensionRouteResponse, PostPluginExtensionRouteData, PostPluginExtensionRouteError, PostPluginExtensionRouteResponse, PutPluginExtensionRouteData, PutPluginExtensionRouteError, PutPluginExtensionRouteResponse, PatchPluginExtensionRouteData, PatchPluginExtensionRouteError, PatchPluginExtensionRouteResponse, DeletePluginExtensionRouteData, DeletePluginExtensionRouteError, DeletePluginExtensionRouteResponse, ListCommandsData, ListCommandsError, ListCommandsResponse, UpdateCommandData, UpdateCommandError, UpdateCommandResponse, ListCommandConflictsError, ListCommandConflictsResponse, ListToolsData, ListToolsError, ListToolsResponse, SetToolEnabledData, SetToolEnabledError, SetToolEnabledResponse, SetToolPermissionData, SetToolPermissionError, SetToolPermissionResponse, ListMcpServersError, ListMcpServersResponse, CreateMcpServerData, CreateMcpServerError, CreateMcpServerResponse, UpdateMcpServerByNameData, UpdateMcpServerByNameError, UpdateMcpServerByNameResponse, DeleteMcpServerByNameData, DeleteMcpServerByNameError, DeleteMcpServerByNameResponse, SetMcpServerEnabledByNameData, SetMcpServerEnabledByNameError, SetMcpServerEnabledByNameResponse, TestMcpServerByNameData, TestMcpServerByNameError, TestMcpServerByNameResponse, UpdateMcpServerData, UpdateMcpServerError, UpdateMcpServerResponse, DeleteMcpServerData, DeleteMcpServerError, DeleteMcpServerResponse, SetMcpServerEnabledData, SetMcpServerEnabledError, SetMcpServerEnabledResponse, TestMcpServerData, TestMcpServerError, TestMcpServerResponse, SyncModelScopeMcpServersData, SyncModelScopeMcpServersError, SyncModelScopeMcpServersResponse, ListSkillsData, ListSkillsError, ListSkillsResponse, UploadSkillData, UploadSkillError, UploadSkillResponse, UploadSkillsBatchData, UploadSkillsBatchError, UploadSkillsBatchResponse, UpdateSkillByNameData, UpdateSkillByNameError, UpdateSkillByNameResponse, DeleteSkillByNameData, DeleteSkillByNameError, DeleteSkillByNameResponse, DownloadSkillByNameData, DownloadSkillByNameError, DownloadSkillByNameResponse, ListSkillFilesByNameData, ListSkillFilesByNameError, ListSkillFilesByNameResponse, GetSkillFileByNameData, GetSkillFileByNameError, GetSkillFileByNameResponse, UpdateSkillFileByNameData, UpdateSkillFileByNameError, UpdateSkillFileByNameResponse, UpdateSkillData, UpdateSkillError, UpdateSkillResponse, DeleteSkillData, DeleteSkillError, DeleteSkillResponse, DownloadSkillData, DownloadSkillError, DownloadSkillResponse, ListSkillFilesData, ListSkillFilesError, ListSkillFilesResponse, GetSkillFileData, GetSkillFileError, GetSkillFileResponse, UpdateSkillFileData, UpdateSkillFileError, UpdateSkillFileResponse, ListNeoSkillCandidatesData, ListNeoSkillCandidatesError, ListNeoSkillCandidatesResponse, ListNeoSkillReleasesData, ListNeoSkillReleasesError, ListNeoSkillReleasesResponse, GetNeoSkillPayloadData, GetNeoSkillPayloadError, GetNeoSkillPayloadResponse, EvaluateNeoSkillCandidateData, EvaluateNeoSkillCandidateError, EvaluateNeoSkillCandidateResponse, PromoteNeoSkillCandidateData, PromoteNeoSkillCandidateError, PromoteNeoSkillCandidateResponse, RollbackNeoSkillReleaseData, RollbackNeoSkillReleaseError, RollbackNeoSkillReleaseResponse, SyncNeoSkillReleaseData, SyncNeoSkillReleaseError, SyncNeoSkillReleaseResponse, DeleteNeoSkillCandidateData, DeleteNeoSkillCandidateError, DeleteNeoSkillCandidateResponse, DeleteNeoSkillReleaseData, DeleteNeoSkillReleaseError, DeleteNeoSkillReleaseResponse, ListKnowledgeBasesData, ListKnowledgeBasesError, ListKnowledgeBasesResponse, CreateKnowledgeBaseData, CreateKnowledgeBaseError, CreateKnowledgeBaseResponse, GetKnowledgeBaseData, GetKnowledgeBaseError, GetKnowledgeBaseResponse, UpdateKnowledgeBaseData, UpdateKnowledgeBaseError, UpdateKnowledgeBaseResponse, DeleteKnowledgeBaseData, DeleteKnowledgeBaseError, DeleteKnowledgeBaseResponse, GetKnowledgeBaseStatsData, GetKnowledgeBaseStatsError, GetKnowledgeBaseStatsResponse, ListKnowledgeDocumentsData, ListKnowledgeDocumentsError, ListKnowledgeDocumentsResponse, UploadKnowledgeDocumentData, UploadKnowledgeDocumentError, UploadKnowledgeDocumentResponse, ImportKnowledgeDocumentsData, ImportKnowledgeDocumentsError, ImportKnowledgeDocumentsResponse, ImportKnowledgeDocumentFromUrlData, ImportKnowledgeDocumentFromUrlError, ImportKnowledgeDocumentFromUrlResponse, GetKnowledgeDocumentData, GetKnowledgeDocumentError, GetKnowledgeDocumentResponse, DeleteKnowledgeDocumentData, DeleteKnowledgeDocumentError, DeleteKnowledgeDocumentResponse, ListKnowledgeChunksData, ListKnowledgeChunksError, ListKnowledgeChunksResponse, DeleteKnowledgeChunkData, DeleteKnowledgeChunkError, DeleteKnowledgeChunkResponse, RetrieveKnowledgeBaseData, RetrieveKnowledgeBaseError, RetrieveKnowledgeBaseResponse, GetKnowledgeTaskData, GetKnowledgeTaskError, GetKnowledgeTaskResponse, GetPersonaTreeError, GetPersonaTreeResponse, ListPersonasData, ListPersonasError, ListPersonasResponse, CreatePersonaData, CreatePersonaError, CreatePersonaResponse, GetPersonaByIdData, GetPersonaByIdError, GetPersonaByIdResponse, UpdatePersonaByIdData, UpdatePersonaByIdError, UpdatePersonaByIdResponse, DeletePersonaByIdData, DeletePersonaByIdError, DeletePersonaByIdResponse, GetPersonaData, GetPersonaError, GetPersonaResponse, UpdatePersonaData, UpdatePersonaError, UpdatePersonaResponse, DeletePersonaData, DeletePersonaError, DeletePersonaResponse, ListPersonaFoldersData, ListPersonaFoldersError, ListPersonaFoldersResponse, CreatePersonaFolderData, CreatePersonaFolderError, CreatePersonaFolderResponse, UpdatePersonaFolderData, UpdatePersonaFolderError, UpdatePersonaFolderResponse, DeletePersonaFolderData, DeletePersonaFolderError, DeletePersonaFolderResponse, MovePersonaItemData, MovePersonaItemError, MovePersonaItemResponse, ReorderPersonaItemsData, ReorderPersonaItemsError, ReorderPersonaItemsResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, ListActiveUmosError, ListActiveUmosResponse, ListSessionRulesData, ListSessionRulesError, ListSessionRulesResponse, UpsertSessionRuleData, UpsertSessionRuleError, UpsertSessionRuleResponse, DeleteSessionRulesData, DeleteSessionRulesError, DeleteSessionRulesResponse, BatchUpdateSessionProviderData, BatchUpdateSessionProviderError, BatchUpdateSessionProviderResponse, BatchUpdateSessionServiceData, BatchUpdateSessionServiceError, BatchUpdateSessionServiceResponse, ListSessionGroupsError, ListSessionGroupsResponse, CreateSessionGroupData, CreateSessionGroupError, CreateSessionGroupResponse, UpdateSessionGroupData, UpdateSessionGroupError, UpdateSessionGroupResponse, DeleteSessionGroupData, DeleteSessionGroupError, DeleteSessionGroupResponse, ListConversationsData, ListConversationsError, ListConversationsResponse, GetConversationFilterOptionsError, GetConversationFilterOptionsResponse, BatchDeleteConversationsData, BatchDeleteConversationsError, BatchDeleteConversationsResponse, GetConversationData, GetConversationError, GetConversationResponse, UpdateConversationData, UpdateConversationError, UpdateConversationResponse, DeleteConversationData, DeleteConversationError, DeleteConversationResponse, ReplaceConversationMessagesData, ReplaceConversationMessagesError, ReplaceConversationMessagesResponse, ExportConversationsData, ExportConversationsError, ExportConversationsResponse, GetStatsData, GetStatsError, GetStatsResponse, GetProviderTokenStatsData, GetProviderTokenStatsError, GetProviderTokenStatsResponse, GetVersionError, GetVersionResponse, GetPublicVersionsError, GetPublicVersionsResponse, GetFirstNoticeData, GetFirstNoticeError, GetFirstNoticeResponse, TestGhproxyConnectionData, TestGhproxyConnectionError, TestGhproxyConnectionResponse, ListChangelogVersionsError, ListChangelogVersionsResponse, GetChangelogData, GetChangelogError, GetChangelogResponse, GetStartTimeError, GetStartTimeResponse, GetStorageStatusError, GetStorageStatusResponse, CleanupStorageData, CleanupStorageError, CleanupStorageResponse, RestartCoreError, RestartCoreResponse, ListBackupsData, ListBackupsError, ListBackupsResponse, CreateBackupData, CreateBackupError, CreateBackupResponse, UploadBackupData, UploadBackupError, UploadBackupResponse, InitBackupUploadData, InitBackupUploadError, InitBackupUploadResponse, UploadBackupChunkData, UploadBackupChunkError, UploadBackupChunkResponse, CompleteBackupUploadData, CompleteBackupUploadError, CompleteBackupUploadResponse, AbortBackupUploadData, AbortBackupUploadError, AbortBackupUploadResponse, GetBackupProgressData, GetBackupProgressError, GetBackupProgressResponse, DownloadBackupData, DownloadBackupError, DownloadBackupResponse, RenameBackupData, RenameBackupError, RenameBackupResponse, DeleteBackupData, DeleteBackupError, DeleteBackupResponse, CheckBackupData, CheckBackupError, CheckBackupResponse, ImportBackupData, ImportBackupError, ImportBackupResponse, CheckUpdateError, CheckUpdateResponse, ListReleasesData, ListReleasesError, ListReleasesResponse, UpdateCoreData, UpdateCoreError, UpdateCoreResponse, UpdateDashboardData, UpdateDashboardError, UpdateDashboardResponse, GetUpdateProgressData, GetUpdateProgressError, GetUpdateProgressResponse, InstallPipPackageData, InstallPipPackageError, InstallPipPackageResponse, ListCronJobsData, ListCronJobsError, ListCronJobsResponse, CreateCronJobData, CreateCronJobError, CreateCronJobResponse, UpdateCronJobData, UpdateCronJobError, UpdateCronJobResponse, DeleteCronJobData, DeleteCronJobError, DeleteCronJobResponse, RunCronJobData, RunCronJobError, RunCronJobResponse, StreamLiveLogsError, StreamLiveLogsResponse, GetLogHistoryError, GetLogHistoryResponse, GetTraceSettingsError, GetTraceSettingsResponse, UpdateTraceSettingsData, UpdateTraceSettingsError, UpdateTraceSettingsResponse, ListT2iTemplatesError, ListT2iTemplatesResponse, CreateT2iTemplateData, CreateT2iTemplateError, CreateT2iTemplateResponse, GetActiveT2iTemplateError, GetActiveT2iTemplateResponse, SetActiveT2iTemplateData, SetActiveT2iTemplateError, SetActiveT2iTemplateResponse, ResetDefaultT2iTemplateError, ResetDefaultT2iTemplateResponse, GetT2iTemplateData, GetT2iTemplateError, GetT2iTemplateResponse, UpdateT2iTemplateData, UpdateT2iTemplateError, UpdateT2iTemplateResponse, DeleteT2iTemplateData, DeleteT2iTemplateError, DeleteT2iTemplateResponse, GetSubagentConfigError, GetSubagentConfigResponse, UpdateSubagentConfigData, UpdateSubagentConfigError, UpdateSubagentConfigResponse, ListSubagentAvailableToolsError, ListSubagentAvailableToolsResponse, VerifyPlatformWebhookData, VerifyPlatformWebhookError, VerifyPlatformWebhookResponse, ReceivePlatformWebhookData, ReceivePlatformWebhookError, ReceivePlatformWebhookResponse } from './types.gen'; +import type { LoginData, LoginError, LoginResponse, LogoutError, LogoutResponse, GetAuthSetupStatusError, GetAuthSetupStatusResponse, SetupAuthData, SetupAuthError, SetupAuthResponse, SetupTotpData, SetupTotpError, SetupTotpResponse, RecoverTotpError, RecoverTotpResponse, UpdateAuthAccountData, UpdateAuthAccountError, UpdateAuthAccountResponse, ListApiKeysError, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyError, CreateApiKeyResponse, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyResponse, DeleteApiKeyData, DeleteApiKeyError, DeleteApiKeyResponse, GetSystemConfigSchemaError, GetSystemConfigSchemaResponse, GetSystemConfigError, GetSystemConfigResponse, UpdateSystemConfigData, UpdateSystemConfigError, UpdateSystemConfigResponse, GetSystemConfigRuntimeError, GetSystemConfigRuntimeResponse, GetConfigProfileSchemaError, GetConfigProfileSchemaResponse, ListConfigProfilesError, ListConfigProfilesResponse, CreateConfigProfileData, CreateConfigProfileError, CreateConfigProfileResponse, GetConfigProfileData, GetConfigProfileError, GetConfigProfileResponse, UpdateConfigProfileContentData, UpdateConfigProfileContentError, UpdateConfigProfileContentResponse, RenameConfigProfileData, RenameConfigProfileError, RenameConfigProfileResponse, DeleteConfigProfileData, DeleteConfigProfileError, DeleteConfigProfileResponse, ListConfigRoutesError, ListConfigRoutesResponse, ReplaceConfigRoutesData, ReplaceConfigRoutesError, ReplaceConfigRoutesResponse, UpsertConfigRouteData, UpsertConfigRouteError, UpsertConfigRouteResponse, DeleteConfigRouteData, DeleteConfigRouteError, DeleteConfigRouteResponse, ListBotTypesError, ListBotTypesResponse, RegisterBotTypeData, RegisterBotTypeError, RegisterBotTypeResponse, ListBotsData, ListBotsError, ListBotsResponse, CreateBotData, CreateBotError, CreateBotResponse, ListBotStatsError, ListBotStatsResponse, GetBotByIdData, GetBotByIdError, GetBotByIdResponse, UpdateBotByIdData, UpdateBotByIdError, UpdateBotByIdResponse, DeleteBotByIdData, DeleteBotByIdError, DeleteBotByIdResponse, SetBotEnabledByIdData, SetBotEnabledByIdError, SetBotEnabledByIdResponse, TestBotByIdData, TestBotByIdError, TestBotByIdResponse, GetBotData, GetBotError, GetBotResponse, UpdateBotData, UpdateBotError, UpdateBotResponse, DeleteBotData, DeleteBotError, DeleteBotResponse, SetBotEnabledData, SetBotEnabledError, SetBotEnabledResponse, TestBotData, TestBotError, TestBotResponse, GetProviderSchemaError, GetProviderSchemaResponse, ListProviderSourcesError, ListProviderSourcesResponse, CreateProviderSourceData, CreateProviderSourceError, CreateProviderSourceResponse, GetProviderSourceByIdData, GetProviderSourceByIdError, GetProviderSourceByIdResponse, UpsertProviderSourceByIdData, UpsertProviderSourceByIdError, UpsertProviderSourceByIdResponse, DeleteProviderSourceByIdData, DeleteProviderSourceByIdError, DeleteProviderSourceByIdResponse, ListProviderSourceModelsByIdData, ListProviderSourceModelsByIdError, ListProviderSourceModelsByIdResponse, ListProvidersBySourceIdData, ListProvidersBySourceIdError, ListProvidersBySourceIdResponse, CreateProviderInSourceByIdData, CreateProviderInSourceByIdError, CreateProviderInSourceByIdResponse, GetProviderSourceData, GetProviderSourceError, GetProviderSourceResponse, UpsertProviderSourceData, UpsertProviderSourceError, UpsertProviderSourceResponse, DeleteProviderSourceData, DeleteProviderSourceError, DeleteProviderSourceResponse, ListProviderSourceModelsData, ListProviderSourceModelsError, ListProviderSourceModelsResponse, ListProvidersBySourceData, ListProvidersBySourceError, ListProvidersBySourceResponse, CreateProviderInSourceData, CreateProviderInSourceError, CreateProviderInSourceResponse, ListProvidersData, ListProvidersError, ListProvidersResponse, CreateProviderData, CreateProviderError, CreateProviderResponse, GetProviderByIdData, GetProviderByIdError, GetProviderByIdResponse, UpdateProviderByIdData, UpdateProviderByIdError, UpdateProviderByIdResponse, DeleteProviderByIdData, DeleteProviderByIdError, DeleteProviderByIdResponse, SetProviderEnabledByIdData, SetProviderEnabledByIdError, SetProviderEnabledByIdResponse, TestProviderByIdData, TestProviderByIdError, TestProviderByIdResponse, GetProviderEmbeddingDimensionByIdData, GetProviderEmbeddingDimensionByIdError, GetProviderEmbeddingDimensionByIdResponse, GetProviderData, GetProviderError, GetProviderResponse, UpdateProviderData, UpdateProviderError, UpdateProviderResponse, DeleteProviderData, DeleteProviderError, DeleteProviderResponse, SetProviderEnabledData, SetProviderEnabledError, SetProviderEnabledResponse, TestProviderData, TestProviderError, TestProviderResponse, GetProviderEmbeddingDimensionData, GetProviderEmbeddingDimensionError, GetProviderEmbeddingDimensionResponse, SendChatMessageData, SendChatMessageError, SendChatMessageResponse, OpenChatWebSocketData, OpenLiveChatWebSocketData, OpenUnifiedChatWebSocketData, ListChatSessionsData, ListChatSessionsError, ListChatSessionsResponse, CreateChatSessionData, CreateChatSessionError, CreateChatSessionResponse, BatchDeleteChatSessionsData, BatchDeleteChatSessionsError, BatchDeleteChatSessionsResponse, GetChatSessionData, GetChatSessionError, GetChatSessionResponse, UpdateChatSessionData, UpdateChatSessionError, UpdateChatSessionResponse, DeleteChatSessionData, DeleteChatSessionError, DeleteChatSessionResponse, StopChatSessionData, StopChatSessionError, StopChatSessionResponse, ResumeChatRunData, ResumeChatRunError, ResumeChatRunResponse, UpdateChatMessageData, UpdateChatMessageError, UpdateChatMessageResponse, RegenerateChatMessageData, RegenerateChatMessageError, RegenerateChatMessageResponse, ListChatConfigsError, ListChatConfigsResponse, CreateChatThreadData, CreateChatThreadError, CreateChatThreadResponse, GetChatThreadData, GetChatThreadError, GetChatThreadResponse, DeleteChatThreadData, DeleteChatThreadError, DeleteChatThreadResponse, SendChatThreadMessageData, SendChatThreadMessageError, SendChatThreadMessageResponse, ListChatProjectsError, ListChatProjectsResponse, CreateChatProjectData, CreateChatProjectError, CreateChatProjectResponse, GetChatProjectData, GetChatProjectError, GetChatProjectResponse, UpdateChatProjectData, UpdateChatProjectError, UpdateChatProjectResponse, DeleteChatProjectData, DeleteChatProjectError, DeleteChatProjectResponse, ListChatProjectSessionsData, ListChatProjectSessionsError, ListChatProjectSessionsResponse, ListChatProjectWorkspaceFilesData, ListChatProjectWorkspaceFilesError, ListChatProjectWorkspaceFilesResponse, GetChatProjectWorkspaceFileData, GetChatProjectWorkspaceFileError, GetChatProjectWorkspaceFileResponse, DownloadChatProjectWorkspaceFileData, DownloadChatProjectWorkspaceFileError, DownloadChatProjectWorkspaceFileResponse, AddChatProjectSessionData, AddChatProjectSessionError, AddChatProjectSessionResponse, RemoveChatProjectSessionData, RemoveChatProjectSessionError, RemoveChatProjectSessionResponse, SendImMessageData, SendImMessageError, SendImMessageResponse, ListImBotsError, ListImBotsResponse, UploadFileData, UploadFileError, UploadFileResponse, InitFileUploadData, InitFileUploadError, InitFileUploadResponse, UploadFileChunkData, UploadFileChunkError, UploadFileChunkResponse, CompleteFileUploadData, CompleteFileUploadError, CompleteFileUploadResponse, AbortFileUploadData, AbortFileUploadError, AbortFileUploadResponse, StatusFileUploadData, StatusFileUploadError, StatusFileUploadResponse, UploadOpenApiFileData, UploadOpenApiFileError, UploadOpenApiFileResponse, DownloadOpenApiFileData, DownloadOpenApiFileError, DownloadOpenApiFileResponse, GetFileByNameData, GetFileByNameError, GetFileByNameResponse, GetTokenFileData, GetTokenFileError, GetTokenFileResponse, GetAttachmentData, GetAttachmentError, GetAttachmentResponse, DeleteAttachmentData, DeleteAttachmentError, DeleteAttachmentResponse, DownloadAttachmentData, DownloadAttachmentError, DownloadAttachmentResponse, ListPluginsData, ListPluginsError, ListPluginsResponse, GetPluginByIdData, GetPluginByIdError, GetPluginByIdResponse, UninstallPluginByIdData, UninstallPluginByIdError, UninstallPluginByIdResponse, GetPluginConfigByIdData, GetPluginConfigByIdError, GetPluginConfigByIdResponse, UpdatePluginConfigByIdData, UpdatePluginConfigByIdError, UpdatePluginConfigByIdResponse, GetPluginConfigSchemaByIdData, GetPluginConfigSchemaByIdError, GetPluginConfigSchemaByIdResponse, ListPluginConfigFilesByIdData, ListPluginConfigFilesByIdError, ListPluginConfigFilesByIdResponse, UploadPluginConfigFilesByIdData, UploadPluginConfigFilesByIdError, UploadPluginConfigFilesByIdResponse, DeletePluginConfigFileByIdData, DeletePluginConfigFileByIdError, DeletePluginConfigFileByIdResponse, GetPluginReadmeByIdData, GetPluginReadmeByIdError, GetPluginReadmeByIdResponse, GetPluginChangelogByIdData, GetPluginChangelogByIdError, GetPluginChangelogByIdResponse, ReloadPluginByIdData, ReloadPluginByIdError, ReloadPluginByIdResponse, SetPluginEnabledByIdData, SetPluginEnabledByIdError, SetPluginEnabledByIdResponse, ListPluginPagesByIdData, ListPluginPagesByIdError, ListPluginPagesByIdResponse, GetPluginPageByIdData, GetPluginPageByIdError, GetPluginPageByIdResponse, GetPluginPageAssetByIdData, GetPluginPageAssetByIdError, GetPluginPageAssetByIdResponse, GetPluginData, GetPluginError, GetPluginResponse, UninstallPluginData, UninstallPluginError, UninstallPluginResponse, GetPluginConfigData, GetPluginConfigError, GetPluginConfigResponse, UpdatePluginConfigData, UpdatePluginConfigError, UpdatePluginConfigResponse, UpdatePluginLogLevelData, UpdatePluginLogLevelError, UpdatePluginLogLevelResponse, GetPluginConfigSchemaData, GetPluginConfigSchemaError, GetPluginConfigSchemaResponse, ListPluginConfigFilesData, ListPluginConfigFilesError, ListPluginConfigFilesResponse, UploadPluginConfigFilesData, UploadPluginConfigFilesError, UploadPluginConfigFilesResponse, DeletePluginConfigFileData, DeletePluginConfigFileError, DeletePluginConfigFileResponse, GetPluginReadmeData, GetPluginReadmeError, GetPluginReadmeResponse, GetPluginChangelogData, GetPluginChangelogError, GetPluginChangelogResponse, ReloadPluginData, ReloadPluginError, ReloadPluginResponse, BindPluginSourceData, BindPluginSourceError, BindPluginSourceResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ValidatePluginRepoData, ValidatePluginRepoError, ValidatePluginRepoResponse, ListFailedPluginsError, ListFailedPluginsResponse, UninstallFailedPluginData, UninstallFailedPluginError, UninstallFailedPluginResponse, ReloadFailedPluginData, ReloadFailedPluginError, ReloadFailedPluginResponse, InstallPluginFromGithubData, InstallPluginFromGithubError, InstallPluginFromGithubResponse, InstallPluginFromGitData, InstallPluginFromGitError, InstallPluginFromGitResponse, InstallPluginFromUrlData, InstallPluginFromUrlError, InstallPluginFromUrlResponse, InstallPluginFromUploadData, InstallPluginFromUploadError, InstallPluginFromUploadResponse, ListPluginMarketData, ListPluginMarketError, ListPluginMarketResponse, ListPluginMarketCategoriesError, ListPluginMarketCategoriesResponse, ListPluginSourcesError, ListPluginSourcesResponse, CreatePluginSourceData, CreatePluginSourceError, CreatePluginSourceResponse, ReplacePluginSourcesData, ReplacePluginSourcesError, ReplacePluginSourcesResponse, DeletePluginSourceData, DeletePluginSourceError, DeletePluginSourceResponse, DeletePluginSourceByIdData, DeletePluginSourceByIdError, DeletePluginSourceByIdResponse, ListPluginPagesData, ListPluginPagesError, ListPluginPagesResponse, GetPluginPageData, GetPluginPageError, GetPluginPageResponse, GetPluginPageAssetData, GetPluginPageAssetError, GetPluginPageAssetResponse, GetPluginPageBridgeSdkError, GetPluginPageBridgeSdkResponse, GetPluginExtensionRouteData, GetPluginExtensionRouteError, GetPluginExtensionRouteResponse, PostPluginExtensionRouteData, PostPluginExtensionRouteError, PostPluginExtensionRouteResponse, PutPluginExtensionRouteData, PutPluginExtensionRouteError, PutPluginExtensionRouteResponse, PatchPluginExtensionRouteData, PatchPluginExtensionRouteError, PatchPluginExtensionRouteResponse, DeletePluginExtensionRouteData, DeletePluginExtensionRouteError, DeletePluginExtensionRouteResponse, ListCommandsData, ListCommandsError, ListCommandsResponse, UpdateCommandData, UpdateCommandError, UpdateCommandResponse, ListCommandConflictsError, ListCommandConflictsResponse, ListToolsData, ListToolsError, ListToolsResponse, SetToolEnabledData, SetToolEnabledError, SetToolEnabledResponse, SetToolPermissionData, SetToolPermissionError, SetToolPermissionResponse, ListMcpServersError, ListMcpServersResponse, CreateMcpServerData, CreateMcpServerError, CreateMcpServerResponse, UpdateMcpServerByNameData, UpdateMcpServerByNameError, UpdateMcpServerByNameResponse, DeleteMcpServerByNameData, DeleteMcpServerByNameError, DeleteMcpServerByNameResponse, SetMcpServerEnabledByNameData, SetMcpServerEnabledByNameError, SetMcpServerEnabledByNameResponse, TestMcpServerByNameData, TestMcpServerByNameError, TestMcpServerByNameResponse, UpdateMcpServerData, UpdateMcpServerError, UpdateMcpServerResponse, DeleteMcpServerData, DeleteMcpServerError, DeleteMcpServerResponse, SetMcpServerEnabledData, SetMcpServerEnabledError, SetMcpServerEnabledResponse, TestMcpServerData, TestMcpServerError, TestMcpServerResponse, SyncModelScopeMcpServersData, SyncModelScopeMcpServersError, SyncModelScopeMcpServersResponse, ListSkillsData, ListSkillsError, ListSkillsResponse, UploadSkillData, UploadSkillError, UploadSkillResponse, UploadSkillsBatchData, UploadSkillsBatchError, UploadSkillsBatchResponse, UpdateSkillByNameData, UpdateSkillByNameError, UpdateSkillByNameResponse, DeleteSkillByNameData, DeleteSkillByNameError, DeleteSkillByNameResponse, DownloadSkillByNameData, DownloadSkillByNameError, DownloadSkillByNameResponse, ListSkillFilesByNameData, ListSkillFilesByNameError, ListSkillFilesByNameResponse, GetSkillFileByNameData, GetSkillFileByNameError, GetSkillFileByNameResponse, UpdateSkillFileByNameData, UpdateSkillFileByNameError, UpdateSkillFileByNameResponse, UpdateSkillData, UpdateSkillError, UpdateSkillResponse, DeleteSkillData, DeleteSkillError, DeleteSkillResponse, DownloadSkillData, DownloadSkillError, DownloadSkillResponse, ListSkillFilesData, ListSkillFilesError, ListSkillFilesResponse, GetSkillFileData, GetSkillFileError, GetSkillFileResponse, UpdateSkillFileData, UpdateSkillFileError, UpdateSkillFileResponse, ListNeoSkillCandidatesData, ListNeoSkillCandidatesError, ListNeoSkillCandidatesResponse, ListNeoSkillReleasesData, ListNeoSkillReleasesError, ListNeoSkillReleasesResponse, GetNeoSkillPayloadData, GetNeoSkillPayloadError, GetNeoSkillPayloadResponse, EvaluateNeoSkillCandidateData, EvaluateNeoSkillCandidateError, EvaluateNeoSkillCandidateResponse, PromoteNeoSkillCandidateData, PromoteNeoSkillCandidateError, PromoteNeoSkillCandidateResponse, RollbackNeoSkillReleaseData, RollbackNeoSkillReleaseError, RollbackNeoSkillReleaseResponse, SyncNeoSkillReleaseData, SyncNeoSkillReleaseError, SyncNeoSkillReleaseResponse, DeleteNeoSkillCandidateData, DeleteNeoSkillCandidateError, DeleteNeoSkillCandidateResponse, DeleteNeoSkillReleaseData, DeleteNeoSkillReleaseError, DeleteNeoSkillReleaseResponse, ListKnowledgeBasesData, ListKnowledgeBasesError, ListKnowledgeBasesResponse, CreateKnowledgeBaseData, CreateKnowledgeBaseError, CreateKnowledgeBaseResponse, GetKnowledgeBaseData, GetKnowledgeBaseError, GetKnowledgeBaseResponse, UpdateKnowledgeBaseData, UpdateKnowledgeBaseError, UpdateKnowledgeBaseResponse, DeleteKnowledgeBaseData, DeleteKnowledgeBaseError, DeleteKnowledgeBaseResponse, GetKnowledgeBaseStatsData, GetKnowledgeBaseStatsError, GetKnowledgeBaseStatsResponse, ListKnowledgeDocumentsData, ListKnowledgeDocumentsError, ListKnowledgeDocumentsResponse, UploadKnowledgeDocumentData, UploadKnowledgeDocumentError, UploadKnowledgeDocumentResponse, ImportKnowledgeDocumentsData, ImportKnowledgeDocumentsError, ImportKnowledgeDocumentsResponse, ImportKnowledgeDocumentFromUrlData, ImportKnowledgeDocumentFromUrlError, ImportKnowledgeDocumentFromUrlResponse, GetKnowledgeDocumentData, GetKnowledgeDocumentError, GetKnowledgeDocumentResponse, DeleteKnowledgeDocumentData, DeleteKnowledgeDocumentError, DeleteKnowledgeDocumentResponse, ListKnowledgeChunksData, ListKnowledgeChunksError, ListKnowledgeChunksResponse, DeleteKnowledgeChunkData, DeleteKnowledgeChunkError, DeleteKnowledgeChunkResponse, RetrieveKnowledgeBaseData, RetrieveKnowledgeBaseError, RetrieveKnowledgeBaseResponse, GetKnowledgeTaskData, GetKnowledgeTaskError, GetKnowledgeTaskResponse, GetPersonaTreeError, GetPersonaTreeResponse, ListPersonasData, ListPersonasError, ListPersonasResponse, CreatePersonaData, CreatePersonaError, CreatePersonaResponse, GetPersonaByIdData, GetPersonaByIdError, GetPersonaByIdResponse, UpdatePersonaByIdData, UpdatePersonaByIdError, UpdatePersonaByIdResponse, DeletePersonaByIdData, DeletePersonaByIdError, DeletePersonaByIdResponse, GetPersonaData, GetPersonaError, GetPersonaResponse, UpdatePersonaData, UpdatePersonaError, UpdatePersonaResponse, DeletePersonaData, DeletePersonaError, DeletePersonaResponse, ListPersonaFoldersData, ListPersonaFoldersError, ListPersonaFoldersResponse, CreatePersonaFolderData, CreatePersonaFolderError, CreatePersonaFolderResponse, UpdatePersonaFolderData, UpdatePersonaFolderError, UpdatePersonaFolderResponse, DeletePersonaFolderData, DeletePersonaFolderError, DeletePersonaFolderResponse, MovePersonaItemData, MovePersonaItemError, MovePersonaItemResponse, ReorderPersonaItemsData, ReorderPersonaItemsError, ReorderPersonaItemsResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, ListActiveUmosError, ListActiveUmosResponse, ListSessionRulesData, ListSessionRulesError, ListSessionRulesResponse, UpsertSessionRuleData, UpsertSessionRuleError, UpsertSessionRuleResponse, DeleteSessionRulesData, DeleteSessionRulesError, DeleteSessionRulesResponse, BatchUpdateSessionProviderData, BatchUpdateSessionProviderError, BatchUpdateSessionProviderResponse, BatchUpdateSessionServiceData, BatchUpdateSessionServiceError, BatchUpdateSessionServiceResponse, ListSessionGroupsError, ListSessionGroupsResponse, CreateSessionGroupData, CreateSessionGroupError, CreateSessionGroupResponse, UpdateSessionGroupData, UpdateSessionGroupError, UpdateSessionGroupResponse, DeleteSessionGroupData, DeleteSessionGroupError, DeleteSessionGroupResponse, ListConversationsData, ListConversationsError, ListConversationsResponse, GetConversationFilterOptionsError, GetConversationFilterOptionsResponse, BatchDeleteConversationsData, BatchDeleteConversationsError, BatchDeleteConversationsResponse, GetConversationData, GetConversationError, GetConversationResponse, UpdateConversationData, UpdateConversationError, UpdateConversationResponse, DeleteConversationData, DeleteConversationError, DeleteConversationResponse, ReplaceConversationMessagesData, ReplaceConversationMessagesError, ReplaceConversationMessagesResponse, ExportConversationsData, ExportConversationsError, ExportConversationsResponse, GetStatsData, GetStatsError, GetStatsResponse, GetProviderTokenStatsData, GetProviderTokenStatsError, GetProviderTokenStatsResponse, GetVersionError, GetVersionResponse, GetPublicVersionsError, GetPublicVersionsResponse, GetFirstNoticeData, GetFirstNoticeError, GetFirstNoticeResponse, TestGhproxyConnectionData, TestGhproxyConnectionError, TestGhproxyConnectionResponse, ListChangelogVersionsError, ListChangelogVersionsResponse, GetChangelogData, GetChangelogError, GetChangelogResponse, GetStartTimeError, GetStartTimeResponse, GetStorageStatusError, GetStorageStatusResponse, CleanupStorageData, CleanupStorageError, CleanupStorageResponse, RestartCoreError, RestartCoreResponse, ListBackupsData, ListBackupsError, ListBackupsResponse, CreateBackupData, CreateBackupError, CreateBackupResponse, UploadBackupData, UploadBackupError, UploadBackupResponse, InitBackupUploadData, InitBackupUploadError, InitBackupUploadResponse, UploadBackupChunkData, UploadBackupChunkError, UploadBackupChunkResponse, CompleteBackupUploadData, CompleteBackupUploadError, CompleteBackupUploadResponse, AbortBackupUploadData, AbortBackupUploadError, AbortBackupUploadResponse, StatusBackupUploadData, StatusBackupUploadError, StatusBackupUploadResponse, GetBackupProgressData, GetBackupProgressError, GetBackupProgressResponse, DownloadBackupData, DownloadBackupError, DownloadBackupResponse, RenameBackupData, RenameBackupError, RenameBackupResponse, DeleteBackupData, DeleteBackupError, DeleteBackupResponse, CheckBackupData, CheckBackupError, CheckBackupResponse, ImportBackupData, ImportBackupError, ImportBackupResponse, CheckUpdateError, CheckUpdateResponse, ListReleasesData, ListReleasesError, ListReleasesResponse, UpdateCoreData, UpdateCoreError, UpdateCoreResponse, UpdateDashboardData, UpdateDashboardError, UpdateDashboardResponse, GetUpdateProgressData, GetUpdateProgressError, GetUpdateProgressResponse, InstallPipPackageData, InstallPipPackageError, InstallPipPackageResponse, ListCronJobsData, ListCronJobsError, ListCronJobsResponse, CreateCronJobData, CreateCronJobError, CreateCronJobResponse, UpdateCronJobData, UpdateCronJobError, UpdateCronJobResponse, DeleteCronJobData, DeleteCronJobError, DeleteCronJobResponse, RunCronJobData, RunCronJobError, RunCronJobResponse, StreamLiveLogsError, StreamLiveLogsResponse, GetLogHistoryError, GetLogHistoryResponse, GetTraceSettingsError, GetTraceSettingsResponse, UpdateTraceSettingsData, UpdateTraceSettingsError, UpdateTraceSettingsResponse, ListT2iTemplatesError, ListT2iTemplatesResponse, CreateT2iTemplateData, CreateT2iTemplateError, CreateT2iTemplateResponse, GetActiveT2iTemplateError, GetActiveT2iTemplateResponse, SetActiveT2iTemplateData, SetActiveT2iTemplateError, SetActiveT2iTemplateResponse, ResetDefaultT2iTemplateError, ResetDefaultT2iTemplateResponse, GetT2iTemplateData, GetT2iTemplateError, GetT2iTemplateResponse, UpdateT2iTemplateData, UpdateT2iTemplateError, UpdateT2iTemplateResponse, DeleteT2iTemplateData, DeleteT2iTemplateError, DeleteT2iTemplateResponse, GetSubagentConfigError, GetSubagentConfigResponse, UpdateSubagentConfigData, UpdateSubagentConfigError, UpdateSubagentConfigResponse, ListSubagentAvailableToolsError, ListSubagentAvailableToolsResponse, VerifyPlatformWebhookData, VerifyPlatformWebhookError, VerifyPlatformWebhookResponse, ReceivePlatformWebhookData, ReceivePlatformWebhookError, ReceivePlatformWebhookResponse } from './types.gen'; export const client = createClient(createConfig()); @@ -1040,6 +1040,61 @@ export const uploadFile = (options: Option }); }; +/** + * Initialize chunked file upload + */ +export const initFileUpload = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/files/upload/init' + }); +}; + +/** + * Upload a file chunk + */ +export const uploadFileChunk = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + ...formDataBodySerializer, + headers: { + 'Content-Type': null, + ...options?.headers + }, + url: '/api/v1/files/upload/chunk' + }); +}; + +/** + * Complete chunked file upload + */ +export const completeFileUpload = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/files/upload/complete' + }); +}; + +/** + * Abort chunked file upload + */ +export const abortFileUpload = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/files/upload/abort' + }); +}; + +/** + * Query chunked file upload status + */ +export const statusFileUpload = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/files/upload/status' + }); +}; + /** * Upload a file */ @@ -2827,6 +2882,16 @@ export const abortBackupUpload = (options: }); }; +/** + * Query chunked backup upload status + */ +export const statusBackupUpload = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/backups/upload/status' + }); +}; + /** * Get backup task progress */ diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index 37cad8802..9b9332e44 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -67,6 +67,12 @@ export type BotRegistrationRequest = { export type action = 'start' | 'poll'; +export type ChatChunkUploadRequest = { + upload_id: string; + chunk_index: number; + chunk: (Blob | File); +}; + /** * Per-request ChatUI feature flags. A value here takes priority over its legacy top-level field, followed by the documented default. */ @@ -190,6 +196,16 @@ export type ChatThreadMessageRequest = { flags?: ChatFlags; }; +export type ChatUploadInitRequest = { + filename: string; + total_size: number; + content_type?: string; +}; + +export type ChatUploadSessionRequest = { + upload_id: string; +}; + export type CommandPatchRequest = { enabled?: boolean; alias?: string; @@ -1666,6 +1682,46 @@ export type UploadFileResponse = (SuccessEnvelope); export type UploadFileError = unknown; +export type InitFileUploadData = { + body: ChatUploadInitRequest; +}; + +export type InitFileUploadResponse = (SuccessEnvelope); + +export type InitFileUploadError = unknown; + +export type UploadFileChunkData = { + body: ChatChunkUploadRequest; +}; + +export type UploadFileChunkResponse = (SuccessEnvelope); + +export type UploadFileChunkError = unknown; + +export type CompleteFileUploadData = { + body: ChatUploadSessionRequest; +}; + +export type CompleteFileUploadResponse = (SuccessEnvelope); + +export type CompleteFileUploadError = unknown; + +export type AbortFileUploadData = { + body: ChatUploadSessionRequest; +}; + +export type AbortFileUploadResponse = (SuccessEnvelope); + +export type AbortFileUploadError = unknown; + +export type StatusFileUploadData = { + body: ChatUploadSessionRequest; +}; + +export type StatusFileUploadResponse = (SuccessEnvelope); + +export type StatusFileUploadError = unknown; + export type UploadOpenApiFileData = { body: FileUploadRequest; }; @@ -3448,6 +3504,14 @@ export type AbortBackupUploadResponse = (SuccessEnvelope); export type AbortBackupUploadError = unknown; +export type StatusBackupUploadData = { + body: BackupUploadSessionRequest; +}; + +export type StatusBackupUploadResponse = (SuccessEnvelope); + +export type StatusBackupUploadError = unknown; + export type GetBackupProgressData = { path: { task_id: string; diff --git a/dashboard/src/api/v1.ts b/dashboard/src/api/v1.ts index 5307a1840..9c6e11324 100644 --- a/dashboard/src/api/v1.ts +++ b/dashboard/src/api/v1.ts @@ -11,6 +11,7 @@ import { type BackupUploadSessionRequest, type BotConfigRequest, type BotRegistrationRequest, + type ChatChunkUploadRequest, type ChatMessagePatchRequest, type ChatMessageRegenerateRequest, type ChatProjectRequest, @@ -19,6 +20,8 @@ import { type ChatSessionPatchRequest, type ChatThreadCreateRequest, type ChatThreadMessageRequest, + type ChatUploadInitRequest, + type ChatUploadSessionRequest, type CommandPatchRequest, type ConfigRouteUpsertRequest, type ConfigRoutesReplaceRequest, @@ -771,6 +774,9 @@ export const backupApi = { abortUpload(payload: BackupUploadSessionRequest) { return typed(openApiV1.abortBackupUpload({ body: payload })); }, + statusUpload(payload: BackupUploadSessionRequest) { + return typed(openApiV1.statusBackupUpload({ body: payload })); + }, check(filename: string) { return typed( openApiV1.checkBackup({ path: { filename } }), @@ -981,6 +987,23 @@ export const fileApi = { openApiV1.uploadFile({ body: generatedFormData(formData) }), ); }, + initUpload(payload: ChatUploadInitRequest) { + return typed(openApiV1.initFileUpload({ body: payload })); + }, + uploadChunk(formData: FormData | ChatChunkUploadRequest) { + return typed( + openApiV1.uploadFileChunk({ body: generatedFormData(formData) }), + ); + }, + completeUpload(payload: ChatUploadSessionRequest) { + return typed(openApiV1.completeFileUpload({ body: payload })); + }, + abortUpload(payload: ChatUploadSessionRequest) { + return typed(openApiV1.abortFileUpload({ body: payload })); + }, + statusUpload(payload: ChatUploadSessionRequest) { + return typed(openApiV1.statusFileUpload({ body: payload })); + }, getByName(filename: string) { return openApiV1.getFileByName({ query: { filename }, diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 9159cc60f..7f4ea64ec 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -248,12 +248,17 @@ :reply-to="chatInputReplyTarget" :send-shortcut="sendShortcut" :show-provider-selector="false" + :failed-uploads="failedUploadViews" + :active-uploads="activeUploadViews" :placeholder="tm('input.projectPlaceholder')" @send="sendCurrentMessage" @stop="stopCurrentSession" @remove-image="removeImage" @remove-audio="removeAudio" @remove-file="removeFile" + @retry-failed-upload="retryFailedUpload" + @discard-failed-upload="discardFailedUpload" + @cancel-active-upload="cancelActiveUpload" @start-recording="startRecording" @stop-recording="stopRecording" @paste-image="handlePaste" @@ -388,6 +393,8 @@ :reply-to="chatInputReplyTarget" :send-shortcut="sendShortcut" :show-provider-selector="false" + :failed-uploads="failedUploadViews" + :active-uploads="activeUploadViews" :placeholder=" activeProject ? tm('input.projectPlaceholder') : undefined " @@ -396,6 +403,9 @@ @remove-image="removeImage" @remove-audio="removeAudio" @remove-file="removeFile" + @retry-failed-upload="retryFailedUpload" + @discard-failed-upload="discardFailedUpload" + @cancel-active-upload="cancelActiveUpload" @start-recording="startRecording" @stop-recording="stopRecording" @paste-image="handlePaste" @@ -592,12 +602,17 @@ const { stagedImagesUrl, stagedAudioUrl, stagedNonImageFiles, + failedUploadViews, + activeUploadViews, processAndUploadImage, processAndUploadFile, handlePaste, removeImage, removeAudio, removeFile, + retryFailedUpload, + discardFailedUpload, + cancelActiveUpload, clearStaged, cleanupMediaCache, } = useMediaHandling(); diff --git a/dashboard/src/components/chat/ChatInput.vue b/dashboard/src/components/chat/ChatInput.vue index 80f70126d..65789986f 100644 --- a/dashboard/src/components/chat/ChatInput.vue +++ b/dashboard/src/components/chat/ChatInput.vue @@ -82,6 +82,69 @@ variant="tonal" /> + +
+
+ + {{ + activePresentation(active).label + }} +
+ {{ active.name }} + {{ active.percent }}% + +
+ +
+
+ +
+
+ +
+ {{ + failed.name + }} + + +
@@ -277,6 +340,7 @@ import StyledMenu from "@/components/shared/StyledMenu.vue"; import CommandSuggestion from "./CommandSuggestion.vue"; import { attachmentPresentation } from "./attachmentPresentation"; import type { Session } from "@/composables/useSessions"; +import type { FailedUploadView, ActiveUploadView } from "@/composables/useMediaHandling"; import type { SuggestionCommand } from "./CommandSuggestion.vue"; interface StagedFileInfo { @@ -304,6 +368,8 @@ interface Props { stagedImagesUrl: string[]; stagedAudioUrl: string; stagedFiles?: StagedFileInfo[]; + failedUploads?: FailedUploadView[]; + activeUploads?: ActiveUploadView[]; disabled: boolean; showSettings?: boolean; isRecording: boolean; @@ -323,6 +389,8 @@ const props = withDefaults(defineProps(), { currentSession: null, configId: null, stagedFiles: () => [], + failedUploads: () => [], + activeUploads: () => [], replyTo: null, sendShortcut: "shift_enter", showProviderSelector: true, @@ -338,6 +406,9 @@ const emit = defineEmits<{ removeImage: [index: number]; removeAudio: []; removeFile: [index: number]; + retryFailedUpload: [index: number]; + discardFailedUpload: [index: number]; + cancelActiveUpload: [index: number]; startRecording: []; stopRecording: []; pasteImage: [event: ClipboardEvent]; @@ -515,7 +586,9 @@ const hasStagedAttachments = computed(() => { return ( props.stagedImagesUrl.length > 0 || props.stagedAudioUrl || - (props.stagedFiles && props.stagedFiles.length > 0) + (props.stagedFiles && props.stagedFiles.length > 0) || + (props.failedUploads && props.failedUploads.length > 0) || + (props.activeUploads && props.activeUploads.length > 0) ); }); @@ -523,6 +596,12 @@ function filePresentation(file: StagedFileInfo) { return attachmentPresentation(file); } +// In-flight uploads are typed by their local filename already; no need to +// wait for the server-side classification. +function activePresentation(active: ActiveUploadView) { + return attachmentPresentation({ original_name: active.name }); +} + // Ctrl+B 长按录音相关 const ctrlKeyDown = ref(false); const ctrlKeyTimer = ref(null); @@ -1258,6 +1337,50 @@ defineExpose({ opacity: 1; } +.attachment-card--failed { + --attachment-color: rgb(var(--v-theme-error)); + padding-right: 56px; +} + +.attachment-icon--failed { + color: rgb(var(--v-theme-error)); +} + +.retry-attachment-btn { + right: 30px; +} + +.attachment-card--active { + padding-right: 10px; +} + +.attachment-progress-text { + flex-shrink: 0; + font-size: 12px; + font-weight: 600; + color: rgb(var(--v-theme-primary)); +} + +.cancel-active-btn { + flex-shrink: 0; + width: 22px !important; + height: 22px !important; + min-width: 22px !important; + opacity: 0.7; +} + +.cancel-active-btn:hover { + opacity: 1; +} + +.attachment-progress-track { + position: absolute; + /* Align with the filename text: card padding + icon width + gap. */ + left: 52px; + right: 10px; + bottom: 3px; +} + .fade-in { animation: fadeIn 0.3s ease-in-out; } diff --git a/dashboard/src/components/shared/BackupDialog.vue b/dashboard/src/components/shared/BackupDialog.vue index 2fc89e9a2..8fe217200 100644 --- a/dashboard/src/components/shared/BackupDialog.vue +++ b/dashboard/src/components/shared/BackupDialog.vue @@ -234,7 +234,17 @@ {{ importError }} - + + mdi-play + {{ t('features.settings.backup.import.resumeUpload') }} + + {{ t('features.settings.backup.import.retry') }} @@ -370,6 +380,7 @@