}
.top-bar .action-btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
-/* 终端页布局: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; }
+/* 通用按钮样式(粘贴 modal 复用) */
.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;
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 {
+.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; }
+.img-chip .remove { cursor: pointer; opacity: 0.6; }
+.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="粘贴文字或图片">📋 粘贴</button>
<button class="action-btn" id="btn-close" title="关闭窗口">✕ 关闭</button>
</div>
</div>
<div id="terminal-container"></div>
- <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>
+ <!-- 粘贴 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;">
+ <div style="color:#4ecca3; font-size:15px; margin-bottom:10px;">📋 粘贴到终端</div>
+ <div class="img-chips" id="paste-img-chips" style="display:none;"></div>
+ <div class="input-row" style="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;">
+ <textarea id="paste-input" rows="3" style="flex:1; background:transparent; border:none; outline:none; color:#e0e0e0; font-family:'Cascadia Code','Fira Code',Menlo,monospace; font-size:14px; line-height:1.5; resize:vertical; max-height:240px; min-height:60px;" placeholder="在此粘贴文字或截图,Ctrl+Enter 发送到终端输入行"></textarea>
+ <div style="display:flex; gap:6px; align-items:center; flex-shrink:0;">
+ <button class="icon-btn" id="paste-attach" title="选择图片">📎</button>
+ <button class="icon-btn" id="paste-clear" title="清空">✕</button>
+ <button class="send-btn" id="paste-send">发送</button>
+ </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>
- <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 }));
}
- // ========== 输入区(方案 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');
+ // ========== 粘贴 modal(右上角 📋 粘贴按钮触发) ==========
+ const pasteModal = document.getElementById('paste-modal');
+ const pasteInput = document.getElementById('paste-input');
+ const pasteSend = document.getElementById('paste-send');
+ const pasteAttach = document.getElementById('paste-attach');
+ const pasteClear = document.getElementById('paste-clear');
+ const pasteFileInput = document.getElementById('paste-file-input');
+ const pasteImgChips = document.getElementById('paste-img-chips');
// 待发送的图片列表:[{path, name}]
let pendingImages = [];
- // textarea 自适应高度
- function autoResize() {
- msgInput.style.height = 'auto';
- msgInput.style.height = Math.min(160, msgInput.scrollHeight) + 'px';
- }
- msgInput.addEventListener('input', autoResize);
-
// 上传图片到后端,返回 {path, name} 或抛错
async function uploadImage(blob) {
const sizeKB = Math.max(1, Math.round(blob.size / 1024));
}
// 渲染图片 chip 列表
- function renderImgChips() {
- if (!pendingImages.length) { imgChips.style.display = 'none'; imgChips.innerHTML = ''; return; }
- imgChips.style.display = 'flex';
- imgChips.innerHTML = pendingImages.map((img, i) =>
+ function renderPasteImgChips() {
+ if (!pendingImages.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('');
- imgChips.querySelectorAll('.remove').forEach(el => {
- el.onclick = () => { pendingImages.splice(parseInt(el.dataset.i), 1); renderImgChips(); };
+ pasteImgChips.querySelectorAll('.remove').forEach(el => {
+ el.onclick = () => { pendingImages.splice(parseInt(el.dataset.i), 1); renderPasteImgChips(); };
});
}
+ // 打开/关闭 modal
+ function openPasteModal() {
+ pasteInput.value = '';
+ pendingImages = [];
+ renderPasteImgChips();
+ pasteModal.style.display = 'flex';
+ setTimeout(() => pasteInput.focus(), 0);
+ }
+ function closePasteModal() {
+ pasteModal.style.display = 'none';
+ if (term && term.focus) term.focus();
+ }
+ document.getElementById('btn-paste').onclick = openPasteModal;
+
// textarea 的 paste 事件:文字直接插入,图片上传后加 chip
- msgInput.addEventListener('paste', async (e) => {
+ pasteInput.addEventListener('paste', async (e) => {
if (!e.clipboardData) return;
- // 优先检测图片
let imgBlob = null;
if (e.clipboardData.items) {
for (const item of e.clipboardData.items) {
}
if (imgBlob) {
e.preventDefault();
- try {
- const img = await uploadImage(imgBlob);
- pendingImages.push(img);
- renderImgChips();
- } catch (err) {
- term.writeln('\x1b[31m✗ 图片上传失败: ' + err.message + '\x1b[0m');
- }
+ try { const img = await uploadImage(imgBlob); pendingImages.push(img); renderPasteImgChips(); }
+ catch (err) { term.writeln('\x1b[31m✗ 图片上传失败: ' + err.message + '\x1b[0m'); }
return;
}
- // 文字:让浏览器默认插入 textarea(不拦截)
+ // 文字:浏览器默认插入
});
- // 拖拽图片到输入区
- msgInput.addEventListener('dragover', (e) => { e.preventDefault(); });
- msgInput.addEventListener('drop', async (e) => {
+ // 拖拽图片到 modal
+ 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/'));
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');
- }
+ try { const img = await uploadImage(f); pendingImages.push(img); }
+ catch (err) { term.writeln('\x1b[31m✗ ' + f.name + ' 上传失败: ' + err.message + '\x1b[0m'); }
}
- renderImgChips();
+ renderPasteImgChips();
});
- // 📎 选择图片文件
- btnAttach.onclick = () => fileInput.click();
- fileInput.onchange = async () => {
- if (!fileInput.files || !fileInput.files.length) return;
- for (const f of fileInput.files) {
+ // 📎 选择图片
+ 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);
- } catch (err) {
- term.writeln('\x1b[31m✗ ' + f.name + ' 上传失败: ' + err.message + '\x1b[0m');
- }
+ try { const img = await uploadImage(f); pendingImages.push(img); }
+ catch (err) { term.writeln('\x1b[31m✗ ' + f.name + ' 上传失败: ' + err.message + '\x1b[0m'); }
}
- fileInput.value = '';
- renderImgChips();
+ pasteFileInput.value = '';
+ renderPasteImgChips();
};
- // ✕ 清空输入
- btnClear.onclick = () => {
- msgInput.value = '';
+ // ✕ 清空
+ pasteClear.onclick = () => {
+ pasteInput.value = '';
pendingImages = [];
- renderImgChips();
- autoResize();
- msgInput.focus();
+ renderPasteImgChips();
+ pasteInput.focus();
};
- // 发送:文字 + 图片路径标签
- function sendMessage() {
+ // 发送:把内容字符流发给 PTY(不加 \r),内容显示在 xterm 输入行,
+ // 用户自己按回车提交。多行内容用 \n 分隔,最后一行留空让用户继续输入。
+ function sendPasteContent() {
if (!ws || ws.readyState !== WebSocket.OPEN) { term.writeln('\x1b[31m✗ 未连接,无法发送\x1b[0m'); return; }
- const text = msgInput.value;
- let payload = '';
- // 先发图片标签
+ const text = pasteInput.value;
+ let parts = [];
for (const img of pendingImages) {
- payload += '<image_local_path>' + img.path + '</image_local_path>\n';
+ parts.push('<image_local_path>' + img.path + '</image_local_path>');
}
- // 再发文字
- if (text) payload += text;
- if (!payload) return;
- payload += '\n'; // 回车确认提交
- sendInput(payload);
- // 清空
- msgInput.value = '';
- pendingImages = [];
- renderImgChips();
- autoResize();
- msgInput.focus();
+ if (text) parts.push(text);
+ if (!parts.length) return;
+ const body = parts.join(' ');
+ // 只发内容,不带回车:内容进 xterm 输入行,用户自行回车提交
+ sendInput(body);
+ closePasteModal();
}
- btnSend.onclick = sendMessage;
- msgInput.addEventListener('keydown', (e) => {
+ pasteSend.onclick = sendPasteContent;
+ pasteInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
- sendMessage();
+ sendPasteContent();
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ closePasteModal();
}
});
- function setWsHint(connected) {
- wsHint.textContent = connected ? '已连接' : '未连接';
- }
-
function initTerminal() {
if (term) return;
term = new Terminal({
function setConnected(v) {
statusDot.className = 'dot ' + (v ? 'connected' : 'disconnected');
statusText.textContent = v ? '已连接' : '未连接';
- setWsHint(v);
- if (v) { try { msgInput.focus(); } catch(e){} }
+ if (v && term && term.focus) { try { term.focus(); } catch(e){} }
}
function connectWS() {