mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
Point 1 of the v1.0 lock-in: commit to a stable HTTP API (the cleanest, fullest form).
Core (centralized):
- Every JSON /api response now uses ONE envelope via a Fastify preSerialization hook (src/web/server.ts): success -> { success:true, data:<payload> }; error -> { success:false, error, errorCode } with a conventional HTTP status. Non-JSON routes (file-raw, tail-file SSE, download, screenshots, /q redirect, WS) are skipped.
- Error-code -> HTTP status is a single source of truth (httpStatusForErrorCode in src/types/api.ts): 400/401/404/409/422/429/500. Expanded ApiErrorCode (added UNAUTHORIZED, CONFLICT, RATE_LIMITED). Errors are no longer HTTP 200.
- Versioned alias: /api/v1/* rewrites to /api/* (rewriteApiV1Url), so external clients pin to a stable surface while the bundled UI keeps using /api/*.
- Handlers stripped of manual 'success:true' (50 across 14 route files) so they return bare payloads the hook wraps uniformly; fixed the mux DELETE {success:<bool>} envelope collision (-> {killed}).
Frontend (48 call sites across 10 files):
- _apiJson() auto-unwraps { success:true, data } -> data (null on error), so most bare-shape readers are transparent. Raw-fetch sites relocate payload reads under .data; success/res.ok/error checks unchanged.
Docs: new docs/api-reference.md (envelope, status table, error codes, /api/v1, SSE); versioning-policy.md flipped — the HTTP/SSE API is now part of the stable, SemVer-covered surface.
Verification: full unit/route suite green (2680 passed) incl. ~166 updated assertions across 24 test files; typecheck/lint/format/frontend-syntax clean; a headless-chromium smoke loaded the migrated UI and drove the panels with 0 console/page errors; /api/status and /api/v1/status confirmed returning the uniform envelope live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
201 lines
7.1 KiB
JavaScript
201 lines
7.1 KiB
JavaScript
/**
|
|
* Image Input Mixin - Clipboard paste and drag-and-drop image support
|
|
*
|
|
* For paste: intercepts Ctrl+V at the xterm keyboard level, creates a temporary
|
|
* hidden contenteditable div ("paste trap"), lets the browser's native paste fill
|
|
* it, then checks for image data. This works on HTTP (no secure context needed).
|
|
*
|
|
* For drag-and-drop: listens on the terminal container for file drops.
|
|
*
|
|
* @dependency app.js (uses global `app` for sendInput, activeSessionId, showToast)
|
|
* @dependency panels-ui.js (provides showToast)
|
|
*/
|
|
|
|
Object.assign(CodemanApp.prototype, {
|
|
|
|
initImageInput() {
|
|
// Drag-and-drop handlers on terminal container
|
|
const container = document.getElementById('terminalContainer');
|
|
if (!container) return;
|
|
|
|
container.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
|
|
container.classList.add('drag-active');
|
|
}
|
|
});
|
|
|
|
container.addEventListener('dragleave', (e) => {
|
|
if (!container.contains(e.relatedTarget)) {
|
|
container.classList.remove('drag-active');
|
|
}
|
|
});
|
|
|
|
container.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
container.classList.remove('drag-active');
|
|
|
|
if (!this.activeSessionId) return;
|
|
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
|
|
|
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
|
if (imageFiles.length === 0) {
|
|
this.showToast('Only image files are supported', 'error');
|
|
return;
|
|
}
|
|
this._uploadAndInsertImages(imageFiles);
|
|
});
|
|
},
|
|
|
|
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
|
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
|
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
|
_handleImagePaste() {
|
|
const self = this;
|
|
|
|
// Create a hidden contenteditable div to receive the paste
|
|
const trap = document.createElement('div');
|
|
trap.contentEditable = 'true';
|
|
trap.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;overflow:hidden';
|
|
document.body.appendChild(trap);
|
|
trap.focus();
|
|
|
|
// Listen for the paste event on our trap
|
|
trap.addEventListener('paste', function(e) {
|
|
e.stopPropagation();
|
|
|
|
// Check for images in clipboard items
|
|
var imageFiles = [];
|
|
var items = e.clipboardData && e.clipboardData.items;
|
|
if (items) {
|
|
for (var i = 0; i < items.length; i++) {
|
|
if (items[i].type.startsWith('image/')) {
|
|
var blob = items[i].getAsFile();
|
|
if (blob) imageFiles.push(blob);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up the trap
|
|
setTimeout(function() {
|
|
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
|
// Refocus the terminal
|
|
if (self.terminal) self.terminal.focus();
|
|
}, 0);
|
|
|
|
if (imageFiles.length > 0) {
|
|
e.preventDefault();
|
|
self._uploadAndInsertImages(imageFiles);
|
|
} else {
|
|
// No image -- route text through xterm's paste() so bracketed-paste
|
|
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
|
// has enabled bracketed-paste mode (Claude Code does). Sending text
|
|
// via raw sendInput() strips those markers and makes pasted input
|
|
// indistinguishable from typed input, weakening the CLI's
|
|
// prompt-injection defenses.
|
|
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
|
e.preventDefault();
|
|
if (text && self.terminal) self.terminal.paste(text);
|
|
}
|
|
});
|
|
|
|
// Trigger the browser's native paste via execCommand
|
|
// (this fires the paste event on our focused trap element)
|
|
document.execCommand('paste');
|
|
},
|
|
|
|
async _uploadAndInsertImages(files) {
|
|
const sessionId = this.activeSessionId;
|
|
if (!sessionId) return;
|
|
|
|
this.showToast('Uploading ' + files.length + ' image' + (files.length > 1 ? 's' : '') + '...', 'info');
|
|
|
|
const paths = [];
|
|
for (const file of files) {
|
|
try {
|
|
// Re-encode to a standard JPEG/PNG before upload. Galleries on some
|
|
// phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and
|
|
// MIME claim "image/jpeg", which passes the server's extension allowlist
|
|
// but fails its magic-byte check ("bytes do not match declared type").
|
|
// Decoding through the browser and re-encoding guarantees the bytes
|
|
// match the extension we send.
|
|
const normalized = await this._normalizeImageForUpload(file);
|
|
const path = await this._uploadPasteImage(sessionId, normalized);
|
|
paths.push(path);
|
|
} catch (err) {
|
|
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
|
|
}
|
|
}
|
|
|
|
if (paths.length > 0) {
|
|
const pathStr = paths.join(' ');
|
|
await this.sendInput(pathStr);
|
|
this.showToast(paths.length + ' image' + (paths.length > 1 ? 's' : '') + ' ready', 'success');
|
|
}
|
|
},
|
|
|
|
async _uploadPasteImage(sessionId, file) {
|
|
const form = new FormData();
|
|
form.append('image', file);
|
|
|
|
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image', {
|
|
method: 'POST',
|
|
body: form,
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const data = await resp.json().catch(() => ({}));
|
|
throw new Error(data.error || 'HTTP ' + resp.status);
|
|
}
|
|
|
|
const data = await resp.json();
|
|
return data.data.path;
|
|
},
|
|
|
|
// Decode an image File through the browser and re-encode it to a format the
|
|
// server accepts, so the uploaded bytes always match their declared
|
|
// extension. PNG is re-encoded as PNG (preserves transparency); everything
|
|
// else (JPEG, WebP, HEIF, unknown) becomes JPEG. Animated GIFs are passed
|
|
// through untouched since a canvas would flatten them to one frame. On any
|
|
// decode/encode failure the original file is returned unchanged so the server
|
|
// still gets a chance (and logs a precise diagnostic).
|
|
async _normalizeImageForUpload(file) {
|
|
if (file.type === 'image/gif') return file;
|
|
|
|
const toPng = file.type === 'image/png';
|
|
const url = URL.createObjectURL(file);
|
|
try {
|
|
const img = new Image();
|
|
await new Promise((resolve, reject) => {
|
|
img.onload = () => resolve();
|
|
img.onerror = () => reject(new Error('decode failed'));
|
|
img.src = url;
|
|
});
|
|
|
|
const width = img.naturalWidth;
|
|
const height = img.naturalHeight;
|
|
if (!width || !height) return file;
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return file;
|
|
ctx.drawImage(img, 0, 0);
|
|
|
|
const mime = toPng ? 'image/png' : 'image/jpeg';
|
|
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
|
|
if (!blob) return file;
|
|
|
|
const baseName = (file.name || 'image').replace(/\.[^.]+$/, '') || 'image';
|
|
return new File([blob], baseName + (toPng ? '.png' : '.jpg'), { type: mime });
|
|
} catch (err) {
|
|
console.warn('Image re-encode failed, uploading original:', err);
|
|
return file;
|
|
} finally {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
},
|
|
|
|
});
|