| import { store } from '../store.js'; |
| import { |
| streamCompletion, |
| formatMessagesForApi, |
| transcribeAudio, |
| } from '../api.js'; |
| import { estimateThreadTokens, resolveContextConfig } from '../context.js'; |
| import { Sidebar } from './sidebar.js'; |
| import { Chat } from './chat.js'; |
| import { InputBar } from './input-bar.js'; |
| import { ModelPicker } from './model-picker.js'; |
| import { SettingsModal } from './settings-modal.js'; |
| import { icon } from '../icons.js'; |
|
|
| export class App { |
| constructor(rootEl) { |
| this.root = rootEl; |
| this.sidebar = new Sidebar(); |
| this.chat = new Chat(); |
| this.inputBar = new InputBar(); |
| this.modelPicker = new ModelPicker(); |
| this.settingsModal = new SettingsModal(); |
| this._sidebarOpen = false; |
| |
| |
| |
| this._sessionMessages = new Map(); |
| } |
|
|
| init() { |
| this._render(); |
| this._initTheme(); |
| this._syncSidebarLayout(); |
| this._bindEvents(); |
| this._loadCurrentConversation(); |
| this.settingsModal.render(); |
| this._updateContextInfo(); |
| this.inputBar.focus(); |
| } |
|
|
| |
|
|
| _pushSessionMsg(convId, msg) { |
| if (!this._sessionMessages.has(convId)) this._sessionMessages.set(convId, []); |
| this._sessionMessages.get(convId).push(msg); |
| } |
|
|
| _clearSessionMsgs(convId) { |
| this._sessionMessages.delete(convId); |
| } |
|
|
| |
| |
| _sessionConvFor(conv) { |
| if (!conv) return conv; |
| const mem = this._sessionMessages.get(conv.id); |
| return mem?.length ? { ...conv, messages: mem } : conv; |
| } |
|
|
| _render() { |
| this.root.className = 'flex h-screen overflow-hidden bg-[var(--c-bg)]'; |
|
|
| |
| const sidebarEl = this.sidebar.render(); |
| this.root.appendChild(sidebarEl); |
|
|
| |
| this._mobileOverlay = document.createElement('div'); |
| this._mobileOverlay.className = 'fixed inset-0 bg-black/50 z-20 hidden md:hidden'; |
| this._mobileOverlay.addEventListener('click', () => this._closeMobileSidebar()); |
| document.body.appendChild(this._mobileOverlay); |
|
|
| |
| const main = document.createElement('div'); |
| main.className = 'flex flex-col flex-1 min-w-0 h-full'; |
| main.id = 'main-content'; |
|
|
| |
| const header = document.createElement('header'); |
| header.className = 'flex items-center justify-between px-4 py-2.5 border-b border-[var(--c-top-bd)] bg-[var(--c-top)] flex-shrink-0 gap-3'; |
| header.innerHTML = ` |
| <div class="flex items-center gap-3"> |
| <button id="mobile-menu-btn" class="md:hidden p-1.5 rounded text-[var(--c-tx3)] hover:text-[var(--c-tx)] transition-colors" aria-label="Toggle sidebar"> |
| ${icon('menu')} |
| </button> |
| <div id="model-picker-mount"></div> |
| </div> |
| <div class="flex items-center gap-2"> |
| <button id="theme-toggle-btn" class="p-1.5 rounded-lg text-[var(--c-tx3)] hover:text-[var(--c-tx2)] hover:bg-[var(--c-top-el)] border border-transparent hover:border-[var(--c-top-bd)] transition-all" aria-label="Toggle theme"> |
| ${icon('sun')} |
| </button> |
| <button id="settings-btn" class="p-1.5 rounded-lg text-[var(--c-tx3)] hover:text-[var(--c-tx2)] hover:bg-[var(--c-top-el)] border border-transparent hover:border-[var(--c-top-bd)] transition-all" aria-label="Settings"> |
| ${icon('settings')} |
| </button> |
| </div> |
| `; |
|
|
| |
| header.querySelector('#model-picker-mount').appendChild(this.modelPicker.render()); |
| main.appendChild(header); |
|
|
| |
| const chatWrapper = document.createElement('div'); |
| chatWrapper.className = 'flex-1 min-h-0 overflow-hidden'; |
| chatWrapper.appendChild(this.chat.render()); |
| main.appendChild(chatWrapper); |
|
|
| |
| main.appendChild(this.inputBar.render()); |
|
|
| this.root.appendChild(main); |
| } |
|
|
| _bindEvents() { |
| |
| this.root.querySelector('#settings-btn').addEventListener('click', () => { |
| this.settingsModal.toggle(); |
| }); |
|
|
| |
| this.root.querySelector('#theme-toggle-btn')?.addEventListener('click', () => { |
| this._toggleTheme(); |
| }); |
|
|
| |
| this.root.querySelector('#mobile-menu-btn')?.addEventListener('click', () => { |
| this._toggleMobileSidebar(); |
| }); |
|
|
| window.addEventListener('resize', () => { |
| this._syncSidebarLayout(); |
| }); |
|
|
| |
| document.addEventListener('sidebar:newchat', () => this._newChat()); |
|
|
| |
| document.addEventListener('sidebar:select', (e) => { |
| this._selectConversation(e.detail.convId); |
| }); |
|
|
| |
| document.addEventListener('sidebar:deleted', () => { |
| const currentId = store.getCurrentConversationId(); |
| if (currentId) { |
| this._selectConversation(currentId); |
| } else { |
| this.chat.clear(); |
| this.sidebar.update(); |
| } |
| }); |
|
|
| |
| document.addEventListener('inputbar:send', (e) => { |
| this._handleSend(e.detail.text, e.detail.image, e.detail.video, e.detail.audio); |
| }); |
|
|
| |
| document.addEventListener('model:changed', () => { |
| this.inputBar.setModel(this.modelPicker.getModel()); |
| this._updateContextInfo(); |
| }); |
|
|
| document.addEventListener('settings:changed', () => { |
| this.inputBar.setModel(this.modelPicker.getModel()); |
| this._updateContextInfo(); |
| }); |
|
|
| document.addEventListener('models:changed', () => { |
| this.modelPicker.setModels(store.getAvailableModels()); |
| this.inputBar.setModel(this.modelPicker.getModel()); |
| this._updateContextInfo(); |
| }); |
| } |
|
|
| _loadCurrentConversation() { |
| const currentId = store.getCurrentConversationId(); |
| if (currentId) { |
| const conv = store.getCurrentConversation(); |
| if (conv) { |
| this.chat.loadConversation(this._sessionConvFor(conv)); |
| this.modelPicker.syncToConversation(conv); |
| this.inputBar.setModel(this.modelPicker.getModel()); |
| this._updateContextInfo(); |
| return; |
| } |
| } |
| |
| const convs = store.getConversations(); |
| if (convs.length > 0) { |
| this._selectConversation(convs[0].id); |
| } |
| } |
|
|
| _newChat() { |
| const model = this.modelPicker.getModel(); |
| const conv = store.createConversation(model); |
| store.setCurrentConversationId(conv.id); |
| this.chat.loadConversation(conv); |
| this.sidebar.update(); |
| this._updateContextInfo(); |
| this.inputBar.focus(); |
| this._syncSidebarLayout(); |
| } |
|
|
| _selectConversation(convId) { |
| store.setCurrentConversationId(convId); |
| const conv = store.getCurrentConversation(); |
| if (!conv) { |
| this.chat.clear(); |
| this._updateContextInfo(); |
| return; |
| } |
| this.chat.loadConversation(this._sessionConvFor(conv)); |
| this.modelPicker.syncToConversation(conv); |
| this.inputBar.setModel(this.modelPicker.getModel()); |
| this.sidebar.update(); |
| this._updateContextInfo(); |
| this.inputBar.focus(); |
| this._closeMobileSidebar(); |
| } |
|
|
| async _handleSend(text, image, video, audio) { |
| const settings = store.getSettings(); |
| const trimmedText = text.trim(); |
|
|
| |
| if (trimmedText === '/clean' && !image && !video && !audio) { |
| const convId = store.getCurrentConversationId(); |
| if (convId) { |
| store.clearMessages(convId); |
| this._clearSessionMsgs(convId); |
| this.chat.clear(); |
| this._updateContextInfo(); |
| } |
| return; |
| } |
|
|
| if (trimmedText === '/reset' && !image && !video && !audio) { |
| this._resetConversationContext(store.getCurrentConversationId(), 'manual /reset command'); |
| return; |
| } |
|
|
| |
| let convId = store.getCurrentConversationId(); |
| if (!convId) { |
| const conv = store.createConversation(this.modelPicker.getModel()); |
| convId = conv.id; |
| store.setCurrentConversationId(convId); |
| this.sidebar.update(); |
| } |
|
|
| const model = this.modelPicker.getModel(); |
| if (!model) { |
| this.chat.showError('Error: no model selected for the current API Base URL'); |
| return; |
| } |
|
|
| if (audio) { |
| await this._handleAudioTask({ |
| convId, |
| model, |
| settings, |
| instruction: trimmedText, |
| audio, |
| }); |
| return; |
| } |
|
|
| const contextConfig = resolveContextConfig(settings, model); |
| const userMessage = this._buildUserMessage(text, image, video); |
| const preConv = store.getCurrentConversation(); |
| const projectedTokens = estimateThreadTokens(preConv?.messages, userMessage); |
|
|
| if (preConv?.messages?.length && projectedTokens >= contextConfig.resetTokens) { |
| this._resetConversationContext( |
| convId, |
| `estimated ${projectedTokens.toLocaleString()} tokens reached the ${contextConfig.resetPercent}% auto-reset threshold` |
| ); |
| } |
|
|
| |
| let renderOk = false; |
| try { |
| this.chat.clearError(); |
| store.addMessage(convId, userMessage); |
| this._pushSessionMsg(convId, userMessage); |
| this.chat.appendUserMessage(userMessage); |
| this._updateContextInfo(); |
| renderOk = true; |
| } catch (err) { |
| this.chat.showError(`Error: ${err.message}`); |
| this.inputBar.setSending(false); |
| this.inputBar.focus(); |
| return; |
| } |
|
|
| |
| const conv = store.getCurrentConversation(); |
| if (conv && conv.messages.length === 1) { |
| const title = text.slice(0, 40) || (video ? 'Video message' : 'Image message'); |
| store.updateConversationTitle(convId, title); |
| this.sidebar.update(); |
| } |
|
|
| |
| this.inputBar.setSending(true); |
| this.chat.showTypingIndicator(); |
|
|
| |
| |
| |
| |
| const apiMessages = formatMessagesForApi([...(preConv?.messages || []), userMessage]); |
|
|
| try { |
| let started = false; |
| let fullText = ''; |
|
|
| for await (const chunk of streamCompletion(settings.baseUrl, settings.apiKey, model, apiMessages)) { |
| if (!started) { |
| this.chat.startAssistantMessage(); |
| started = true; |
| } |
| fullText += chunk; |
| this.chat.appendToAssistantMessage(chunk); |
| } |
|
|
| if (!started) this.chat.startAssistantMessage(); |
|
|
| |
| this.chat.finalizeAssistantMessage(fullText); |
| const assistantMsg = { |
| role: 'assistant', |
| content: fullText, |
| timestamp: new Date().toISOString(), |
| }; |
| store.addMessage(convId, assistantMsg); |
| this._pushSessionMsg(convId, assistantMsg); |
| this._updateContextInfo(); |
| this.sidebar.update(); |
| } catch (err) { |
| this.chat.showError(`Error: ${err.message}`); |
| } finally { |
| this.inputBar.setSending(false); |
| this.inputBar.focus(); |
| } |
| } |
|
|
| _buildUserMessage(text, image, video) { |
| let content; |
| if (image) { |
| content = [ |
| { type: 'text', text: text || '' }, |
| { type: 'image_url', image_url: { url: image.dataUrl } }, |
| ]; |
| } else if (video) { |
| content = [ |
| { type: 'text', text: text || '' }, |
| { type: 'video_url', video_url: { url: video.dataUrl } }, |
| ]; |
| } else { |
| content = text; |
| } |
|
|
| return { |
| role: 'user', |
| content, |
| timestamp: new Date().toISOString(), |
| }; |
| } |
|
|
| _buildAudioUserMessage(audio, instruction = '') { |
| const summary = `Audio upload: ${audio.file?.name || 'audio'}`; |
| const suffix = instruction ? `\nInstruction: ${instruction}` : ''; |
| return { |
| role: 'user', |
| content: `${summary}${suffix}`, |
| timestamp: new Date().toISOString(), |
| meta: { |
| type: 'audio', |
| fileName: audio.file?.name || 'audio', |
| }, |
| }; |
| } |
|
|
| async _handleAudioTask({ convId, model, settings, instruction, audio }) { |
| const userMessage = this._buildAudioUserMessage(audio, instruction); |
| store.addMessage(convId, userMessage); |
| this._pushSessionMsg(convId, userMessage); |
| this.chat.appendUserMessage(userMessage); |
| this._updateContextInfo(); |
|
|
| const conv = store.getCurrentConversation(); |
| if (conv && conv.messages.length === 1) { |
| const title = (instruction || userMessage.content).slice(0, 40) || 'Audio task'; |
| store.updateConversationTitle(convId, title); |
| this.sidebar.update(); |
| } |
|
|
| this.inputBar.setSending(true); |
| this.chat.showTypingIndicator(); |
|
|
| try { |
| const transcript = await transcribeAudio(settings.baseUrl, settings.apiKey, model, audio.file); |
|
|
| if (!instruction) { |
| this.chat.hideTypingIndicator(); |
| this.chat.startAssistantMessage(); |
| this.chat.finalizeAssistantMessage(transcript); |
| const assistantMsg = { |
| role: 'assistant', |
| content: transcript, |
| timestamp: new Date().toISOString(), |
| }; |
| store.addMessage(convId, assistantMsg); |
| this._pushSessionMsg(convId, assistantMsg); |
| this._updateContextInfo(); |
| this.sidebar.update(); |
| return; |
| } |
|
|
| const followUpMessage = { |
| role: 'user', |
| content: [ |
| `The following text came from an uploaded audio file.`, |
| `Task: ${instruction}`, |
| `Audio text:`, |
| transcript, |
| ].join('\n\n'), |
| timestamp: new Date().toISOString(), |
| }; |
|
|
| const currentConv = store.getCurrentConversation(); |
| const apiMessages = [ |
| ...formatMessagesForApi(currentConv.messages), |
| { role: 'user', content: followUpMessage.content }, |
| ]; |
|
|
| this.chat.hideTypingIndicator(); |
| this.chat.showSystemMessage('Audio transcribed — applying instruction'); |
| this.chat.startAssistantMessage(); |
|
|
| let fullText = ''; |
| for await (const chunk of streamCompletion(settings.baseUrl, settings.apiKey, model, apiMessages)) { |
| fullText += chunk; |
| this.chat.appendToAssistantMessage(chunk); |
| } |
|
|
| this.chat.finalizeAssistantMessage(fullText); |
| const assistantMsg = { |
| role: 'assistant', |
| content: fullText, |
| timestamp: new Date().toISOString(), |
| }; |
| store.addMessage(convId, assistantMsg); |
| this._pushSessionMsg(convId, assistantMsg); |
| this._updateContextInfo(); |
| this.sidebar.update(); |
| } catch (err) { |
| this.chat.showError(`Error: ${err.message}`); |
| } finally { |
| this.inputBar.setSending(false); |
| this.inputBar.focus(); |
| } |
| } |
|
|
| _initTheme() { |
| const saved = localStorage.getItem('theme') || 'dark'; |
| document.documentElement.classList.toggle('dark', saved === 'dark'); |
| this._updateThemeBtn(); |
| } |
|
|
| _toggleTheme() { |
| const isDark = document.documentElement.classList.toggle('dark'); |
| localStorage.setItem('theme', isDark ? 'dark' : 'light'); |
| this._updateThemeBtn(); |
| } |
|
|
| _updateThemeBtn() { |
| const btn = this.root.querySelector('#theme-toggle-btn'); |
| if (!btn) return; |
| const isDark = document.documentElement.classList.contains('dark'); |
| btn.innerHTML = isDark ? icon('sun') : icon('moon'); |
| btn.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode'); |
| } |
|
|
| _resetConversationContext(convId, reason) { |
| if (!convId) return; |
| const conv = store.getConversations().find((item) => item.id === convId); |
| if (!conv) return; |
|
|
| const { maxTokens } = resolveContextConfig(store.getSettings(), this.modelPicker.getModel()); |
| const currentTokens = estimateThreadTokens(conv.messages); |
| store.clearMessages(convId); |
| this.chat.showSystemMessage( |
| `Context /reset — cleared ${currentTokens.toLocaleString()} estimated tokens (${reason}; window ${maxTokens.toLocaleString()})` |
| ); |
| this._updateContextInfo(); |
| } |
|
|
| _updateContextInfo() { |
| const conv = store.getCurrentConversation(); |
| const model = this.modelPicker.getModel(); |
| const { maxTokens, warnTokens } = resolveContextConfig(store.getSettings(), model); |
| const currentTokens = estimateThreadTokens(conv?.messages); |
| this.inputBar.setContextInfo(currentTokens, maxTokens, warnTokens); |
| } |
|
|
| _toggleMobileSidebar() { |
| if (this._isDesktopLayout()) return; |
| this._sidebarOpen = !this._sidebarOpen; |
| this._syncSidebarLayout(); |
| } |
|
|
| _closeMobileSidebar() { |
| if (this._isDesktopLayout()) { |
| this._syncSidebarLayout(); |
| return; |
| } |
| this._sidebarOpen = false; |
| this._syncSidebarLayout(); |
| } |
|
|
| _isDesktopLayout() { |
| return window.innerWidth >= 768; |
| } |
|
|
| _syncSidebarLayout() { |
| const sidebar = this.root.querySelector('#sidebar'); |
| if (!sidebar) return; |
|
|
| if (this._isDesktopLayout()) { |
| sidebar.classList.remove('-translate-x-full', 'translate-x-0', 'fixed', 'inset-y-0', 'left-0', 'z-30'); |
| this._mobileOverlay?.classList.add('hidden'); |
| this._sidebarOpen = true; |
| return; |
| } |
|
|
| sidebar.classList.add('fixed', 'inset-y-0', 'left-0', 'z-30'); |
|
|
| if (this._sidebarOpen) { |
| sidebar.classList.remove('-translate-x-full'); |
| sidebar.classList.add('translate-x-0'); |
| this._mobileOverlay?.classList.remove('hidden'); |
| } else { |
| sidebar.classList.add('-translate-x-full'); |
| sidebar.classList.remove('translate-x-0'); |
| this._mobileOverlay?.classList.add('hidden'); |
| } |
| } |
| } |
|
|