From: Codebuddy Date: Thu, 18 Jun 2026 07:58:56 +0000 (+0800) Subject: 支持终端粘贴文字与截图(未完成,待方案B重构) X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=51a1de4b0f971a9d764c4383cb1b1f19e374c757;p=codebuddy-web.git 支持终端粘贴文字与截图(未完成,待方案B重构) 新增: - backend: /api/upload-image 端点,接收 base64 图片存到 data/pastes/ - frontend: paste 事件检测图片/文字,上传后以 标签发 CLI - frontend: 顶部栏 📋 粘贴按钮 + modal(多行 textarea 输入) - frontend: extractPasteText/htmlToText(HTML 转 TSV/纯文本) - .gitignore: 排除 data/pastes/ 已知问题: 自定义 paste 监听与 xterm 内部冲突,文字粘贴失效。 下一步: 按方案 B 重构——xterm 区保留 TUI 输出,下方加独立 textarea 输入框。 --- diff --git a/.gitignore b/.gitignore index d634206..a1329c5 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/backend/app.py b/backend/app.py index 73e7d15..e863092 100644 --- a/backend/app.py +++ b/backend/app.py @@ -261,6 +261,47 @@ async def api_kill(pid: int, request: Request): return JSONResponse({"ok": True}) +# ==================== 图片上传(用于截图粘贴) ==================== +# codebuddy CLI 用 ... 标签引用图片 +# 前端把粘贴的截图 POST 到这里,后端存盘后返回绝对路径 +# 前端再把 路径 当作输入发到 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): diff --git a/frontend/index.html b/frontend/index.html index 7a20c92..f5a4e92 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -140,10 +140,21 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
+
+ @@ -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; + } + + // 上传图片到后端,再把 路径标签发给 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 用 引用图片) + sendInput('' + data.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));