}
.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; }
<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>
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() {
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() {
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));