Files
aethera/frontend/public/pages/chats.html
Evan Reichard bf3b308a05
All checks were successful
continuous-integration/drone/push Build is passing
chore: better colors
2026-05-17 20:29:18 -04:00

502 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<div x-data="chatManager()">
<div
:class="$store.chatSidebar.collapsed ? 'md:grid-cols-[3.5rem_minmax(0,1fr)]' : 'md:grid-cols-[18rem_minmax(0,1fr)]'"
class="md:grid transition-[grid-template-columns] duration-300 ease-out"
>
<!-- Desktop Sidebar - Flat rail flush with the page. Extends the full
viewport height; the transparent nav floats above its top region.
Internal padding-top clears the nav. -->
<aside
class="hidden md:flex md:flex-col md:sticky top-0 border-r border-primary-200/60"
style="height: 100dvh;"
>
<!-- Expanded Rail -->
<template x-if="!$store.chatSidebar.collapsed">
<div
class="flex flex-col h-full"
style="padding-top: max(1rem, env(safe-area-inset-top));"
>
<div class="px-2 pb-3 flex items-center gap-1">
<button
@click="$store.chatSidebar.toggleCollapsed()"
class="size-9 flex items-center justify-center rounded-md text-primary-500 hover:bg-primary-200 hover:text-primary-700 transition-colors cursor-pointer flex-shrink-0"
title="Collapse sidebar"
aria-label="Collapse sidebar"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
@click="selectChat(null)"
class="size-9 flex items-center justify-center rounded-md text-primary-700 hover:bg-primary-200 transition-colors cursor-pointer"
title="New conversation"
aria-label="New conversation"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
<div
x-show="chats.length === 0"
class="h-full flex flex-col justify-center text-center py-8 text-primary-500"
>
<p class="text-sm">No chats yet</p>
</div>
<template x-for="group in chatGroups" :key="group.label">
<div class="mt-2 first:mt-0">
<div
class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-wider text-primary-500/70 font-medium"
x-text="group.label"
></div>
<div class="space-y-0.5">
<template x-for="chat in group.chats" :key="chat.id">
<div
@click="selectChat(chat.id)"
:class="selectedChatID === chat.id ? 'bg-primary-200' : 'hover:bg-primary-200/60'"
class="group relative px-3 py-2 rounded-lg cursor-pointer transition-colors"
:title="chat.title || chat.initial_message"
>
<div class="flex items-center justify-between gap-2">
<div
:class="selectedChatID === chat.id ? 'font-semibold' : 'font-medium'"
class="text-sm text-primary-900 truncate min-w-0 flex-1"
x-text="chat.title || chat.initial_message || 'New conversation'"
></div>
<button
@click.stop="deleteChat($event, chat.id)"
class="opacity-0 group-hover:opacity-100 focus:opacity-100 shrink-0 p-1 -mr-1 text-primary-400 hover:text-tertiary-600 hover:bg-tertiary-100 rounded transition-opacity"
title="Delete chat"
aria-label="Delete chat"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div
x-show="chat.title && chat.initial_message"
class="text-xs text-primary-500/80 truncate mt-0.5"
x-text="chat.initial_message"
></div>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- Collapsed Icon Rail -->
<template x-if="$store.chatSidebar.collapsed">
<div
class="flex flex-col items-center px-2 pb-3 gap-1"
style="padding-top: max(1rem, env(safe-area-inset-top));"
>
<button
@click="$store.chatSidebar.toggleCollapsed()"
class="size-9 flex items-center justify-center rounded-md text-primary-500 hover:bg-primary-200 hover:text-primary-700 transition-colors cursor-pointer"
title="Expand sidebar"
aria-label="Expand sidebar"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<button
@click="selectChat(null)"
class="size-9 flex items-center justify-center rounded-md text-primary-700 hover:bg-primary-200 transition-colors cursor-pointer"
title="New conversation"
aria-label="New conversation"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
</template>
</aside>
<!-- Main Chat Column - min-h ensures the sticky input pins to the viewport bottom
even when there are few messages (the column always fills the visible area).
The reading column (max-w-3xl) is centered within whatever space the grid gives. -->
<div
class="flex flex-col min-w-0 px-4 md:px-6"
style="min-height: 100dvh;"
>
<!-- Messages (oldest first, newest last) -->
<div
class="flex-1 flex flex-col gap-1 pb-4 w-full"
style="padding-top: var(--nav-h);"
>
<template x-for="message in currentChatMessages" :key="message.id">
<div :class="['mb-4', message.role === 'user' ? 'text-right' : 'text-left']">
<div
:class="['inline-block px-4 py-3 text-left rounded-lg max-w-[95%] md:max-w-[85%]',
message.role === 'user'
? 'bg-primary-100 text-primary-900 rounded-br-none'
: 'bg-primary-200 text-primary-900 rounded-bl-none'
]"
>
<div
x-show="message.role === 'user' && message.images && message.images.length > 0"
class="flex gap-1 mb-2 flex-wrap"
>
<template x-for="(img, imgIdx) in message.images" :key="imgIdx">
<img :src="img" class="max-w-full h-auto rounded-lg max-h-48" />
</template>
</div>
<div
x-show="message.thinking"
x-data="{ expanded: false }"
role="button"
tabindex="0"
@click="expanded = !expanded"
@keydown.enter.prevent="expanded = !expanded"
@keydown.space.prevent="expanded = !expanded"
class="mb-3 rounded-md border-l-2 border-primary-500/60 bg-primary-100/35 px-3 py-2 ring-1 ring-primary-400/20 cursor-pointer"
>
<div class="flex items-center gap-2 text-xs font-medium text-primary-700 transition-colors">
<span x-text="expanded ? '▾' : '▸'"></span>
<span>Reasoning</span>
</div>
<div
x-show="expanded"
class="prose mt-2 max-w-none text-xs text-primary-700 opacity-90 prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-pre:p-0"
x-html="renderMarkdown(message.thinking)"
></div>
</div>
<div
x-show="message.role === 'assistant' && message.status === 'streaming' && !message.thinking && !message.content"
class="flex items-center gap-2 py-1"
>
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"></div>
<span class="text-xs text-primary-600">Thinking...</span>
</div>
<div
x-show="message.content || message.status !== 'streaming'"
class="prose max-w-none text-sm prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-pre:p-0"
x-html="renderMarkdown(message.content)"
></div>
<div class="flex items-center justify-between gap-2 mt-2">
<div class="text-[10px] opacity-60" x-text="new Date(message.created_at).toLocaleTimeString()"></div>
<div
x-show="message.role === 'assistant' && ['stopped', 'error', 'failed'].includes(message.status)"
:class="message.status === 'stopped' ? 'bg-primary-300/50 text-primary-700' : 'bg-tertiary-100 text-tertiary-700'"
class="px-2 py-0.5 rounded-full text-[10px] font-medium"
x-text="message.status === 'stopped' ? 'Stopped' : 'Error'"
></div>
</div>
</div>
<div
x-show="message.role === 'assistant' && message.stats"
class="flex items-center gap-1 py-2 flex-wrap justify-start text-primary-700"
>
<div
x-show="message.stats?.prompt_tokens || message.stats?.generated_tokens"
class="group relative px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full cursor-help"
>
<span x-text="`${(message.stats?.prompt_tokens || 0) + (message.stats?.generated_tokens || 0)} tokens`"></span>
<div class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 m-2 px-2 py-1 bg-gray-900 text-white text-[10px] rounded whitespace-nowrap pointer-events-none grid grid-cols-[auto_1fr] gap-x-2">
<div x-show="message.stats?.prompt_tokens" x-text="message.stats?.prompt_tokens"></div>
<div x-show="message.stats?.prompt_tokens">prompt tokens</div>
<div x-show="message.stats?.generated_tokens" x-text="message.stats?.generated_tokens"></div>
<div x-show="message.stats?.generated_tokens">generated tokens</div>
</div>
</div>
<span
x-show="message.stats?.prompt_per_second"
class="px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full"
x-text="`${message.stats?.prompt_per_second.toFixed(1)} ppt/s`"
></span>
<span
x-show="message.stats?.generated_per_second"
class="px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full"
x-text="`${message.stats?.generated_per_second.toFixed(1)} tgt/s`"
></span>
<span
x-show="message.stats?.time_to_first_token"
class="px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full"
x-text="`${(message.stats?.time_to_first_token / 1000).toFixed(2)}s TTFT`"
></span>
</div>
</div>
</template>
</div>
<!-- Sticky Input Bar -->
<div
class="sticky bottom-0 pt-2 bg-primary-50"
style="padding-bottom: max(0.5rem, env(safe-area-inset-bottom));"
>
<form
@submit.prevent="sendMessage"
class="flex flex-col gap-2 p-3 bg-primary-50 rounded-2xl border border-primary-200"
>
<input
x-ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/webp"
multiple
class="hidden"
@change="
Array.from($event.target.files).forEach(file => {
const reader = new FileReader();
reader.onload = (e) => { selectedImages.push(e.target.result); };
reader.readAsDataURL(file);
});
$event.target.value = '';
"
/>
<div x-show="selectedImages.length > 0" class="flex gap-2 flex-wrap">
<template x-for="(img, idx) in selectedImages" :key="idx">
<div class="relative">
<img :src="img" class="w-20 h-20 object-cover rounded-lg" />
<button
type="button"
@click="selectedImages.splice(idx, 1)"
class="absolute -top-1 -right-1 w-5 h-5 bg-tertiary-700 text-white rounded-full flex items-center justify-center text-xs hover:bg-tertiary-900"
>
×
</button>
</div>
</template>
</div>
<textarea
x-model="inputMessage"
placeholder="Type your message..."
rows="2"
class="scrollbar-hide w-full px-2 py-1 bg-transparent border-0 focus:outline-none focus:ring-0 text-primary-900 text-sm resize-none overflow-y-auto max-h-60"
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); sendMessage(); }"
@input="$el.style.height = 'auto'; $el.style.height = $el.scrollHeight + 'px'"
></textarea>
<div class="flex items-center gap-2">
<div class="relative min-w-0 flex-shrink inline-flex items-center pl-7 pr-6 py-1 bg-primary-200/70 hover:bg-primary-300 border border-primary-200 rounded-full transition-colors">
<span
class="text-primary-900 text-xs font-medium truncate pointer-events-none"
x-text="(models.find(m => m.id === selectedModel)?.name) || selectedModel || 'Select Model'"
></span>
<select
x-model="selectedModel"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer focus:outline-none"
aria-label="Select model"
>
<option value="">Select Model</option>
<template x-for="model in models" :key="model.id">
<option :value="model.id" x-text="model.name || model.id"></option>
</template>
</select>
<svg class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary-500 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<svg class="absolute right-1.5 top-1/2 -translate-y-1/2 h-3 w-3 text-primary-500 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<button
type="button"
@click="thinkingEnabled = !thinkingEnabled"
:class="thinkingEnabled
? 'bg-primary-600 text-white border-primary-600 hover:bg-primary-700'
: 'bg-transparent text-primary-500 border-primary-300 hover:bg-primary-200/70'"
class="relative h-7 w-7 rounded-full border transition-colors flex items-center justify-center flex-shrink-0"
:title="thinkingEnabled ? 'Thinking on' : 'Thinking off'"
:aria-pressed="thinkingEnabled ? 'true' : 'false'"
aria-label="Toggle thinking"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5a3 3 0 0 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 0 0 12 21z" />
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 0 1 12 21" />
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" />
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375" />
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" />
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" />
<path d="M19.938 10.5a4 4 0 0 1 .585.396" />
<path d="M6 18a4 4 0 0 1-1.967-.516" />
<path d="M19.967 17.484A4 4 0 0 1 18 18" />
</svg>
<svg
x-show="!thinkingEnabled"
class="absolute inset-0 h-full w-full text-primary-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
aria-hidden="true"
>
<line x1="5" y1="19" x2="19" y2="5" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<button
type="button"
@click="$refs.fileInput.click()"
class="h-7 w-7 bg-primary-200/70 text-primary-700 rounded-full transition-colors flex items-center justify-center flex-shrink-0 hover:bg-primary-300"
title="Attach Image"
aria-label="Attach Image"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<div class="ml-auto flex items-center gap-2">
<button
x-show="loading"
type="button"
@click="stopResponse()"
:disabled="!activeStreamChatID"
:class="!activeStreamChatID ? 'opacity-50 cursor-not-allowed' : 'hover:bg-tertiary-700'"
class="h-7 w-7 bg-tertiary-600 text-white rounded-full transition-colors flex items-center justify-center flex-shrink-0"
title="Stop response"
aria-label="Stop response"
>
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6h12v12H6z" />
</svg>
</button>
<button
type="submit"
:disabled="(!inputMessage.trim() && selectedImages.length === 0) || loading"
:class=" ((!inputMessage.trim() && selectedImages.length === 0) || loading) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-primary-600'"
class="h-7 w-7 bg-gradient-to-r from-primary-600 to-primary-500 text-white rounded-full transition-all flex items-center justify-center flex-shrink-0"
title="Send"
aria-label="Send"
>
<template x-if="loading">
<div class="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
</template>
<template x-if="!loading">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</template>
</button>
</div>
</div>
<div
x-show="error"
class="bg-tertiary-50 border border-tertiary-200 rounded-lg px-4 py-2"
>
<p class="text-sm text-tertiary-700" x-text="error"></p>
</div>
</form>
</div>
</div>
</div>
<!-- Mobile Sidebar Drawer (controlled from the top nav hamburger) -->
<div
x-show="$store.chatSidebar.mobileOpen"
x-cloak
x-transition.opacity
@click="$store.chatSidebar.mobileOpen = false"
class="md:hidden fixed inset-0 z-60 bg-black/40"
></div>
<div
x-show="$store.chatSidebar.mobileOpen"
x-cloak
x-transition:enter="transform transition-transform duration-300 ease-out"
x-transition:enter-start="-translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition-transform duration-300 ease-in"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="-translate-x-full"
class="md:hidden fixed top-0 left-0 bottom-0 z-60 w-[calc(100vw-3.5rem)] max-w-none bg-primary-50 flex flex-col"
>
<div
class="px-3 pb-3 flex items-center gap-2 border-b border-primary-200/60"
style="padding-top: max(1rem, env(safe-area-inset-top));"
>
<button
@click="$store.chatSidebar.mobileOpen = false"
class="size-9 flex items-center justify-center rounded-md text-primary-500 hover:bg-primary-200 hover:text-primary-700 transition-colors cursor-pointer flex-shrink-0"
title="Collapse sidebar"
aria-label="Collapse sidebar"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
@click="selectChat(null); $store.chatSidebar.mobileOpen = false"
class="size-9 flex items-center justify-center rounded-md text-primary-700 hover:bg-primary-200 transition-colors cursor-pointer"
title="New conversation"
aria-label="New conversation"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
<div
x-show="chats.length === 0"
class="h-full flex flex-col justify-center text-center py-8 text-primary-500"
>
<p class="text-sm">No chats yet</p>
</div>
<template x-for="group in chatGroups" :key="group.label">
<div class="mt-2 first:mt-0">
<div
class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-wider text-primary-500/70 font-medium"
x-text="group.label"
></div>
<div class="space-y-0.5">
<template x-for="chat in group.chats" :key="chat.id">
<div
@click="selectChat(chat.id); $store.chatSidebar.mobileOpen = false"
:class="selectedChatID === chat.id ? 'bg-primary-200' : 'hover:bg-primary-200/60'"
class="group relative px-3 py-2 rounded-lg cursor-pointer transition-colors"
:title="chat.title || chat.initial_message"
>
<div class="flex items-center justify-between gap-2">
<div
:class="selectedChatID === chat.id ? 'font-semibold' : 'font-medium'"
class="text-sm text-primary-900 truncate min-w-0 flex-1"
x-text="chat.title || chat.initial_message || 'New conversation'"
></div>
<button
@click.stop="deleteChat($event, chat.id)"
class="shrink-0 p-1 -mr-1 text-primary-400 hover:text-tertiary-600 hover:bg-tertiary-100 rounded transition-colors"
title="Delete chat"
aria-label="Delete chat"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div
x-show="chat.title && chat.initial_message"
class="text-xs text-primary-500/80 truncate mt-0.5"
x-text="chat.initial_message"
></div>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</div>