]> acesimba.cloud Git - codebuddy-web.git/commitdiff
方案B: xterm输出区 + 独立输入框(豆包式)
authorCodebuddy <codebuddy@localhost>
Thu, 18 Jun 2026 08:07:46 +0000 (16:07 +0800)
committerCodebuddy <codebuddy@localhost>
Thu, 18 Jun 2026 08:07:46 +0000 (16:07 +0800)
重构终端页为上下两区:
- 上: xterm 区保留 codebuddy TUI 输出(流式/工具调用/交互确认)
- 下: 独立多行 textarea 输入区 + 发送/📎/✕ 按钮

输入区能力:
- 文字粘贴: 浏览器原生 textarea paste,可靠不失效
- 图片粘贴: Ctrl+V 截图自动上传到 /api/upload-image,显示绿色 chip
- 图片拖拽: 拖文件到输入框上传
- 📎 按钮: 选择图片文件
- 发送: Ctrl+Enter 或点发送按钮,文字+图片标签一起进 PTY

移除:
- 旧的 xterm paste 监听(与 xterm 内部冲突导致粘贴失效)
- 旧的 📋 粘贴 modal(被独立输入区取代)

保留: backend /api/upload-image 端点不变

frontend/index.html

index f5a4e92a7a98dbdda973ed0d76b52a605c0155a0..4a3eac05e7864ba0d48ca51f374f47f1e7652f9a 100644 (file)
@@ -59,7 +59,51 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
 }
 .top-bar .action-btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
 
-#terminal-container { flex: 1; overflow: hidden; padding: 4px; }
+/* 终端页布局:xterm 输出区 + 独立输入区 */
+#terminal-page { display: none; height: 100vh; flex-direction: column; }
+#terminal-container { flex: 1; overflow: hidden; padding: 4px; min-height: 0; }
+
+/* 输入区(豆包式) */
+#input-bar {
+  flex-shrink: 0; border-top: 1px solid rgba(255,255,255,0.12);
+  background: rgba(0,0,0,0.35); padding: 10px 14px;
+}
+#input-bar .input-row {
+  display: flex; gap: 8px; align-items: flex-end;
+  background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15);
+  border-radius: 12px; padding: 8px 10px;
+}
+#input-bar .input-row:focus-within { border-color: #4ecca3; }
+#msg-input {
+  flex: 1; background: transparent; border: none; outline: none; color: #e0e0e0;
+  font-family: 'Cascadia Code', 'Fira Code', Menlo, Monaco, 'Courier New', monospace;
+  font-size: 14px; line-height: 1.5; resize: none; max-height: 160px; min-height: 24px;
+  padding: 4px 4px;
+}
+#msg-input::placeholder { color: #666; }
+#input-bar .input-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
+.icon-btn {
+  width: 32px; height: 32px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.15);
+  background: rgba(255,255,255,0.05); color: #ccc; font-size: 16px; cursor: pointer;
+  display: flex; align-items: center; justify-content: center; transition: all 0.15s;
+}
+.icon-btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
+.send-btn {
+  padding: 0 16px; height: 32px; border-radius: 8px; border: none;
+  background: linear-gradient(135deg, #4ecca3, #38b2ac); color: #1a1a2e;
+  font-size: 14px; font-weight: 600; cursor: pointer;
+}
+.send-btn:hover { opacity: 0.9; }
+.send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+#input-bar .input-hint { font-size: 11px; color: #666; margin-top: 4px; text-align: right; }
+#input-bar .img-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
+#input-bar .img-chip {
+  display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px;
+  background: rgba(78,204,163,0.12); border: 1px solid rgba(78,204,163,0.3);
+  border-radius: 6px; font-size: 12px; color: #4ecca3;
+}
+#input-bar .img-chip .remove { cursor: pointer; opacity: 0.6; }
+#input-bar .img-chip .remove:hover { opacity: 1; }
 
 /* 任务列表页 */
 #picker-page { display: none; flex-direction: column; height: 100vh; }
@@ -140,20 +184,22 @@ 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 id="input-bar">
+    <div class="img-chips" id="img-chips" style="display:none;"></div>
+    <div class="input-row">
+      <textarea id="msg-input" rows="1" placeholder="输入消息,Ctrl+Enter 发送;可粘贴文字或截图"></textarea>
+      <div class="input-actions">
+        <button class="icon-btn" id="btn-attach" title="选择图片文件">📎</button>
+        <button class="icon-btn" id="btn-clear" title="清空输入">✕</button>
+        <button class="send-btn" id="btn-send">发送</button>
       </div>
     </div>
+    <div class="input-hint">Ctrl+Enter 发送 · 粘贴截图自动上传 · <span id="ws-hint">未连接</span></div>
+    <input type="file" id="file-input" accept="image/*" style="display:none;">
   </div>
 </div>
 
@@ -340,102 +386,170 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
     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;
+  // ========== 输入区(方案 B:独立 textarea 输入框) ==========
+  const msgInput = document.getElementById('msg-input');
+  const btnSend = document.getElementById('btn-send');
+  const btnAttach = document.getElementById('btn-attach');
+  const btnClear = document.getElementById('btn-clear');
+  const fileInput = document.getElementById('file-input');
+  const imgChips = document.getElementById('img-chips');
+  const wsHint = document.getElementById('ws-hint');
+  // 待发送的图片列表:[{path, name}]
+  let pendingImages = [];
+
+  // textarea 自适应高度
+  function autoResize() {
+    msgInput.style.height = 'auto';
+    msgInput.style.height = Math.min(160, msgInput.scrollHeight) + 'px';
   }
