mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
Stage 319: PR #1868 — oversized upload preflight by @franksong2702
This commit is contained in:
@@ -365,6 +365,7 @@ const LOCALES = {
|
||||
remove_title: 'Remove',
|
||||
empty_dir: '(empty)',
|
||||
upload_failed: 'Upload failed: ',
|
||||
upload_too_large: (maxMb, fileMb) => `File is too large (${fileMb} MB). Maximum upload size is ${maxMb} MB.`,
|
||||
all_uploads_failed: (n) => `All ${n} upload(s) failed`,
|
||||
archive_extracted: (n, c) => `Extracted ${n} file(s) from ${c} archive(s)`,
|
||||
session_pin: 'Pin conversation',
|
||||
@@ -1383,6 +1384,7 @@ const LOCALES = {
|
||||
remove_title: '削除',
|
||||
empty_dir: '(空)',
|
||||
upload_failed: 'アップロード失敗: ',
|
||||
upload_too_large: (maxMb, fileMb) => `ファイルが大きすぎます (${fileMb} MB)。最大アップロードサイズは ${maxMb} MB です。`,
|
||||
all_uploads_failed: (n) => `${n} 件のアップロードがすべて失敗しました`,
|
||||
archive_extracted: (n, c) => `${c} 個のアーカイブから ${n} 件のファイルを展開しました`,
|
||||
session_pin: '会話をピン留め',
|
||||
@@ -2321,6 +2323,7 @@ const LOCALES = {
|
||||
remove_title: 'Удаление',
|
||||
empty_dir: '(пусто)',
|
||||
upload_failed: 'Не удалось загрузить: ',
|
||||
upload_too_large: (maxMb, fileMb) => `Файл слишком большой (${fileMb} МБ). Максимальный размер загрузки: ${maxMb} МБ.`,
|
||||
all_uploads_failed: (n) => `Не удалось загрузить все ${n} файлов`,
|
||||
archive_extracted: (n, c) => `Извлечено ${n} файл(ов) из ${c} архив(ов)`,
|
||||
settings_title: 'Настройки',
|
||||
@@ -3253,6 +3256,7 @@ const LOCALES = {
|
||||
remove_title: 'Quitar',
|
||||
empty_dir: '(vacío)',
|
||||
upload_failed: 'Error al subir: ',
|
||||
upload_too_large: (maxMb, fileMb) => `El archivo es demasiado grande (${fileMb} MB). El tamaño máximo de subida es ${maxMb} MB.`,
|
||||
all_uploads_failed: (n) => `Fallaron las ${n} subida(s)`,
|
||||
archive_extracted: (n, c) => `${n} archivo(s) extraído(s) de ${c} archivo(s) comprimido(s)`,
|
||||
// settings panel
|
||||
@@ -4194,6 +4198,7 @@ const LOCALES = {
|
||||
remove_title: 'Entfernen',
|
||||
empty_dir: '(leer)',
|
||||
upload_failed: 'Upload fehlgeschlagen: ',
|
||||
upload_too_large: (maxMb, fileMb) => `Datei ist zu groß (${fileMb} MB). Die maximale Uploadgröße beträgt ${maxMb} MB.`,
|
||||
all_uploads_failed: (n) => `Alle ${n} Upload(s) fehlgeschlagen`,
|
||||
// settings panel
|
||||
settings_title: 'Einstellungen',
|
||||
@@ -5165,6 +5170,7 @@ const LOCALES = {
|
||||
remove_title: '\u79fb\u9664',
|
||||
empty_dir: '(\u7a7a)',
|
||||
upload_failed: '\u4e0a\u4f20\u5931\u8d25\uff1a',
|
||||
upload_too_large: (maxMb, fileMb) => `\u6587\u4ef6\u8fc7\u5927\uff08${fileMb} MB\uff09\u3002\u6700\u5927\u4e0a\u4f20\u5927\u5c0f\u4e3a ${maxMb} MB\u3002`,
|
||||
all_uploads_failed: (n) => `${n} \u4e2a\u6587\u4ef6\u5168\u90e8\u4e0a\u4f20\u5931\u8d25`,
|
||||
// settings panel
|
||||
settings_title: '\u8bbe\u7f6e',
|
||||
@@ -6064,6 +6070,7 @@ const LOCALES = {
|
||||
remove_title: '\u79fb\u9664',
|
||||
empty_dir: '(空)',
|
||||
upload_failed: '上傳失敗:',
|
||||
upload_too_large: (maxMb, fileMb) => `檔案過大(${fileMb} MB)。最大上傳大小為 ${maxMb} MB。`,
|
||||
all_uploads_failed: (n) => `${n} 個檔案全部上傳失敗`,
|
||||
session_pin: '釘選對話',
|
||||
session_unpin: '取消釘選',
|
||||
@@ -6389,6 +6396,7 @@ const LOCALES = {
|
||||
title_set: '\u6a19\u984c\u5df2\u8a2d\u70ba',
|
||||
todos_no_active: '\u6b64\u6703\u8a71\u4e2d\u7121\u6d3b\u8e8d\u4efb\u52d9\u6e05\u55ae\u3002',
|
||||
upload_failed: '\u4e0a\u50b3\u5931\u6557\uff1a',
|
||||
upload_too_large: (maxMb, fileMb) => `\u6a94\u6848\u904e\u5927\uff08${fileMb} MB\uff09\u3002\u6700\u5927\u4e0a\u50b3\u5927\u5c0f\u70ba ${maxMb} MB\u3002`,
|
||||
active_conversation_none: '\u672a\u9078\u53d6\u6d3b\u8e8d\u6703\u8a71\u3002',
|
||||
add: '\u65b0\u589e',
|
||||
add_failed: '\u65b0\u589e\u5931\u6557\uff1a',
|
||||
@@ -7103,6 +7111,7 @@ const LOCALES = {
|
||||
remove_title: 'Remover',
|
||||
empty_dir: '(vazio)',
|
||||
upload_failed: 'Falha ao upload: ',
|
||||
upload_too_large: (maxMb, fileMb) => `O arquivo é grande demais (${fileMb} MB). O tamanho máximo de upload é ${maxMb} MB.`,
|
||||
all_uploads_failed: (n) => `Todos ${n} upload(s) falharam`,
|
||||
session_pin: 'Fixar conversa',
|
||||
session_unpin: 'Desfixar conversa',
|
||||
@@ -8008,6 +8017,7 @@ const LOCALES = {
|
||||
remove_title: 'Remove',
|
||||
empty_dir: '(비어 있음)',
|
||||
upload_failed: 'Upload failed: ',
|
||||
upload_too_large: (maxMb, fileMb) => `File is too large (${fileMb} MB). Maximum upload size is ${maxMb} MB.`,
|
||||
all_uploads_failed: (n) => `All ${n} upload(s) failed`,
|
||||
session_pin: 'Pin conversation',
|
||||
session_unpin: 'Unpin conversation',
|
||||
|
||||
+22
-3
@@ -1,6 +1,8 @@
|
||||
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default',showHiddenWorkspaceFiles:false};
|
||||
const INFLIGHT={}; // keyed by session_id while request in-flight
|
||||
const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
|
||||
const MAX_UPLOAD_BYTES=20*1024*1024;
|
||||
const MAX_UPLOAD_MB=Math.round(MAX_UPLOAD_BYTES/1024/1024);
|
||||
// Tracks which session's queue to drain in setBusy(false).
|
||||
// Set to activeSid just before setBusy(false) in done/error handlers so the
|
||||
// queue drains the session that *finished*, not the one currently viewed.
|
||||
@@ -6758,7 +6760,22 @@ function renderTray(){ // non-media files use paperclip chip
|
||||
tray.appendChild(chip);
|
||||
});
|
||||
}
|
||||
function addFiles(files){for(const f of files){if(!S.pendingFiles.find(p=>p.name===f.name))S.pendingFiles.push(f);}renderTray();}
|
||||
function _uploadTooLargeMessage(file){
|
||||
const fileSizeMb=Math.ceil(((file&&file.size)||0)/1024/1024);
|
||||
return t('upload_too_large',MAX_UPLOAD_MB,fileSizeMb);
|
||||
}
|
||||
function _showUploadTooLarge(file){
|
||||
const message=`${t('upload_failed')}${file&&file.name?file.name:'file'} \u2014 ${_uploadTooLargeMessage(file)}`;
|
||||
if(typeof setStatus==='function')setStatus(`\u274c ${message}`);
|
||||
else if(typeof showToast==='function')showToast(message,5000,'error');
|
||||
}
|
||||
function addFiles(files){
|
||||
for(const f of files){
|
||||
if(f&&f.size>MAX_UPLOAD_BYTES){_showUploadTooLarge(f);continue;}
|
||||
if(!S.pendingFiles.find(p=>p.name===f.name))S.pendingFiles.push(f);
|
||||
}
|
||||
renderTray();
|
||||
}
|
||||
async function uploadPendingFiles(){
|
||||
if(!S.pendingFiles.length||!S.session)return[];
|
||||
const names=[];let failures=0;
|
||||
@@ -6766,9 +6783,11 @@ async function uploadPendingFiles(){
|
||||
barWrap.classList.add('active');bar.style.width='0%';
|
||||
const total=S.pendingFiles.length;
|
||||
for(let i=0;i<total;i++){
|
||||
const f=S.pendingFiles[i];const fd=new FormData();
|
||||
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
|
||||
const f=S.pendingFiles[i];
|
||||
try{
|
||||
if(f&&f.size>MAX_UPLOAD_BYTES)throw new Error(_uploadTooLargeMessage(f));
|
||||
const fd=new FormData();
|
||||
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
|
||||
const isArchive=_ARCHIVE_EXTS.test(f.name);
|
||||
const url=new URL(isArchive?'api/upload/extract':'api/upload',document.baseURI||location.href).href;
|
||||
const res=await fetch(url,{method:'POST',credentials:'include',body:fd});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = ROOT / "static" / "ui.js"
|
||||
I18N_JS = ROOT / "static" / "i18n.js"
|
||||
CONFIG_PY = ROOT / "api" / "config.py"
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
marker = f"function {name}"
|
||||
start = src.index(marker)
|
||||
brace = src.index("{", start)
|
||||
depth = 0
|
||||
for idx in range(brace, len(src)):
|
||||
if src[idx] == "{":
|
||||
depth += 1
|
||||
elif src[idx] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[brace : idx + 1]
|
||||
raise AssertionError(f"{name} function body not found")
|
||||
|
||||
|
||||
def test_upload_limit_constant_matches_server_limit():
|
||||
"""The browser preflight limit must match api.config.MAX_UPLOAD_BYTES."""
|
||||
ui = UI_JS.read_text(encoding="utf-8")
|
||||
config = CONFIG_PY.read_text(encoding="utf-8")
|
||||
|
||||
assert "const MAX_UPLOAD_BYTES=20*1024*1024;" in ui
|
||||
assert "MAX_UPLOAD_BYTES = 20 * 1024 * 1024" in config
|
||||
|
||||
|
||||
def test_file_picker_rejects_oversize_files_before_queueing():
|
||||
"""Selecting an oversized file should never add it to pending uploads."""
|
||||
src = UI_JS.read_text(encoding="utf-8")
|
||||
body = _function_body(src, "addFiles")
|
||||
|
||||
size_gate = body.index("f&&f.size>MAX_UPLOAD_BYTES")
|
||||
status_notice = body.index("_showUploadTooLarge(f)")
|
||||
push_pending = body.index("S.pendingFiles.push(f)")
|
||||
|
||||
assert size_gate < status_notice < push_pending
|
||||
assert "continue;" in body[size_gate:push_pending]
|
||||
|
||||
|
||||
def test_pending_uploads_skip_fetch_for_oversize_files():
|
||||
"""Restored or queued oversized files should fail locally before fetch()."""
|
||||
src = UI_JS.read_text(encoding="utf-8")
|
||||
body = _function_body(src, "uploadPendingFiles")
|
||||
|
||||
size_gate = body.index("f&&f.size>MAX_UPLOAD_BYTES")
|
||||
form_data = body.index("const fd=new FormData()")
|
||||
upload_fetch = body.index("fetch(url")
|
||||
|
||||
assert size_gate < form_data < upload_fetch
|
||||
assert "throw new Error(_uploadTooLargeMessage(f))" in body[size_gate:form_data]
|
||||
|
||||
|
||||
def test_upload_too_large_has_user_facing_message():
|
||||
"""The status toast should explain the 20 MB limit instead of a network reset."""
|
||||
i18n = I18N_JS.read_text(encoding="utf-8")
|
||||
ui = UI_JS.read_text(encoding="utf-8")
|
||||
|
||||
assert "upload_too_large" in i18n
|
||||
assert "Maximum upload size is" in i18n
|
||||
assert "_uploadTooLargeMessage(file)" in ui
|
||||
Reference in New Issue
Block a user