]> acesimba.cloud Git - codebuddy-web.git/commitdiff
支持终端粘贴文字与截图(未完成,待方案B重构)
authorCodebuddy <codebuddy@localhost>
Thu, 18 Jun 2026 07:58:56 +0000 (15:58 +0800)
committerCodebuddy <codebuddy@localhost>
Thu, 18 Jun 2026 07:58:56 +0000 (15:58 +0800)
新增:
- backend: /api/upload-image 端点,接收 base64 图片存到 data/pastes/
- frontend: paste 事件检测图片/文字,上传后以 <image_local_path> 标签发 CLI
- frontend: 顶部栏 📋 粘贴按钮 + modal(多行 textarea 输入)
- frontend: extractPasteText/htmlToText(HTML 转 TSV/纯文本)
- .gitignore: 排除 data/pastes/

已知问题: 自定义 paste 监听与 xterm 内部冲突,文字粘贴失效。
下一步: 按方案 B 重构——xterm 区保留 TUI 输出,下方加独立 textarea 输入框。

.gitignore
backend/app.py
frontend/index.html

index d634206604270e637d00753463f4801d1754b242..a1329c57721fd2b1a67c1a5e8dedb512b89a2b88 100644 (file)
@@ -9,5 +9,8 @@ logs/*.log
 # Runtime state: task registry (session IDs, last-used timestamps)
 data/tasks.json
 
+# Runtime state: pasted screenshots uploaded via web console
+data/pastes/
+
 # Backups (git history serves this purpose)
 *.bak
index 73e7d1585ae41dabe87a854f0760881e9b3ba6c5..e863092a02fa2136e9e564c8f03ecf9e6edd3751 100644 (file)
@@ -261,6 +261,47 @@ async def api_kill(pid: int, request: Request):
     return JSONResponse({"ok": True})
 
 
+# ==================== 图片上传(用于截图粘贴) ====================
+# codebuddy CLI 用 <image_local_path>...</image_local_path> 标签引用图片
+# 前端把粘贴的截图 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
+_MIME_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg",
+             "image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp"}
+
+
+@app.post("/api/upload-image")
+async def upload_image(request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"error": "unauthorized"}, status_code=401)
+    try:
+        body = await request.json()
+    except Exception:
+        return JSONResponse({"error": "invalid json body"}, status_code=400)
+    mime = (body.get("mime") or "").lower()
+    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")
+    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)}
+
+
 # ==================== WebSocket 终端 ====================
 @app.websocket("/ws")
 async def websocket_terminal(websocket: WebSocket):
index 7a20c925c9afd40c0147499d4a2d1b7ae54e0a32..f5a4e92a7a98dbdda973ed0d76b52a605c0155a0 100644 (file)
@@ -140,10 +140,21 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
     <div class="actions">
       <button class="action-btn" id="btn-restart" title="重启会话(续上次历史)">↻ 重启会话</button>
       <button class="action-btn" id="btn-list" title="打开任务列表">≡ 列表</button>
+      <button class="action-btn" id="btn-paste" title="粘贴 (Ctrl+V 或点击)">📋 粘贴</button>
       <button class="action-btn" id="btn-close" title="关闭窗口">✕ 关闭</button>
     </div>
   </div>
   <div id="terminal-container"></div>
+  <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 style="background:#1a1a2e; border:1px solid #4ecca3; border-radius:8px; padding:20px; width:520px; max-width:90vw;">
+      <div style="color:#4ecca3; font-size:16px; margin-bottom:12px;">📋 粘贴到终端</div>
+      <textarea id="paste-area" style="width:100%; height:200px; background:#0d0d1a; color:#e0e0e0; border:1px solid #444; border-radius:4px; padding:8px; font-family:monospace; font-size:14px; resize:vertical;" placeholder="在此处按 Ctrl+V 粘贴,然后点下方按钮发送到终端"></textarea>
+      <div style="margin-top:12px; text-align:right;">
+        <button class="action-btn" id="paste-cancel">取消</button>
+        <button class="action-btn" id="paste-send" style="background:#4ecca3; color:#1a1a2e;">发送 (Ctrl+Enter)</button>
+      </div>
+    </div>
+  </div>
 </div>
 
 <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
@@ -324,6 +335,109 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
     connectWS();
   }
 
+  // ========== 终端输入辅助 ==========
+  function sendInput(text) {
+    if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: text }));
+  }
+
+  // 检测剪贴板里是否有图片,有则返回 Blob,无则返回 null
+  function extractPasteImage(clipboardData) {
+    if (!clipboardData) return null;
+    // 1) 标准 file item(截图工具、浏览器复制的图片)
+    if (clipboardData.items) {
+      for (const item of clipboardData.items) {
+        if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
+          const blob = item.getAsFile();
+          if (blob) { console.log('[paste] image from items:', item.type, blob.size); return blob; }
+        }
+      }
+    }
+    // 2) 部分 Edge/旧浏览器把图片放在 files 里
+    if (clipboardData.files && clipboardData.files.length) {
+      for (const f of clipboardData.files) {
+        if (f.type && f.type.startsWith('image/')) { console.log('[paste] image from files:', f.type, f.size); return f; }
+      }
+    }
+    return null;
+  }
+
+  // 上传图片到后端,再把 <image_local_path> 路径标签发给 CLI
+  async function uploadAndPasteImage(blob) {
+    if (!blob) return;
+    const sizeKB = Math.max(1, Math.round(blob.size / 1024));
+    term.writeln('\x1b[33m⏳ 正在上传图片 (' + sizeKB + 'KB, ' + blob.type + ')...\x1b[0m');
+    // 读为 base64
+    const reader = new FileReader();
+    const b64 = await new Promise((resolve, reject) => {
+      reader.onload = () => {
+        const r = reader.result;
+        // data:image/png;base64,xxxx  →  只取 base64 部分
+        const i = r.indexOf(',');
+        resolve(i >= 0 ? r.slice(i + 1) : '');
+      };
+      reader.onerror = () => reject(reader.error);
+      reader.readAsDataURL(blob);
+    });
+    if (!b64) { term.writeln('\x1b[31m✗ 图片读取失败\x1b[0m'); return; }
+    try {
+      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 })
+      });
+      if (!r.ok) {
+        let msg = 'HTTP ' + r.status;
+        try { const j = await r.json(); if (j.error) msg = j.error; } catch (_) {}
+        throw new Error(msg);
+      }
+      const data = await r.json();
+      if (data.error) throw new Error(data.error);
+      // 把图片路径标签当作用户输入发给 CLI(codebuddy 用 <image_local_path> 引用图片)
+      sendInput('<image_local_path>' + data.path + '</image_local_path>\n');
+      term.writeln('\x1b[32m✓ 图片已发送: ' + data.path + '\x1b[0m');
+    } catch (err) {
+      term.writeln('\x1b[31m✗ 图片上传失败: ' + err.message + '\x1b[0m');
+    }
+  }
+
+  function extractPasteText(clipboardData) {
+    if (!clipboardData) return '';
+    // 优先纯文本(IDE 复制代码时保留缩进/换行)
+    const plain = clipboardData.getData('text/plain');
+    if (plain) return plain;
+    // 只有 HTML 时(Word/网页/Excel)做转换
+    const html = clipboardData.getData('text/html');
+    if (!html) return '';
+    return htmlToText(html);
+  }
+
+  function htmlToText(html) {
+    const doc = new DOMParser().parseFromString(html, 'text/html');
+    // 表格转 TSV
+    doc.querySelectorAll('table').forEach(table => {
+      const rows = [];
+      table.querySelectorAll('tr').forEach(tr => {
+        const cells = [];
+        tr.querySelectorAll('td,th').forEach(td => cells.push((td.textContent || '').trim()));
+        rows.push(cells.join('\t'));
+      });
+      const tsv = rows.join('\n');
+      table.replaceWith(doc.createTextNode('\n' + tsv + '\n'));
+    });
+    // 块级元素转换行
+    let text = doc.body.innerHTML;
+    const tmp = document.createElement('div');
+    tmp.innerHTML = text;
+    // 在块级元素后插入换行
+    tmp.querySelectorAll('br,p,div,li,h1,h2,h3,h4,h5,h6').forEach(el => {
+      el.after(document.createTextNode('\n'));
+    });
+    text = tmp.textContent || '';
+    // 压缩连续空行为单个换行
+    text = text.replace(/\n{3,}/g, '\n\n').trim();
+    return text;
+  }
+
   function initTerminal() {
     if (term) return;
     term = new Terminal({
@@ -346,11 +460,52 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
     term.open(termContainer);
     fitAddon.fit();
 
-    term.onData(data => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data })); });
+    term.onData(data => sendInput(data));
     term.onResize(({ cols, rows }) => {
       if (ws && ws.readyState === WebSocket.OPEN && pid) ws.send(JSON.stringify({ type: 'resize', cols, rows }));
     });
     window.addEventListener('resize', () => { if (fitAddon) fitAddon.fit(); });
+
+    // 绑定 paste 事件到 xterm 内部 textarea(HTTP 下 navigator.clipboard 不可用)
+    // xterm 的 term.textarea 是真实可编辑元素,浏览器 paste 事件只会在它有焦点时触发
+    // 显式绑定:检测图片(截图)/文字,发给后端
+    const attachPaste = (target, label) => {
+      if (!target) return;
+      target.addEventListener('paste', (e) => {
+        console.log('[paste:'+label+'] target=', e.target.tagName, 'active=', (document.activeElement||{}).tagName, 'items=', e.clipboardData && e.clipboardData.items ? Array.from(e.clipboardData.items).map(i=>i.kind+'/'+i.type) : []);
+        if (!ws || ws.readyState !== WebSocket.OPEN) {
+          console.log('[paste:'+label+'] ws not open, skip');
+          return;
+        }
+        // 优先检测图片(截图粘贴)
+        const img = extractPasteImage(e.clipboardData);
+        if (img) {
+          console.log('[paste:'+label+'] image detected', img.type, img.size);
+          e.preventDefault();
+          e.stopPropagation();
+          uploadAndPasteImage(img);
+          return;
+        }
+        // 文字:让 xterm 默认行为处理(term.textarea 会把文字走 onData → sendInput)
+        // 但部分浏览器/情况下默认行为不可靠,显式也发一份
+        const text = extractPasteText(e.clipboardData);
+        if (text) {
+          console.log('[paste:'+label+'] text detected len=', text.length);
+          e.preventDefault();
+          e.stopPropagation();
+          sendInput(text);
+        } else {
+          console.log('[paste:'+label+'] no image/text in clipboard');
+        }
+      });
+    };
+    attachPaste(term.textarea, 'term.textarea');
+    // 终端容器兜底(contenteditable 才会触发,但部分浏览器 div 也能收到)
+    attachPaste(termContainer, 'termContainer');
+    // term.textarea 可能在 open 后异步渲染,延迟再绑一次
+    setTimeout(() => { if (term && term.textarea) attachPaste(term.textarea, 'term.textarea.late'); }, 200);
+
+    if (term.focus) term.focus();
   }
 
   function setConnected(v) {
@@ -399,6 +554,37 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
     if (ws) ws.close();
     window.close();
   };
+
+  // ========== 粘贴 modal ==========
+  const pasteModal = document.getElementById('paste-modal');
+  const pasteArea = document.getElementById('paste-area');
+  function openPasteModal() {
+    pasteArea.value = '';
+    pasteModal.style.display = 'flex';
+    setTimeout(() => pasteArea.focus(), 0);
+  }
+  function closePasteModal() {
+    pasteModal.style.display = 'none';
+    if (term && term.focus) term.focus();
+  }
+  document.getElementById('btn-paste').onclick = openPasteModal;
+  document.getElementById('paste-cancel').onclick = closePasteModal;
+  document.getElementById('paste-send').onclick = () => {
+    const text = pasteArea.value;
+    if (text) sendInput(text);
+    closePasteModal();
+  };
+  pasteArea.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      const text = pasteArea.value;
+      if (text) sendInput(text);
+      closePasteModal();
+    } else if (e.key === 'Escape') {
+      e.preventDefault();
+      closePasteModal();
+    }
+  });
   // 窗口关闭前尽力杀进程
   window.addEventListener('beforeunload', () => {
     if (pid) navigator.sendBeacon && navigator.sendBeacon(API + '/api/kill/' + pid + '?token=' + encodeURIComponent(authToken));