diff --git a/default-themes/nebula-dawn.v2.css b/default-themes/nebula-dawn.v2.css index 112522d..9787ba7 100644 --- a/default-themes/nebula-dawn.v2.css +++ b/default-themes/nebula-dawn.v2.css @@ -466,6 +466,7 @@ body.chat-collapsed .chat-toggle { .chat-input-wrapper .chat-input { width: 100%; + padding-left: 36px; padding-right: 36px; } @@ -704,3 +705,112 @@ body.chat-collapsed .chat-toggle { #loadingOverlay { position: absolute; } .chat-submit:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .chat-input:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ---- Attach button (left side of input) ---- */ +.attach-btn { + position: absolute; + left: 6px; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s, background 0.2s; + z-index: 2; +} +.attach-btn:hover { + color: var(--accent-primary); + background: var(--accent-glow); +} +.light-mode .attach-btn { + color: var(--text-secondary); +} +.light-mode .attach-btn:hover { + color: var(--accent-primary); +} + +/* ---- Attach popup menu ---- */ +.attach-menu { + display: none; + flex-direction: column; + position: absolute; + bottom: calc(100% + 6px); + left: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.12); + overflow: hidden; + z-index: 100; + min-width: 160px; +} +.attach-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + font-size: 13px; + color: var(--text-primary); + cursor: pointer; + transition: background 0.15s; +} +.attach-menu-item:hover { + background: var(--accent-glow); +} + +/* ---- Attachment pills ---- */ +.attachment-pills { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 4px; + min-height: 0; +} +.attachment-pills:empty { display: none; } +.attachment-pill { + display: flex; + align-items: center; + gap: 6px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 4px 8px; + max-width: 180px; +} +.attachment-pill-remove { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + padding: 0 2px; + line-height: 1; +} +.attachment-pill-remove:hover { + color: #ff6b6b; +} + +/* ---- Screenshot overlay ---- */ +.screenshot-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.15); + cursor: crosshair; + z-index: 1000; +} +.screenshot-rect { + display: none; + position: absolute; + border: 2px solid red; + background: rgba(255,0,0,0.05); + pointer-events: none; +} diff --git a/default-themes/nebula-dusk.v2.css b/default-themes/nebula-dusk.v2.css index d2bd17a..8e4c2cc 100644 --- a/default-themes/nebula-dusk.v2.css +++ b/default-themes/nebula-dusk.v2.css @@ -466,6 +466,7 @@ body.chat-collapsed .chat-toggle { .chat-input-wrapper .chat-input { width: 100%; + padding-left: 36px; padding-right: 36px; } @@ -696,3 +697,106 @@ body.chat-collapsed .chat-toggle { #loadingOverlay { position: absolute; } .chat-submit:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .chat-input:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ---- Attach button (left side of input) ---- */ +.attach-btn { + position: absolute; + left: 6px; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s, background 0.2s; + z-index: 2; +} +.attach-btn:hover { + color: var(--accent-primary); + background: var(--accent-glow); +} + +/* ---- Attach popup menu ---- */ +.attach-menu { + display: none; + flex-direction: column; + position: absolute; + bottom: calc(100% + 6px); + left: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.3); + overflow: hidden; + z-index: 100; + min-width: 160px; +} +.attach-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + font-size: 13px; + color: var(--text-primary); + cursor: pointer; + transition: background 0.15s; +} +.attach-menu-item:hover { + background: var(--accent-glow); +} + +/* ---- Attachment pills ---- */ +.attachment-pills { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 4px; + min-height: 0; +} +.attachment-pills:empty { display: none; } +.attachment-pill { + display: flex; + align-items: center; + gap: 6px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 4px 8px; + max-width: 180px; +} +.attachment-pill-remove { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + padding: 0 2px; + line-height: 1; +} +.attachment-pill-remove:hover { + color: #ff6b6b; +} + +/* ---- Screenshot overlay ---- */ +.screenshot-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.2); + cursor: crosshair; + z-index: 1000; +} +.screenshot-rect { + display: none; + position: absolute; + border: 2px solid red; + background: rgba(255,0,0,0.05); + pointer-events: none; +} diff --git a/page-scripts/page-v2.js b/page-scripts/page-v2.js index 90944b8..64b2371 100644 --- a/page-scripts/page-v2.js +++ b/page-scripts/page-v2.js @@ -99,24 +99,28 @@ var chatInput = document.getElementById('chatInput'); - // 2. Form submit handler — show overlay + disable inputs + // 2. Form submit handler — fetch+JSON with attachment support var chatForm = document.getElementById('chatForm'); if (chatForm) { - chatForm.addEventListener('submit', function() { + chatForm.addEventListener('submit', function(e) { + e.preventDefault(); + + var ci = document.getElementById('chatInput'); + var messageText = ci ? ci.value : ''; + // Append any captured console errors to the outgoing message var errors = window.__synthOSErrors; - if (errors && errors.length > 0) { - var ci = document.getElementById('chatInput'); - if (ci && ci.value.trim()) { - ci.value = ci.value + '\n\nCONSOLE_ERRORS:\n' + errors.join('\n---\n'); - window.__synthOSErrors = []; - } + if (errors && errors.length > 0 && messageText.trim()) { + messageText = messageText + '\n\nCONSOLE_ERRORS:\n' + errors.join('\n---\n'); + window.__synthOSErrors = []; } + + if (!messageText.trim()) return; + + // Show overlay and disable inputs var overlay = document.getElementById('loadingOverlay'); if (overlay) overlay.style.display = 'flex'; - chatForm.action = window.location.pathname; setTimeout(function() { - var ci = document.getElementById('chatInput'); if (ci) ci.disabled = true; var sb = document.querySelector('.chat-submit'); if (sb) sb.disabled = true; @@ -125,6 +129,41 @@ a.style.opacity = '0.5'; }); }, 50); + + // Build JSON body with optional attachments + var body = { message: messageText }; + var attachments = window.__synthOSAttachments; + if (attachments && attachments.length > 0) { + body.attachments = attachments.map(function(a) { + return { mediaType: a.mediaType, data: a.data, name: a.name }; + }); + } + + fetch(window.location.pathname, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + .then(function(res) { return res.text(); }) + .then(function(html) { + // Reset guard so page-v2.js re-initializes on the new DOM + window.__synthOSChatPanel = false; + document.open(); + document.write(html); + document.close(); + }) + .catch(function(err) { + console.error('Submit failed:', err); + if (overlay) overlay.style.display = 'none'; + if (ci) ci.disabled = false; + var sb = document.querySelector('.chat-submit'); + if (sb) sb.disabled = false; + }); + + // Clear attachments after submit + window.__synthOSAttachments = []; + var pillsContainer = document.querySelector('.attachment-pills'); + if (pillsContainer) pillsContainer.innerHTML = ''; }); } @@ -143,64 +182,64 @@ // --- Create save modal --- var modal = document.createElement('div'); - modal.id = 'saveModal'; + modal.id = 'synthos-saveModal'; modal.className = 'modal-overlay'; modal.innerHTML = ''; document.body.appendChild(modal); // --- Create error modal --- var errorModal = document.createElement('div'); - errorModal.id = 'errorModal'; + errorModal.id = 'synthos-errorModal'; errorModal.className = 'modal-overlay'; errorModal.innerHTML = ''; document.body.appendChild(errorModal); // --- Element references --- - var titleInput = document.getElementById('saveTitleInput'); - var categoriesInput = document.getElementById('saveCategoriesInput'); - var greetingInput = document.getElementById('saveGreetingInput'); - var greetingHint = document.getElementById('saveGreetingHint'); - var titleError = document.getElementById('saveTitleError'); - var categoriesError = document.getElementById('saveCategoriesError'); + var titleInput = document.getElementById('synthos-saveTitleInput'); + var categoriesInput = document.getElementById('synthos-saveCategoriesInput'); + var greetingInput = document.getElementById('synthos-saveGreetingInput'); + var greetingHint = document.getElementById('synthos-saveGreetingHint'); + var titleError = document.getElementById('synthos-saveTitleError'); + var categoriesError = document.getElementById('synthos-saveCategoriesError'); // --- Greeting enable/disable based on title change --- titleInput.addEventListener('input', function() { @@ -240,7 +279,7 @@ } function showError(msg) { - document.getElementById('errorMessage').textContent = msg; + document.getElementById('synthos-errorMessage').textContent = msg; errorModal.classList.add('show'); } @@ -274,7 +313,7 @@ var categories = cats.split(',').map(function(c) { return c.trim(); }).filter(Boolean); // Disable button during save - var confirmBtn = document.getElementById('saveConfirmBtn'); + var confirmBtn = document.getElementById('synthos-saveConfirmBtn'); confirmBtn.disabled = true; confirmBtn.textContent = 'Saving...'; @@ -313,11 +352,11 @@ // --- Event listeners --- saveLink.addEventListener('click', openSaveModal); - document.getElementById('saveCloseBtn').addEventListener('click', closeSaveModal); - document.getElementById('saveCancelBtn').addEventListener('click', closeSaveModal); - document.getElementById('saveConfirmBtn').addEventListener('click', submitSave); - document.getElementById('errorCloseBtn').addEventListener('click', closeError); - document.getElementById('errorOkBtn').addEventListener('click', closeError); + document.getElementById('synthos-saveCloseBtn').addEventListener('click', closeSaveModal); + document.getElementById('synthos-saveCancelBtn').addEventListener('click', closeSaveModal); + document.getElementById('synthos-saveConfirmBtn').addEventListener('click', submitSave); + document.getElementById('synthos-errorCloseBtn').addEventListener('click', closeError); + document.getElementById('synthos-errorOkBtn').addEventListener('click', closeError); var saveModalMouseDownTarget = null; modal.addEventListener('mousedown', function(e) { saveModalMouseDownTarget = e.target; }); @@ -445,18 +484,18 @@ // --- Create brainstorm modal --- var modal = document.createElement('div'); - modal.id = 'brainstormModal'; + modal.id = 'synthos-brainstormModal'; modal.className = 'modal-overlay brainstorm-modal'; modal.innerHTML = ''; document.body.appendChild(modal); @@ -481,11 +520,11 @@ function closeBrainstorm() { modal.classList.remove('show'); brainstormHistory = []; - document.getElementById('brainstormMessages').innerHTML = ''; + document.getElementById('synthos-brainstormMessages').innerHTML = ''; } function scrollBrainstormToBottom() { - var el = document.getElementById('brainstormMessages'); + var el = document.getElementById('synthos-brainstormMessages'); el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); } @@ -542,13 +581,13 @@ } else { div.textContent = text; } - document.getElementById('brainstormMessages').appendChild(div); + document.getElementById('synthos-brainstormMessages').appendChild(div); scrollBrainstormToBottom(); } function submitSuggestion(text) { // Disable old suggestion chips so they can't be double-clicked - var oldChips = document.querySelectorAll('#brainstormMessages .brainstorm-suggestion-chip'); + var oldChips = document.querySelectorAll('#synthos-brainstormMessages .brainstorm-suggestion-chip'); for (var i = 0; i < oldChips.length; i++) { oldChips[i].disabled = true; } @@ -571,7 +610,7 @@ // Send from the input field function sendBrainstormMessage() { - var input = document.getElementById('brainstormInput'); + var input = document.getElementById('synthos-brainstormInput'); var text = input.value.trim(); if (!text) return; input.value = ''; @@ -580,7 +619,7 @@ // Core fetch — isOpener=true means this is the initial call when brainstorm opens function sendBrainstormText(text, isOpener) { - var input = document.getElementById('brainstormInput'); + var input = document.getElementById('synthos-brainstormInput'); var userMsg = text || (isOpener ? 'Look at the conversation so far and suggest what we could build or improve.' : ''); if (!userMsg) return; @@ -592,13 +631,13 @@ var thinking = document.createElement('div'); thinking.className = 'brainstorm-thinking'; - thinking.id = 'brainstormThinking'; + thinking.id = 'synthos-brainstormThinking'; thinking.textContent = 'Thinking...'; - document.getElementById('brainstormMessages').appendChild(thinking); + document.getElementById('synthos-brainstormMessages').appendChild(thinking); scrollBrainstormToBottom(); input.disabled = true; - document.getElementById('brainstormSendBtn').disabled = true; + document.getElementById('synthos-brainstormSendBtn').disabled = true; fetch('/api/brainstorm', { method: 'POST', @@ -613,7 +652,7 @@ return res.json(); }) .then(function(data) { - var thinkingEl = document.getElementById('brainstormThinking'); + var thinkingEl = document.getElementById('synthos-brainstormThinking'); if (thinkingEl) thinkingEl.remove(); var response = data.response || 'Sorry, I didn\'t get a response.'; @@ -626,20 +665,20 @@ }); }) .catch(function(err) { - var thinkingEl = document.getElementById('brainstormThinking'); + var thinkingEl = document.getElementById('synthos-brainstormThinking'); if (thinkingEl) thinkingEl.remove(); appendBrainstormMessage('assistant', 'Something went wrong: ' + err.message); }) .finally(function() { input.disabled = false; - document.getElementById('brainstormSendBtn').disabled = false; + document.getElementById('synthos-brainstormSendBtn').disabled = false; input.focus(); }); } // --- Event listeners --- brainstormBtn.addEventListener('click', openBrainstorm); - document.getElementById('brainstormCloseBtn').addEventListener('click', closeBrainstorm); + document.getElementById('synthos-brainstormCloseBtn').addEventListener('click', closeBrainstorm); var brainstormMouseDownTarget = null; modal.addEventListener('mousedown', function(e) { brainstormMouseDownTarget = e.target; }); @@ -652,8 +691,8 @@ if (e.key === 'Escape' && modal.classList.contains('show')) closeBrainstorm(); }); - document.getElementById('brainstormSendBtn').addEventListener('click', sendBrainstormMessage); - document.getElementById('brainstormInput').addEventListener('keydown', function(e) { + document.getElementById('synthos-brainstormSendBtn').addEventListener('click', sendBrainstormMessage); + document.getElementById('synthos-brainstormInput').addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendBrainstormMessage(); @@ -661,6 +700,339 @@ }); })(); + // 9. Attach button — + menu, attachment pills, file picker, screenshot tool + (function() { + var chatInput = document.getElementById('chatInput'); + if (!chatInput) return; + var wrapper = chatInput.parentElement; + if (!wrapper || !wrapper.classList.contains('chat-input-wrapper')) return; + + // --- Attachments state --- + if (!window.__synthOSAttachments) window.__synthOSAttachments = []; + + // --- Create pills container (between .link-group and #chatForm) --- + var pillsContainer = document.createElement('div'); + pillsContainer.className = 'attachment-pills'; + var chatForm = document.getElementById('chatForm'); + if (chatForm && chatForm.parentNode) { + chatForm.parentNode.insertBefore(pillsContainer, chatForm); + } + + function renderPills() { + pillsContainer.innerHTML = ''; + var attachments = window.__synthOSAttachments; + if (!attachments || attachments.length === 0) return; + for (var i = 0; i < attachments.length; i++) { + (function(idx) { + var att = attachments[idx]; + var pill = document.createElement('div'); + pill.className = 'attachment-pill'; + + var thumb = document.createElement('img'); + thumb.src = 'data:' + att.mediaType + ';base64,' + att.data; + thumb.style.cssText = 'width:24px;height:24px;object-fit:cover;border-radius:3px;'; + pill.appendChild(thumb); + + var nameSpan = document.createElement('span'); + nameSpan.textContent = att.name || 'image'; + nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;'; + pill.appendChild(nameSpan); + + var removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'attachment-pill-remove'; + removeBtn.textContent = '\u00d7'; + removeBtn.addEventListener('click', function() { + window.__synthOSAttachments.splice(idx, 1); + renderPills(); + }); + pill.appendChild(removeBtn); + + pillsContainer.appendChild(pill); + })(i); + } + } + + // Expose for debugging + window.__synthOSRenderPills = renderPills; + + // --- Helper: add an image attachment from a data URL --- + function addImageFromDataUrl(dataUrl, name) { + var commaIdx = dataUrl.indexOf(','); + if (commaIdx === -1) return; + var meta = dataUrl.substring(0, commaIdx); // data:image/png;base64 + var base64 = dataUrl.substring(commaIdx + 1); + var mediaType = meta.replace('data:', '').replace(';base64', ''); + window.__synthOSAttachments.push({ + mediaType: mediaType, + data: base64, + name: name || 'image' + }); + renderPills(); + } + + // --- Hidden file input --- + var fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/*'; + fileInput.style.display = 'none'; + document.body.appendChild(fileInput); + + fileInput.addEventListener('change', function() { + var file = fileInput.files && fileInput.files[0]; + if (!file) return; + var reader = new FileReader(); + reader.onload = function() { + addImageFromDataUrl(reader.result, file.name); + }; + reader.readAsDataURL(file); + fileInput.value = ''; + }); + + // --- Paste image from clipboard (document-level to catch all pastes) --- + document.addEventListener('paste', function(e) { + // Only handle pastes when chat input is focused or no other editable is focused + var active = document.activeElement; + var isEditable = active && (active.isContentEditable || active.tagName === 'TEXTAREA' || + (active.tagName === 'INPUT' && active !== chatInput)); + if (isEditable) return; + + var items = e.clipboardData && e.clipboardData.items; + if (!items) return; + for (var i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image/') === 0) { + var blob = items[i].getAsFile(); + if (!blob) continue; + e.preventDefault(); + var reader = new FileReader(); + reader.onload = function() { + addImageFromDataUrl(reader.result, 'pasted-image.png'); + }; + reader.readAsDataURL(blob); + return; // only handle the first image + } + } + }); + + // --- + button --- + var attachBtn = document.createElement('button'); + attachBtn.type = 'button'; + attachBtn.className = 'attach-btn'; + if (window.__synthOSTooltip) window.__synthOSTooltip(attachBtn, 'Attach file or screenshot'); + attachBtn.innerHTML = ''; + wrapper.insertBefore(attachBtn, chatInput); + + // --- Popup menu --- + var menu = document.createElement('div'); + menu.className = 'attach-menu'; + + var menuAttachFile = document.createElement('div'); + menuAttachFile.className = 'attach-menu-item'; + menuAttachFile.innerHTML = ' Attach File'; + menu.appendChild(menuAttachFile); + + var menuScreenshot = document.createElement('div'); + menuScreenshot.className = 'attach-menu-item'; + menuScreenshot.innerHTML = ' Screenshot'; + menu.appendChild(menuScreenshot); + + wrapper.appendChild(menu); + + var menuOpen = false; + function toggleMenu() { + menuOpen = !menuOpen; + menu.style.display = menuOpen ? 'flex' : 'none'; + } + function closeMenu() { + menuOpen = false; + menu.style.display = 'none'; + } + + attachBtn.addEventListener('click', function(e) { + e.stopPropagation(); + toggleMenu(); + }); + + document.addEventListener('click', function() { + if (menuOpen) closeMenu(); + }); + menu.addEventListener('click', function(e) { e.stopPropagation(); }); + + menuAttachFile.addEventListener('click', function() { + closeMenu(); + fileInput.click(); + }); + + // --- Screenshot annotation flow (multi-rectangle) --- + menuScreenshot.addEventListener('click', function() { + closeMenu(); + startScreenshotAnnotation(); + }); + + function startScreenshotAnnotation() { + var viewerPanel = document.getElementById('viewerPanel'); + if (!viewerPanel) return; + + // Create overlay + var overlay = document.createElement('div'); + overlay.className = 'screenshot-overlay'; + + // Instructions bar + var instrBar = document.createElement('div'); + instrBar.style.cssText = 'position:absolute;top:10px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.7);color:white;padding:6px 14px;border-radius:6px;font-size:13px;z-index:10;pointer-events:none;'; + instrBar.textContent = 'Draw rectangles to highlight areas, then click Capture'; + overlay.appendChild(instrBar); + + // Persistent action buttons (always visible) + var actions = document.createElement('div'); + actions.className = 'screenshot-actions'; + actions.style.cssText = 'position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:10;'; + + var captureBtn = document.createElement('button'); + captureBtn.type = 'button'; + captureBtn.textContent = 'Capture'; + captureBtn.className = 'brainstorm-send-btn'; + captureBtn.style.cssText = 'padding:6px 16px;font-size:13px;'; + + var cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.className = 'brainstorm-send-btn'; + cancelBtn.style.cssText = 'padding:6px 16px;font-size:13px;background:transparent;border:1px solid rgba(255,255,255,0.3);color:white;'; + + actions.appendChild(captureBtn); + actions.appendChild(cancelBtn); + overlay.appendChild(actions); + + // Drawing state + var currentRect = null; + var startX, startY, isDrawing = false; + var allRects = []; // Array of {el, x, y, w, h} + + overlay.addEventListener('mousedown', function(e) { + if (e.target.tagName === 'BUTTON') return; + isDrawing = true; + var overlayBounds = overlay.getBoundingClientRect(); + startX = e.clientX - overlayBounds.left; + startY = e.clientY - overlayBounds.top; + + // Create a new rectangle element + currentRect = document.createElement('div'); + currentRect.className = 'screenshot-rect'; + currentRect.style.display = 'block'; + currentRect.style.left = startX + 'px'; + currentRect.style.top = startY + 'px'; + currentRect.style.width = '0'; + currentRect.style.height = '0'; + overlay.appendChild(currentRect); + }); + + overlay.addEventListener('mousemove', function(e) { + if (!isDrawing || !currentRect) return; + var overlayBounds = overlay.getBoundingClientRect(); + var curX = e.clientX - overlayBounds.left; + var curY = e.clientY - overlayBounds.top; + var x = Math.min(startX, curX); + var y = Math.min(startY, curY); + var w = Math.abs(curX - startX); + var h = Math.abs(curY - startY); + currentRect.style.left = x + 'px'; + currentRect.style.top = y + 'px'; + currentRect.style.width = w + 'px'; + currentRect.style.height = h + 'px'; + }); + + overlay.addEventListener('mouseup', function() { + if (!isDrawing || !currentRect) return; + isDrawing = false; + var w = parseInt(currentRect.style.width); + var h = parseInt(currentRect.style.height); + if (w < 10 || h < 10) { + // Too small — discard + currentRect.remove(); + currentRect = null; + return; + } + allRects.push({ + el: currentRect, + x: parseInt(currentRect.style.left), + y: parseInt(currentRect.style.top), + w: w, + h: h + }); + currentRect = null; + }); + + captureBtn.addEventListener('click', function() { + doCapture(); + }); + cancelBtn.addEventListener('click', function() { + cleanup(); + }); + + function doCapture() { + if (typeof html2canvas === 'undefined') { + console.error('html2canvas not loaded'); + cleanup(); + return; + } + + // Capture the full viewer panel without the overlay + overlay.style.visibility = 'hidden'; + + html2canvas(viewerPanel, { useCORS: true, logging: false }).then(function(fullCanvas) { + // Draw red rectangles onto the captured canvas + var vpRect = viewerPanel.getBoundingClientRect(); + var scaleX = fullCanvas.width / vpRect.width; + var scaleY = fullCanvas.height / vpRect.height; + + var ctx = fullCanvas.getContext('2d'); + ctx.strokeStyle = 'red'; + ctx.lineWidth = 3 * Math.max(scaleX, scaleY); + + for (var i = 0; i < allRects.length; i++) { + var r = allRects[i]; + ctx.strokeRect( + Math.round(r.x * scaleX), + Math.round(r.y * scaleY), + Math.round(r.w * scaleX), + Math.round(r.h * scaleY) + ); + } + + var dataUrl = fullCanvas.toDataURL('image/png'); + var base64 = dataUrl.split(',')[1]; + window.__synthOSAttachments.push({ + mediaType: 'image/png', + data: base64, + name: 'screenshot.png' + }); + renderPills(); + cleanup(); + }).catch(function(err) { + console.error('Screenshot capture failed:', err); + cleanup(); + }); + } + + function cleanup() { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + document.removeEventListener('keydown', onKeyDown); + } + + // Escape to cancel + function onKeyDown(e) { + if (e.key === 'Escape') { + cleanup(); + } + } + document.addEventListener('keydown', onKeyDown); + + viewerPanel.style.position = 'relative'; + viewerPanel.appendChild(overlay); + } + })(); + // Initial focus — run after all setup (including chat-collapsed check) if (chatInput && !document.body.classList.contains('chat-collapsed')) { chatInput.focus(); diff --git a/required-pages/builder.html b/required-pages/builder.html index 247f1c4..9ecd883 100644 --- a/required-pages/builder.html +++ b/required-pages/builder.html @@ -8,6 +8,7 @@ + diff --git a/src/builders/anthropic.ts b/src/builders/anthropic.ts index 895143a..aa39512 100644 --- a/src/builders/anthropic.ts +++ b/src/builders/anthropic.ts @@ -1,6 +1,6 @@ -import { anthropic as createAnthropicModel, completePrompt } from '../models'; +import { anthropic as createAnthropicModel, completePrompt, ContentBlock } from '../models'; import { parseChangeList, getTransformInstr } from '../service/transformPage'; -import { Builder, BuilderResult, CHANGE_OPS_SCHEMA, ContextSection } from './types'; +import { Attachment, Builder, BuilderResult, CHANGE_OPS_SCHEMA, ContextSection } from './types'; // --------------------------------------------------------------------------- // Builder options — passed from the route handler @@ -97,19 +97,19 @@ export function createAnthropicBuilder( const name = productName ?? 'SynthOS'; return { - async run(currentPage, additionalSections, userMessage, newBuild): Promise { + async run(currentPage, additionalSections, userMessage, newBuild, attachments?): Promise { try { const isOpus = options?.model?.startsWith('claude-opus-'); // Non-Opus models or missing apiKey: existing behavior if (!isOpus || !options?.apiKey) { - return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name); + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name, attachments); } // Console errors bypass classification — always route to Opus if (userMessage.includes('CONSOLE_ERRORS:')) { console.log('classifyRequest: console errors detected → routing to ' + options.model!); - return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name); + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name, attachments); } // Classify the request using Sonnet @@ -123,18 +123,18 @@ export function createAnthropicBuilder( // New builds always use Opus (the configured model) if (newBuild) { - return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name); + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name, attachments); } // Easy changes use Sonnet if (classifyResult.classification === 'easy-change') { let sonnet: completePrompt = createAnthropicModel({ apiKey: options.apiKey, model: 'claude-sonnet-4-5' }); if (options.wrapModel) sonnet = options.wrapModel(sonnet); - return buildWithModel(sonnet, currentPage, additionalSections, userMessage, userInstructions, name); + return buildWithModel(sonnet, currentPage, additionalSections, userMessage, userInstructions, name, attachments); } // Hard changes use Opus - return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name); + return buildWithModel(complete, currentPage, additionalSections, userMessage, userInstructions, name, attachments); } catch (err: unknown) { return { kind: 'error', error: err instanceof Error ? err : new Error(String(err)) }; } @@ -152,7 +152,8 @@ export async function buildWithModel( additionalSections: ContextSection[], userMessage: string, userInstructions: string | undefined, - productName: string + productName: string, + attachments?: Attachment[] ): Promise { // -- System message: all static content (cacheable) -- const systemParts: string[] = []; @@ -178,7 +179,20 @@ export async function buildWithModel( const systemContent = systemParts.join('\n\n'); // -- User message: dynamic content only (current page + user message) -- - const promptContent = `${currentPage.title}\n${currentPage.content}\n\n\n${userMessage}`; + const promptText = `${currentPage.title}\n${currentPage.content}\n\n\n${userMessage}`; + + // Build prompt content — multimodal when image attachments are present + const imageAttachments = (attachments ?? []).filter(a => a.mediaType.startsWith('image/')); + let promptContent: string | ContentBlock[]; + if (imageAttachments.length > 0) { + const blocks: ContentBlock[] = [{ type: 'text', text: promptText }]; + for (const att of imageAttachments) { + blocks.push({ type: 'image', mediaType: att.mediaType, data: att.data }); + } + promptContent = blocks; + } else { + promptContent = promptText; + } // -- Call model -- const result = await model({ diff --git a/src/builders/fireworksai.ts b/src/builders/fireworksai.ts index c88f9f4..958b060 100644 --- a/src/builders/fireworksai.ts +++ b/src/builders/fireworksai.ts @@ -12,7 +12,7 @@ export function createFireworksAIBuilder(complete: completePrompt, userInstructi const name = productName ?? 'SynthOS'; return { - async run(currentPage, additionalSections, userMessage, newBuild): Promise { + async run(currentPage, additionalSections, userMessage, newBuild, _attachments?): Promise { try { // -- System message -- const systemParts: string[] = [ diff --git a/src/builders/index.ts b/src/builders/index.ts index d498955..c4a4389 100644 --- a/src/builders/index.ts +++ b/src/builders/index.ts @@ -4,7 +4,7 @@ import { createOpenAIBuilder } from './openai'; import { createFireworksAIBuilder } from './fireworksai'; import { Builder } from './types'; -export { ContextSection, BuilderResult, Builder } from './types'; +export { ContextSection, BuilderResult, Builder, Attachment } from './types'; export { createAnthropicBuilder, AnthropicBuilderOptions } from './anthropic'; export { createOpenAIBuilder } from './openai'; export { createFireworksAIBuilder } from './fireworksai'; diff --git a/src/builders/openai.ts b/src/builders/openai.ts index 5a832b2..eb3f136 100644 --- a/src/builders/openai.ts +++ b/src/builders/openai.ts @@ -1,4 +1,4 @@ -import { completePrompt } from '../models'; +import { completePrompt, ContentBlock } from '../models'; import { getTransformInstr } from '../service/transformPage'; import { parseBuilderResponse } from './anthropic'; import { Builder, BuilderResult, OPENAI_CHANGE_OPS_SCHEMA } from './types'; @@ -11,7 +11,7 @@ export function createOpenAIBuilder(complete: completePrompt, userInstructions?: const name = productName ?? 'SynthOS'; return { - async run(currentPage, additionalSections, userMessage, newBuild): Promise { + async run(currentPage, additionalSections, userMessage, newBuild, attachments?): Promise { try { // -- System message -- const systemParts: string[] = [ @@ -37,7 +37,20 @@ export function createOpenAIBuilder(complete: completePrompt, userInstructions?: instructionParts.push(getTransformInstr(name)); const instructions = instructionParts.filter(s => s.trim() !== '').join('\n'); - const promptContent = `\n${userMessage}\n\n\n${instructions}`; + const promptText = `\n${userMessage}\n\n\n${instructions}`; + + // Build prompt content — multimodal when image attachments are present + const imageAttachments = (attachments ?? []).filter(a => a.mediaType.startsWith('image/')); + let promptContent: string | ContentBlock[]; + if (imageAttachments.length > 0) { + const blocks: ContentBlock[] = [{ type: 'text', text: promptText }]; + for (const att of imageAttachments) { + blocks.push({ type: 'image', mediaType: att.mediaType, data: att.data }); + } + promptContent = blocks; + } else { + promptContent = promptText; + } const result = await complete({ system: { role: 'system', content: systemContent }, diff --git a/src/builders/types.ts b/src/builders/types.ts index 68b0686..2cea2c6 100644 --- a/src/builders/types.ts +++ b/src/builders/types.ts @@ -182,6 +182,16 @@ export type BuilderResult = | { kind: 'reply'; text: string } | { kind: 'error'; error: Error }; +// --------------------------------------------------------------------------- +// Attachments — images/files sent alongside the user message +// --------------------------------------------------------------------------- + +export interface Attachment { + mediaType: string; + data: string; + name?: string; +} + // --------------------------------------------------------------------------- // Builder interface // --------------------------------------------------------------------------- @@ -191,6 +201,7 @@ export interface Builder { currentPage: ContextSection, additionalSections: ContextSection[], userMessage: string, - newBuild: boolean + newBuild: boolean, + attachments?: Attachment[] ): Promise; } diff --git a/src/models/anthropic.ts b/src/models/anthropic.ts index 91264b5..33395e4 100644 --- a/src/models/anthropic.ts +++ b/src/models/anthropic.ts @@ -1,5 +1,5 @@ import Anthropic from '@anthropic-ai/sdk'; -import { AgentCompletion, completePrompt, PromptCompletionArgs, RequestError } from './types'; +import { AgentCompletion, completePrompt, PromptCompletionArgs, RequestError, isMultimodalContent } from './types'; export interface AnthropicArgs { apiKey: string; @@ -14,27 +14,44 @@ export interface AnthropicArgs { * Pure function — no SDK dependency. */ export function buildAnthropicRequest(args: PromptCompletionArgs, defaultTemp: number): { - messages: { role: string; content: string }[]; + messages: { role: string; content: string | Anthropic.ContentBlockParam[] }[]; system: string | Anthropic.TextBlockParam[] | undefined; temperature: number; outputConfig?: Anthropic.OutputConfig; } { const reqTemp = args.temperature ?? defaultTemp; - const messages: { role: string; content: string }[] = []; + const messages: { role: string; content: string | Anthropic.ContentBlockParam[] }[] = []; if (args.history) { for (const msg of args.history) { messages.push({ role: msg.role, content: msg.content }); } } + // Build user content — multimodal when ContentBlock[] is provided + const promptContent = args.prompt.content; + let userContent: string | Anthropic.ContentBlockParam[]; + if (isMultimodalContent(promptContent)) { + userContent = promptContent.map(block => { + if (block.type === 'text') { + return { type: 'text' as const, text: block.text }; + } + return { + type: 'image' as const, + source: { type: 'base64' as const, media_type: block.mediaType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp', data: block.data }, + }; + }); + } else { + userContent = promptContent; + } + // Structured output via output_config is incompatible with prefilling const useJsonPrefill = !args.outputSchema && (args.jsonMode || args.jsonSchema); if (useJsonPrefill) { - messages.push({ role: 'user', content: args.prompt.content }); + messages.push({ role: 'user', content: userContent }); messages.push({ role: 'assistant', content: '{' }); } else { - messages.push({ role: 'user', content: args.prompt.content }); + messages.push({ role: 'user', content: userContent }); } let system = args.system?.content; diff --git a/src/models/fireworksai.ts b/src/models/fireworksai.ts index a6ff938..f122b7d 100644 --- a/src/models/fireworksai.ts +++ b/src/models/fireworksai.ts @@ -1,5 +1,5 @@ import OpenAI from 'openai'; -import { AgentCompletion, completePrompt, PromptCompletionArgs, RequestError } from './types'; +import { AgentCompletion, completePrompt, PromptCompletionArgs, RequestError, isMultimodalContent } from './types'; export interface FireworksAIArgs { apiKey: string; @@ -83,7 +83,14 @@ export function buildFireworksRequest(args: PromptCompletionArgs, defaultTemp: n } } - let userContent = args.prompt.content; + // Strip images — FireworksAI has no vision support; keep text only + const promptContent = args.prompt.content; + let userContent: string; + if (isMultimodalContent(promptContent)) { + userContent = promptContent.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('\n'); + } else { + userContent = promptContent; + } if (useJson) { userContent += '\n\nRespond with valid JSON only. No markdown fences.'; } diff --git a/src/models/index.ts b/src/models/index.ts index 0a806c6..97db378 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,4 +1,4 @@ -export { ProviderName, ProviderConfig, ModelEntry, Provider, SystemMessage, UserMessage, Message, AgentCompletion, completePrompt, PromptCompletionArgs, AgentArgs, RequestError } from './types'; +export { ProviderName, ProviderConfig, ModelEntry, Provider, SystemMessage, UserMessage, Message, AgentCompletion, completePrompt, PromptCompletionArgs, AgentArgs, RequestError, TextBlock, ImageBlock, ContentBlock, MessageContent, isMultimodalContent } from './types'; export { AnthropicProvider, OpenAIProvider, PROVIDERS, getProvider, detectProvider } from './providers'; export { anthropic, AnthropicArgs, buildAnthropicRequest } from './anthropic'; export { openai, OpenaiArgs, buildOpenAIRequest } from './openai'; diff --git a/src/models/openai.ts b/src/models/openai.ts index c4f168e..4cb8201 100644 --- a/src/models/openai.ts +++ b/src/models/openai.ts @@ -1,5 +1,5 @@ import OpenAI from 'openai'; -import { AgentCompletion, completePrompt, PromptCompletionArgs, RequestError } from './types'; +import { AgentCompletion, completePrompt, PromptCompletionArgs, RequestError, isMultimodalContent } from './types'; export interface OpenaiArgs { apiKey: string; @@ -15,23 +15,43 @@ export interface OpenaiArgs { * Pure function — no SDK dependency. */ export function buildOpenAIRequest(args: PromptCompletionArgs): { - input: { role: string; content: string }[]; + input: { role: string; content: string | any[] }[]; text?: { format: any }; } { - const input: { role: string; content: string }[] = []; + const input: { role: string; content: string | any[] }[] = []; if (args.history) { for (const msg of args.history) { input.push({ role: msg.role, content: msg.content }); } } - input.push({ role: 'user', content: args.prompt.content }); + + // Build user content — multimodal when ContentBlock[] is provided + const promptContent = args.prompt.content; + if (isMultimodalContent(promptContent)) { + const parts: any[] = promptContent.map(block => { + if (block.type === 'text') { + return { type: 'input_text', text: block.text }; + } + return { + type: 'input_image', + image_url: `data:${block.mediaType};base64,${block.data}`, + }; + }); + input.push({ role: 'user', content: parts }); + } else { + input.push({ role: 'user', content: promptContent }); + } if (args.jsonMode || args.jsonSchema) { - const inputText = input.map(m => m.content).join(' '); + const inputText = input.map(m => typeof m.content === 'string' ? m.content : '').join(' '); if (!/json/i.test(inputText)) { const last = input[input.length - 1]; if (last) { - last.content += '\nReturn JSON.'; + if (typeof last.content === 'string') { + last.content += '\nReturn JSON.'; + } else if (Array.isArray(last.content)) { + last.content.push({ type: 'input_text', text: '\nReturn JSON.' }); + } } } } diff --git a/src/models/types.ts b/src/models/types.ts index 1dcc1be..0e614a9 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -25,6 +25,30 @@ export interface Provider { detectModel(model: string): boolean; } +// --------------------------------------------------------------------------- +// Multimodal content +// --------------------------------------------------------------------------- + +export interface TextBlock { + type: 'text'; + text: string; +} + +export interface ImageBlock { + type: 'image'; + mediaType: string; + data: string; +} + +export type ContentBlock = TextBlock | ImageBlock; + +export type MessageContent = string | ContentBlock[]; + +/** Type guard: returns true when content is a multimodal ContentBlock array. */ +export function isMultimodalContent(content: MessageContent): content is ContentBlock[] { + return Array.isArray(content); +} + // --------------------------------------------------------------------------- // Messages // --------------------------------------------------------------------------- @@ -39,8 +63,10 @@ export interface SystemMessage extends Message { role: 'system'; } -export interface UserMessage extends Message { +export interface UserMessage { role: 'user'; + content: MessageContent; + name?: string; } // --------------------------------------------------------------------------- diff --git a/src/service/server.ts b/src/service/server.ts index 6dca159..a4c2b10 100644 --- a/src/service/server.ts +++ b/src/service/server.ts @@ -26,8 +26,8 @@ export function server(config: SynthOSConfig, customizer: Customizer = defaultCu // Middleware to parse URL-encoded data (form data) app.use(express.urlencoded({ extended: true })); - // Middleware to parse JSON data - app.use(express.json()); + // Middleware to parse JSON data (10 MB limit for image attachments) + app.use(express.json({ limit: '10mb' })); // Page handling routes if (customizer.isEnabled('pages')) usePageRoutes(config, app, customizer); diff --git a/src/service/transformPage.ts b/src/service/transformPage.ts index 72745cc..a9a0101 100644 --- a/src/service/transformPage.ts +++ b/src/service/transformPage.ts @@ -1,7 +1,7 @@ import { AgentCompletion } from "../models"; import * as cheerio from "cheerio"; import { Customizer } from "../customizer"; -import { Builder, ContextSection } from "../builders/types"; +import { Attachment, Builder, ContextSection } from "../builders/types"; // --------------------------------------------------------------------------- // Types @@ -17,6 +17,8 @@ export interface TransformPageArgs { isBuilder?: boolean; /** Product name for branding in prompts (defaults to 'SynthOS'). */ productName?: string; + /** Optional image attachments sent alongside the user message. */ + attachments?: Attachment[]; } export type ChangeOp = @@ -69,7 +71,7 @@ export async function transformPage(args: TransformPageArgs): Promise