]> acesimba.cloud Git - codebuddy-web.git/commitdiff
fix(iPad+paste): 终端页布局固定/键盘适配/自动滚动;粘贴栏支持任意文件(≤5MB)与已上传列表/删除
authorCodebuddy <codebuddy@localhost>
Sun, 16 Aug 2026 23:12:11 +0000 (07:12 +0800)
committerCodebuddy <codebuddy@localhost>
Sun, 16 Aug 2026 23:12:11 +0000 (07:12 +0800)
backend/app.py
frontend/index.html

index 97671eca3ecf6b31f23c426e4aafb573d8bfe47b..69c53a026f13a5a5ddf1d8beb3081a8313202256 100644 (file)
@@ -450,7 +450,7 @@ def load_html():
 # ==================== 后端路由 ====================
 @app.get("/")
 async def index():
-    return HTMLResponse(load_html())
+    return HTMLResponse(load_html(), headers={"Cache-Control": "no-store"})
 
 
 @app.post("/api/login")
@@ -724,8 +724,7 @@ async def api_kill(pid: int, request: Request):
 # 前端把粘贴的截图 POST 到这里,后端存盘后返回绝对路径
 # 前端再把 <image_local_path>路径</image_local_path> 当作输入发到 CLI
 PASTES_DIR = Path(__file__).resolve().parent.parent / "data" / "pastes"
-ALLOWED_IMAGE_MIME = {"image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp", "image/bmp"}
-MAX_IMAGE_SIZE = 10 * 1024 * 1024  # 10MB
+MAX_UPLOAD_SIZE = 5 * 1024 * 1024  # 5MB(粘贴栏上传上限:图片/视频/zip/任意格式)
 _MIME_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg",
              "image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp"}
 
@@ -739,25 +738,83 @@ async def upload_image(request: Request):
     except Exception:
         return JSONResponse({"error": "invalid json body"}, status_code=400)
     mime = (body.get("mime") or "").lower()
+    name = body.get("name") or ""
     b64 = body.get("data") or ""
-    if mime not in ALLOWED_IMAGE_MIME:
-        return JSONResponse({"error": f"unsupported mime: {mime}"}, status_code=400)
     import base64
     try:
         raw = base64.b64decode(b64)
     except Exception:
         return JSONResponse({"error": "invalid base64 data"}, status_code=400)
     if len(raw) == 0:
-        return JSONResponse({"error": "empty image"}, status_code=400)
-    if len(raw) > MAX_IMAGE_SIZE:
-        return JSONResponse({"error": f"image too large (max {MAX_IMAGE_SIZE // 1024 // 1024}MB)"}, status_code=400)
-    ext = _MIME_EXT.get(mime, "png")
+        return JSONResponse({"error": "empty file"}, status_code=400)
+    if len(raw) > MAX_UPLOAD_SIZE:
+        return JSONResponse({"error": f"file too large (max {MAX_UPLOAD_SIZE // 1024 // 1024}MB)"}, status_code=400)
+    # 扩展名:优先用原文件名,其次按 mime 推断,否则 bin
+    ext = ""
+    if name and "." in name:
+        ext = name.rsplit(".", 1)[1].lower()[:12]
+    if not ext:
+        ext = _MIME_EXT.get(mime, "bin")
     PASTES_DIR.mkdir(parents=True, exist_ok=True)
     fname = f"{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}_{secrets.token_hex(4)}.{ext}"
     fpath = PASTES_DIR / fname
     fpath.write_bytes(raw)
