import { store } from '../store.js'; import { formatCompactTokenCount } from '../api.js'; import { supportsAudio, supportsImage, supportsVideo } from '../capabilities.js'; import { icon } from '../icons.js'; const VIDEO_SIZE_LIMIT_MB = 50; export class InputBar { constructor() { this.el = null; this._pendingImage = null; // { dataUrl, file } this._pendingVideo = null; // { dataUrl, file } this._pendingAudio = null; // { file } this._currentModel = ''; this._sending = false; } render() { const el = document.createElement('div'); el.className = 'border-t border-[var(--c-bd)] bg-[var(--c-bg)]'; el.innerHTML = this._template(); this.el = el; this._bindEvents(); return this.el; } _template() { const imageSupported = supportsImage(this._currentModel, store.getModelCapabilities()); const videoSupported = supportsVideo(this._currentModel, store.getModelCapabilities()); const audioSupported = supportsAudio(this._currentModel, store.getModelCapabilities()); const mediaSupported = imageSupported || videoSupported; const mediaAccept = [imageSupported && 'image/*', videoSupported && 'video/*'].filter(Boolean).join(',') || 'image/*,video/*'; const mediaTitle = mediaSupported ? `Attach ${[imageSupported && 'image', videoSupported && 'video'].filter(Boolean).join(' / ')} (or paste)` : 'Vision not enabled for this model'; return `
ctx 0
`; } _bindEvents() { const textarea = this.el.querySelector('#message-input'); const sendBtn = this.el.querySelector('#send-btn'); const mediaBtn = this.el.querySelector('#media-upload-btn'); const audioBtn = this.el.querySelector('#audio-upload-btn'); const fileInput = this.el.querySelector('#media-file-input'); const audioInput = this.el.querySelector('#audio-file-input'); const removeImageBtn = this.el.querySelector('#remove-image-btn'); const removeVideoBtn = this.el.querySelector('#remove-video-btn'); const removeAudioBtn = this.el.querySelector('#remove-audio-btn'); // Auto-resize textarea textarea.addEventListener('input', () => this._autoResize(textarea)); // Enter sends; Shift+Enter inserts newline (default behaviour) textarea.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this._submit(); } }); sendBtn.addEventListener('click', () => this._submit()); // Media (image/video) upload mediaBtn?.addEventListener('click', () => { if (!mediaBtn.disabled) fileInput.click(); }); audioBtn?.addEventListener('click', () => { if (!audioBtn.disabled) audioInput.click(); }); fileInput.addEventListener('change', (e) => { const file = e.target.files?.[0]; if (file) this._handleMediaFile(file); fileInput.value = ''; }); audioInput.addEventListener('change', (e) => { const file = e.target.files?.[0]; if (file) this._handleAudioFile(file); audioInput.value = ''; }); removeImageBtn.addEventListener('click', () => this._clearImage()); removeVideoBtn?.addEventListener('click', () => this._clearVideo()); removeAudioBtn?.addEventListener('click', () => this._clearAudio()); // Paste media support textarea.addEventListener('paste', (e) => this._handlePaste(e)); // Model change document.addEventListener('model:changed', (e) => { this.setModel(e.detail.model); }); document.addEventListener('caps:changed', () => { this._updateAttachmentButtons(); }); } _autoResize(textarea) { textarea.style.height = 'auto'; const scrollH = textarea.scrollHeight; const maxH = 144; // max-height ~6 rows textarea.style.height = Math.min(scrollH, maxH) + 'px'; } async _handleImageFile(file) { if (!file.type.startsWith('image/')) return; if (this._pendingAudio) this._clearAudio(); if (this._pendingVideo) this._clearVideo(); const dataUrl = await this._fileToDataUrl(file); this._pendingImage = { dataUrl, file }; this._showImagePreview(dataUrl); } async _handleVideoFile(file) { if (!file.type.startsWith('video/')) return; const sizeMB = file.size / (1024 * 1024); if (sizeMB > VIDEO_SIZE_LIMIT_MB) { alert(`Video file is too large (${sizeMB.toFixed(1)} MB). Please keep it under ${VIDEO_SIZE_LIMIT_MB} MB.`); return; } if (this._pendingAudio) this._clearAudio(); if (this._pendingImage) this._clearImage(); const dataUrl = await this._fileToDataUrl(file); this._pendingVideo = { dataUrl, file }; this._showVideoPreview(dataUrl); } _handleMediaFile(file) { if (file.type.startsWith('image/')) { this._handleImageFile(file); } else if (file.type.startsWith('video/')) { this._handleVideoFile(file); } } _handleAudioFile(file) { if (!String(file?.type || '').startsWith('audio/') && !/\.(mp3|wav|m4a|aac|ogg|flac|webm)$/i.test(file?.name || '')) return; if (this._pendingImage) this._clearImage(); this._pendingAudio = { file }; this._showAudioPreview(); } _fileToDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => resolve(e.target.result); reader.onerror = reject; reader.readAsDataURL(file); }); } _showImagePreview(dataUrl) { const previewArea = this.el.querySelector('#image-preview-area'); const thumb = this.el.querySelector('#image-preview-thumb'); thumb.src = dataUrl; previewArea.classList.remove('hidden'); } _clearImage() { this._pendingImage = null; const previewArea = this.el.querySelector('#image-preview-area'); const thumb = this.el.querySelector('#image-preview-thumb'); thumb.src = ''; previewArea.classList.add('hidden'); } _showVideoPreview(dataUrl) { const previewArea = this.el.querySelector('#video-preview-area'); const player = this.el.querySelector('#video-preview-player'); player.src = dataUrl; previewArea.classList.remove('hidden'); } _clearVideo() { this._pendingVideo = null; const previewArea = this.el.querySelector('#video-preview-area'); const player = this.el.querySelector('#video-preview-player'); if (player) { player.pause(); player.src = ''; } previewArea?.classList.add('hidden'); } _showAudioPreview() { const previewArea = this.el.querySelector('#audio-preview-area'); const nameEl = this.el.querySelector('#audio-file-name'); if (!previewArea || !this._pendingAudio) return; nameEl.textContent = this._pendingAudio.file?.name || 'audio'; previewArea.classList.remove('hidden'); } _clearAudio() { this._pendingAudio = null; const previewArea = this.el.querySelector('#audio-preview-area'); const nameEl = this.el.querySelector('#audio-file-name'); if (nameEl) nameEl.textContent = ''; previewArea?.classList.add('hidden'); } async _handlePaste(e) { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type.startsWith('image/')) { const imageSupported = supportsImage(this._currentModel, store.getModelCapabilities()); if (!imageSupported) return; e.preventDefault(); const file = item.getAsFile(); if (file) await this._handleImageFile(file); break; } if (item.type.startsWith('video/')) { const videoSupported = supportsVideo(this._currentModel, store.getModelCapabilities()); if (!videoSupported) return; e.preventDefault(); const file = item.getAsFile(); if (file) await this._handleVideoFile(file); break; } if (item.type.startsWith('audio/')) { const audioSupported = supportsAudio(this._currentModel, store.getModelCapabilities()); if (!audioSupported) return; e.preventDefault(); const file = item.getAsFile(); if (file) this._handleAudioFile(file); break; } } } _submit() { if (this._sending) return; const textarea = this.el.querySelector('#message-input'); const text = textarea.value.trim(); if (!text && !this._pendingImage && !this._pendingVideo && !this._pendingAudio) return; const image = this._pendingImage; const video = this._pendingVideo; const audio = this._pendingAudio; this._clearImage(); this._clearVideo(); this._clearAudio(); textarea.value = ''; this._autoResize(textarea); document.dispatchEvent(new CustomEvent('inputbar:send', { detail: { text, image, video, audio }, })); } setSending(sending) { this._sending = sending; const sendBtn = this.el.querySelector('#send-btn'); const textarea = this.el.querySelector('#message-input'); if (sendBtn) { sendBtn.disabled = sending; sendBtn.className = sending ? 'flex items-center justify-center w-8 h-8 rounded-xl text-[var(--c-tx3)] cursor-not-allowed transition-all' : 'flex items-center justify-center w-8 h-8 rounded-xl transition-all hover:bg-[var(--c-hi)]'; sendBtn.innerHTML = icon(sending ? 'sendMuted' : 'send'); } if (textarea) textarea.disabled = sending; } setContextInfo(currentTokens, maxTokens, warnTokens = maxTokens) { const el = this.el.querySelector('#context-info'); if (!el) return; el.textContent = `ctx ${formatCompactTokenCount(currentTokens)}/${formatCompactTokenCount(maxTokens)}`; el.title = `Estimated context ${Math.round(currentTokens)} / ${Math.round(maxTokens)} tokens`; const warning = currentTokens >= warnTokens; el.className = warning ? 'ml-auto text-right text-[11px] font-mono tabular-nums text-amber-400 transition-colors select-none' : 'ml-auto text-right text-[11px] font-mono tabular-nums text-[var(--c-tx3)] transition-colors select-none'; } setKvCount(current, max) { this.setContextInfo(current, max, Math.floor(max * 0.8)); } setModel(modelId) { this._currentModel = modelId; this._updateAttachmentButtons(); } _updateAttachmentButtons() { const mediaBtn = this.el.querySelector('#media-upload-btn'); const audioBtn = this.el.querySelector('#audio-upload-btn'); const fileInput = this.el.querySelector('#media-file-input'); if (!mediaBtn || !audioBtn) return; const imageSupported = supportsImage(this._currentModel, store.getModelCapabilities()); const videoSupported = supportsVideo(this._currentModel, store.getModelCapabilities()); const audioSupported = supportsAudio(this._currentModel, store.getModelCapabilities()); const mediaSupported = imageSupported || videoSupported; if (mediaSupported) { mediaBtn.disabled = false; mediaBtn.className = 'flex items-center justify-center w-8 h-8 rounded-lg transition-all text-[var(--c-tx3)] hover:text-[var(--c-tx2)] hover:bg-[var(--c-hi)]'; const labels = [imageSupported && 'image', videoSupported && 'video'].filter(Boolean).join(' / '); mediaBtn.title = `Attach ${labels} (or paste)`; if (fileInput) { fileInput.accept = [imageSupported && 'image/*', videoSupported && 'video/*'].filter(Boolean).join(','); } } else { mediaBtn.disabled = true; mediaBtn.className = 'flex items-center justify-center w-8 h-8 rounded-lg transition-all text-[var(--c-tx3)] opacity-40 cursor-not-allowed'; mediaBtn.title = 'Vision not enabled for this model'; if (this._pendingImage) this._clearImage(); if (this._pendingVideo) this._clearVideo(); } if (audioSupported) { audioBtn.disabled = false; audioBtn.className = 'flex items-center justify-center w-8 h-8 rounded-lg transition-all text-[var(--c-tx3)] hover:text-[var(--c-tx2)] hover:bg-[var(--c-hi)]'; audioBtn.title = 'Attach audio for transcription or translation'; } else { audioBtn.disabled = true; audioBtn.className = 'flex items-center justify-center w-8 h-8 rounded-lg transition-all text-[var(--c-tx3)] opacity-40 cursor-not-allowed'; audioBtn.title = 'Audio not enabled for this model'; if (this._pendingAudio) this._clearAudio(); } } focus() { this.el?.querySelector('#message-input')?.focus(); } }