From e2a67f08032515cfe6befec738bb19e79a9be337 Mon Sep 17 00:00:00 2001 From: dd3xp Date: Thu, 21 May 2026 23:39:39 +0800 Subject: [PATCH] fix(upload): surface payload-too-large errors, reject empty files, use toast not chat-log Root cause of "uploads silently land as 0-byte __file": aiohttp default body limit is 1 MiB; PDFs almost always exceed it, request.json() raises HTTPRequestEntityTooLarge, the generic read_json() swallows it as bare Exception, upload_handler then sees an empty dict and happily writes an empty file with name='file', returning ok=true. Front-end thinks the upload worked. Bridge: - Set web.Application(client_max_size=1MB) explicitly so the limit is visible at the call site (kept at 1MB intentionally for now; bump later if larger uploads are needed) - upload_handler reads body itself instead of read_json so it can catch HTTPRequestEntityTooLarge and HTTPBadRequest and return a real error to the client instead of silently writing 0 bytes - Reject blob == b'' as a second line of defence Frontend: - addFiles skips files with size === 0 (handles drag-folder ghost File and edge-case clipboard items) with a dedicated 'upload.empty' toast - 4x showSystem(...) in addFiles -> showChanToast(title, detail, 'err'). Upload failures should be transient toasts, not permanent system messages glued into the chat transcript. --- frontends/desktop/static/app.js | 13 ++++++++----- frontends/desktop_bridge.py | 13 +++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/frontends/desktop/static/app.js b/frontends/desktop/static/app.js index 75a993db..130e1b51 100644 --- a/frontends/desktop/static/app.js +++ b/frontends/desktop/static/app.js @@ -269,7 +269,7 @@ const I18N = { 'slash.unknown': '未知命令', 'upload.hint': '上传文件:选择 / 拖拽 / 粘贴', 'upload.button': '上传文件', - 'upload.tooLarge': '文件过大或数量超限', + 'upload.tooLarge': '文件过大或数量超限', 'upload.empty': '跳过空文件', 'upload.failed': '上传失败', 'upload.removeTitle': '移除', 'upload.dropHint': '松开以上传文件', @@ -362,7 +362,7 @@ const I18N = { 'slash.unknown': 'Unknown command', 'upload.hint': 'Upload file: pick / drag / paste', 'upload.button': 'Upload file', - 'upload.tooLarge': 'File too large or limit reached', + 'upload.tooLarge': 'File too large or limit reached', 'upload.empty': 'Skipped empty file', 'upload.failed': 'Upload failed', 'upload.removeTitle': 'Remove', 'upload.dropHint': 'Drop to upload files', @@ -1572,14 +1572,17 @@ async function addFiles(fileList) { const files = Array.from(fileList || []); if (files.length === 0) return; let skipped = false; + let emptyHit = false; const accepted = []; for (const f of files) { + if (!f || f.size === 0) { emptyHit = true; continue; } if (f.size > MAX_UPLOAD_BYTES) { skipped = true; continue; } if (state.pendingFiles.length + accepted.length >= MAX_UPLOAD_FILES) { skipped = true; break; } accepted.push(f); } + if (emptyHit) showChanToast(t('upload.empty'), '', 'err'); if (accepted.length === 0) { - if (skipped) showSystem(t('upload.tooLarge')); + if (skipped) showChanToast(t('upload.tooLarge'), '', 'err'); return; } for (const f of accepted) { @@ -1602,10 +1605,10 @@ async function addFiles(fileList) { insertPlaceholderInComposer(placeholderFor(entry)); renderThumbStrip(); } catch (e) { - showSystem(t('upload.failed') + ': ' + (e.message || e)); + showChanToast(t('upload.failed'), e.message || String(e), 'err'); } } - if (skipped) showSystem(t('upload.tooLarge')); + if (skipped) showChanToast(t('upload.tooLarge'), '', 'err'); } if (uploadBtn && imgInput) uploadBtn.addEventListener('click', (e) => { diff --git a/frontends/desktop_bridge.py b/frontends/desktop_bridge.py index 8599a2fc..ff02a6b6 100644 --- a/frontends/desktop_bridge.py +++ b/frontends/desktop_bridge.py @@ -1081,7 +1081,14 @@ async def upload_handler(request): Body: {name: "", dataUrl: "data:;base64,<...>"} Returns: {ok: true, path: ""} """ - data = await read_json(request) + try: + data = await request.json() + if not isinstance(data, dict): + data = {} + except web.HTTPRequestEntityTooLarge: + return json_ok({"ok": False, "error": "file too large for bridge body limit"}) + except Exception as e: + return json_ok({"ok": False, "error": f"invalid request: {e}"}) name = (data.get("name") or "file").strip().replace("/", "_").replace("\\", "_") data_url = data.get("dataUrl") or "" if "," in data_url: @@ -1092,6 +1099,8 @@ async def upload_handler(request): blob = base64.b64decode(b64) except Exception as e: return json_ok({"ok": False, "error": f"decode failed: {e}"}) + if not blob: + return json_ok({"ok": False, "error": "empty file"}) safe_name = name or "file" fpath = _WEB_UPLOAD_DIR / f"{uuid.uuid4().hex[:12]}__{safe_name}" fpath.write_bytes(blob) @@ -1216,7 +1225,7 @@ async def token_stats_handler(request): def create_app(): - app = web.Application(middlewares=[cors_middleware]) + app = web.Application(middlewares=[cors_middleware], client_max_size=1 * 1024 * 1024) app.router.add_get("/ws", ws_handler) app.router.add_get("/status", status_handler) app.router.add_get("/config", get_config_handler)