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