| 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; |
| this._pendingVideo = null; |
| this._pendingAudio = null; |
| 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 ` |
| <div class="px-4 pb-4 pt-3 max-w-4xl mx-auto w-full"> |
| <input id="media-file-input" type="file" accept="${mediaAccept}" class="hidden" aria-label="Attach image or video" /> |
| <input id="audio-file-input" type="file" accept="audio/*,.mp3,.wav,.m4a,.aac,.ogg,.flac,.webm" class="hidden" aria-label="Attach audio" /> |
| <div class="flex flex-col bg-[var(--c-card)] border border-[var(--c-bd)] rounded-2xl focus-within:border-[var(--c-bd-hi)] transition-all duration-200 shadow-lg shadow-black/10"> |
| |
| <div id="image-preview-area" class="hidden px-3 pt-3 pb-1"> |
| <div class="relative inline-block"> |
| <img id="image-preview-thumb" src="" alt="Attached image" |
| class="max-h-24 max-w-[200px] rounded-xl border border-[var(--c-bd)] object-cover" /> |
| <button id="remove-image-btn" |
| class="absolute -top-1.5 -right-1.5 bg-[var(--c-card)] border border-[var(--c-bd)] rounded-full w-5 h-5 flex items-center justify-center text-[var(--c-tx3)] hover:text-[var(--c-tx)] transition-colors" |
| aria-label="Remove image"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> |
| </button> |
| </div> |
| </div> |
| |
| <div id="video-preview-area" class="hidden px-3 pt-3 pb-1"> |
| <div class="relative inline-block"> |
| <video id="video-preview-player" src="" controls muted playsinline |
| class="max-h-36 max-w-xs rounded-xl border border-[var(--c-bd)] bg-black"></video> |
| <button id="remove-video-btn" |
| class="absolute -top-1.5 -right-1.5 bg-[var(--c-card)] border border-[var(--c-bd)] rounded-full w-5 h-5 flex items-center justify-center text-[var(--c-tx3)] hover:text-[var(--c-tx)] transition-colors" |
| aria-label="Remove video"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> |
| </button> |
| </div> |
| </div> |
| |
| <div id="audio-preview-area" class="hidden px-3 pt-3 pb-1"> |
| <div class="flex items-center gap-2 rounded-xl border border-[var(--c-bd)] bg-[var(--c-ho)] px-3 py-2"> |
| <div class="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--c-hi)] text-[var(--c-tx3)]"> |
| ${icon('audio')} |
| </div> |
| <div class="min-w-0 flex-1"> |
| <div id="audio-file-name" class="truncate text-[13px] text-[var(--c-tx2)]"></div> |
| <div id="audio-mode-label" class="text-[11px] text-[var(--c-tx3)]">Audio will be transcribed before text processing</div> |
| </div> |
| <button id="remove-audio-btn" |
| class="flex h-7 w-7 items-center justify-center rounded-full border border-[var(--c-bd)] bg-[var(--c-ho)] text-[var(--c-tx3)] transition-colors hover:text-[var(--c-tx2)]" |
| aria-label="Remove audio" |
| type="button"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> |
| </button> |
| </div> |
| </div> |
| |
| <textarea |
| id="message-input" |
| class="bg-transparent px-4 pt-3 pb-1 text-[13.5px] text-[var(--c-tx)] placeholder-[var(--c-txph)] focus:outline-none leading-relaxed resize-none w-full" |
| placeholder="Type a message…" |
| rows="1" |
| style="max-height: 144px; overflow-y: auto;" |
| aria-label="Message input" |
| ></textarea> |
| |
| <div class="flex items-center justify-between px-2 pb-2 pt-1 gap-2"> |
| <div class="flex items-center gap-0.5"> |
| <button id="media-upload-btn" |
| class="flex items-center justify-center w-8 h-8 rounded-lg transition-all ${mediaSupported ? 'text-[var(--c-tx3)] hover:text-[var(--c-tx2)] hover:bg-[var(--c-hi)]' : 'text-[var(--c-tx3)] opacity-40 cursor-not-allowed'}" |
| ${mediaSupported ? '' : 'disabled'} |
| aria-label="Attach image or video" |
| title="${mediaTitle}"> |
| ${icon('image')} |
| </button> |
| <button id="audio-upload-btn" |
| class="flex items-center justify-center w-8 h-8 rounded-lg transition-all ${audioSupported ? 'text-[var(--c-tx3)] hover:text-[var(--c-tx2)] hover:bg-[var(--c-hi)]' : 'text-[var(--c-tx3)] opacity-40 cursor-not-allowed'}" |
| ${audioSupported ? '' : 'disabled'} |
| aria-label="Attach audio" |
| title="${audioSupported ? 'Attach audio for transcription or translation' : 'Audio not enabled for this model'}"> |
| ${icon('audio')} |
| </button> |
| </div> |
| <span id="context-info" |
| class="ml-auto text-right text-[11px] font-mono tabular-nums text-[var(--c-tx3)] transition-colors select-none" |
| title="Estimated context tokens / configured window">ctx 0</span> |
| <button id="send-btn" |
| class="flex items-center justify-center w-8 h-8 rounded-xl transition-all hover:bg-[var(--c-hi)]" |
| aria-label="Send message" |
| title="Send (Enter) · Shift+Enter for new line"> |
| ${icon('send')} |
| </button> |
| </div> |
| |
| </div> |
| </div> |
| `; |
| } |
|
|
| _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'); |
|
|
| |
| textarea.addEventListener('input', () => this._autoResize(textarea)); |
|
|
| |
| textarea.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| this._submit(); |
| } |
| }); |
|
|
| sendBtn.addEventListener('click', () => this._submit()); |
|
|
| |
| 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()); |
|
|
| |
| textarea.addEventListener('paste', (e) => this._handlePaste(e)); |
|
|
| |
| 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; |
| 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(); |
| } |
| } |
|
|