+  msgInput.addEventListener('input', autoResize);
 
-  // 上传图片到后端,再把 <image_local_path> 路径标签发给 CLI
-  async function uploadAndPasteImage(blob) {
-    if (!blob) return;
+  // 上传图片到后端,返回 {path, name} 或抛错
+  async function uploadImage(blob) {
     const sizeKB = Math.max(1, Math.round(blob.size / 1024));
-    term.writeln('\x1b[33m⏳ 正在上传图片 (' + sizeKB + 'KB, ' + blob.type + ')...\x1b[0m');
-    // 读为 base64
+    const name = blob.name || ('截图_' + Date.now() + '.' + (blob.type.split('/')[1] || 'png'));
+    term.writeln('\x1b[33m⏳ 上传图片 ' + name + ' (' + sizeKB + 'KB)...\x1b[0m');
     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.onload = () => { const i = reader.result.indexOf(','); resolve(i >= 0 ? reader.result.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');
+    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 })
+    });
+    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);
+    term.writeln('\x1b[32m✓ ' + name + ' 已上传\x1b[0m');
+    return { path: data.path, name: name };
   }
 
-  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);
+  // 渲染图片 chip 列表
+  function renderImgChips() {
+    if (!pendingImages.length) { imgChips.style.display = 'none'; imgChips.innerHTML = ''; return; }
+    imgChips.style.display = 'flex';
+    imgChips.innerHTML = pendingImages.map((img, i) =>
+      '<span class="img-chip">🖼 ' + img.name + ' <span class="remove" data-i="' + i + '">✕</span></span>'
+    ).join('');
+    imgChips.querySelectorAll('.remove').forEach(el => {
+      el.onclick = () => { pendingImages.splice(parseInt(el.dataset.i), 1); renderImgChips(); };
+    });
   }
 
-  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;
+  // textarea 的 paste 事件:文字直接插入,图片上传后加 chip
+  msgInput.addEventListener('paste', async (e) => {
+    if (!e.clipboardData) return;
+    // 优先检测图片
+    let imgBlob = 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 (!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 (imgBlob) {
+      e.preventDefault();
+      try {
+        const img = await uploadImage(imgBlob);
+        pendingImages.push(img);
+        renderImgChips();
+      } catch (err) {
+        term.writeln('\x1b[31m✗ 图片上传失败: ' + err.message + '\x1b[0m');
+      }
+      return;
+    }
+    // 文字:让浏览器默认插入 textarea(不拦截)
+  });
+
+  // 拖拽图片到输入区
+  msgInput.addEventListener('dragover', (e) => { e.preventDefault(); });
+  msgInput.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/'));
+    if (!files.length) return;
+    e.preventDefault();
+    for (const f of files) {
+      try {
+        const img = await uploadImage(f);
+        pendingImages.push(img);
+      } catch (err) {
+        term.writeln('\x1b[31m✗ ' + f.name + ' 上传失败: ' + err.message + '\x1b[0m');
+      }
+    }
+    renderImgChips();
+  });
+
+  // 📎 选择图片文件
+  btnAttach.onclick = () => fileInput.click();
+  fileInput.onchange = async () => {
+    if (!fileInput.files || !fileInput.files.length) return;
+    for (const f of fileInput.files) {
+      if (!f.type || !f.type.startsWith('image/')) continue;
+      try {
+        const img = await uploadImage(f);
+        pendingImages.push(img);
+      } catch (err) {
+        term.writeln('\x1b[31m✗ ' + f.name + ' 上传失败: ' + err.message + '\x1b[0m');
+      }
+    }
+    fileInput.value = '';
+    renderImgChips();
+  };
+
+  // ✕ 清空输入
+  btnClear.onclick = () => {
+    msgInput.value = '';
+    pendingImages = [];
+    renderImgChips();
+    autoResize();
+    msgInput.focus();
+  };
+
+  // 发送:文字 + 图片路径标签
+  function sendMessage() {
+    if (!ws || ws.readyState !== WebSocket.OPEN) { term.writeln('\x1b[31m✗ 未连接,无法发送\x1b[0m'); return; }
+    const text = msgInput.value;
+    let payload = '';
+    // 先发图片标签
+    for (const img of pendingImages) {
+      payload += '<image_local_path>' + img.path + '</image_local_path>\n';
+    }
+    // 再发文字
+    if (text) payload += text;
+    if (!payload) return;
+    payload += '\n';  // 回车确认提交
+    sendInput(payload);
+    // 清空
+    msgInput.value = '';
+    pendingImages = [];
+    renderImgChips();
+    autoResize();
+    msgInput.focus();
+  }
+  btnSend.onclick = sendMessage;
+  msgInput.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      sendMessage();
+    }
+  });
+
+  function setWsHint(connected) {
+    wsHint.textContent = connected ? '已连接' : '未连接';
   }
 
   function initTerminal() {
@@ -465,52 +579,14 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
       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();
+    // 输入由 #msg-input 输入区处理,xterm 仅作 TUI 输出展示
   }
 
   function setConnected(v) {
     statusDot.className = 'dot ' + (v ? 'connected' : 'disconnected');
     statusText.textContent = v ? '已连接' : '未连接';
+    setWsHint(v);
+    if (v) { try { msgInput.focus(); } catch(e){} }
   }
 
   function connectWS() {
@@ -555,36 +631,6 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
     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));