Squash Odysseus development history

This commit is contained in:
pewdiepie-archdaemon
2026-09-11 06:04:19 +00:00
parent e5c99a5eee
commit 6ee6502010
2050 changed files with 538359 additions and 57745 deletions
+256 -15
View File
@@ -4,7 +4,7 @@
* File attachment and upload handling
*/
import uiModule from './ui.js';
import uiModule from './ui.js?v=20260908weekhoverfix1';
import spinnerModule from './spinner.js';
let pendingFiles = [];
@@ -15,8 +15,168 @@ let uploaded = [];
let _lastUploadedMeta = [];
let API_BASE = '';
let _uploadSpinners = [];
let _uploadAbortCtrl = null;
let _uploading = false;
let _lastUploadCancelled = false;
const _previewUrls = new WeakMap();
const MAX_FILES = 10;
const MAX_VISIBLE = 3;
let _expanded = false;
function _isMobileViewport() {
return window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
}
function _isCroppableImage(f) {
const mime = (f?.type || '').toLowerCase();
const name = (f?.name || '').toLowerCase();
if (!(mime.startsWith('image/') || /\.(png|jpe?g|webp|bmp)$/i.test(name))) return false;
return !mime.includes('svg') && !mime.includes('gif') && !/\.svg|\.gif$/i.test(name);
}
function _loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
}
function _canvasToBlob(canvas, type, quality) {
return new Promise((resolve) => canvas.toBlob(resolve, type || 'image/png', quality));
}
async function _openMobileCropper(file) {
const url = _getPreviewUrl(file);
let imgProbe = await _loadImage(url);
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'attach-crop-overlay';
overlay.innerHTML = `
<div class="attach-crop-panel" role="dialog" aria-modal="true" aria-label="Crop image">
<div class="attach-crop-stage">
<img class="attach-crop-img" alt="">
<div class="attach-crop-box"><span class="attach-crop-handle"></span></div>
</div>
<div class="attach-crop-actions">
<button type="button" class="attach-crop-btn" data-action="cancel">Cancel</button>
<button type="button" class="attach-crop-btn" data-action="original">Original</button>
<button type="button" class="attach-crop-btn" data-action="rotate" title="Rotate image clockwise">Rotate</button>
<button type="button" class="attach-crop-btn attach-crop-primary" data-action="crop">Use crop</button>
</div>
</div>`;
document.body.appendChild(overlay);
const img = overlay.querySelector('.attach-crop-img');
const box = overlay.querySelector('.attach-crop-box');
let workingFile = file;
let workingUrl = url;
let rotatedUrl = null;
img.src = workingUrl;
img.alt = file.name || 'image';
let crop = { x: 0.08, y: 0.08, w: 0.84, h: 0.84 };
let drag = null;
function applyCrop() {
const r = img.getBoundingClientRect();
const pr = overlay.querySelector('.attach-crop-stage').getBoundingClientRect();
box.style.left = (r.left - pr.left + crop.x * r.width) + 'px';
box.style.top = (r.top - pr.top + crop.y * r.height) + 'px';
box.style.width = (crop.w * r.width) + 'px';
box.style.height = (crop.h * r.height) + 'px';
}
function clampCrop() {
crop.w = Math.max(0.12, Math.min(1, crop.w));
crop.h = Math.max(0.12, Math.min(1, crop.h));
crop.x = Math.max(0, Math.min(1 - crop.w, crop.x));
crop.y = Math.max(0, Math.min(1 - crop.h, crop.y));
}
function finish(value) {
overlay.remove();
window.removeEventListener('resize', applyCrop);
if (rotatedUrl) URL.revokeObjectURL(rotatedUrl);
resolve(value);
}
requestAnimationFrame(applyCrop);
img.addEventListener('load', applyCrop);
window.addEventListener('resize', applyCrop);
box.addEventListener('pointerdown', (e) => {
e.preventDefault();
box.setPointerCapture(e.pointerId);
drag = {
mode: e.target.classList.contains('attach-crop-handle') ? 'resize' : 'move',
sx: e.clientX,
sy: e.clientY,
start: { ...crop },
};
});
box.addEventListener('pointermove', (e) => {
if (!drag) return;
const r = img.getBoundingClientRect();
const dx = (e.clientX - drag.sx) / Math.max(1, r.width);
const dy = (e.clientY - drag.sy) / Math.max(1, r.height);
if (drag.mode === 'resize') {
crop.w = drag.start.w + dx;
crop.h = drag.start.h + dy;
} else {
crop.x = drag.start.x + dx;
crop.y = drag.start.y + dy;
}
clampCrop();
applyCrop();
});
box.addEventListener('pointerup', () => { drag = null; });
box.addEventListener('pointercancel', () => { drag = null; });
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => finish(null));
overlay.querySelector('[data-action="original"]').addEventListener('click', () => finish(file));
overlay.querySelector('[data-action="rotate"]').addEventListener('click', async () => {
const canvas = document.createElement('canvas');
canvas.width = imgProbe.naturalHeight;
canvas.height = imgProbe.naturalWidth;
const ctx = canvas.getContext('2d');
ctx.translate(canvas.width, 0);
ctx.rotate(Math.PI / 2);
ctx.drawImage(imgProbe, 0, 0);
const type = workingFile.type && workingFile.type !== 'image/bmp' ? workingFile.type : 'image/png';
const blob = await _canvasToBlob(canvas, type, 0.92);
if (!blob) return;
const ext = type.includes('jpeg') ? 'jpg' : (type.split('/')[1] || 'png');
const base = (file.name || 'image').replace(/\.[^.]+$/, '');
workingFile = new File([blob], `${base}-rotated.${ext}`, { type, lastModified: Date.now() });
const nextUrl = URL.createObjectURL(workingFile);
if (rotatedUrl) URL.revokeObjectURL(rotatedUrl);
rotatedUrl = nextUrl;
workingUrl = nextUrl;
imgProbe = await _loadImage(workingUrl);
crop = { x: 0.08, y: 0.08, w: 0.84, h: 0.84 };
img.src = workingUrl;
requestAnimationFrame(applyCrop);
});
overlay.querySelector('[data-action="crop"]').addEventListener('click', async () => {
clampCrop();
const canvas = document.createElement('canvas');
const sx = Math.round(crop.x * imgProbe.naturalWidth);
const sy = Math.round(crop.y * imgProbe.naturalHeight);
const sw = Math.max(1, Math.round(crop.w * imgProbe.naturalWidth));
const sh = Math.max(1, Math.round(crop.h * imgProbe.naturalHeight));
canvas.width = sw;
canvas.height = sh;
const ctx = canvas.getContext('2d');
ctx.drawImage(imgProbe, sx, sy, sw, sh, 0, 0, sw, sh);
const type = workingFile.type && workingFile.type !== 'image/bmp' ? workingFile.type : 'image/png';
const blob = await _canvasToBlob(canvas, type, 0.92);
if (!blob) { finish(file); return; }
const ext = type.includes('jpeg') ? 'jpg' : (type.split('/')[1] || 'png');
const base = (file.name || 'image').replace(/\.[^.]+$/, '');
finish(new File([blob], `${base}-cropped.${ext}`, { type, lastModified: Date.now() }));
});
});
}
function _getPreviewUrl(f) {
if (!f) return '';
let url = _previewUrls.get(f);
@@ -49,10 +209,6 @@ export function openPicker() {
document.getElementById('file-input').click();
}
const MAX_VISIBLE = 3;
const MAX_EXPAND = 6; // beyond this, the badge stays collapsed (too many chips to preview)
let _expanded = false;
/**
* Render the attachment strip with pending files.
* 1-3 files: show individual chips.
@@ -80,11 +236,9 @@ export function renderAttachStrip() {
label.className = 'thumb-collapsed-label';
badge.appendChild(label);
badge.title = pendingFiles.map(f => f.name || 'pasted-image').join('\n');
const canExpand = total <= MAX_EXPAND;
badge.style.cursor = canExpand ? 'pointer' : 'default';
badge.style.cursor = 'pointer';
badge.addEventListener('click', (e) => {
if (e.target.closest('.thumb-collapsed-x')) return;
if (!canExpand) return; // too many files — don't expand into chips
_expanded = true;
renderAttachStrip();
});
@@ -112,8 +266,22 @@ function _createChip(f, idx) {
chip.classList.add('thumb-image'); // lets CSS overlay the remove-X on the corner (mobile)
const img = document.createElement('img');
img.className = 'thumb-img';
img.src = URL.createObjectURL(f);
img.src = _getPreviewUrl(f);
img.alt = f.name || 'image';
img.title = _isMobileViewport() && _isCroppableImage(f) ? 'Crop or rotate image' : '';
img.addEventListener('click', async (e) => {
if (!_isMobileViewport() || !_isCroppableImage(f) || _uploading) return;
e.stopPropagation();
try {
const nextFile = await _openMobileCropper(f);
if (!nextFile || nextFile === f || pendingFiles[idx] !== f) return;
_revokePreviewUrl(f);
pendingFiles[idx] = nextFile;
renderAttachStrip();
} catch (_) {
_showToast('Could not edit image');
}
});
chip.appendChild(img);
} else {
const span = document.createElement('span');
@@ -132,6 +300,7 @@ function _createChip(f, idx) {
* Remove a pending file by index
*/
export function removePending(idx) {
if (_uploading) cancelUpload();
_revokePreviewUrl(pendingFiles[idx]);
pendingFiles.splice(idx, 1);
renderAttachStrip();
@@ -140,8 +309,9 @@ export function removePending(idx) {
/**
* Upload all pending files to server
*/
export async function uploadPending() {
export async function uploadPending(opts = {}) {
if (pendingFiles.length === 0) return [];
_lastUploadCancelled = false;
// The message bubble is shown immediately, but the upload can take a moment —
// dim the chips and overlay a whirlpool so it's clear the files are still
@@ -166,21 +336,56 @@ export async function uploadPending() {
const fd = new FormData();
pendingFiles.forEach(f => fd.append('files', f, f.name || 'paste.png'));
if (opts.sessionId) fd.append('session_id', opts.sessionId);
_uploadAbortCtrl = new AbortController();
_uploading = true;
const timeoutId = setTimeout(() => {
if (_uploadAbortCtrl && !_uploadAbortCtrl.signal.aborted) {
try { _uploadAbortCtrl.abort(); } catch (_) {}
}
}, 120000);
try {
const res = await fetch(`${API_BASE}/api/upload`, {
method: 'POST',
body: fd
body: fd,
signal: _uploadAbortCtrl.signal,
});
if (!res.ok) {
// Surface the failure instead of swallowing it. Previously a non-OK
// response (e.g. 429 rate limit, 413 too large) was ignored: the files
// silently vanished and the chat sent with no attachments, so the model
// "didn't even see them" (issue #1346). Show the server's reason and keep
// pendingFiles so the strip re-renders for a retry (see finally below).
let detail = '';
try { const e = await res.json(); detail = e.detail || e.error || ''; } catch (_) {}
_showToast('Upload failed' + (detail ? ': ' + detail : ` (HTTP ${res.status})`));
return [];
}
const data = await res.json();
uploaded = (data.files || []);
if (uploaded.some(x => x && x.gallery_id)) {
try { localStorage.setItem('gallery-fresh-chat-upload', String(Date.now())); } catch (_) {}
window.dispatchEvent(new CustomEvent('gallery-refresh', { detail: { source: 'chat-upload' } }));
}
pendingFiles = []; // clear only on success
// Stash the full meta (incl. width/height for images) on the module so
// callers that want it can grab it via getLastUploadedMeta(). Keep the
// returned shape as `ids` for backward-compatibility with existing call sites.
_lastUploadedMeta = uploaded;
return uploaded.map(x => x.id);
} catch (e) {
if (e && e.name === 'AbortError') {
_lastUploadCancelled = true;
_showToast('Upload cancelled');
return [];
}
_showToast('Upload failed: ' + (e?.message || 'network error'));
return [];
} finally {
clearTimeout(timeoutId);
_uploading = false;
_uploadAbortCtrl = null;
_uploadSpinners.forEach(sp => { try { sp.stop && sp.stop(); } catch (_) {} });
_uploadSpinners = [];
if (strip) strip.classList.remove('attach-uploading');
@@ -190,22 +395,38 @@ export async function uploadPending() {
}
}
const MAX_FILES = 10;
/**
* Add files to pending list (capped at MAX_FILES)
*/
export function addFiles(files) {
export async function addFiles(files, opts = {}) {
for (const f of files) {
if (pendingFiles.length >= MAX_FILES) {
_showToast(`Max ${MAX_FILES} files allowed`);
break;
}
pendingFiles.push(f);
let nextFile = f;
if (!opts.skipCrop && _isMobileViewport() && _isCroppableImage(f)) {
try {
nextFile = await _openMobileCropper(f);
} catch (_) {
nextFile = f;
}
if (!nextFile) continue;
}
pendingFiles.push(nextFile);
}
renderAttachStrip();
}
export async function cropForMobileUpload(file) {
if (!_isMobileViewport() || !_isCroppableImage(file)) return file;
try {
return await _openMobileCropper(file);
} catch (_) {
return file;
}
}
function _showToast(msg) {
if (window.showToast) { window.showToast(msg); return; }
// Fallback inline toast
@@ -255,6 +476,7 @@ export function getPendingInfo() {
* Clear all pending files
*/
export function clearPending() {
if (_uploading) cancelUpload();
pendingFiles.forEach(_revokePreviewUrl);
pendingFiles = [];
renderAttachStrip();
@@ -265,6 +487,21 @@ export function getLastUploadedMeta() {
return _lastUploadedMeta;
}
export function isUploading() {
return _uploading;
}
export function wasLastUploadCancelled() {
return _lastUploadCancelled;
}
export function cancelUpload() {
_lastUploadCancelled = true;
if (_uploadAbortCtrl && !_uploadAbortCtrl.signal.aborted) {
try { _uploadAbortCtrl.abort(); } catch (_) {}
}
}
var escapeHtml = uiModule.esc;
const fileHandlerModule = {
@@ -274,11 +511,15 @@ const fileHandlerModule = {
removePending,
uploadPending,
addFiles,
cropForMobileUpload,
getPendingCount,
getPendingInfo,
getPendingRaw,
clearPending,
getLastUploadedMeta,
isUploading,
wasLastUploadCancelled,
cancelUpload,
};
export default fileHandlerModule;