initial commit
This commit is contained in:
180
frontend/src/client.ts
Normal file
180
frontend/src/client.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
Settings,
|
||||
ImageRecord,
|
||||
GenerateImageRequest,
|
||||
Model,
|
||||
Chat,
|
||||
GenerateTextRequest,
|
||||
ChatListResponse,
|
||||
MessageChunk,
|
||||
} from './types/index';
|
||||
|
||||
export async function saveSettings(settings: Settings): Promise<Settings> {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getSettings(): Promise<Settings> {
|
||||
const response = await fetch('/api/settings');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
requestData: GenerateImageRequest,
|
||||
): Promise<ImageRecord[]> {
|
||||
const response = await fetch('/api/images', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getModels(): Promise<Model[]> {
|
||||
const response = await fetch('/api/models');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getGeneratedImages(): Promise<ImageRecord[]> {
|
||||
const response = await fetch('/api/images');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteImage(filename: string): Promise<void> {
|
||||
const response = await fetch(`/api/images/${filename}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
chatId: string,
|
||||
requestData: GenerateTextRequest,
|
||||
onChunk: (chunk: MessageChunk) => void,
|
||||
) {
|
||||
const url = chatId ? `/api/chats/${chatId}` : '/api/chats';
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
return streamMessage(response, onChunk);
|
||||
}
|
||||
|
||||
export async function getChatMessages(chatId: string): Promise<Chat> {
|
||||
const response = await fetch(`/api/chats/${chatId}`);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listChats(): Promise<ChatListResponse> {
|
||||
const response = await fetch('/api/chats');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteChat(chatId: string): Promise<void> {
|
||||
const response = await fetch(`/api/chats/${chatId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function streamMessage(
|
||||
response: Response,
|
||||
onChunk: (chunk: MessageChunk) => void,
|
||||
) {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// Add Buffer
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Split
|
||||
const lines = buffer.split('\n');
|
||||
|
||||
// Keep Incomplete Line
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
// Parse Complete Lines
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const msgChunk: MessageChunk = JSON.parse(trimmed);
|
||||
onChunk(msgChunk);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse:', trimmed);
|
||||
throw new Error(`JSON Metadata Parsing ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
243
frontend/src/components/chatManager.ts
Normal file
243
frontend/src/components/chatManager.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import { Marked } from 'marked';
|
||||
import { markedHighlight } from 'marked-highlight';
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
import {
|
||||
getSettings,
|
||||
getModels,
|
||||
sendMessage,
|
||||
getChatMessages,
|
||||
listChats,
|
||||
deleteChat,
|
||||
} from '../client';
|
||||
import { Chat, Message, MessageChunk, Model, Settings } from '../types';
|
||||
import { applyFilter } from '../utils';
|
||||
|
||||
const CHAT_ROUTE = '#/chats';
|
||||
const MODEL_KEY = 'aethera-chat-model';
|
||||
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: '',
|
||||
inputMessage: '',
|
||||
error: '',
|
||||
|
||||
selectedChatID: null as string | null,
|
||||
chatListOpen: false,
|
||||
loading: false,
|
||||
|
||||
async init() {
|
||||
// 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);
|
||||
},
|
||||
|
||||
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);
|
||||
if (!this.selectedChatID) this.chatListOpen = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting conversation:', err);
|
||||
this.error = 'Failed to delete conversation';
|
||||
}
|
||||
},
|
||||
|
||||
async sendMessage() {
|
||||
const message = this.inputMessage.trim();
|
||||
if (!message || this.loading) return;
|
||||
|
||||
// Update State
|
||||
this.inputMessage = '';
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
// Save Model
|
||||
if (this.selectedModel) localStorage.setItem(MODEL_KEY, this.selectedModel);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// New User Message
|
||||
let userMessage: Message = {
|
||||
id: IN_PROGRESS_UUID,
|
||||
chat_id: this.selectedChatID,
|
||||
role: 'user',
|
||||
thinking: '',
|
||||
content: message,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Get Chat
|
||||
let currentChat: Chat = this.chats.find(
|
||||
(c) => c.id === this.selectedChatID,
|
||||
)!;
|
||||
|
||||
// Add User Message
|
||||
currentChat.messages.push(userMessage);
|
||||
currentChat.message_count += 1;
|
||||
|
||||
// Assistant Message Placeholder
|
||||
let assistantMessage: Message | undefined;
|
||||
|
||||
try {
|
||||
await sendMessage(
|
||||
this.selectedChatID === IN_PROGRESS_UUID ? '' : this.selectedChatID,
|
||||
{ model: this.selectedModel, prompt: message },
|
||||
(chunk: MessageChunk) => {
|
||||
// Handle Chat
|
||||
if (chunk.chat) {
|
||||
Object.assign(currentChat, {
|
||||
...chunk.chat,
|
||||
messages: currentChat.messages,
|
||||
});
|
||||
this.selectedChatID = chunk.chat.id;
|
||||
this.updateHash(chunk.chat.id);
|
||||
}
|
||||
|
||||
// Handle User Message
|
||||
if (chunk.user_message) {
|
||||
Object.assign(userMessage, chunk.user_message);
|
||||
}
|
||||
|
||||
// Handle Assistant Message
|
||||
if (chunk.assistant_message) {
|
||||
if (!assistantMessage) {
|
||||
assistantMessage = chunk.assistant_message;
|
||||
currentChat.messages.push(assistantMessage);
|
||||
} else {
|
||||
const index = currentChat.messages.findIndex(
|
||||
(m) => m.id === assistantMessage!.id,
|
||||
);
|
||||
if (index !== -1) {
|
||||
currentChat.messages[index] = {
|
||||
...assistantMessage,
|
||||
...chunk.assistant_message,
|
||||
};
|
||||
currentChat.messages = [...currentChat.messages];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error sending message:', err);
|
||||
this.error = parseError(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
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) this.chatListOpen = false;
|
||||
else this.loadChatMessages();
|
||||
},
|
||||
|
||||
async loadChatMessages() {
|
||||
if (!this.selectedChatID) return;
|
||||
|
||||
try {
|
||||
const response = await getChatMessages(this.selectedChatID);
|
||||
const chatIndex = this.chats.findIndex(
|
||||
(c) => c.id == this.selectedChatID,
|
||||
);
|
||||
|
||||
this.chats[chatIndex].messages = response.messages || [];
|
||||
} catch (err) {
|
||||
console.error('Error loading chat messages:', err);
|
||||
this.error = 'Failed to load messages';
|
||||
}
|
||||
},
|
||||
|
||||
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].reverse();
|
||||
},
|
||||
|
||||
renderMarkdown(content: string) {
|
||||
return marked.parse(content);
|
||||
},
|
||||
}));
|
||||
|
||||
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';
|
||||
}
|
||||
396
frontend/src/components/imageManager.ts
Normal file
396
frontend/src/components/imageManager.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import {
|
||||
deleteImage,
|
||||
generateImage,
|
||||
getGeneratedImages,
|
||||
getModels,
|
||||
getSettings,
|
||||
} from '../client';
|
||||
import { ImageRecord } from '../types';
|
||||
import { applyFilter } from '../utils';
|
||||
|
||||
// Constants
|
||||
const STORAGE_KEYS = {
|
||||
MODEL: 'aethera-model',
|
||||
N: 'aethera-n',
|
||||
SEED: 'aethera-seed',
|
||||
SIZE: 'aethera-size',
|
||||
};
|
||||
|
||||
// Types
|
||||
interface StoredSettings {
|
||||
model: string | null;
|
||||
n: string | null;
|
||||
seed: string | null;
|
||||
size: string | null;
|
||||
}
|
||||
|
||||
// Utilities
|
||||
const fileToDataURL = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target?.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Storage Manager
|
||||
const storageManager = {
|
||||
load(): StoredSettings {
|
||||
return {
|
||||
model: localStorage.getItem(STORAGE_KEYS.MODEL),
|
||||
n: localStorage.getItem(STORAGE_KEYS.N),
|
||||
seed: localStorage.getItem(STORAGE_KEYS.SEED),
|
||||
size: localStorage.getItem(STORAGE_KEYS.SIZE),
|
||||
};
|
||||
},
|
||||
|
||||
save({
|
||||
model,
|
||||
n,
|
||||
seed,
|
||||
size,
|
||||
}: {
|
||||
model: string;
|
||||
n: number;
|
||||
seed: number;
|
||||
size: string;
|
||||
}) {
|
||||
localStorage.setItem(STORAGE_KEYS.MODEL, model);
|
||||
localStorage.setItem(STORAGE_KEYS.N, n.toString());
|
||||
localStorage.setItem(STORAGE_KEYS.SEED, seed.toString());
|
||||
localStorage.setItem(STORAGE_KEYS.SIZE, size);
|
||||
},
|
||||
};
|
||||
|
||||
// Canvas Manager
|
||||
const canvasManager = (canvasId: string) => {
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
const getCanvas = () => {
|
||||
if (!canvas)
|
||||
canvas = document.getElementById(canvasId) as HTMLCanvasElement;
|
||||
return canvas;
|
||||
};
|
||||
|
||||
const getContext = () => {
|
||||
if (!ctx) ctx = getCanvas()?.getContext('2d');
|
||||
return ctx;
|
||||
};
|
||||
|
||||
const clearContext = () => {
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
context.fillStyle = 'rgba(0, 0, 0, 0)';
|
||||
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
|
||||
};
|
||||
|
||||
return {
|
||||
getCanvas,
|
||||
getContext,
|
||||
|
||||
clear() {
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
|
||||
},
|
||||
|
||||
reset() {
|
||||
clearContext();
|
||||
},
|
||||
|
||||
toDataURL(format = 'image/png') {
|
||||
return getCanvas()?.toDataURL(format);
|
||||
},
|
||||
|
||||
async resizeToImage(imageUrl: string) {
|
||||
const cnv = getCanvas();
|
||||
if (!cnv) return { width: 0, height: 0 };
|
||||
|
||||
const img = new Image();
|
||||
return new Promise<{ width: number; height: number }>((resolve) => {
|
||||
img.onload = () => {
|
||||
cnv.width = img.width;
|
||||
cnv.height = img.height;
|
||||
resolve({ width: img.width, height: img.height });
|
||||
};
|
||||
img.src = imageUrl;
|
||||
});
|
||||
},
|
||||
|
||||
initDrawing(lineWidthGetter: () => number) {
|
||||
const cnv = getCanvas();
|
||||
const context = getContext();
|
||||
if (!cnv || !context) return;
|
||||
|
||||
let isDrawing = false;
|
||||
clearContext();
|
||||
|
||||
const getCoords = (e: MouseEvent | TouchEvent) => {
|
||||
const rect = cnv.getBoundingClientRect();
|
||||
const scaleX = cnv.width / rect.width;
|
||||
const scaleY = cnv.height / rect.height;
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
return {
|
||||
x: (clientX - rect.left) * scaleX,
|
||||
y: (clientY - rect.top) * scaleY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
};
|
||||
};
|
||||
|
||||
const startDrawing = (e: MouseEvent | TouchEvent) => {
|
||||
e.preventDefault();
|
||||
isDrawing = true;
|
||||
const { x, y } = getCoords(e);
|
||||
context.beginPath();
|
||||
context.moveTo(x, y);
|
||||
};
|
||||
|
||||
const draw = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDrawing) return;
|
||||
e.preventDefault();
|
||||
const { x, y, scaleX, scaleY } = getCoords(e);
|
||||
|
||||
context.lineWidth = lineWidthGetter() * Math.max(scaleX, scaleY);
|
||||
context.lineCap = 'round';
|
||||
context.strokeStyle = 'black';
|
||||
context.lineTo(x, y);
|
||||
context.stroke();
|
||||
context.beginPath();
|
||||
context.moveTo(x, y);
|
||||
};
|
||||
|
||||
const stopDrawing = () => {
|
||||
isDrawing = false;
|
||||
context.beginPath();
|
||||
};
|
||||
|
||||
cnv.addEventListener('mousedown', startDrawing as EventListener);
|
||||
cnv.addEventListener('mousemove', draw as EventListener);
|
||||
cnv.addEventListener('mouseup', stopDrawing);
|
||||
cnv.addEventListener('mouseout', stopDrawing);
|
||||
cnv.addEventListener('touchstart', startDrawing as EventListener);
|
||||
cnv.addEventListener('touchmove', draw as EventListener);
|
||||
cnv.addEventListener('touchend', stopDrawing);
|
||||
cnv.addEventListener('touchcancel', stopDrawing);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Main Component
|
||||
Alpine.data('imageGenerator', () => {
|
||||
const canvas = canvasManager('mask');
|
||||
|
||||
return {
|
||||
// Generation State
|
||||
prompt: '',
|
||||
n: 1,
|
||||
seed: -1,
|
||||
selectedModel: '',
|
||||
size: 'auto',
|
||||
|
||||
// Editing State
|
||||
editingImage: null as { url: string; name: string } | null,
|
||||
editMode: false,
|
||||
isLandscape: false,
|
||||
lineWidth: 20,
|
||||
|
||||
// Object State
|
||||
generatedImages: [] as ImageRecord[],
|
||||
_settings: {} as Record<string, unknown>,
|
||||
_models: [] as unknown[],
|
||||
|
||||
// API State
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
// Lightbox State
|
||||
lightbox: {
|
||||
open: false,
|
||||
imageSrc: '',
|
||||
currentIndex: 0,
|
||||
touchStartX: 0,
|
||||
touchEndX: 0,
|
||||
},
|
||||
|
||||
async init() {
|
||||
[this._models, this._settings, this.generatedImages] = await Promise.all([
|
||||
getModels(),
|
||||
getSettings(),
|
||||
getGeneratedImages(),
|
||||
]);
|
||||
|
||||
this.loadStoredSettings();
|
||||
canvas.initDrawing(() => this.lineWidth);
|
||||
},
|
||||
|
||||
get models() {
|
||||
return applyFilter(
|
||||
this._models,
|
||||
this._settings.image_generation_selector as string,
|
||||
);
|
||||
},
|
||||
|
||||
loadStoredSettings() {
|
||||
const saved = storageManager.load();
|
||||
if (saved.model) this.selectedModel = saved.model;
|
||||
if (saved.n) this.n = parseInt(saved.n);
|
||||
if (saved.seed) this.seed = parseInt(saved.seed);
|
||||
if (saved.size) this.size = saved.size;
|
||||
},
|
||||
|
||||
saveSettings() {
|
||||
storageManager.save({
|
||||
model: this.selectedModel,
|
||||
n: this.n,
|
||||
seed: this.seed,
|
||||
size: this.size,
|
||||
});
|
||||
},
|
||||
|
||||
async buildRequestData() {
|
||||
const requestData: any = {
|
||||
prompt: this.prompt,
|
||||
n: parseInt(this.n.toString()),
|
||||
seed: parseInt(this.seed.toString()),
|
||||
size: this.size || 'auto',
|
||||
model: this.selectedModel,
|
||||
};
|
||||
|
||||
if (this.editMode) {
|
||||
const imageUploader = document.querySelector(
|
||||
'#image-upload',
|
||||
) as HTMLInputElement;
|
||||
requestData.mask = canvas.toDataURL();
|
||||
requestData.image = await fileToDataURL(imageUploader.files![0]);
|
||||
}
|
||||
|
||||
return requestData;
|
||||
},
|
||||
|
||||
async generateImage() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.saveSettings();
|
||||
|
||||
try {
|
||||
const requestData = await this.buildRequestData();
|
||||
const data = await generateImage(requestData);
|
||||
this.generatedImages.unshift(...data);
|
||||
} catch (err: any) {
|
||||
this.error = err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteImage(filename: string) {
|
||||
try {
|
||||
await deleteImage(filename);
|
||||
this.generatedImages = this.generatedImages.filter(
|
||||
(img: ImageRecord) => img.name !== filename,
|
||||
);
|
||||
} catch (err: any) {
|
||||
this.error = err;
|
||||
}
|
||||
},
|
||||
|
||||
async startEdit(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.match('image/*')) {
|
||||
this.error = 'Please select a valid image file';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
this.editMode = true;
|
||||
this.editingImage = { url: imageUrl, name: file.name };
|
||||
|
||||
canvas.reset();
|
||||
|
||||
document
|
||||
.getElementById('edit-panel')
|
||||
?.scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
const dimensions = await canvas.resizeToImage(imageUrl);
|
||||
this.isLandscape = dimensions.width > dimensions.height;
|
||||
} catch (err) {
|
||||
console.error('Error starting image edit:', err);
|
||||
this.error = 'Failed to start editing uploaded image';
|
||||
}
|
||||
},
|
||||
|
||||
cancelEdit() {
|
||||
this.editMode = false;
|
||||
this.editingImage = null;
|
||||
const fileInput = document.getElementById(
|
||||
'image-upload',
|
||||
) as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
},
|
||||
|
||||
clearMask() {
|
||||
canvas.clear();
|
||||
},
|
||||
|
||||
openLightbox(imageSrc: string) {
|
||||
this.lightbox.currentIndex = this.generatedImages.findIndex(
|
||||
(img: ImageRecord) => img.path === imageSrc,
|
||||
);
|
||||
this.lightbox.imageSrc = imageSrc;
|
||||
this.lightbox.open = true;
|
||||
document.body.style.overflow = 'hidden';
|
||||
},
|
||||
|
||||
closeLightbox() {
|
||||
this.lightbox.open = false;
|
||||
document.body.style.overflow = '';
|
||||
},
|
||||
|
||||
nextImage() {
|
||||
if (this.lightbox.currentIndex < this.generatedImages.length - 1) {
|
||||
this.lightbox.currentIndex++;
|
||||
this.lightbox.imageSrc =
|
||||
this.generatedImages[this.lightbox.currentIndex].path;
|
||||
}
|
||||
},
|
||||
|
||||
prevImage() {
|
||||
if (this.lightbox.currentIndex > 0) {
|
||||
this.lightbox.currentIndex--;
|
||||
this.lightbox.imageSrc =
|
||||
this.generatedImages[this.lightbox.currentIndex].path;
|
||||
}
|
||||
},
|
||||
|
||||
handleTouchStart(e: TouchEvent) {
|
||||
this.lightbox.touchStartX = e.changedTouches[0].screenX;
|
||||
},
|
||||
|
||||
handleTouchEnd(e: TouchEvent) {
|
||||
this.lightbox.touchEndX = e.changedTouches[0].screenX;
|
||||
this.handleSwipe();
|
||||
},
|
||||
|
||||
handleSwipe() {
|
||||
const swipeThreshold = 50;
|
||||
const diff = this.lightbox.touchStartX - this.lightbox.touchEndX;
|
||||
|
||||
if (Math.abs(diff) > swipeThreshold) {
|
||||
if (diff > 0) {
|
||||
this.nextImage();
|
||||
} else {
|
||||
this.prevImage();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
48
frontend/src/components/navigationManager.ts
Normal file
48
frontend/src/components/navigationManager.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Alpine: typeof Alpine;
|
||||
}
|
||||
}
|
||||
|
||||
interface NavigationStore {
|
||||
activeTab: string;
|
||||
|
||||
init(): void;
|
||||
loadPage(): Promise<void>;
|
||||
}
|
||||
|
||||
const navigationStore: NavigationStore = {
|
||||
activeTab: '',
|
||||
|
||||
async init() {
|
||||
await this.loadPage();
|
||||
window.addEventListener('hashchange', () => this.loadPage());
|
||||
},
|
||||
|
||||
async loadPage() {
|
||||
const tab = window.location.hash.split('/')[1] || 'chats';
|
||||
if (this.activeTab === tab) return;
|
||||
this.activeTab = tab;
|
||||
|
||||
const pageContent = document.getElementById('page-content');
|
||||
if (!pageContent) throw new Error('Failed to find #page-content');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/pages/${tab}.html`);
|
||||
if (!response.ok) throw new Error('Failed to load page');
|
||||
|
||||
pageContent.innerHTML = await response.text();
|
||||
} catch (error) {
|
||||
console.error('Page load error:', error);
|
||||
pageContent.innerHTML = `
|
||||
<div class="bg-tertiary-50 border border-tertiary-200 rounded-lg p-4">
|
||||
<p class="text-tertiary-700">Failed to load page. Please try again.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Alpine.store('navigation', navigationStore);
|
||||
29
frontend/src/components/settingsManager.ts
Normal file
29
frontend/src/components/settingsManager.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import { getSettings, saveSettings } from '../client';
|
||||
import { Settings } from '../types';
|
||||
|
||||
Alpine.data('settingsManager', () => ({
|
||||
settings: {} as Settings,
|
||||
loading: false,
|
||||
saved: false,
|
||||
error: '',
|
||||
|
||||
async init() {
|
||||
this.settings = await getSettings();
|
||||
},
|
||||
|
||||
async saveSettings() {
|
||||
this.loading = true;
|
||||
this.saved = false;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
await saveSettings(this.settings);
|
||||
this.saved = true;
|
||||
} catch (err) {
|
||||
this.error = String(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
49
frontend/src/components/themeManager.ts
Normal file
49
frontend/src/components/themeManager.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import {
|
||||
type ThemeMode,
|
||||
loadTheme,
|
||||
saveThemeMode,
|
||||
applyTheme,
|
||||
getNextThemeMode,
|
||||
} from '../theme';
|
||||
|
||||
interface ThemeStore {
|
||||
mode: ThemeMode;
|
||||
|
||||
init(): void;
|
||||
cycleTheme(): void;
|
||||
getThemeIcon(): string;
|
||||
}
|
||||
|
||||
const themeStore: ThemeStore = {
|
||||
mode: 'system',
|
||||
|
||||
init() {
|
||||
const { mode } = loadTheme();
|
||||
this.mode = mode;
|
||||
applyTheme(mode);
|
||||
|
||||
// System Theme Changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', () => {
|
||||
if (this.mode === 'system') {
|
||||
applyTheme('system');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
cycleTheme() {
|
||||
const nextMode = getNextThemeMode(this.mode);
|
||||
this.mode = nextMode;
|
||||
saveThemeMode(nextMode);
|
||||
applyTheme(nextMode);
|
||||
},
|
||||
|
||||
getThemeIcon() {
|
||||
if (this.mode === 'dark') return 'moon';
|
||||
if (this.mode === 'light') return 'sun';
|
||||
return 'system';
|
||||
},
|
||||
};
|
||||
|
||||
Alpine.store('theme', themeStore);
|
||||
19
frontend/src/main.ts
Normal file
19
frontend/src/main.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
// Define Window
|
||||
declare global {
|
||||
interface Window {
|
||||
Alpine: typeof Alpine;
|
||||
}
|
||||
}
|
||||
|
||||
// Import Components
|
||||
import './components/chatManager';
|
||||
import './components/imageManager';
|
||||
import './components/settingsManager';
|
||||
import './components/themeManager';
|
||||
import './components/navigationManager';
|
||||
|
||||
// Start Alpine
|
||||
window.Alpine = Alpine;
|
||||
Alpine.start();
|
||||
83
frontend/src/theme.ts
Normal file
83
frontend/src/theme.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export const AETHERA_THEME_KEY = 'aethera-theme';
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
export interface ThemeState {
|
||||
mode: ThemeMode;
|
||||
}
|
||||
|
||||
export function getSystemTheme(): 'light' | 'dark' {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
export function getEffectiveTheme(themeMode: ThemeMode): 'light' | 'dark' {
|
||||
if (themeMode === 'system') {
|
||||
return getSystemTheme();
|
||||
}
|
||||
return themeMode;
|
||||
}
|
||||
|
||||
export function loadTheme(): ThemeState {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return { mode: 'system' };
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(AETHERA_THEME_KEY);
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
||||
return { mode: stored };
|
||||
}
|
||||
|
||||
return { mode: 'system' };
|
||||
}
|
||||
|
||||
export function saveThemeMode(mode: ThemeMode): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(AETHERA_THEME_KEY, mode);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTheme(mode: ThemeMode): void {
|
||||
const effectiveTheme = getEffectiveTheme(mode);
|
||||
if (typeof document !== 'undefined') {
|
||||
if (effectiveTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
applySyntaxTheme(effectiveTheme);
|
||||
}
|
||||
|
||||
export function applySyntaxTheme(theme: 'light' | 'dark'): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const linkId = 'hljs-theme';
|
||||
let link = document.getElementById(linkId) as HTMLLinkElement;
|
||||
|
||||
const cssFile =
|
||||
theme === 'dark'
|
||||
? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/stackoverflow-dark.css'
|
||||
: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.css';
|
||||
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.id = linkId;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = cssFile;
|
||||
document.head.appendChild(link);
|
||||
} else if (link.href !== cssFile) {
|
||||
link.href = cssFile;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNextThemeMode(currentMode: ThemeMode): ThemeMode {
|
||||
const cycle: ThemeMode[] = ['light', 'dark', 'system'];
|
||||
const currentIndex = cycle.indexOf(currentMode);
|
||||
const nextIndex = (currentIndex + 1) % cycle.length;
|
||||
return cycle[nextIndex];
|
||||
}
|
||||
73
frontend/src/types/index.ts
Normal file
73
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface Chat {
|
||||
id: string;
|
||||
created_at: string;
|
||||
title: string;
|
||||
initial_message: string;
|
||||
message_count: number;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
chat_id: string;
|
||||
created_at: string;
|
||||
role: 'user' | 'assistant';
|
||||
thinking: string;
|
||||
content: string;
|
||||
stats?: MessageStats;
|
||||
}
|
||||
|
||||
export interface MessageStats {
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
prompt_tokens?: number;
|
||||
generated_tokens?: number;
|
||||
prompt_per_second?: number;
|
||||
generated_per_second?: number;
|
||||
time_to_first_token?: number;
|
||||
time_to_last_token?: number;
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
name: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
api_endpoint?: string;
|
||||
image_edit_selector?: string;
|
||||
image_generation_selector?: string;
|
||||
text_generation_selector?: string;
|
||||
}
|
||||
|
||||
export interface ImageRecord {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface MessageChunk {
|
||||
chat?: Chat;
|
||||
user_message?: Message;
|
||||
assistant_message?: Message;
|
||||
}
|
||||
|
||||
export interface GenerateImageRequest {
|
||||
model: string;
|
||||
prompt: string;
|
||||
n: number;
|
||||
size: string;
|
||||
mask?: string;
|
||||
image?: string;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
export interface GenerateTextRequest {
|
||||
model: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface ChatListResponse {
|
||||
chats: Chat[];
|
||||
}
|
||||
37
frontend/src/utils.ts
Normal file
37
frontend/src/utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Model } from './types';
|
||||
|
||||
export const parseFilter = (filterStr: string) => {
|
||||
const colonIndex = (filterStr || '').indexOf(':');
|
||||
if (colonIndex === -1) return null;
|
||||
|
||||
const path = filterStr.slice(0, colonIndex).trim().replace(/^\./, '');
|
||||
const value = filterStr
|
||||
.slice(colonIndex + 1)
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '');
|
||||
|
||||
return { path, value };
|
||||
};
|
||||
|
||||
export const matchesFilter = <T>(
|
||||
obj: T,
|
||||
path: string,
|
||||
value: string,
|
||||
): boolean => {
|
||||
const fieldValue = path
|
||||
.split('.')
|
||||
.reduce<unknown>(
|
||||
(o, key) => (o as Record<string, unknown>)?.[key],
|
||||
obj as Record<string, unknown>,
|
||||
);
|
||||
return Array.isArray(fieldValue)
|
||||
? fieldValue.includes(value)
|
||||
: fieldValue === value;
|
||||
};
|
||||
|
||||
export const applyFilter = (data: Model[], filterStr: string) => {
|
||||
const filter = parseFilter(filterStr);
|
||||
return filter
|
||||
? data.filter((item) => matchesFilter(item, filter.path, filter.value))
|
||||
: data;
|
||||
};
|
||||
Reference in New Issue
Block a user