-    logger.info(f"图片上传: {fpath} ({len(raw)} bytes, {mime})")
-    return {"path": str(fpath)}
+    logger.info(f"文件上传: {fpath} ({len(raw)} bytes, {mime})")
+    return {"path": str(fpath), "name": name or fname}
+
+
+# 已上传文件:列出 / 删除(data/pastes/ 下,供前端“已上传文件”面板使用)
+_IMG_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
+_VIDEO_EXT = {".mp4", ".mov", ".webm", ".mkv", ".avi"}
+_ZIP_EXT = {".zip", ".tar", ".gz", ".tgz", ".7z", ".rar"}
+
+
+@app.get("/api/uploads")
+async def list_uploads(request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    try:
+        files = []
+        if PASTES_DIR.exists():
+            for p in PASTES_DIR.iterdir():
+                if p.is_file():
+                    ext = p.suffix.lower()
+                    kind = "other"
+                    if ext in _IMG_EXT:
+                        kind = "image"
+                    elif ext in _VIDEO_EXT:
+                        kind = "video"
+                    elif ext in _ZIP_EXT:
+                        kind = "zip"
+                    st = p.stat()
+                    files.append({
+                        "name": p.name, "path": str(p),
+                        "size": st.st_size, "mtime": int(st.st_mtime), "kind": kind,
+                    })
+        files.sort(key=lambda x: x["mtime"], reverse=True)
+        return {"files": files}
+    except Exception as e:
+        return JSONResponse({"error": str(e)}, status_code=500)
+
+
+@app.delete("/api/uploads/{name}")
+async def delete_upload(name: str, request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    # 防路径穿越:只允许纯文件名
+    if not name or "/" in name or "\\" in name or name == ".." or name.startswith("."):
+        return JSONResponse({"error": "invalid name"}, status_code=400)
+    try:
+        target = (PASTES_DIR / name).resolve()
+        if target.parent != PASTES_DIR.resolve():
+            return JSONResponse({"error": "invalid path"}, status_code=400)
+        if not target.exists() or not target.is_file():
+            return JSONResponse({"error": "not found"}, status_code=404)
+        target.unlink()
+        logger.info(f"删除上传文件: {target}")
+        return {"ok": True}
+    except Exception as e:
+        return JSONResponse({"error": str(e)}, status_code=500)
 
 
 # ==================== 自定义 Skill 管理(全局 ~/.codebuddy/skills) ====================
index 48882257992b30e2e6a84f495b9e7cf533866157..07dab50b2b2b8a26ab382da4a664fe25b04ba6f1 100644 (file)
@@ -2,13 +2,13 @@
 <html lang="zh-CN">
 <head>
 <meta charset="UTF-8">
-<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
 <title>Codebuddy Web Console</title>
 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
 <style>
 * { margin: 0; padding: 0; box-sizing: border-box; }
 html { font-size: 15px; height: 100%; }
-body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; overflow: hidden; background: #1a1a2e; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
+body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%; height: 100dvh; overflow: hidden; overscroll-behavior: none; background: #1a1a2e; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
 
 /* 登录页 */
 #login-page {
@@ -43,7 +43,8 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
 /* 顶栏 */
 .top-bar {
   display: flex; justify-content: space-between; align-items: center;
-  padding: 8px 16px; background: rgba(0,0,0,0.3); border-bottom: 1px solid rgba(255,255,255,0.1);
+  padding: calc(8px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right)) 8px calc(16px + env(safe-area-inset-left));
+  background: rgba(0,0,0,0.3); border-bottom: 1px solid rgba(255,255,255,0.1);
   font-size: 15px; flex-shrink: 0; gap: 12px;
 }
 .top-bar .left { display: flex; align-items: center; gap: 10px; min-width: 0; }
@@ -68,8 +69,16 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
 .top-bar .model-select option { background: #1a1a2e; color: #e0e0e0; }
 
 /* 终端页布局 */
-#terminal-page { display: none; height: 100%; flex-direction: column; }
-#terminal-container { flex: 1; overflow: hidden; padding: 4px; min-height: 0; }
+#terminal-page { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; height: 100dvh; flex-direction: column; }
+#terminal-container { flex: 1; overflow: hidden; padding: 4px 4px calc(4px + env(safe-area-inset-bottom)); min-height: 0; }
+/* 回到底部按钮:上滑离开底部时浮现,避开安全区域 */
+#jump-bottom {
+  display: none; position: fixed; right: calc(16px + env(safe-area-inset-right));
+  bottom: calc(16px + env(safe-area-inset-bottom)); z-index: 50;
+  padding: 8px 14px; border-radius: 20px; border: 1px solid rgba(78,204,163,0.5);
+  background: rgba(26,26,46,0.95); color: #4ecca3; font-size: 13px; cursor: pointer;
+  box-shadow: 0 2px 10px rgba(0,0,0,0.4);
+}
 
 /* 通用按钮样式(粘贴 modal 复用) */
 .icon-btn {
@@ -284,6 +293,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     </div>
   </div>
   <div id="terminal-container"></div>
+  <button id="jump-bottom" title="回到底部(最新输出)">↓ 回到底部</button>
   <!-- 粘贴 modal:支持文字/图片粘贴,发送内容到 xterm 输入行(不自动提交) -->
   <div id="paste-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:100; align-items:center; justify-content:center;">
     <div id="paste-dialog" style="background:#1a1a2e; border:1px solid #4ecca3; border-radius:12px; padding:16px; width:560px; max-width:90vw;">
@@ -298,7 +308,14 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
         </div>
       </div>
       <div style="font-size:11px; color:#666; margin-top:6px; text-align:right;">Ctrl+Enter 发送到终端输入行(不自动提交,需自行回车)</div>
-      <input type="file" id="paste-file-input" accept="image/*" style="display:none;">
+      <div style="margin-top:12px; border-top:1px solid rgba(255,255,255,0.1); padding-top:10px;">
+        <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
+          <span style="color:#74c0fc; font-size:13px;">📂 已上传文件</span>
+          <button class="icon-btn" id="uploads-refresh" title="刷新列表">⟳</button>
+        </div>
+        <div id="uploads-list" style="display:flex; flex-direction:column; gap:6px; max-height:160px; overflow:auto;"></div>
+      </div>
+      <input type="file" id="paste-file-input" accept="*/*" style="display:none;">
     </div>
   </div>
   <!-- 技能管理 modal(全局,首页与终端页共用) -->
@@ -350,6 +367,29 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
   let fitAddon = null;
   let pid = 0;
   let currentTask = null;
+  let followBottom = true;   // 终端是否随新输出自动贴底
+  const jumpBtn = document.getElementById('jump-bottom');
+
+  // iPad/iOS 关键修复:把页面壳精确钉到“可视视口”,避开顶部地址栏与软键盘,
+  // 否则 position:fixed;top:0 会被钉到布局视口顶部(地址栏后面),标题栏被遮且整页可滚。
+  function layoutShell() {
+    const vv = window.visualViewport;
+    if (!vv) return;
+    const s = document.body.style;
+    s.position = 'fixed';
+    s.top = vv.offsetTop + 'px';
+    s.left = vv.offsetLeft + 'px';
+    s.width = vv.width + 'px';
+    s.height = vv.height + 'px';
+    if (fitAddon && term) { try { fitAddon.fit(); } catch (e) {} if (followBottom) term.scrollToBottom(); }
+  }
+  if (window.visualViewport) {
+    window.visualViewport.addEventListener('resize', layoutShell);
+    window.visualViewport.addEventListener('scroll', layoutShell);
+  }
+  window.addEventListener('resize', layoutShell);
+  window.addEventListener('orientationchange', () => setTimeout(layoutShell, 300));
+  layoutShell();
 
   const loginPage = document.getElementById('login-page');
   const pickerPage = document.getElementById('picker-page');
@@ -694,6 +734,8 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     }
     document.getElementById('task-title').textContent = '🗂 ' + currentTask.name;
     document.getElementById('task-cwd').textContent = currentTask.cwd;
+    followBottom = true;
+    if (jumpBtn) jumpBtn.style.display = 'none';
     updateTitle();
     show('terminal');
     initTerminal();
@@ -750,24 +792,29 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
   const pasteFileInput = document.getElementById('paste-file-input');
   const pasteImgChips = document.getElementById('paste-img-chips');
   // 待发送的图片列表:[{path, name}]
-  let pendingImages = [];
-
-  // 上传图片到后端,返回 {path, name} 或抛错
-  async function uploadImage(blob) {
+  let pendingFiles = [];
+
+  // 上传任意文件到后端,返回 {path, name} 或抛错(上限 5MB)
+  async function uploadFile(blob) {
+    const MAX = 5 * 1024 * 1024;
+    if (blob.size > MAX) {
+      const mb = Math.round(blob.size / 1024 / 1024 * 10) / 10;
+      throw new Error('文件过大(' + mb + 'MB),上限 5MB');
+    }
     const sizeKB = Math.max(1, Math.round(blob.size / 1024));
-    const name = blob.name || ('æ\88ªå\9b¾_' + Date.now() + '.' + (blob.type.split('/')[1] || 'png'));
-    term.writeln('\x1b[33m⏳ 上传图片 ' + name + ' (' + sizeKB + 'KB)...\x1b[0m');
+    const name = blob.name || ('æ\96\87ä»¶_' + Date.now() + '.' + ((blob.type.split('/')[1]) || 'bin'));
+    term.writeln('\x1b[33m⏳ 上传文件 ' + name + ' (' + sizeKB + 'KB)...\x1b[0m');
     const reader = new FileReader();
     const b64 = await new Promise((resolve, reject) => {
       reader.onload = () => { const i = reader.result.indexOf(','); resolve(i >= 0 ? reader.result.slice(i + 1) : ''); };
       reader.onerror = () => reject(reader.error);
       reader.readAsDataURL(blob);
     });
-    if (!b64) throw new Error('图片读取失败');
+    if (!b64) throw new Error('文件读取失败');
     const r = await fetch(API + '/api/upload-image', {
       method: 'POST',
       headers: Object.assign({}, authHeaders(), { 'Content-Type': 'application/json' }),
-      body: JSON.stringify({ mime: blob.type, data: b64 })
+      body: JSON.stringify({ mime: blob.type, name: name, data: b64 })
     });
     if (!r.ok) {
       let msg = 'HTTP ' + r.status;
@@ -777,27 +824,32 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     const data = await r.json();
     if (data.error) throw new Error(data.error);
     term.writeln('\x1b[32m✓ ' + name + ' 已上传\x1b[0m');
-    return { path: data.path, name: name };
+    return { path: data.path, name: name, mime: blob.type };
   }
 
-  // æ¸²æ\9f\93å\9b¾ç\89\87 chip å\88\97表
-  function renderPasteImgChips() {
-    if (!pendingImages.length) { pasteImgChips.style.display = 'none'; pasteImgChips.innerHTML = ''; return; }
+  // æ¸²æ\9f\93å¾\85å\8f\91é\80\81æ\96\87ä»¶ chip å\88\97表ï¼\88å\9b¾ç\89\87/è§\86é¢\91/zip/å\85¶ä»\96ï¼\89
+  function renderPasteChips() {
+    if (!pendingFiles.length) { pasteImgChips.style.display = 'none'; pasteImgChips.innerHTML = ''; return; }
     pasteImgChips.style.display = 'flex';
-    pasteImgChips.innerHTML = pendingImages.map((img, i) =>
-      '<span class="img-chip">🖼 ' + img.name + ' <span class="remove" data-i="' + i + '">✕</span></span>'
-    ).join('');
+    pasteImgChips.innerHTML = pendingFiles.map((f, i) => {
+      let icon = '📄'; const m = (f.mime || ''); const n = (f.name || '').toLowerCase();
+      if (m.startsWith('image/')) icon = '🖼';
+      else if (m.startsWith('video/')) icon = '🎬';
+      else if (m === 'application/zip' || m === 'application/x-zip-compressed' || n.endsWith('.zip') || n.endsWith('.tar') || n.endsWith('.gz')) icon = '📦';
+      return '<span class="img-chip">' + icon + ' ' + f.name + ' <span class="remove" data-i="' + i + '">✕</span></span>';
+    }).join('');
     pasteImgChips.querySelectorAll('.remove').forEach(el => {
-      el.onclick = () => { pendingImages.splice(parseInt(el.dataset.i), 1); renderPasteImgChips(); };
+      el.onclick = () => { pendingFiles.splice(parseInt(el.dataset.i), 1); renderPasteChips(); };
     });
   }
 
   // 打开/关闭 modal
   function openPasteModal() {
     pasteInput.value = '';
-    pendingImages = [];
-    renderPasteImgChips();
+    pendingFiles = [];
+    renderPasteChips();
     pasteModal.style.display = 'flex';
+    refreshUploadsList();
     setTimeout(() => pasteInput.focus(), 0);
   }
   function closePasteModal() {
@@ -818,58 +870,102 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     if (e.target === pasteModal) closePasteModal();
   });
 
-  // textarea 的 paste 事件:文字直接插入,图片上传后加 chip
+  // textarea 的 paste 事件:文字直接插入,文件(图片/视频/zip/任意)上传后加 chip
   pasteInput.addEventListener('paste', async (e) => {
     if (!e.clipboardData) return;
-    let imgBlob = null;
+    let fileBlob = null;
     if (e.clipboardData.items) {
       for (const item of e.clipboardData.items) {
-        if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
-          imgBlob = item.getAsFile(); if (imgBlob) break;
-        }
+        if (item.kind === 'file') { fileBlob = item.getAsFile(); if (fileBlob) break; }
       }
     }
-    if (!imgBlob && e.clipboardData.files && e.clipboardData.files.length) {
-      for (const f of e.clipboardData.files) {
-        if (f.type && f.type.startsWith('image/')) { imgBlob = f; break; }
-      }
+    if (!fileBlob && e.clipboardData.files && e.clipboardData.files.length) {
+      fileBlob = e.clipboardData.files[0];
     }
-    if (imgBlob) {
+    if (fileBlob) {
       e.preventDefault();
-      try { const img = await uploadImage(imgBlob); pendingImages.push(img); renderPasteImgChips(); }
-      catch (err) { term.writeln('\x1b[31m✗ 图片上传失败: ' + err.message + '\x1b[0m'); }
+      try { const f = await uploadFile(fileBlob); pendingFiles.push(f); renderPasteChips(); }
+      catch (err) { term.writeln('\x1b[31m✗ 文件上传失败: ' + err.message + '\x1b[0m'); }
       return;
     }
     // 文字:浏览器默认插入
   });
 
-  // 拖拽图片到 modal
+  // 拖拽文件到 modal(图片/视频/zip/任意)
   pasteInput.addEventListener('dragover', (e) => { e.preventDefault(); });
   pasteInput.addEventListener('drop', async (e) => {
     if (!e.dataTransfer || !e.dataTransfer.files) return;
-    const files = Array.from(e.dataTransfer.files).filter(f => f.type && f.type.startsWith('image/'));
+    const files = Array.from(e.dataTransfer.files);
     if (!files.length) return;
     e.preventDefault();
     for (const f of files) {
-      try { const img = await uploadImage(f); pendingImages.push(img); }
+      try { const uf = await uploadFile(f); pendingFiles.push(uf); }
       catch (err) { term.writeln('\x1b[31m✗ ' + f.name + ' 上传失败: ' + err.message + '\x1b[0m'); }
     }
-    renderPasteImgChips();
+    renderPasteChips();
   });
 
-  // 📎 选择图片
+  // 📎 选择文件(图片/视频/zip/任意)
   pasteAttach.onclick = () => pasteFileInput.click();
   pasteFileInput.onchange = async () => {
     if (!pasteFileInput.files || !pasteFileInput.files.length) return;
     for (const f of pasteFileInput.files) {
-      if (!f.type || !f.type.startsWith('image/')) continue;
-      try { const img = await uploadImage(f); pendingImages.push(img); }
+      try { const uf = await uploadFile(f); pendingFiles.push(uf); }
       catch (err) { term.writeln('\x1b[31m✗ ' + f.name + ' 上传失败: ' + err.message + '\x1b[0m'); }
     }
     pasteFileInput.value = '';
-    renderPasteImgChips();
+    renderPasteChips();
+    refreshUploadsList();
   };
 
+  // ========== 已上传文件列表(列出 + 删除) ==========
+  const uploadsList = document.getElementById('uploads-list');
+  function formatSize(b) {
+    if (b < 1024) return b + 'B';
+    if (b < 1024 * 1024) return (b / 1024).toFixed(1) + 'KB';
+    return (b / 1024 / 1024).toFixed(1) + 'MB';
+  }
+  async function refreshUploadsList() {
+    if (!uploadsList) return;
+    try {
+      const res = await fetch(API + '/api/uploads', { headers: authHeaders() });
+      if (!res.ok) { uploadsList.innerHTML = '<div style="color:#888; font-size:12px;">加载失败</div>'; return; }
+      const data = await res.json();
+      const files = data.files || [];
+      if (!files.length) { uploadsList.innerHTML = '<div style="color:#888; font-size:12px;">暂无已上传文件</div>'; return; }
+      uploadsList.innerHTML = files.map(f => {
+        let icon = '📄'; const n = (f.name || '').toLowerCase();
+        if (f.kind === 'image') icon = '🖼';
+        else if (f.kind === 'video') icon = '🎬';
+        else if (f.kind === 'zip') icon = '📦';
+        return '<div style="display:flex;align-items:center;gap:8px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:6px 8px;font-size:13px;">'
+          + '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + icon + ' ' + escHtml(f.name) + ' <span style="color:#888;">(' + formatSize(f.size) + ')</span></span>'
+          + '<span class="up-del" style="cursor:pointer;color:#ff6b6b;flex-shrink:0;">✕</span>'
+          + '</div>';
+      }).join('');
+      uploadsList.querySelectorAll('.up-del').forEach((el, i) => {
+        el.onclick = () => deleteUpload(files[i].name);
+      });
+    } catch (e) {
+      uploadsList.innerHTML = '<div style="color:#888; font-size:12px;">加载失败</div>';
+    }
+  }
+  async function deleteUpload(name) {
+    if (!confirm('确定删除文件 ' + name + '?')) return;
+    try {
+      const res = await fetch(API + '/api/uploads/' + encodeURIComponent(name), { method: 'DELETE', headers: authHeaders() });
+      const d = await res.json().catch(() => ({}));
+      if (!res.ok || !d.ok) throw new Error(d.error || ('HTTP ' + res.status));
+      term.writeln('\x1b[32m✓ 已删除 ' + name + '\x1b[0m');
+      refreshUploadsList();
+    } catch (e) {
+      term.writeln('\x1b[31m✗ 删除失败: ' + e.message + '\x1b[0m');
+      alert('删除失败: ' + e.message);
+    }
+  }
+  const uploadsRefresh = document.getElementById('uploads-refresh');
+  if (uploadsRefresh) uploadsRefresh.onclick = refreshUploadsList;
+
   // ========== 技能管理 ==========
   const skillsModal = document.getElementById('skills-modal');
   const skillsList = document.getElementById('skills-list');
@@ -1241,8 +1337,8 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
   // ✕ 清空
   pasteClear.onclick = () => {
     pasteInput.value = '';
-    pendingImages = [];
-    renderPasteImgChips();
+    pendingFiles = [];
+    renderPasteChips();
     pasteInput.focus();
   };
 
@@ -1252,8 +1348,13 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     if (!ws || ws.readyState !== WebSocket.OPEN) { term.writeln('\x1b[31m✗ 未连接,无法发送\x1b[0m'); return; }
     const text = pasteInput.value;
     let parts = [];
-    for (const img of pendingImages) {
-      parts.push('<image_local_path>' + img.path + '</image_local_path>');
+    for (const f of pendingFiles) {
+      // 图片用 codebuddy 识别的标记;其他文件(视频/zip/任意)发送本地路径,供命令行引用
+      if ((f.mime || '').startsWith('image/')) {
+        parts.push('<image_local_path>' + f.path + '</image_local_path>');
+      } else {
+        parts.push(f.path);
+      }
     }
     if (text) parts.push(text);
     if (!parts.length) return;
@@ -1297,6 +1398,28 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     termContainer.addEventListener('click', focusTerm);
     termContainer.addEventListener('touchstart', focusTerm, { passive: true });
 
+    // 自动贴底:根据滚动位置判断是否停在底部,决定是否跟随新输出
+    const updateFollow = () => {
+      const buf = term.buffer.active;
+      const atBottom = buf.viewportY >= buf.baseY - term.rows + 1;
+      followBottom = atBottom;
+      jumpBtn.style.display = atBottom ? 'none' : 'block';
+    };
+    term.onScroll(updateFollow);
+    jumpBtn.onclick = () => { term.scrollToBottom(); followBottom = true; jumpBtn.style.display = 'none'; };
+
+    // iPad 软键盘/工具栏收起时:重算终端尺寸并贴底,避免被遮挡
+    if (window.visualViewport) {
+      const applyVVHeight = () => { terminalPage.style.height = window.visualViewport.height + 'px'; };
+      const syncVV = () => {
+        applyVVHeight();
+        if (fitAddon) fitAddon.fit();
+        if (followBottom) term.scrollToBottom();
+      };
+      window.visualViewport.addEventListener('resize', syncVV);
+      window.visualViewport.addEventListener('scroll', applyVVHeight);
+    }
+
     // 复制支持:有选区时 Ctrl+C 复制到剪贴板(不发中断信号)
     // Ctrl+Shift+C 也复制(xterm 默认行为)
     term.attachCustomKeyEventHandler((e) => {
@@ -1374,7 +1497,9 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; over
     ws.onmessage = (event) => {
       try {
         const msg = JSON.parse(event.data);
-        if (msg.type === 'output' && msg.data) term.write(msg.data);
+        if (msg.type === 'output' && msg.data) {
+          term.write(msg.data, () => { if (followBottom) term.scrollToBottom(); });
+        }
         else if (msg.type === 'pid') pid = msg.pid;
         else if (msg.type === 'reattach') { pid = msg.pid || pid; renderReattach(msg); }
         else if (msg.type === 'error') { term.writeln('\x1b[31m✗ ' + msg.data + '\x1b[0m'); }