Files
aethera/frontend/public/pages/chats.html
Evan Reichard 0dd9521419
All checks were successful
continuous-integration/drone/push Build is passing
fix: improve chat UI streaming feedback and fix test image path
- Add loading spinner with 'Thinking...' text during streaming when
  content is not yet available
- Fix :key binding to use message.id instead of message.content
- Remove unnecessary TypeScript cast in file reader handler
- Move test image into testdata/ directory for proper test organization
- Remove t.Skip and simplify TestSummarizeChat test message
2026-05-01 21:04:26 -04:00

478 lines
18 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()">
<!-- Chat Content -->
<div
class="h-dvh pt-16 flex flex-col-reverse pb-36 overflow-scroll mx-auto px-4 md:px-6 max-w-6xl"
>
<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'
]"
>
<!-- User Images -->
<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>
<!-- Thinking Section -->
<div
x-show="message.thinking"
x-data="{ expanded: false }"
@click="expanded = !expanded"
>
<div
class="cursor-pointer rounded-lg overflow-hidden bg-primary-100 hover:bg-primary-50"
>
<div
class="flex justify-center w-full px-3 py-2 text-xs text-primary-700 flex items-center gap-2 transition-colors"
>
<span x-text="expanded ? '▼' : '◀'"></span>
<span class="font-medium">Reasoning</span>
<span x-text="expanded ? '▼' : '▶'"></span>
</div>
<div
x-show="expanded"
class="prose p-4 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.thinking)"
></div>
</div>
</div>
<hr x-show="message.thinking" class="my-2 border-primary-400/50" />
<!-- Loading Spinner (Streaming with no content yet) -->
<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>
<!-- Main Content -->
<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>
<!-- Timestamp -->
<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>
</div>
<!-- Stats Badges (Assistant) -->
<div
x-show="message.role === 'assistant' && message.stats"
class="flex items-center gap-1 py-2 flex-wrap justify-start text-primary-700"
>
<!-- Cumulative Tokens with Hover Breakdown -->
<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>
<!-- Tokens -->
<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>
<!-- Floating Input and Model Selection -->
<div class="fixed bottom-4 w-full flex justify-center px-4 md:px-6">
<div class="w-full sm:w-[calc(100%-2rem)] max-w-3xl z-10">
<div
class="flex flex-col gap-3 p-3 bg-primary-50/95 backdrop-blur-sm rounded-2xl shadow-2xl border border-primary-200"
>
<!-- Model Select -->
<div class="relative">
<select
x-model="selectedModel"
class="w-full appearance-none px-9 py-3 bg-gradient-to-r from-primary-50 to-primary-300 border border-primary-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-400 text-primary-900 text-sm font-medium cursor-pointer transition-shadow hover:shadow-md"
>
<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>
<!-- Computer Icon -->
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-primary-400 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>
<!-- Chevron Icon -->
<svg
class="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-primary-400 pointer-events-none transition-colors hover:text-primary-500"
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>
<!-- Image Preview Strip -->
<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
@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>
<!-- Message Form -->
<form @submit.prevent="sendMessage" class="flex gap-2 items-end">
<!-- Attach Image Button -->
<button
type="button"
@click="$refs.fileInput.click()"
class="self-stretch w-[44px] bg-primary-200 text-primary-700 rounded-xl transition-all flex items-center justify-center flex-shrink-0 hover:bg-primary-300 hover:shadow-md"
title="Attach Image"
>
<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="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>
<!-- Hidden File Input -->
<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 = '';
"
/>
<textarea
x-model="inputMessage"
placeholder="Type your message..."
rows="1"
class="scrollbar-hide flex-1 p-3 bg-primary-50 border border-primary-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-400 text-primary-900 text-sm transition-shadow hover:bg-primary-100 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>
<button
type="submit"
:disabled="(!inputMessage.trim() && selectedImages.length === 0) || loading"
:class=" ((!inputMessage.trim() && selectedImages.length === 0) || loading) ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-md hover:scale-105'"
class="self-stretch w-[44px] bg-gradient-to-r from-primary-600 to-primary-500 text-white rounded-xl transition-all flex items-center justify-center flex-shrink-0"
>
<template x-if="loading">
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
</template>
<template x-if="!loading">
<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 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
</template>
</button>
</form>
<!-- Error Message -->
<div
x-show="error"
class="bg-tertiary-50 border border-tertiary-200 px-4 py-2"
>
<p class="text-sm text-tertiary-700" x-text="error"></p>
</div>
</div>
</div>
</div>
<!-- Floating Conversation List Toggle -->
<button
@click="chatListOpen = !chatListOpen"
:aria-expanded="chatListOpen ? 'true' : 'false'"
aria-label="Toggle left navigation"
class="isolate cursor-pointer fixed z-50 flex justify-between top-4 left-4 md:left-6 p-2 rounded-md text-primary-700 hover:bg-primary-300 transition-colors"
>
<svg
x-show="!chatListOpen"
xmlns="http://www.w3.org/2000/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="M4 6h16M4 12h16M4 18h16"
/>
</svg>
<svg
x-show="chatListOpen"
xmlns="http://www.w3.org/2000/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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<!-- Floating Conversation List -->
<div
x-show="chatListOpen"
x-transition:enter="transform transition-all duration-300 ease-out"
x-transition:enter-start="-translate-x-full opacity-0"
x-transition:enter-end="translate-x-0 opacity-100"
x-transition:leave="transform transition-all duration-300 ease-in"
x-transition:leave-start="translate-x-0 opacity-100"
x-transition:leave-end="-translate-x-full opacity-0"
class="fixed top-16 left-0 right-0 mx-auto md:left-6 md:right-auto md:mx-0 bottom-4 w-86 bg-primary-100 rounded-xl shadow-lg z-20 overflow-hidden flex flex-col"
>
<div class="px-4 py-3 border-b border-primary-200 flex justify-center">
<h4 class="font-semibold text-primary-900">
<span>Conversations</span>
</h4>
</div>
<!-- Conversation List-->
<div id="left-nav-desktop" class="flex-1 overflow-y-auto p-4">
<div
x-show="chats.length === 0"
class="h-full flex flex-col justify-center text-center py-8 text-primary-600"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 mx-auto mb-2 text-primary-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<p class="text-sm">No chats yet</p>
</div>
<div class="space-y-2">
<template x-for="chat in chats" :key="chat.id">
<div
@click="selectChat(chat.id); chatListOpen = false;"
:class="[
'p-3 rounded-lg cursor-pointer transition-all border-l-3',
selectedChatID === chat.id
? 'bg-primary-200 border-l-primary-600'
: 'hover:bg-primary-200 border-l-transparent'
]"
:title="chat.title"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="mt-0.5 shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
:class="[
'h-4 w-4',
selectedChatID === chat.id ? 'text-primary-600' : 'text-primary-400'
]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm text-primary-900 truncate">
<span x-text="chat.title || 'New Conversation'"></span>
</div>
<div
class="flex items-center gap-2 mt-1.5 text-xs text-primary-600"
>
<span
x-show="chat.message_count > 0"
class="shrink-0 bg-primary-300 text-primary-700 px-1.5 py-0.5 rounded text-[10px] font-medium"
x-text="chat.message_count"
></span>
<span class="truncate" x-text="chat.initial_message"></span>
</div>
</div>
<button
@click.stop="deleteChat($event, chat.id)"
class="cursor-pointer shrink-0 p-1 text-primary-400 hover:text-tertiary-600 hover:bg-tertiary-100 rounded transition-colors"
title="Delete Chat"
>
<svg
xmlns="http://www.w3.org/2000/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="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>
</template>
</div>
</div>
<!-- Left Nav Footer -->
<div
x-show="$store.navigation.activeTab === 'chats'"
class="p-4 border-t border-primary-200 shrink-0"
>
<button
@click="selectChat(null)"
class="w-full px-4 py-2.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium flex cursor-pointer items-center justify-center gap-2"
>
<svg
xmlns="http://www.w3.org/2000/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>
New Conversation
</button>
</div>
</div>
</div>