364 lines
10 KiB
TypeScript
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';
|
|
}
|