diff --git a/frontend/src/features/notes/ui/cardInteractions.js b/frontend/src/features/notes/ui/cardInteractions.js index 09e083c41..4160e5c1b 100644 --- a/frontend/src/features/notes/ui/cardInteractions.js +++ b/frontend/src/features/notes/ui/cardInteractions.js @@ -112,7 +112,7 @@ export function createCardInteractions({ if (state.selectMode) { body.querySelectorAll('.note-card').forEach(card => { card.addEventListener('click', (e) => { - if (e.target.closest('.note-card-cb')) return; // checkbox handles itself + if (e.target.closest('.note-card-cb, a')) return; // links open without toggling selection e.stopPropagation(); tapToEditOrSelect(card); }); diff --git a/frontend/src/features/notes/ui/editor.js b/frontend/src/features/notes/ui/editor.js index f5bdc7c9f..678b08804 100644 --- a/frontend/src/features/notes/ui/editor.js +++ b/frontend/src/features/notes/ui/editor.js @@ -51,7 +51,7 @@ export function createEditor({ - ${currentImageUrl && type !== 'draw' ? `
` : ''} + ${currentImageUrl && type !== 'draw' ? `
` : ''}
${type === 'note' ? `` @@ -297,7 +297,7 @@ export function createEditor({ form.querySelector('.note-form-image-wrap')?.remove(); const wrap = document.createElement('div'); wrap.className = 'note-form-image-wrap'; - wrap.innerHTML = ``; + wrap.innerHTML = ``; // Insert AFTER the whole header (a flex-row), not after the // title input itself : otherwise the image lands as a sibling // of the title inside the header and flex puts them side-by-side. diff --git a/frontend/src/features/notes/ui/text.js b/frontend/src/features/notes/ui/text.js index 75ecbaf78..bb71cb690 100644 --- a/frontend/src/features/notes/ui/text.js +++ b/frontend/src/features/notes/ui/text.js @@ -31,7 +31,7 @@ export function createText({ url = url.slice(0, -1); } const href = url.startsWith('www.') ? `https://${url}` : url; - return `${url}` + (url !== m ? m.slice(url.length) : ''); + return `${url}` + (url !== m ? m.slice(url.length) : ''); }); } diff --git a/routes/upload_routes.py b/routes/upload_routes.py index 347da487e..92bbb84ea 100644 --- a/routes/upload_routes.py +++ b/routes/upload_routes.py @@ -25,11 +25,12 @@ from src.auth_helpers import effective_user, is_delegated_credential from src.owner_identity import auth_disabled, is_request_sentinel_owner from src.attachment_refs import attachment_refs_from_metadata from src.constants import GENERATED_IMAGES_DIR -from src.path_confinement import is_inside +from src.path_confinement import PathEscape, canonical_root, confine, is_inside from src.upload_handler import ( UploadCleanupSafetyError, count_recent_uploads, extract_upload_ids, + secure_filename, ) logger = logging.getLogger(__name__) @@ -153,30 +154,47 @@ def setup_upload_routes(upload_handler): def _upload_root() -> str: from src.constants import UPLOAD_DIR - return os.path.realpath(getattr(upload_handler, "upload_dir", UPLOAD_DIR)) + return canonical_root(getattr(upload_handler, "upload_dir", UPLOAD_DIR)) def _path_inside_upload_dir(path: str) -> bool: return is_inside(_upload_root(), path) def _resolve_upload_path(file_id: str) -> str: - from src.constants import UPLOAD_DIR - upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR) - direct = os.path.join(upload_root, file_id) + safe_id = secure_filename(os.path.basename(file_id)) + if not safe_id: + raise HTTPException(400, "Invalid file ID") + root = _upload_root() + + direct = os.path.normpath(os.path.join(root, safe_id)) if os.path.lexists(direct): - if not _path_inside_upload_dir(direct): + try: + canonical_direct = confine(root, direct, allow_root=False) + except (PathEscape, ValueError): raise HTTPException(403, "Access denied") - if os.path.isfile(direct): - return direct + if os.path.isfile(canonical_direct): + return canonical_direct raise HTTPException(404, "File not found") - for root, _dirs, files in os.walk(upload_root, followlinks=False): - if file_id not in files: - continue - path = os.path.join(root, file_id) - if not _path_inside_upload_dir(path): + info = _load_upload_info(safe_id) + stored_path = (info or {}).get("path") + if stored_path: + try: + canonical_stored = confine(root, stored_path, allow_root=False) + if os.path.isfile(canonical_stored): + return canonical_stored + except (PathEscape, ValueError): raise HTTPException(403, "Access denied") - if os.path.isfile(path): - return path + + for walk_root, _dirs, files in os.walk(root, followlinks=False): + if safe_id not in files: + continue + cand = os.path.normpath(os.path.join(walk_root, safe_id)) + try: + canonical_cand = confine(root, cand, allow_root=False) + except (PathEscape, ValueError): + raise HTTPException(403, "Access denied") + if os.path.isfile(canonical_cand): + return canonical_cand raise HTTPException(404, "File not found") raise HTTPException(404, "File not found") @@ -435,52 +453,79 @@ def setup_upload_routes(upload_handler): logger.error(f"Failed to get upload stats: {e}") raise HTTPException(500, "Failed to get upload statistics") - @router.get("/{file_id}") + @router.api_route("/{file_id}", methods=["GET", "HEAD"]) async def download_file(request: Request, file_id: str, thumb: int = 0): - """Serve an uploaded file by its ID. `?thumb=1` returns a small cached - JPEG thumbnail for images (used by chat attachment previews) so the - client isn't downloading the full-resolution photo just to show it tiny.""" + """Serve an uploaded file by its ID or probe via HEAD. `?thumb=1` returns + a small cached JPEG thumbnail for images (used by chat attachment previews) + so the client isn't downloading the full-resolution photo just to show it tiny.""" if not upload_handler.validate_upload_id(file_id): raise HTTPException(400, "Invalid file ID") + safe_file_id = secure_filename(os.path.basename(file_id)) import mimetypes as _mt # Look up original filename and owner from uploads.json - original_name = file_id + original_name = safe_file_id # _load_upload_index() tolerates a missing/corrupt uploads.json (it falls # back to the .bak sibling, then to {}), so a truncated DB degrades to # "no metadata" instead of a 500 from an unhandled JSONDecodeError. db = upload_handler._load_upload_index() - info = next((fi for fi in db.values() if fi.get("id") == file_id), None) + info = next((fi for fi in db.values() if fi.get("id") == safe_file_id), None) if info: - original_name = info.get("name", file_id) + original_name = info.get("name", safe_file_id) file_owner = info.get("owner") if info else None _verify_upload_access(request, file_owner) - path = _resolve_upload_path(file_id) + upload_root = _upload_root() + path = _resolve_upload_path(safe_file_id) + try: + path = confine(upload_root, path, allow_root=False) + except (PathEscape, ValueError): + raise HTTPException(403, "Access denied") + if not path.startswith(upload_root + os.sep) or not _path_inside_upload_dir(path): + raise HTTPException(403, "Access denied") mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream" from fastapi.responses import FileResponse # Downscaled thumbnail for image previews — generated once and cached. + # Skip generation during HEAD probe to avoid loading/processing whole image. if thumb and mime.startswith("image/"): + thumb_dir = os.path.normpath(os.path.join(upload_root, ".thumbs")) + thumb_path = os.path.normpath(os.path.join(thumb_dir, safe_file_id + ".jpg")) try: - from PIL import Image, ImageOps - thumb_dir = os.path.join(_upload_root(), ".thumbs") - os.makedirs(thumb_dir, exist_ok=True) - thumb_path = os.path.join(thumb_dir, file_id + ".jpg") - if (not os.path.exists(thumb_path) - or os.path.getmtime(thumb_path) < os.path.getmtime(path)): - im = Image.open(path) - # iPhone / camera JPEGs encode rotation in EXIF rather than - # the pixel data. Browsers honour that on the original via - # image-orientation:from-image, but PIL strips EXIF when it - # saves the JPEG thumb, leaving the pixels sideways. Bake - # the rotation into the pixels before thumbnailing. - im = ImageOps.exif_transpose(im) - im.thumbnail((320, 320)) - if im.mode not in ("RGB", "L"): - im = im.convert("RGB") - im.save(thumb_path, "JPEG", quality=80) - return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS) - except Exception as e: - logger.warning(f"Thumbnail generation failed for {file_id}: {e}") - # Fall through to the full image. + thumb_path = confine(upload_root, thumb_path, allow_root=False) + except (PathEscape, ValueError): + raise HTTPException(403, "Access denied") + if not thumb_path.startswith(thumb_dir + os.sep) or not _path_inside_upload_dir(thumb_path): + raise HTTPException(403, "Access denied") + if request.method == "HEAD": + if (os.path.isfile(thumb_path) + and os.path.getmtime(thumb_path) >= os.path.getmtime(path)): + return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS) + # GET may generate a JPEG or fall back to the original on failure. + # Neither representation is known without decoding the image. HEAD + # must not advertise the original/stale file's headers as a thumb. + from fastapi.responses import Response + response = Response(status_code=200, headers=UPLOAD_RESPONSE_HEADERS) + del response.headers["content-length"] + return response + else: + try: + from PIL import Image, ImageOps + os.makedirs(thumb_dir, exist_ok=True) + if (not os.path.exists(thumb_path) + or os.path.getmtime(thumb_path) < os.path.getmtime(path)): + im = Image.open(path) + # iPhone / camera JPEGs encode rotation in EXIF rather than + # the pixel data. Browsers honour that on the original via + # image-orientation:from-image, but PIL strips EXIF when it + # saves the JPEG thumb, leaving the pixels sideways. Bake + # the rotation into the pixels before thumbnailing. + im = ImageOps.exif_transpose(im) + im.thumbnail((320, 320)) + if im.mode not in ("RGB", "L"): + im = im.convert("RGB") + im.save(thumb_path, "JPEG", quality=80) + return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS) + except Exception as e: + logger.warning(f"Thumbnail generation failed for {file_id}: {e}") + # Fall through to the full image. return FileResponse( path, media_type=mime, @@ -496,9 +541,10 @@ def setup_upload_routes(upload_handler): return next((fi for fi in db.values() if fi.get("id") == file_id), None) def _vision_cache_path(file_id: str) -> str: - cache_dir = os.path.join(_upload_root(), ".vision") + safe_id = secure_filename(os.path.basename(file_id)) + cache_dir = os.path.normpath(os.path.join(_upload_root(), ".vision")) os.makedirs(cache_dir, exist_ok=True) - return os.path.join(cache_dir, file_id + ".txt") + return os.path.normpath(os.path.join(cache_dir, safe_id + ".txt")) def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None: """Copy upload OCR/vision text onto the promoted gallery image row.""" diff --git a/static/css/01-agent-chat.css b/static/css/01-agent-chat.css index 6a98cfd25..9a5a836cb 100644 --- a/static/css/01-agent-chat.css +++ b/static/css/01-agent-chat.css @@ -7256,6 +7256,39 @@ body.bg-pattern-ascii-fireflies { text-align: center; } + /* Inline assistant markdown images (`![alt](url)`) — rendered by + static/js/markdown.js as a.clickable wrapper. */ + a.chat-image { + display: inline-block; + max-width: 100%; + margin: 0.35rem 0; + } + a.chat-image img.chat-inline-image { + display: block; + max-width: 100%; + height: auto; + border-radius: 8px; + cursor: zoom-in; + } + a.chat-image .chat-image-fallback { + display: none; + font-size: 0.8rem; + color: var(--color-muted, #94a3b8); + word-break: break-all; + padding: 0.5rem 0.75rem; + border: 1px dashed var(--border, #475569); + border-radius: 8px; + background: var(--panel, #202428); + } + a.chat-image.image-broken img.chat-inline-image { + display: none; + } + a.chat-image.image-broken .chat-image-fallback { + display: block; + text-decoration: underline; + cursor: pointer; + } + /* Setup wizard */ .setup-wizard { padding: 16px; max-width: 500px; } .setup-wizard .setup-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; color: var(--fg); } diff --git a/static/css/03-agent-chat.css b/static/css/03-agent-chat.css index 0e8c95f91..c245899c2 100644 --- a/static/css/03-agent-chat.css +++ b/static/css/03-agent-chat.css @@ -22,6 +22,20 @@ /* Session items */ .session-item { position: relative; } + +/* Title fade: the server auto-renames a chat shortly after its first + prompt, which re-renders the sidebar. Fade the new title in so the + change reads as smooth rather than a snap. */ +.session-item .grow.session-title-updated { + animation: sessionTitleFade 0.5s ease; +} +@keyframes sessionTitleFade { + from { opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); } +} +@media (prefers-reduced-motion: reduce) { + .session-item .grow.session-title-updated { animation: none; } +} .text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .session-menu-btn { padding: 0 2px !important; min-width: 20px; height: 20px; display: inline-flex !important; align-items: center; justify-content: center; background: none !important; border-color: transparent !important; } .session-menu-btn:hover { background: none !important; border-color: transparent !important; } diff --git a/static/css/04-memory.css b/static/css/04-memory.css index 71b2537bd..67cb7ca16 100644 --- a/static/css/04-memory.css +++ b/static/css/04-memory.css @@ -704,6 +704,7 @@ textarea.memory-add-input { #serve-bulk-bar, #gallery-bulk-bar, #gallery-editor-drafts-bulk, +#notes-bulk-bar, #memory-modal .memory-bulk-bar { padding-right: 18px; } diff --git a/static/index.html b/static/index.html index 901b42510..aafacc2e1 100644 --- a/static/index.html +++ b/static/index.html @@ -664,6 +664,7 @@
- + +
@@ -1356,7 +1357,7 @@ async function loadEndpoints() {
None
${warningHtml}${showSearch ? `` : ''}
` + sortedModels.map(m => { - const mode = ['none', 'compact', 'full'].includes(String(m.tool_mode || '').toLowerCase()) + const mode = ['none', 'compact', 'full', 'spec'].includes(String(m.tool_mode || '').toLowerCase()) ? String(m.tool_mode).toLowerCase() : ''; return `
@@ -1370,7 +1371,8 @@ async function loadEndpoints() {
@@ -1440,7 +1442,7 @@ async function _saveEpModelState(epId, panel) { const value = String(sel.value || '').toLowerCase(); const original = String(sel.dataset.originalToolMode || '').toLowerCase(); if (modelId && (sel.dataset.toolModeTouched === '1' || original)) { - modelToolModes[modelId] = ['none', 'compact', 'full'].includes(value) ? value : ''; + modelToolModes[modelId] = ['none', 'compact', 'full', 'spec'].includes(value) ? value : ''; } }); const total = panel.querySelectorAll('input.adm-cb-hidden[type=checkbox]').length; diff --git a/static/js/chat.js b/static/js/chat.js index b02eb743c..da27508ef 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -44,6 +44,283 @@ import { invalidateSettings } from './appConfig.js'; const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting const RESEARCH_SVG = ''; + // Inline chat images share the editor's 8192px dimension ceiling, with a + // conservative 16MP budget for its multiple canvas surfaces. No blob URLs are + // needed here: chat previews use server-authorized upload references. + const _MAX_DATA_URL_LENGTH = 10 * 1024 * 1024; + const _MAX_INLINE_BYTES = 7 * 1024 * 1024; + const _MAX_IMAGE_DIMENSION = 8192; + const _MAX_IMAGE_PIXELS = 16 * 1024 * 1024; + let _inlineRasterCache = null; // One bounded source; repeated actions reuse its decode. + + function _imageDimensionsAllowed(width, height) { + return width > 0 && height > 0 && width <= _MAX_IMAGE_DIMENSION && + height <= _MAX_IMAGE_DIMENSION && width * height <= _MAX_IMAGE_PIXELS; + } + + function _rasterDimensions(bytes, mime) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const ascii = (offset, text) => [...text].every((char, i) => bytes[offset + i] === char.charCodeAt(0)); + if (mime === 'image/png' && bytes.length >= 33 && + bytes[0] === 137 && ascii(1, 'PNG\r\n\x1a\n') && view.getUint32(8) === 13 && ascii(12, 'IHDR')) { + return [view.getUint32(16), view.getUint32(20)]; + } + if (mime === 'image/gif' && bytes.length >= 13 && (ascii(0, 'GIF87a') || ascii(0, 'GIF89a'))) { + return [view.getUint16(6, true), view.getUint16(8, true)]; + } + if (mime === 'image/jpeg' && bytes[0] === 255 && bytes[1] === 216) { + let pos = 2; + while (pos + 4 <= bytes.length) { + if (bytes[pos++] !== 255) return null; + while (bytes[pos] === 255) pos++; + const marker = bytes[pos++]; + if (marker === 218 || marker === 217) break; + if (marker === 1 || (marker >= 208 && marker <= 215)) continue; + if (pos + 2 > bytes.length) return null; + const size = view.getUint16(pos); + if (size < 2 || pos + size > bytes.length) return null; + if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker) && size >= 8) { + return [view.getUint16(pos + 5), view.getUint16(pos + 3)]; + } + pos += size; + } + } + if (mime === 'image/webp' && bytes.length >= 30 && ascii(0, 'RIFF') && + view.getUint32(4, true) + 8 === bytes.length && ascii(8, 'WEBP')) { + if (ascii(12, 'VP8X')) return [1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16), 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16)]; + if (ascii(12, 'VP8L') && bytes[20] === 47) return [1 + ((bytes[21] | (bytes[22] << 8)) & 16383), 1 + (((bytes[22] >> 6) | (bytes[23] << 2) | (bytes[24] << 10)) & 16383)]; + if (ascii(12, 'VP8 ') && bytes[23] === 157 && bytes[24] === 1 && bytes[25] === 42) return [view.getUint16(26, true) & 16383, view.getUint16(28, true) & 16383]; + } + return null; + } + + function _inlineRaster(url) { + if (_inlineRasterCache?.url === url) return _inlineRasterCache; + const result = { url, valid: false }; + _inlineRasterCache = result; + if (url.length > _MAX_DATA_URL_LENGTH) return result; + const match = /^data:(image\/(?:png|jpeg|jpg|webp|gif));base64,/i.exec(url); + if (!match) return result; + const payload = url.slice(match[0].length); + if (!payload || payload.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) return result; + const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0; + const size = payload.length / 4 * 3 - padding; + if (size > _MAX_INLINE_BYTES) return result; + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + if (padding && (alphabet.indexOf(payload[payload.length - padding - 1]) & (padding === 2 ? 15 : 3))) return result; + try { + const bytes = typeof Uint8Array.fromBase64 === 'function' ? Uint8Array.fromBase64(payload) : (() => { + const binary = atob(payload); const array = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i); + return array; + })(); + const mime = match[1].toLowerCase().replace('image/jpg', 'image/jpeg'); + const dimensions = _rasterDimensions(bytes, mime); + if (!dimensions || !_imageDimensionsAllowed(...dimensions)) return result; + Object.assign(result, { valid: true, bytes, mime, dimensions }); + } catch (_) {} + return result; + } + + // This synchronous guard rejects untrusted references before any fetch/decode. + // Inline images additionally require a successful browser decode before launch. + function _isAuthorizedImageUrl(url) { + if (!url || typeof url !== 'string') return false; + const trimmed = url.trim(); + if (trimmed.startsWith('data:')) return _inlineRaster(trimmed).valid; + if (!trimmed || trimmed.startsWith('blob:') || trimmed.startsWith('//')) return false; + try { + const base = window.location.origin; + const parsed = new URL(trimmed, base); + return parsed.origin === base && !parsed.search && !parsed.hash && + /^\/api\/(?:upload|gallery)\/[A-Za-z0-9_\-]+(?:\.[a-zA-Z0-9]+)?$/.test(parsed.pathname); + } catch (_) { return false; } + } + + async function _validateInlineImage(url) { + const raster = _inlineRaster(url.trim()); + if (!raster.valid) return false; + if (!raster.decoded) raster.decoded = (async () => { + try { + const image = await createImageBitmap(new Blob([raster.bytes], { type: raster.mime })); + try { + return _imageDimensionsAllowed(image.width, image.height) && + image.width * image.height === raster.dimensions[0] * raster.dimensions[1]; + } finally { image.close(); } + } catch (_) { return false; } + finally { raster.bytes = null; } + })(); + return raster.decoded; + } + + // Find the specific user message that originated the turn containing container. + function _findOriginatingUserMessage(container) { + if (!container) return null; + let curr = container.previousElementSibling; + while (curr) { + if (curr.classList && curr.classList.contains('msg-user')) { + return curr; + } + curr = curr.previousElementSibling; + } + const box = document.getElementById('chat-history'); + if (box && box.contains(container)) { + const all = Array.from(box.querySelectorAll('.msg, .agent-thread, .ai-msg-wrap, .round-holder')); + const idx = all.findIndex((el) => el === container || el.contains(container)); + if (idx > 0) { + for (let i = idx - 1; i >= 0; i--) { + if (all[i].classList && all[i].classList.contains('msg-user')) { + return all[i]; + } + } + } + } + return null; + } + + // Resolve the image attached to the exact turn associated with container / action. + // Never falls back to unrelated images from earlier conversation turns. + function _attachedImageForTurn(container, action) { + const explicitId = action && (action.imageId || action.image_id || action.fileId || action.file_id); + if (explicitId && typeof explicitId === 'string' && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(explicitId)) { + return (API_BASE || '') + '/api/upload/' + explicitId; + } + const explicitUrl = action && (action.imageUrl || action.image_url); + if (explicitUrl) return _isAuthorizedImageUrl(explicitUrl) ? explicitUrl : null; + + const userMsg = _findOriginatingUserMessage(container); + if (!userMsg) return null; + + const previews = userMsg.querySelectorAll( + '.attach-image-preview[data-file-id], .attach-card[data-file-id]' + ); + for (let i = previews.length - 1; i >= 0; i--) { + const fid = previews[i].dataset ? previews[i].dataset.fileId : null; + if (fid && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(fid)) { + return (API_BASE || '') + '/api/upload/' + fid; + } + const img = previews[i].querySelector('img'); + const src = img ? (img.getAttribute('src') || '') : ''; + if (_isAuthorizedImageUrl(src)) return src; + } + + const imgs = userMsg.querySelectorAll('.attach-image-preview img, .attach-card img'); + for (let i = imgs.length - 1; i >= 0; i--) { + const src = imgs[i].getAttribute('src') || ''; + if (_isAuthorizedImageUrl(src)) return src; + } + + return null; + } + + function _currentAttachedImageUrl(container, action) { + return _attachedImageForTurn(container, action); + } + + function _notifyError(msg) { + try { + if (uiModule && typeof uiModule.showError === 'function') { + uiModule.showError(msg); + return; + } + } catch (_) {} + console.warn('[chat]', msg); + } + + // Render the one-click "Open in editor & apply" button on a photo-edit reply + // that had no open editor (photo_editor_action kind 'open_and_apply'). + function renderPhotoEditorApplyButton(container, action) { + if (!container || !action || action.kind !== 'open_and_apply') return; + if (container.querySelector('.chat-open-in-editor-btn')) return; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'chat-open-in-editor-btn'; + btn.textContent = 'Open in editor & apply'; + btn.style.cssText = 'margin-top:8px;padding:6px 12px;border-radius:6px;border:1px solid rgba(120,120,120,0.4);background:transparent;color:inherit;cursor:pointer;font:inherit;'; + btn.addEventListener('click', async () => { + if (btn.disabled) return; + btn.disabled = true; + try { + let imageUrl = _attachedImageForTurn(container, action); + if (!imageUrl) { + _notifyError('No attached photo found for this conversation turn.'); + btn.disabled = false; + return; + } + + if (!_isAuthorizedImageUrl(imageUrl)) { + _notifyError('Unauthorized or invalid image reference.'); + btn.disabled = false; + return; + } + + if (imageUrl.trim().startsWith('data:') && !await _validateInlineImage(imageUrl)) { + _notifyError('Invalid or oversized image attachment.'); + return; + } + // Gallery references name an owner-scoped metadata resource, not image bytes. + if (!imageUrl.trim().startsWith('data:') && new URL(imageUrl, window.location.origin).pathname.startsWith('/api/gallery/')) { + const response = await fetch(imageUrl, { credentials: 'same-origin' }); + if (!response.ok) { _notifyError('The gallery image is unavailable.'); return; } + const image = await response.json(); + const source = new URL(image.url || '', window.location.origin); + if (source.origin !== window.location.origin || source.search || source.hash || + !/^\/api\/generated-image\/[A-Za-z0-9_.-]+$/.test(source.pathname)) { + _notifyError('Invalid gallery image reference.'); return; + } + imageUrl = source.href; + } + + // Verify attachment is accessible and authorized on the server before launching editor + if (imageUrl.includes('/api/upload/')) { + try { + const probe = await fetch(imageUrl, { method: 'HEAD' }); + if (!probe.ok) { + if (probe.status === 404 || probe.status === 410) { + _notifyError('The attached image is no longer available or upload has expired.'); + } else if (probe.status === 401 || probe.status === 403) { + _notifyError('You are not authorized to edit this attached image.'); + } else { + _notifyError('Could not verify image attachment authorization.'); + } + btn.disabled = false; + return; + } + } catch (_) { + _notifyError('Could not verify image attachment authorization.'); + btn.disabled = false; + return; + } + } + + if (typeof window.gePhotoEditorApply !== 'function') { + await import('/static/js/galleryEditor.js?v=20260909movepicklayer1'); + } + await window.gePhotoEditorApply({ + imageId: (action && (action.imageId || action.image_id)) || null, + imageUrl, + prompt: action.prompt || '', + displayName: 'Chat edit', + }); + } catch (err) { + console.error('[chat] open_and_apply failed', err); + _notifyError('Failed to apply edit: ' + (err.message || err)); + } finally { + btn.disabled = false; + } + }); + container.appendChild(btn); + } + + if (typeof window !== 'undefined') { + window.__chatPhotoEditorTestHooks = { + _isAuthorizedImageUrl, + _findOriginatingUserMessage, + _attachedImageForTurn, + renderPhotoEditorApplyButton, + }; + } + let API_BASE = ''; let currentAbort = null; let isStreaming = false; @@ -4095,6 +4372,7 @@ import { invalidateSettings } from './appConfig.js'; threadAbove.classList.add('has-bottom'); } terminalFinalResponseRendered = _renderTerminalAnswer(finalText, '', json.render_owner, json.replacement_scope); + if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action); lastContentRoundHolder = roundHolder || lastContentRoundHolder; uiModule.scrollHistory(); continue; @@ -6519,6 +6797,7 @@ import { invalidateSettings } from './appConfig.js'; gotDelta = true; try { spinner.destroy(); } catch (_) {} renderDelta(json); + if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action); replayRendering.settle(); } else if (json.delta) { if (!replayRendering.accepts(json)) continue; diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index dceec9b24..267b2e3e7 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -457,6 +457,7 @@ function _makeActionBtn(className, title, text, handler) { btn.className = className; btn.type = 'button'; btn.title = title; + btn.setAttribute('aria-label', title); btn.textContent = text; btn.addEventListener('click', handler); return btn; @@ -2375,6 +2376,7 @@ export function createMsgFooter(msgElement, { animate = false } = {}) { moreBtn.className = 'msg-action-btn msg-more-btn'; moreBtn.type = 'button'; moreBtn.title = 'More actions'; + moreBtn.setAttribute('aria-label', 'More actions'); moreBtn.textContent = '\u00B7\u00B7\u00B7'; moreBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -2571,6 +2573,7 @@ export function createUserMsgFooter(msgElement) { moreBtn.className = 'msg-action-btn msg-more-btn'; moreBtn.type = 'button'; moreBtn.title = 'More actions'; + moreBtn.setAttribute('aria-label', 'More actions'); moreBtn.textContent = '\u00B7\u00B7\u00B7'; moreBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -3443,7 +3446,8 @@ export function addMessage(role, content, modelName, metadata) { for (const ev of roundTools) { const image = generatedImageResult(ev); if (image) { - box.appendChild(buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id)); + const imgBubble = buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id); + box.appendChild(imgBubble); } } } diff --git a/static/js/chatStream.js b/static/js/chatStream.js index 4981ac34d..84497c163 100644 --- a/static/js/chatStream.js +++ b/static/js/chatStream.js @@ -212,6 +212,8 @@ export function handleUIControl(uiData) { var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes)); if (fn) fn(); }).catch(function(){}); + } else if (panel === 'tasks') { + document.getElementById('tool-tasks-btn')?.click(); } else if (panel === 'theme' || panel === 'themes') { import('./theme.js?v=20260911organsrain1').then(function(mod) { var fn = mod.togglePopup || (mod.default && mod.default.togglePopup); diff --git a/static/js/codeRunner.js b/static/js/codeRunner.js index fd896883d..c9ea1ac9d 100644 --- a/static/js/codeRunner.js +++ b/static/js/codeRunner.js @@ -390,11 +390,11 @@ export function runHTML(code, panel) { * Main entry point — called when a Run button is clicked */ export function run(btn) { - const code = btn.getAttribute('data-code'); - const lang = (btn.getAttribute('data-lang') || '').toLowerCase(); - const pre = btn.closest('pre'); if (!pre) return; + const codeEl = pre.querySelector('code'); + const code = codeEl ? codeEl.textContent : btn.getAttribute('data-code'); + const lang = (btn.getAttribute('data-lang') || '').toLowerCase(); const panel = getOrCreatePanel(pre); if (!code || !code.trim()) { diff --git a/static/js/colorPicker.js b/static/js/colorPicker.js index f1784c74e..a2901fb74 100644 --- a/static/js/colorPicker.js +++ b/static/js/colorPicker.js @@ -101,12 +101,14 @@ function suggestionsForHsv(h, s, v) { ]; } +// ── Reusable helpers for hosts that surface swatches inline ────────── +// (e.g. the editor's SWATCHES panel showing suggestions + recents). function computeSuggestions() { return suggestionsForHsv(_h, _s, _v); } export function getRecentColors() { return getRecents(); } export function recordRecentColor(hex) { addRecent(hex); } export function suggestColorsFor(hex) { - const { h, s, v } = hexToHsv(hex); + const { h, s, v } = hexToHsv(hex || '#000000'); return suggestionsForHsv(h, s, v); } diff --git a/static/js/cookbookServe.js b/static/js/cookbookServe.js index f9511ed60..2ebf18086 100644 --- a/static/js/cookbookServe.js +++ b/static/js/cookbookServe.js @@ -1498,7 +1498,8 @@ function _rerenderCachedModels() { const _ggufOptions = _ggufChoices.map(f => `` ).join(''); - const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo); + const _minimaxM3Snapshot = '~/.cache/huggingface/hub/models--cyankiwi--MiniMax-M3-AWQ-INT4/snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b'; + const _defaultServeModel = _isMiniMaxM3 ? _minimaxM3Snapshot : (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo); const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim(); const _modelPathValue = _savedModelPath || _defaultServeModel; const _defaultServedModelName = _isMiniMaxM3 ? repo : ''; diff --git a/static/js/document.js b/static/js/document.js index a3e01dc45..e265a4420 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -1996,6 +1996,17 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; // Active drop mode for the PDF toolbar — toolbar buttons set this; the // next click on a page consumes it. null means clicks do nothing. let _pdfDropMode = null; + let _pdfAnnotationDragging = false; + function _setPdfAnnotationDragging(dragging) { + _pdfAnnotationDragging = dragging; + for (const ref of _pdfPaneAnnotationsByDoc.get(activeDocId) || []) { + ref._refreshHandles?.(); + if (dragging) { + const menu = ref.wrap?.querySelector('.pdf-annotation-text-menu'); + if (menu) menu.style.display = 'none'; + } + } + } // Per-doc last-used line spacing for text annotations. Once the user picks // 1.6 for one box, every text box dropped after that defaults to 1.6. const _pdfLastLineHeight = new Map(); // docId -> number @@ -2290,8 +2301,16 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; // active (toolbar buttons set the mode). Without a mode, clicking does // nothing — keeps page interactions predictable so users don't get // surprise boxes from stray clicks. + const isBlankPageTarget = target => target === pageWrap + || (target?.tagName === 'IMG' && target.parentElement === pageWrap); + let placementStartedOnPage = false; + pageWrap.addEventListener('pointerdown', ev => { + placementStartedOnPage = isBlankPageTarget(ev.target); + }, true); pageWrap.addEventListener('click', (ev) => { - if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return; + const canPlace = placementStartedOnPage; + placementStartedOnPage = false; + if (!canPlace || !isBlankPageTarget(ev.target) || _pdfAnnotationDragging) return; if (!_pdfDropMode) return; const rect = pageWrap.getBoundingClientRect(); const xPct = ((ev.clientX - rect.left) / rect.width) * 100; @@ -2423,9 +2442,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; // Set handle visibility together; clicking/tapping the annotation itself // brings hidden controls back. + let handlesRequested = _isTouch; const _setHandlesVisible = (show) => { + if (!_pdfAnnotationDragging) handlesRequested = show; const dismissed = wrap.dataset.controlsDismissed === '1'; - const v = (show && !dismissed) ? '' : 'none'; + const v = (show && !dismissed && !_pdfAnnotationDragging) ? '' : 'none'; del.style.display = v; grip.style.display = v; resize.style.display = v; @@ -2446,7 +2467,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; _setHandlesVisible(true); }); - const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible }; + const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible, + _refreshHandles: () => _setHandlesVisible(handlesRequested) }; if (kind === 'check') { // Stamp checkmark — value is fixed, nothing to listen for. @@ -2568,7 +2590,12 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; ref._autoGrow = _autoGrow; } + del.addEventListener('pointerdown', ev => { + ev.preventDefault(); + ev.stopPropagation(); + }); del.addEventListener('click', (ev) => { + ev.preventDefault(); ev.stopPropagation(); _pushPdfUndoSnapshot(); _removeAnnotation(ref); @@ -2580,6 +2607,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; ev.stopPropagation(); _pushPdfUndoSnapshot(); try { grip.setPointerCapture(ev.pointerId); } catch (_) {} + _setPdfAnnotationDragging(true); // Hide the × and resize handles while moving so they don't obscure the // box — easier to see exactly where it lands. Restored on release. del.style.display = 'none'; @@ -2598,11 +2626,14 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); + document.removeEventListener('pointercancel', onUp); + _setPdfAnnotationDragging(false); _setHandlesVisible(true); _schedulePdfPaneSave(); }; document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp); + document.addEventListener('pointercancel', onUp); }); // Drag bottom-right corner to resize. Width/height stored as percentages. @@ -2611,6 +2642,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; ev.stopPropagation(); _pushPdfUndoSnapshot(); try { resize.setPointerCapture(ev.pointerId); } catch (_) {} + _setPdfAnnotationDragging(true); // Hide the × and move handles while resizing — clean view of the box edge. del.style.display = 'none'; grip.style.display = 'none'; @@ -2628,11 +2660,14 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); + document.removeEventListener('pointercancel', onUp); + _setPdfAnnotationDragging(false); _setHandlesVisible(true); _schedulePdfPaneSave(); }; document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp); + document.addEventListener('pointercancel', onUp); }); // Text options menu — opened from the floating … button so the spacing @@ -15239,7 +15274,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; menu.style.top = (rect.bottom + 2) + 'px'; menu.style.right = (window.innerWidth - rect.right) + 'px'; menu.style.left = 'auto'; - menu.style.zIndex = '9999'; + const editorPane = document.querySelector('.doc-editor-pane'); + menu.style.zIndex = String(Math.max(topPortalZ(), (parseInt(getComputedStyle(editorPane).zIndex, 10) || 0) + 1)); const langLabel = lang ? lang.toUpperCase() : 'TXT'; // Form-backed markdown doc → primary export is the filled PDF, not the @@ -15314,7 +15350,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; } // Outside-click AND Escape both route through the central esc-stack via // bindMenuDismiss; onClose owns the actual node removal. - const close = bindMenuDismiss(menu, () => { menu.remove(); }); + const exportBtn = document.getElementById('doc-footer-export-btn'); + const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target) && !exportBtn?.contains(ev.target)); } function _richTextExportCss() { diff --git a/static/js/editor/ai-models.js b/static/js/editor/ai-models.js index 57debeba5..a4a51609f 100644 --- a/static/js/editor/ai-models.js +++ b/static/js/editor/ai-models.js @@ -140,12 +140,41 @@ export function modelCaps(modelId, endpointName, endpointType) { return { gen: false, inpaint: false }; } -export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2img }) { +// Global listeners resolve live controls instead of retaining editor instances. +function syncCommandModel(value) { + const select = document.getElementById('ge-ai-command-model'); + if (!select || !select._modelsReady) return; + if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) { + if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model'); + value = ''; + } + if (select.value !== value) { + select.value = value; + if (select.value !== value) select.value = ''; + updateFooterEditModel(); + } +} +window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || '')); +window.addEventListener('storage', e => { + if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || ''); +}); +window.addEventListener('ge:model-endpoints-updated', e => { + document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || ''); +}); + +export function wireAIModelSelectors({ container, apiBase = '', openCookbookForImg2img = () => {} } = {}) { + const root = container || document; + root._aiModelListeners?.abort(); + const listeners = new AbortController(); + root._aiModelListeners = listeners; + const listen = (target, type, handler, options = {}) => target?.addEventListener(type, handler, { + ...(typeof options === 'boolean' ? { capture: options } : options), signal: listeners.signal, + }); // Delegated handler for the "+ Serve a model in Cookbook…" sentinel // option — catches clicks regardless of whether loadAIModels has // rewired the individual select yet and survives any innerHTML // reset later. - container.addEventListener('change', (e) => { + listen(root, 'change', (e) => { const sel = e.target.closest('select'); if (!sel) return; if (sel.value !== '__serve_cookbook__') return; @@ -155,7 +184,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im openCookbookForImg2img(); }); // Track prior value so we can restore it after the sentinel fires. - container.addEventListener('focus', (e) => { + listen(root, 'focus', (e) => { const sel = e.target.closest('select'); if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value; }, true); @@ -164,8 +193,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im const aiInpaintSelect = document.getElementById('ge-ai-inpaint'); const aiCommandSelect = document.getElementById('ge-ai-command-model'); const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model'); - if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel()); - if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel()); + if (aiInpaintSelect) listen(aiInpaintSelect, 'change', () => updateFooterEditModel()); // The Quick Edit agent (planner) picker: dynamically populates from // /api/editor/agent-models; restore/save choice from localStorage. const populateAgentSelect = async () => { @@ -176,9 +204,10 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im const res = await fetch(`${apiBase}/api/editor/agent-models`); if (res.ok) { const data = await res.json(); + if (listeners.signal.aborted || !root.isConnected) return; const serverModels = data.models || []; for (const m of serverModels) { - const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : (m.base_url || ''))}::${m.id}`; + const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : '')}::${m.id}`; const label = m.label || m.id; if (!agentOptions.some(o => o.value === optVal)) { agentOptions.push({ value: optVal, label }); @@ -202,9 +231,9 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im aiCommandAgentSelect.value = prevVal; } } catch {} - if (!aiCommandAgentSelect._agentWired) { - aiCommandAgentSelect._agentWired = true; - aiCommandAgentSelect.addEventListener('change', () => { + if (aiCommandAgentSelect._agentWired !== listeners) { + aiCommandAgentSelect._agentWired = listeners; + listen(aiCommandAgentSelect, 'change', () => { try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {} }); } @@ -222,8 +251,10 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im const prevGenValue = aiGenSelect?.value || ''; const prevInpaintValue = aiInpaintSelect?.value || ''; const res = await fetch(`${apiBase}/api/editor/image-models`); + if (listeners.signal.aborted || !root.isConnected) return; if (!res.ok) throw new Error(`Could not load image models (${res.status})`); const data = await res.json(); + if (listeners.signal.aborted || !root.isConnected) return; const modelsList = data.models || []; if (aiGenSelect) aiGenSelect.innerHTML = ''; if (aiInpaintSelect) aiInpaintSelect.innerHTML = ''; @@ -241,8 +272,8 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im const canInpaint = Boolean(caps.inpaint || m.kind === 'edit'); if (!canGen && !canInpaint) continue; - // Use opaque endpoint_id if available; fallback to provider name or base_url for admin - const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.base_url || ''); + // Use opaque endpoint_id if available; never fall back to raw base_url + const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : ''); const value = endpointKey ? `${endpointKey}::${modelId}` : modelId; const shortModel = modelId ? String(modelId).split('/').pop() : (m.label || m.provider); const epHint = m.provider && m.provider !== modelId && m.provider !== 'image' ? ` · ${m.provider}` : ''; @@ -312,32 +343,50 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im if (aiGenSelect) appendServeSentinel(aiGenSelect); if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect); if (aiCommandSelect) appendServeSentinel(aiCommandSelect); - // Wire the sentinel on the Gen + Inpaint selects too. - const wireServeSentinel = (sel) => { - if (!sel) return; - let prev = sel.value; - sel.addEventListener('change', () => { - if (sel.value === '__serve_cookbook__') { - sel.value = prev; - openCookbookForImg2img(); - return; - } - prev = sel.value; - }); - }; - wireServeSentinel(aiGenSelect); - wireServeSentinel(aiInpaintSelect); - wireServeSentinel(aiCommandSelect); + // Sentinel changes are handled by the component's delegated listener. if (aiCommandSelect) { + aiCommandSelect._modelsReady = true; try { - const saved = localStorage.getItem('ge-ai-command-model'); - if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) aiCommandSelect.value = saved; + const rawSaved = localStorage.getItem('ge-ai-command-model') || ''; + let saved = rawSaved; + if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) { + const modelPart = saved.includes('::') ? saved.split('::').pop() : ''; + const matching = loaded.filter(l => l.model === modelPart); + if (matching.length === 1) { + saved = matching[0].value; + } else { + saved = ''; + } + try { + if (saved) localStorage.setItem('ge-ai-command-model', saved); + else localStorage.removeItem('ge-ai-command-model'); + } catch {} + } + if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) { + aiCommandSelect.value = saved; + } else { + aiCommandSelect.value = ''; + } + if (aiCommandSelect.value !== rawSaved) { + if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); + else localStorage.removeItem('ge-ai-command-model'); + window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } })); + } } catch {} - aiCommandSelect.addEventListener('change', () => { - if (aiCommandSelect.value === '__serve_cookbook__') return; - try { localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); } catch {} - updateFooterEditModel(); - }); + + if (aiCommandSelect._commandWired !== listeners) { + aiCommandSelect._commandWired = listeners; + listen(aiCommandSelect, 'change', () => { + if (aiCommandSelect.value === '__serve_cookbook__') return; + const val = aiCommandSelect.value || ''; + try { + if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); + else localStorage.removeItem('ge-ai-command-model'); + } catch {} + updateFooterEditModel(); + window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } })); + }); + } } // Restore each per-tool selection from localStorage. for (const ts of perToolSelects) { @@ -349,7 +398,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im } } catch {} let prevValue = ts.value; - ts.addEventListener('change', () => { + listen(ts, 'change', () => { if (ts.value === '__serve_cookbook__') { ts.value = prevValue; openCookbookForImg2img(); @@ -362,6 +411,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im _aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() }; try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {} } catch (e) { + if (listeners.signal.aborted || !root.isConnected) return; // Fetch failed — still give the user the affordance to set up // a model. Otherwise the dropdown shows only "Auto" with no // hint about what to do next. @@ -370,22 +420,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback; if (aiCommandSelect) aiCommandSelect.innerHTML = fallback; document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; }); - const wireServe = (sel) => { - if (!sel) return; - let prev = sel.value; - sel.addEventListener('change', () => { - if (sel.value === '__serve_cookbook__') { - sel.value = prev; - openCookbookForImg2img(); - return; - } - prev = sel.value; - }); - }; - wireServe(aiGenSelect); - wireServe(aiInpaintSelect); - wireServe(aiCommandSelect); - document.querySelectorAll('select.ge-tool-model').forEach(wireServe); void populateAgentSelect(); updateFooterEditModel(); _aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() }; @@ -393,15 +427,11 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im } } loadAIModels(); - const onModelEndpointsUpdated = (e) => { - if (!container.isConnected) { - window.removeEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated); - return; - } - loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' }); + if (aiCommandSelect) aiCommandSelect._refreshAIModels = baseUrl => { + if (listeners.signal.aborted || !root.isConnected) return; + void loadAIModels({ selectBaseUrl: baseUrl }); void populateAgentSelect(); }; - window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated); // Re-fetch the model list when the user opens the inpaint dropdown, // so a model served via Cookbook mid-edit shows up without having to // close and reopen the editor. Debounced to one refresh per 3s so @@ -420,5 +450,5 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im if ([...sel.options].some(o => o.value === keep)) sel.value = keep; }); }; - container.addEventListener('mousedown', refreshOnOpen, true); + listen(root, 'mousedown', refreshOnOpen, true); } diff --git a/static/js/emailLibrary/index.js b/static/js/emailLibrary/index.js index afb21d76c..ab2d690f5 100644 --- a/static/js/emailLibrary/index.js +++ b/static/js/emailLibrary/index.js @@ -1498,8 +1498,11 @@ let _libSyncStatus = { source: '', warming: false, loading: false, + stale: false, }; let _libSyncTicker = null; +let _libStaleRefetchTimer = null; +let _libStaleRefetchCount = 0; function _libSyncDateFrom(value) { if (!value) return null; @@ -1554,9 +1557,37 @@ function _setEmailSyncStatus(next = {}) { if (Object.prototype.hasOwnProperty.call(next, 'loading')) { _libSyncStatus.loading = Boolean(next.loading); } + if (Object.prototype.hasOwnProperty.call(next, 'stale')) { + _libSyncStatus.stale = Boolean(next.stale); + } _renderEmailSyncStatus(); } +// A server-side stale (sync.stale) response means the SWR snapshot was served +// while a background IMAP refresh is still running. Re-fetch in place after a +// short delay (bounded) so the fresh rows land with no skeleton and no scroll +// jump. We only keep retrying while the response is still marked stale. +function _clearStaleEmailRefetch() { + if (_libStaleRefetchTimer) { + clearTimeout(_libStaleRefetchTimer); + _libStaleRefetchTimer = null; + } + _libStaleRefetchCount = 0; +} + +function _scheduleStaleEmailRefetch() { + if (_libStaleRefetchTimer || _libStaleRefetchCount >= 3) return; + const folderAtSchedule = state._libFolder; + const accountAtSchedule = state._libAccountId || ''; + _libStaleRefetchCount++; + _libStaleRefetchTimer = setTimeout(() => { + _libStaleRefetchTimer = null; + if (document.hidden) return; + if (folderAtSchedule !== state._libFolder || accountAtSchedule !== (state._libAccountId || '')) return; + _loadEmails({ force: false, useCache: false, skipFastPath: true }); + }, 2500); +} + function _libCacheKeyFor(accountId, folder, filter, hasAttachments) { return [ accountId || '', @@ -4990,7 +5021,7 @@ export async function _refreshUnreadBadge({ unreadCountOverride = null, preserve } catch (_) { _syncUnreadTabBadge(0); } } -export async function _loadEmails({ force = false, useCache = true } = {}) { +export async function _loadEmails({ force = false, useCache = true, skipFastPath = false } = {}) { const seq = ++_libLoadSeq; state._libLoading = true; const accountAtStart = state._libAccountId || ''; @@ -5044,11 +5075,15 @@ export async function _loadEmails({ force = false, useCache = true } = {}) { } const stats = document.getElementById('email-lib-stats'); if (stats) stats.textContent = `${state._libTotal} emails`; + const _isStale = Boolean(sync.stale); _setEmailSyncStatus({ updatedAt: sync.updated_at || '', source: sync.source || (cacheSource ? 'client_cache' : ''), loading: false, + stale: _isStale, }); + if (_isStale) _scheduleStaleEmailRefetch(); + else _clearStaleEmailRefetch(); if (cacheable && !cacheSource) { _libCachePut(ck, { emails: state._libEmails.slice(), total: state._libTotal, sync }); } @@ -5084,7 +5119,7 @@ export async function _loadEmails({ force = false, useCache = true } = {}) { const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : ''; const attQS = hasAttachmentsAtStart ? '&has_attachments=1' : ''; const dateQS = `${dateFromAtStart ? `&date_from=${encodeURIComponent(dateFromAtStart)}` : ''}${dateToAtStart ? `&date_to=${encodeURIComponent(dateToAtStart)}` : ''}`; - if (!cached && cacheable && !force) { + if (!cached && cacheable && !force && !skipFastPath) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 450); try { diff --git a/static/js/features/notes/ui/cardInteractions.js b/static/js/features/notes/ui/cardInteractions.js index 09e083c41..4160e5c1b 100644 --- a/static/js/features/notes/ui/cardInteractions.js +++ b/static/js/features/notes/ui/cardInteractions.js @@ -112,7 +112,7 @@ export function createCardInteractions({ if (state.selectMode) { body.querySelectorAll('.note-card').forEach(card => { card.addEventListener('click', (e) => { - if (e.target.closest('.note-card-cb')) return; // checkbox handles itself + if (e.target.closest('.note-card-cb, a')) return; // links open without toggling selection e.stopPropagation(); tapToEditOrSelect(card); }); diff --git a/static/js/features/notes/ui/editor.js b/static/js/features/notes/ui/editor.js index f5bdc7c9f..678b08804 100644 --- a/static/js/features/notes/ui/editor.js +++ b/static/js/features/notes/ui/editor.js @@ -51,7 +51,7 @@ export function createEditor({
- ${currentImageUrl && type !== 'draw' ? `
` : ''} + ${currentImageUrl && type !== 'draw' ? `
` : ''}
${type === 'note' ? `` @@ -297,7 +297,7 @@ export function createEditor({ form.querySelector('.note-form-image-wrap')?.remove(); const wrap = document.createElement('div'); wrap.className = 'note-form-image-wrap'; - wrap.innerHTML = ``; + wrap.innerHTML = ``; // Insert AFTER the whole header (a flex-row), not after the // title input itself : otherwise the image lands as a sibling // of the title inside the header and flex puts them side-by-side. diff --git a/static/js/features/notes/ui/text.js b/static/js/features/notes/ui/text.js index 75ecbaf78..bb71cb690 100644 --- a/static/js/features/notes/ui/text.js +++ b/static/js/features/notes/ui/text.js @@ -31,7 +31,7 @@ export function createText({ url = url.slice(0, -1); } const href = url.startsWith('www.') ? `https://${url}` : url; - return `${url}` + (url !== m ? m.slice(url.length) : ''); + return `${url}` + (url !== m ? m.slice(url.length) : ''); }); } diff --git a/static/js/gallery.js b/static/js/gallery.js index bc5d408c1..8804cb15d 100644 --- a/static/js/gallery.js +++ b/static/js/gallery.js @@ -2229,6 +2229,36 @@ function _makeGalleryDraggable(content) { // Re-export the manager for the rail click handler import * as Modals from './modalManager.js'; +// Module-owned listeners resolve the current picker; closed modals are never retained. +window.addEventListener('ge-ai-command-model-change', (e) => { + const imageModelSelect = document.getElementById('gallery-image-model'); + if (!imageModelSelect || !imageModelSelect._modelsReady) return; + let val = (e && e.detail && typeof e.detail.value === 'string') ? e.detail.value : ''; + if (val && ![...imageModelSelect.options].some(o => o.value === val)) { + if (localStorage.getItem('ge-ai-command-model') === val) localStorage.removeItem('ge-ai-command-model'); + val = ''; + } + if (imageModelSelect.value !== val) { + imageModelSelect.value = val; + if (imageModelSelect.value !== val) imageModelSelect.value = ''; + } +}); +window.addEventListener('storage', (e) => { + const imageModelSelect = document.getElementById('gallery-image-model'); + if (e && e.key === 'ge-ai-command-model' && imageModelSelect?._modelsReady) { + let val = e.newValue || ''; + if (val && ![...imageModelSelect.options].some(o => o.value === val)) { + if (localStorage.getItem('ge-ai-command-model') === val) localStorage.removeItem('ge-ai-command-model'); + val = ''; + } + if (imageModelSelect.value !== val) { + imageModelSelect.value = val; + if (imageModelSelect.value !== val) imageModelSelect.value = ''; + } + } +}); + + export function openGallery() { // If already minimized — restore in place, preserve all state if (Modals.isRegistered('gallery-modal') && Modals.isMinimized('gallery-modal')) { @@ -2607,7 +2637,7 @@ export function openGallery() { // ── Image model (Settings tab) ── // Mirror of the Quick Edit picker: same source (GET /api/editor/image-models) - // and the SAME localStorage key `ge-ai-command-model` (value `::`) + // and the SAME localStorage key `ge-ai-command-model` (value `::`) // so the two stay in sync. const _GE_MODEL_KEY = 'ge-ai-command-model'; const imageModelSelect = document.getElementById('gallery-image-model'); @@ -2617,7 +2647,8 @@ export function openGallery() { const r = await fetch(`${API_BASE}/api/editor/image-models`, { credentials: 'same-origin' }); const data = r.ok ? await r.json() : null; const models = (data && Array.isArray(data.models)) ? data.models : []; - const saved = localStorage.getItem(_GE_MODEL_KEY) || ''; + const rawSaved = localStorage.getItem(_GE_MODEL_KEY) || ''; + let saved = rawSaved; imageModelSelect.innerHTML = ''; const def = document.createElement('option'); def.value = ''; @@ -2625,12 +2656,34 @@ export function openGallery() { imageModelSelect.appendChild(def); for (const m of models) { const opt = document.createElement('option'); - opt.value = `${m.base_url || ''}::${m.id}`; + const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : ''); + opt.value = endpointKey ? `${endpointKey}::${m.id}` : m.id; opt.textContent = `${m.id} · ${m.provider || ''} (${m.kind || 'edit'})`.trim(); imageModelSelect.appendChild(opt); } + imageModelSelect._modelsReady = true; + // Invalidate or migrate legacy localStorage values containing URLs or leading :: + if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) { + const modelPart = saved.includes('::') ? saved.split('::').pop() : ''; + const matching = models.filter(m => m.id === modelPart); + if (matching.length === 1) { + const epKey = matching[0].endpoint_id ? String(matching[0].endpoint_id) : (matching[0].source === 'local' ? 'local' : ''); + saved = epKey ? `${epKey}::${matching[0].id}` : matching[0].id; + } else { + saved = ''; + } + try { + if (saved) localStorage.setItem(_GE_MODEL_KEY, saved); + else localStorage.removeItem(_GE_MODEL_KEY); + } catch (_) {} + } imageModelSelect.value = saved; if (imageModelSelect.value !== saved) imageModelSelect.value = ''; + if (imageModelSelect.value !== rawSaved) { + if (imageModelSelect.value) localStorage.setItem(_GE_MODEL_KEY, imageModelSelect.value); + else localStorage.removeItem(_GE_MODEL_KEY); + window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: imageModelSelect.value } })); + } } catch (e) { imageModelSelect.innerHTML = ''; } @@ -2645,11 +2698,7 @@ export function openGallery() { }); _fillImageModels(); } - window.addEventListener('storage', (e) => { - if (e && e.key === _GE_MODEL_KEY && imageModelSelect) { - imageModelSelect.value = e.newValue || ''; - } - }); + // ── Tag All Untagged ── let _tagCancelRequested = false; diff --git a/static/js/markdown.js b/static/js/markdown.js index abf7bcf36..b69f0de92 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -294,14 +294,61 @@ function moreListPayloadFromHref(href) { return _moreListPayloads.get(`${match[1]}:${match[2]}`) || ''; } +// Assistant markdown images (`![alt](url)`) render inline as a clickable +// thumbnail that opens the full-size image in a new tab. Only https URLs are +// allowed — an http URL (or anything unparseable) is downgraded to its alt +// text rather than rendered as an image. A broken image (the model can +// fabricate a URL) must never leave an empty gap, so each image is paired with +// a hidden fallback caption (alt text + URL) that _bindChatImageFallback() +// reveals when the load fails. function imageHtml(alt, url, title) { - const safeUrl = safeLinkUrl(url); - if (!safeUrl || safeUrl.startsWith('#')) return escapeHtml(alt || ''); + const rawUrl = String(url || '').trim(); + let safeUrl = ''; + try { + const parsed = new URL(rawUrl, window.location.origin); + if (parsed.protocol === 'https:') safeUrl = parsed.href; + } catch (_) { + safeUrl = ''; + } + if (!safeUrl) return escapeHtml(alt || rawUrl || ''); const safeAlt = escapeHtml(alt || ''); const safeTitle = title ? ` title="${escapeHtml(title)}"` : ''; - return `${safeAlt}`; + const displayUrl = escapeHtml(safeUrl); + const fallbackLabel = escapeHtml(alt || 'Image'); + // The fallback caption shows the URL as readable text, but a bare + // `https://` in that text would be caught by the later bare-URL autolink + // pass and wrapped in a nested — which then truncates this outer + // when it is extracted as an allowed-HTML block. Encode the `//` so it + // displays as `https://` yet is invisible to the autolink regex. + const fallbackUrl = displayUrl.replace('://', '://'); + return ( + `` + + `${safeAlt}` + + `${fallbackLabel} — ${fallbackUrl}` + + `` + ); } +// Reveal the alt-text + link fallback whenever an inline chat image fails to +// load, and hide it again if a late load succeeds. `error`/`load` on do +// not bubble, so this listens in the capture phase (document → target). +function _bindChatImageFallback() { + if (typeof document === 'undefined' || !document.addEventListener) return; + const wrapOf = (target) => { + if (!target || target.tagName !== 'IMG' || !target.classList || !target.classList.contains('chat-inline-image')) return null; + return target.closest ? target.closest('a.chat-image') : null; + }; + document.addEventListener('error', (event) => { + const wrap = wrapOf(event && event.target); + if (wrap) wrap.classList.add('image-broken'); + }, true); + document.addEventListener('load', (event) => { + const wrap = wrapOf(event && event.target); + if (wrap) wrap.classList.remove('image-broken'); + }, true); +} +_bindChatImageFallback(); + function _isModelEndpointUrl(rawUrl) { try { const parsed = new URL(String(rawUrl || ''), window.location.origin); diff --git a/static/js/modelPicker.js b/static/js/modelPicker.js index 45150c67f..324364c7c 100644 --- a/static/js/modelPicker.js +++ b/static/js/modelPicker.js @@ -810,7 +810,10 @@ async function _pick(m) { _renderLoading('Loading models…'); } if (window.modelsModule && window.modelsModule.refreshModels) { - _refreshPickerModels({ force: true, showLoading: !hasCache }).then(isLatest => { + // Open instantly from cache; never force a re-probe on open (a dead + // endpoint would otherwise stall every open). The explicit refresh + // button still forces a full re-probe. + _refreshPickerModels({ force: false, showLoading: !hasCache }).then(isLatest => { if (isLatest && !menu.classList.contains('hidden')) _populate(search.value || ''); updateModelPicker(); }).catch(() => {}); diff --git a/static/js/models.js b/static/js/models.js index b75b86907..a377cd2ba 100644 --- a/static/js/models.js +++ b/static/js/models.js @@ -589,6 +589,12 @@ export async function refreshModels(force = false, opts = {}) { + 'Ask an admin to configure model endpoints'; } box.appendChild(noModels); + const openAdmin = noModels.querySelector('#models-open-admin'); + if (openAdmin) openAdmin.addEventListener('click', (e) => { + e.preventDefault(); + const btn = document.getElementById('user-bar-admin'); + if (btn) btn.click(); + }); } } catch (e) { console.error(e); diff --git a/static/js/research/jobs.js b/static/js/research/jobs.js index 1bd316a1f..4340f9c4d 100644 --- a/static/js/research/jobs.js +++ b/static/js/research/jobs.js @@ -80,7 +80,10 @@ export function init(apiBase) { // sidebar — _reconnectActive only ran once at load before, so // agent-started jobs never appeared until a page reload. if (_activePollInterval) clearInterval(_activePollInterval); - _activePollInterval = setInterval(() => { _reconnectActive(); }, 20000); + _activePollInterval = setInterval(() => { + if (document.hidden) return; + _reconnectActive(); + }, 60000); } // Allow an immediate adopt when the chat stream signals a new research diff --git a/static/js/sessions.js b/static/js/sessions.js index 0a910ef00..111e4b791 100644 --- a/static/js/sessions.js +++ b/static/js/sessions.js @@ -38,6 +38,89 @@ let _historyPager = null; const _serverStreamChecksInFlight = new Set(); const _serverStreamAbsentUntil = new Map(); const SERVER_STREAM_ABSENT_TTL_MS = 30000; +// Last title we rendered for each session, so the sidebar can fade a title +// in when the server auto-renames a chat (e.g. the spec model titles a new +// chat from its first prompt) instead of snapping to it. +const _prevSessionTitles = new Map(); + +// === copy-chat header (debug trace block) === +function _toolSurfaceLabel(mode) { + const m = String(mode == null ? '' : mode).toLowerCase(); + if (m === 'full') return 'Regular tools'; + if (m === 'compact') return 'Ajax Compact'; + if (m === 'spec') return 'Ajax Spec (router)'; + if (m === 'none') return 'Tools off'; + if (!m) return 'Auto'; + return 'unknown'; +} + +function _formatJst(now) { + const jst = new Date(now.getTime() + 9 * 60 * 60 * 1000); + const p = (n) => String(n).padStart(2, '0'); + return `${jst.getUTCFullYear()}-${p(jst.getUTCMonth() + 1)}-${p(jst.getUTCDate())} ${p(jst.getUTCHours())}:${p(jst.getUTCMinutes())}`; +} + +function _buildCopyChatHeader(info) { + const version = info.version || 'unknown'; + const build = info.build || 'unknown'; + const commit = (info.sourceCommit || 'unknown').slice(0, 8); + const model = info.model || 'unknown'; + const endpointUrl = info.endpointUrl || 'unknown'; + const toolSurface = info.toolSurface || 'unknown'; + const copiedAt = info.copiedAt ? _formatJst(info.copiedAt) : 'unknown'; + const sessionId = info.sessionId || 'unknown'; + return [ + '--- Odysseus info ---', + `Odysseus: ${version} | Harness build: ${build} | Commit: ${commit}`, + `Model: ${model} @ ${endpointUrl} | Tool surface: ${toolSurface}`, + `Copied: ${copiedAt} JST | Session: ${sessionId}`, + '----------------------', + ].join('\n'); +} +// === end copy-chat header === + +async function _copyChatHeaderFor(s) { + let version = 'unknown'; + let build = 'unknown'; + let sourceCommit = 'unknown'; + try { + const res = await fetch(`${API_BASE}/api/version`); + const v = await res.json(); + if (v && typeof v === 'object') { + version = v.version || 'unknown'; + build = v.build || 'unknown'; + sourceCommit = v.source_commit || 'unknown'; + } + } catch (_) {} + + let toolSurface = 'unknown'; + try { + const res = await fetch(`${API_BASE}/api/model-endpoints`); + if (res.ok) { + const eps = await res.json(); + const list = Array.isArray(eps) ? eps : []; + const model = (s && s.model) || ''; + const ep = list.find((x) => x && ( + (s && s.endpoint_id != null && x.id === s.endpoint_id) || + (s && s.endpoint_url && x.base_url === s.endpoint_url) + )); + if (ep && ep.model_tool_modes && model) { + toolSurface = _toolSurfaceLabel(ep.model_tool_modes[model]); + } + } + } catch (_) {} + + return _buildCopyChatHeader({ + version, + build, + sourceCommit, + model: (s && s.model) || undefined, + endpointUrl: (s && s.endpoint_url) || undefined, + toolSurface, + sessionId: (s && s.id) || undefined, + copiedAt: new Date(), + }); +} function _shouldPreserveStartupComposer(msgInput) { if (!msgInput || !msgInput.value) return false; @@ -684,6 +767,14 @@ function createSessionItem(s) { let chatTitle = s.name || ''; if (_isFork) chatTitle = chatTitle.replace(/^Fork:\s*/, '').replace(/^\u2ADD\s*/, ''); if (_isGroup) chatTitle = chatTitle.replace(/^\[GRP\]\s*/, ''); + // Fade the row's title in only when it actually changed since the last + // render; everything else about the re-render stays silent. + const _prevTitle = _prevSessionTitles.get(s.id); + if (chatTitle && _prevTitle !== undefined && _prevTitle !== chatTitle) { + span.classList.add('session-title-updated'); + } + if (chatTitle) _prevSessionTitles.set(s.id, chatTitle); + let label = chatTitle; if (s.model) label += ' · ' + s.model.split('/').pop(); if (s.archived) label += ' [archived]'; @@ -930,7 +1021,8 @@ function createSessionItem(s) { const text = typeof m.content === 'string' ? m.content.trim() : JSON.stringify(m.content); return `${label}: ${text}`; }); - const text = lines.join('\n\n'); + const header = await _copyChatHeaderFor(s); + const text = header + '\n\n' + lines.join('\n\n'); try { await navigator.clipboard.writeText(text); } catch (_clipErr) { diff --git a/static/js/sidebar-layout.js b/static/js/sidebar-layout.js index 92f371070..02d6f2654 100644 --- a/static/js/sidebar-layout.js +++ b/static/js/sidebar-layout.js @@ -106,6 +106,9 @@ export function initSidebarLayout(Storage, opts) { document.body.classList.toggle('hamburger-only', sidebarHidden && railHidden); document.body.classList.toggle('sidebar-collapsed', sidebarHidden); _setSidebarModeClasses(!sidebarHidden ? 'full' : (railHidden ? 'off' : 'mini')); + hamburgerBtn.setAttribute('aria-expanded', String(!sidebarHidden)); + const sidebarToggleBtn = document.getElementById('sidebar-toggle-btn'); + if (sidebarToggleBtn) sidebarToggleBtn.setAttribute('aria-expanded', String(!sidebarHidden)); } // Keep incognito button clear of hamburger const incogBtn = document.getElementById('incognito-btn'); diff --git a/static/js/slashCommands.js b/static/js/slashCommands.js index d288cb3a1..06dc5f336 100644 --- a/static/js/slashCommands.js +++ b/static/js/slashCommands.js @@ -5336,6 +5336,29 @@ function _eggRender(html) { uiModule.scrollHistory(); } +// Delegated handlers for egg commands that used inline onclick (blocked by +// Firefox CSP script-src-attr). Dice re-roll + color copy. +if (!window._eggDelegatedBound) { + window._eggDelegatedBound = true; + document.addEventListener('click', (e) => { + const dice = e.target.closest && e.target.closest('.egg-dice'); + if (dice) { + const sides = Math.min(parseInt(dice.dataset.diceSides) || 6, 1000); + dice.style.animation = 'none'; + void dice.offsetHeight; + dice.textContent = Math.floor(Math.random() * sides) + 1; + dice.style.animation = 'egg-shake 0.3s ease'; + return; + } + const swatch = e.target.closest && e.target.closest('.egg-color-copy'); + if (swatch) { + navigator.clipboard.writeText(swatch.dataset.color || ''); + swatch.style.transform = 'scale(0.9)'; + setTimeout(() => { swatch.style.transform = ''; }, 150); + } + }); +} + async function _cmdFlip(args, ctx) { const isHeads = Math.random() < 0.5; const edge = Math.random() < 0.001; @@ -5373,7 +5396,7 @@ async function _cmdRoll(args, ctx) { const results = Array.from({ length: count }, () => Math.floor(Math.random() * sides) + 1); const total = results.reduce((a, b) => a + b, 0); const dice = results.map((v, i) => { - return `
${v}
`; + return `
${v}
`; }).join(''); const totalHtml = count > 1 ? `
${count}d${sides} = ${total}
` : ''; _eggRender(`
${dice}
${totalHtml}
`); @@ -5690,7 +5713,7 @@ async function _cmdColor(args, ctx) { const hex = args[0] || '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6,'0'); const c = hex.startsWith('#') ? hex : '#' + hex; _eggRender(`
-
+
${ctx.esc(c)} click swatch to copy
diff --git a/static/js/streamingRenderer.js b/static/js/streamingRenderer.js index 5aa05ec66..a230e434e 100644 --- a/static/js/streamingRenderer.js +++ b/static/js/streamingRenderer.js @@ -34,6 +34,9 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) { let tailShownLen = 0; // rendered-text length of the live tail (drives token fade) let appendMode = null; // { codeText: Text, appendedLen } while an open fence streams let degraded = !ENABLED; // true once we fall back to full re-render + let tailRaf = null; // pending requestAnimationFrame id for the live-tail re-render + let pendingTail = ''; // most recent tail text awaiting a rAF re-render + let tailGeneration = 0; function start() { contentEl.textContent = ''; @@ -79,6 +82,32 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) { while (holder.firstChild) contentEl.appendChild(holder.firstChild); } + // Coalesce the per-token tail re-render into at most one render per animation + // frame. Fast providers emit many deltas per frame; the frozen-block pass is + // cheap and stays synchronous, but the markdown re-parse + innerHTML write of + // the still-growing tail only needs to happen once per frame for smooth output. + function scheduleTailRender(tailText) { + pendingTail = tailText; + if (tailRaf !== null) return; + const generation = tailGeneration; + tailRaf = requestAnimationFrame(() => { + if (generation !== tailGeneration) return; + tailRaf = null; + // The container may have been rewritten out from under us (chat.js writes + // contentEl.innerHTML for thinking indicators and tool blocks). Skip and + // let the next update() self-heal rather than appending stale tail nodes. + if (tailMarker && tailMarker.parentNode === contentEl) { + try { + renderTail(pendingTail); + } catch (err) { + degraded = true; + console.error('streamingRenderer: falling back to full render in rAF', err); + safeFullRender(lastText); + } + } + }); + } + // Stream the body of an unterminated code fence by appending only the new // characters to a stable
 text node — no re-parse, no re-highlight.
   function appendOpenFence(tailText, fence) {
@@ -137,6 +166,41 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
     highlight(contentEl);
   }
 
+  function safeFullRender(fullText) {
+    try {
+      fullRender(fullText);
+    } catch (err) {
+      // The source survives even when the parser/highlighter is unavailable.
+      // textContent also ensures that untrusted message text stays inert.
+      console.error('streamingRenderer: preserving source as plain text', err);
+      contentEl.textContent = fullText;
+    }
+  }
+
+  function cancelPendingTail() {
+    tailGeneration++;
+    if (tailRaf !== null) {
+      cancelAnimationFrame(tailRaf);
+      tailRaf = null;
+    }
+    pendingTail = '';
+  }
+
+  function resetDom() {
+    cancelPendingTail();
+    started = false;
+    tailMarker = null;
+    committedLen = 0;
+    tailShownLen = 0;
+    appendMode = null;
+  }
+
+  function reset() {
+    resetDom();
+    lastText = '';
+    degraded = !ENABLED;
+  }
+
   // Render the latest full source text.
   //
   // PRECONDITION: callers must pass append-only text — each call's `fullText` must
@@ -148,7 +212,8 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
   function update(fullText) {
     lastText = fullText;
     if (degraded) {
-      fullRender(fullText);
+      cancelPendingTail();
+      safeFullRender(fullText);
       return;
     }
     try {
@@ -158,24 +223,34 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
       // container. Rebuild from scratch so we never append onto foreign content or
       // touch a detached marker.
       if (started && (!tailMarker || tailMarker.parentNode !== contentEl)) {
-        started = false;
-        committedLen = 0;
-        tailShownLen = 0;
-        appendMode = null;
+        resetDom();
       }
       if (!started) start();
       const next = splitFinalized(fullText, render, committedLen);
       if (next > committedLen) {
+        cancelPendingTail();
+        clearTail();
         freeze(fullText.slice(committedLen, next));
         committedLen = next;
         appendMode = null; // whatever was streaming is now frozen
         tailShownLen = 0;
       }
-      renderTail(fullText.slice(committedLen));
+      const tail = fullText.slice(committedLen);
+      if (!tail) {
+        cancelPendingTail();
+        clearTail();
+        tailShownLen = 0;
+      } else if (tailShownLen === 0) {
+        cancelPendingTail();
+        renderTail(tail);
+      } else {
+        scheduleTailRender(tail);
+      }
     } catch (err) {
+      cancelPendingTail();
       degraded = true;
       console.error('streamingRenderer: falling back to full render', err);
-      fullRender(fullText);
+      safeFullRender(fullText);
     }
   }
 
@@ -185,7 +260,11 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
   // reasons and so doesn't call this, but it completes the renderer's lifecycle and
   // is exercised by the tests.
   function finalize() {
-    if (degraded) return;
+    cancelPendingTail();
+    if (degraded) {
+      safeFullRender(lastText);
+      return;
+    }
     try {
       if (!started) start();
       clearTail();
@@ -198,9 +277,9 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
     } catch (err) {
       degraded = true;
       console.error('streamingRenderer: falling back to full render', err);
-      fullRender(lastText);
+      safeFullRender(lastText);
     }
   }
 
-  return { update, finalize };
+  return { update, finalize, reset };
 }
diff --git a/static/js/tasks.js b/static/js/tasks.js
index 5c511dc0c..ee00af11f 100644
--- a/static/js/tasks.js
+++ b/static/js/tasks.js
@@ -3185,7 +3185,7 @@ export function openTasks(focusId, opts) {