import Alpine from 'alpinejs'; import { Marked } from 'marked'; import { markedHighlight } from 'marked-highlight'; import hljs from 'highlight.js/lib/common'; import { getSettings, getModels, sendMessage, streamChatUpdates, stopChatGeneration, getChatMessages, listChats, deleteChat, } from '../client'; import { Chat, Message, MessageChunk, Model, Settings } from '../types'; import { applyFilter } from '../utils'; import { createAutoScroll, AutoScroll } from '../utils/autoScroll'; const CHAT_ROUTE = '#/chats'; const MODEL_KEY = 'aethera-chat-model'; const THINKING_KEY = 'aethera-chat-thinking'; const IN_PROGRESS_UUID = '00000000-0000-0000-0000-000000000000'; // Markdown Renderer const marked = new Marked( markedHighlight({ emptyLangClass: 'hljs', langPrefix: 'hljs language-', highlight(code, lang) { const language = hljs.getLanguage(lang) ? lang : 'plaintext'; return hljs.highlight(code, { language }).value; }, }), ); Alpine.data('chatManager', () => ({ chats: [] as Chat[], settings: {} as Settings, _models: [] as Model[], selectedModel: '', thinkingEnabled: localStorage.getItem(THINKING_KEY) !== 'false', inputMessage: '', selectedImages: [] as string[], error: '', selectedChatID: null as string | null, loading: false, activeStreamChatID: null as string | null, _autoScroll: null as AutoScroll | null, async init() { this._autoScroll = createAutoScroll(); // Acquire Data this._models = await getModels(); this.settings = await getSettings(); this.selectedModel = localStorage.getItem(MODEL_KEY) || ''; await this.loadChats(); // Route Chat const chatID = window.location.hash.split('/')[2]; if (chatID) await this.selectChat(chatID); this._autoScroll.scrollToBottom(); }, async loadChats() { try { const response = await listChats(); this.chats = response.chats || []; } catch (err) { console.error('Error loading conversations:', err); } }, async deleteChat(event: Event, chatId: string) { event.stopPropagation(); try { await deleteChat(chatId); // Delete Chat const chatIndex = this.chats.findIndex((c) => c.id == chatId); this.chats.splice(chatIndex, 1); // Update Index if (this.selectedChatID == chatId) { const newIndex = Math.min(chatIndex, this.chats.length - 1); this.selectChat(this.chats[newIndex]?.id); } } catch (err) { console.error('Error deleting conversation:', err); this.error = 'Failed to delete conversation'; } }, async stopResponse() { if (!this.activeStreamChatID) return; // Stop Active Generation try { await stopChatGeneration(this.activeStreamChatID); } catch (err) { console.error('Error stopping response:', err); this.error = parseError(err); } }, async sendMessage() { const message = this.inputMessage.trim(); if ((!message && this.selectedImages.length === 0) || this.loading) return; // Update State const images = [...this.selectedImages]; this.inputMessage = ''; this.selectedImages = []; this.loading = true; this.error = ''; // Save Model if (this.selectedModel) localStorage.setItem(MODEL_KEY, this.selectedModel); localStorage.setItem(THINKING_KEY, String(this.thinkingEnabled)); // New Chat if (!this.selectedChatID) { this.chats.unshift({ id: IN_PROGRESS_UUID, created_at: new Date().toISOString(), title: '', initial_message: message, message_count: 0, messages: [], }); this.selectedChatID = IN_PROGRESS_UUID; } // Add Optimistic User Message const currentChat: Chat = this.chats.find( (c) => c.id === this.selectedChatID, )!; currentChat.messages.push({ id: IN_PROGRESS_UUID, chat_id: this.selectedChatID, role: 'user', thinking: '', content: message, images: images, created_at: new Date().toISOString(), }); currentChat.message_count += 1; this._autoScroll?.scrollToBottom('smooth'); try { await sendMessage( this.selectedChatID === IN_PROGRESS_UUID ? '' : this.selectedChatID, { model: this.selectedModel, prompt: message, images, thinking: this.thinkingEnabled }, (chunk: MessageChunk) => { if (chunk.chat) this.activeStreamChatID = chunk.chat.id; this.applyMessageChunk(chunk); }, ); } catch (err) { console.error('Error sending message:', err); this.error = parseError(err); } finally { this.loading = false; this.activeStreamChatID = null; } }, applyMessageChunk(chunk: MessageChunk) { // Handle Chat if (chunk.chat) { let chat = this.chats.find((c) => c.id === chunk.chat!.id); if (!chat) chat = this.chats.find((c) => c.id === IN_PROGRESS_UUID); if (!chat) { chat = { ...chunk.chat, messages: chunk.chat.messages || [] }; this.chats.unshift(chat); } else { // Preserve Messages - Object.assign would overwrite the existing // messages array before we can check whether the chunk has any. const existingMessages = chat.messages; Object.assign(chat, chunk.chat); chat.messages = chunk.chat.messages?.length ? chunk.chat.messages : existingMessages; } this.selectedChatID = chunk.chat.id; this.updateHash(chunk.chat.id); } const chatID = chunk.chat?.id || this.selectedChatID; const currentChat = this.chats.find((c) => c.id === chatID); if (!currentChat) return; // Handle Messages if (chunk.user_message) this.upsertMessage(currentChat, chunk.user_message); if (chunk.assistant_message) this.upsertMessage(currentChat, chunk.assistant_message); this._autoScroll?.maybeScrollToBottom(); }, upsertMessage(chat: Chat, message: Message) { // Upsert Message const existingIndex = chat.messages.findIndex( (m) => m.id === message.id || (m.id === IN_PROGRESS_UUID && m.role === message.role), ); if (existingIndex === -1) { chat.messages.push(message); } else { chat.messages[existingIndex] = { ...chat.messages[existingIndex], ...message, }; } chat.messages = [...chat.messages]; }, updateHash(chatID: string | null) { const newRoute = CHAT_ROUTE + (chatID ? '/' + chatID : ''); window.history.pushState(null, '', newRoute); }, async selectChat(chatID: string | null) { this.updateHash(chatID); // Load Messages this.selectedChatID = chatID; if (this.selectedChatID) { await this.loadChatMessages(); this._autoScroll?.scrollToBottom(); } }, async loadChatMessages() { if (!this.selectedChatID) return; try { const response = await getChatMessages(this.selectedChatID); const chatIndex = this.chats.findIndex( (c) => c.id == this.selectedChatID, ); if (chatIndex === -1) return; this.chats[chatIndex] = { ...this.chats[chatIndex], ...response, messages: response.messages || [], }; await this.reconnectChatStream(response); } catch (err) { console.error('Error loading chat messages:', err); this.error = 'Failed to load messages'; } }, async reconnectChatStream(chat: Chat) { const latestMessage = chat.messages[chat.messages.length - 1]; if ( !latestMessage || latestMessage.role !== 'assistant' || latestMessage.status !== 'streaming' || this.activeStreamChatID === chat.id ) { return; } // Reconnect Stream this.loading = true; this.activeStreamChatID = chat.id; try { await streamChatUpdates(chat.id, (chunk: MessageChunk) => this.applyMessageChunk(chunk), ); } catch (err) { console.error('Error reconnecting chat stream:', err); this.error = parseError(err); } finally { this.loading = false; this.activeStreamChatID = null; } }, get models(): Model[] { if (!this.settings.text_generation_selector) return this._models; return applyFilter(this._models, this.settings.text_generation_selector); }, get currentChatMessages(): Message[] { if (!this.selectedChatID) return []; const currentChat = this.chats.find((c) => c.id === this.selectedChatID) ?? null; if (!currentChat) return []; return currentChat.messages; }, get chatGroups(): { label: string; chats: Chat[] }[] { return groupChatsByDay(this.chats); }, renderMarkdown(content: string) { return marked.parse(content); }, })); function startOfDay(d: Date): number { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); } function groupChatsByDay( chats: Chat[], ): { label: string; chats: Chat[] }[] { const now = new Date(); const today = startOfDay(now); const yesterday = today - 86_400_000; const groups: { key: number; label: string; chats: Chat[] }[] = []; let current: { key: number; label: string; chats: Chat[] } | null = null; for (const chat of chats) { const created = new Date(chat.created_at); const day = startOfDay(created); if (!current || current.key !== day) { let label: string; if (day === today) label = 'Today'; else if (day === yesterday) label = 'Yesterday'; else { const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', }; if (created.getFullYear() !== now.getFullYear()) opts.year = 'numeric'; label = created.toLocaleDateString(undefined, opts); } current = { key: day, label, chats: [] }; groups.push(current); } current.chats.push(chat); } return groups; } function parseError(err: unknown): string { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('401')) return 'Authentication failed. Please check your API settings.'; if (msg.includes('404')) return 'API endpoint not found. Please check your configuration.'; if (msg.includes('500')) return 'Server error. The text generation service is unavailable.'; if (msg.includes('network') || msg.includes('failed to fetch')) return 'Network error. Please check your internet connection.'; return msg || 'Failed to send message'; }