Files
aethera/frontend/src/components/chatManager.ts

364 lines
10 KiB
TypeScript

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';
}