diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 4ca11d3..76f7e5d 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -19,9 +19,10 @@ func writeJSON(w http.ResponseWriter, status int, v any) { // handleHealthz reports liveness and model readiness (public). func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ - "status": "ok", - "models": s.manager != nil && s.manager.Ready(), - "version": s.modelVersion(), + "status": "ok", + "models": s.manager != nil && s.manager.Ready(), + "version": s.modelVersion(), + "max_upload_bytes": s.cfg.MaxUploadBytes, }) } diff --git a/internal/server/ui/index.html b/internal/server/ui/index.html index c459dbe..3287396 100644 --- a/internal/server/ui/index.html +++ b/internal/server/ui/index.html @@ -165,6 +165,18 @@ .link{color:var(--accent);cursor:pointer;text-decoration:none} .link:hover{text-decoration:underline} .dropzone .ext{margin-top:10px;font-family:var(--mono);font-size:11px;color:var(--faint)} +.upload-list{display:flex;flex-direction:column;gap:10px;padding:16px;background:var(--surface);border:1px solid var(--border);border-radius:12px;margin-bottom:16px} +.upload-item{display:flex;flex-direction:column;gap:6px} +.upload-head{display:flex;justify-content:space-between;align-items:center;gap:8px} +.upload-name{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.upload-pct{font-size:12px;font-family:var(--mono);color:var(--accent);flex:none} +.upload-bar{height:6px;background:var(--border);border-radius:3px;overflow:hidden} +.upload-bar>div{height:100%;width:0%;background:var(--accent);border-radius:3px;transition:width .15s ease} +.upload-status{font-size:11px;color:var(--muted)} +.upload-item.done .upload-status{color:var(--accent)} +.upload-item.done .upload-bar>div{background:var(--ok,#22c55e)} +.upload-item.err .upload-status{color:var(--err)} +.upload-item.err .upload-bar>div{background:var(--err)} .detail-body{padding:18px 24px 60px;max-width:920px} .detail-title{display:flex;align-items:center;gap:10px;flex-wrap:wrap} @@ -356,7 +368,7 @@ const fmtTime = iso=>{ if(!iso) return "—"; const d=new Date(iso); return isNa const fmtDur = s=> s==null?"—": (s<60?s.toFixed(1)+"s" : Math.floor(s/60)+"m"+String(Math.round(s%60)).padStart(2,"0")+"s"); const escapeHtml = s=>(s||"").replace(/[&<>]/g,c=>({"&":"&","<":"<",">":">"}[c])); -const state = { pw: localStorage.getItem("asr_pw")||"", tasks:[], sel:null, busy:false, authing:false }; +const state = { pw: localStorage.getItem("asr_pw")||"", tasks:[], sel:null, busy:false, authing:false, uploading:false, maxUpload:0 }; // --- password modal (shown only when verification is needed) --- const authModal=$("#authModal"), authPw=$("#authPw"), authMsg=$("#authMsg"), authOk=$("#authOk"), authCancel=$("#authCancel"); @@ -432,6 +444,9 @@ class ApiError extends Error{constructor(m){super(m);this.name="ApiError"}} function toast(msg,type){ const t=el("div",{class:"toast "+(type||"")}); t.textContent=msg; $("#toast").appendChild(t); setTimeout(()=>t.remove(),3200); } +// fmtSize formats a byte count as a human-readable string. +function fmtSize(b){ if(b<1024) return b+"B"; if(b<1048576) return (b/1024).toFixed(1)+"KB"; if(b<1073741824) return (b/1048576).toFixed(1)+"MB"; return (b/1073741824).toFixed(2)+"GB"; } + // Copy to clipboard with a fallback for non-secure contexts (http via IP, // where navigator.clipboard is unavailable). "done" flashes the button label. function copyText(text,btn){ @@ -464,6 +479,7 @@ function mkCopyBtn(label, getText){ async function loadHealth(){ try{ const h = await api("/healthz"); + state.maxUpload = h.max_upload_bytes || 0; $("#htext").textContent = h.models?"模型就绪":"模型加载中"; $("#hdot").className = "dot "+(h.models?"ok":"down"); }catch(e){ $("#htext").textContent="服务不可达"; $("#hdot").className="dot down"; } @@ -599,16 +615,41 @@ function renderDropzone(c){ async function uploadFiles(files){ if(!state.pw){ askForPassword("请先输入访问密码后再上传。"); return; } if(!files||!files.length) return; + state.uploading=true; + const detail=$("#detail"); + let list=$("#uploadList"); + if(!list) list=el("div",{class:"upload-list",id:"uploadList"}); + detail.prepend(list); for(const f of files){ + if(state.maxUpload && f.size>state.maxUpload){ toast("文件过大: "+f.name+"("+fmtSize(f.size)+"),上限 "+fmtSize(state.maxUpload),"err"); continue; } + const item=el("div",{class:"upload-item"}); + item.innerHTML=`
0%
上传中…
`; + item.querySelector(".upload-name").textContent=f.name; + list.appendChild(item); + const bar=item.querySelector(".upload-bar>div"), pct=item.querySelector(".upload-pct"), st=item.querySelector(".upload-status"); const fd=new FormData(); fd.append("file",f); - toast("上传中: "+f.name); try{ - const t=await api("/api/tasks",{method:"POST",body:fd}); + const t=await new Promise((res,rej)=>{ + const x=new XMLHttpRequest(); + x.open("POST","/api/tasks"); + x.setRequestHeader("X-Api-Key",state.pw); + x.upload.onprogress=e=>{ if(e.lengthComputable){ const p=Math.round(e.loaded/e.total*100); bar.style.width=p+"%"; pct.textContent=p+"%"; } }; + x.onload=()=>{ + if(x.status>=200&&x.status<300){ try{res(JSON.parse(x.responseText));}catch(e){res(null);} } + else if(x.status===401){ state.pw=""; localStorage.removeItem("asr_pw"); openAuthModal("密码不正确或服务已重启,请重新输入。",true); rej(new Error("unauthorized")); } + else { let m="HTTP "+x.status; try{m=(JSON.parse(x.responseText).error)||m;}catch(e){} rej(new Error(m)); } + }; + x.onerror=()=>rej(new Error("网络错误,上传失败")); + x.send(fd); + }); + bar.style.width="100%"; pct.textContent="100%"; + st.textContent="已入队,转写排队中…"; item.classList.add("done"); toast("已加入队列: "+(t.original_name||t.id),"ok"); state.sel=t.id; - }catch(e){ toast(e.message,"err"); } + }catch(e){ st.textContent="失败: "+e.message; item.classList.add("err"); toast(e.message,"err"); } } - await loadTasks(); + // Let the user see the finished state briefly, then refresh the queue. + setTimeout(()=>{ state.uploading=false; loadTasks(); }, 600); } $("#file").addEventListener("change",e=>{ if(e.target.files.length) uploadFiles(e.target.files); e.target.value=""; }); $("#uploadBtn").addEventListener("click",()=>$("#file").click()); @@ -644,7 +685,7 @@ async function refresh(){ await Promise.all([loadHealth(), loadTasks()]); } if(state.pw){ refresh(); } else { loadHealth(); openAuthModal("请输入访问密码。验证后本次会话不再提示。"); } // Background refresh — skip task fetches while the password modal is open to // avoid spamming 401s; health still updates. -setInterval(()=>{ if(state.authing){ loadHealth(); return; } loadHealth(); loadTasks(); }, 2500); +setInterval(()=>{ if(state.authing||state.uploading){ loadHealth(); return; } loadHealth(); loadTasks(); }, 2500);