initial commit

This commit is contained in:
2025-12-31 15:33:16 -05:00
commit 89f2114b06
51 changed files with 4779 additions and 0 deletions

180
frontend/src/client.ts Normal file
View 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}`);
}
}
}
}

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

View 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();
}
}
},
};
});

View 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);

View 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;
}
},
}));

View 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
View 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
View 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];
}

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