Add multi-user auth, background generation, and chat UX improvements
Phase 5: Multi-user authentication with session cookies, bcrypt passwords, first-user-is-admin pattern, per-user data isolation, backup/restore, Docker Swarm production stack with secrets and network isolation. Phase 5.1: Chat UX improvements: - Background generation architecture (GenerationBuffer + asyncio task) with SSE fan-out, reconnect support, and periodic DB flushes - LLM-generated conversation titles (first exchange + every 10th message) - Stop generation button with cancel_event and partial content preservation - Relative timestamps in sidebar (5m ago, 3h ago, then dates) - Empty chat auto-cleanup on navigation away - Save-as-note uses LLM for title generation, tags notes with "chat" - Summarize-as-note also tags with "chat" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+161
-10
@@ -18,6 +18,7 @@ const messagesEl = ref<HTMLElement | null>(null);
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
||||
const sending = ref(false);
|
||||
const summarizing = ref(false);
|
||||
const sidebarOpen = ref(false);
|
||||
|
||||
// Note picker state
|
||||
const attachedNote = ref<{ id: number; title: string } | null>(null);
|
||||
@@ -30,11 +31,32 @@ let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Exclude tracking (session-scoped per conversation)
|
||||
const excludedNoteIds = ref<Set<number>>(new Set());
|
||||
|
||||
let prevConvId: number | null = null;
|
||||
|
||||
const convId = computed(() => {
|
||||
const id = route.params.id;
|
||||
return id ? Number(id) : null;
|
||||
});
|
||||
|
||||
function formatConvDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
const diffHrs = Math.floor(diffMs / 3_600_000);
|
||||
|
||||
// Relative time for < 10 hours
|
||||
if (diffMin < 1) return "Just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHrs < 10) return `${diffHrs}h ago`;
|
||||
|
||||
// Date for older
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
const streamingRendered = computed(() => {
|
||||
if (!store.streamingContent) return "";
|
||||
return renderMarkdown(store.streamingContent);
|
||||
@@ -54,11 +76,24 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
watch(convId, async (newId) => {
|
||||
// Clean up empty previous conversation
|
||||
if (prevConvId && prevConvId !== newId) {
|
||||
const prev = store.conversations.find((c) => c.id === prevConvId);
|
||||
if (prev && prev.message_count === 0) {
|
||||
store.deleteConversation(prevConvId);
|
||||
}
|
||||
}
|
||||
prevConvId = newId ?? null;
|
||||
|
||||
excludedNoteIds.value = new Set();
|
||||
attachedNote.value = null;
|
||||
store.lastContextMeta = null;
|
||||
if (newId) {
|
||||
await store.fetchConversation(newId);
|
||||
// Skip re-fetch if this conversation is already loaded (avoids race
|
||||
// condition where a stale GET overwrites messages during streaming)
|
||||
if (store.currentConversation?.id !== newId) {
|
||||
await store.fetchConversation(newId);
|
||||
}
|
||||
scrollToBottom();
|
||||
} else {
|
||||
store.currentConversation = null;
|
||||
@@ -79,7 +114,12 @@ function scrollToBottom() {
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen.value = !sidebarOpen.value;
|
||||
}
|
||||
|
||||
async function selectConversation(id: number) {
|
||||
sidebarOpen.value = false;
|
||||
router.push(`/chat/${id}`);
|
||||
}
|
||||
|
||||
@@ -228,7 +268,12 @@ function excludeAutoNote(noteId: number) {
|
||||
|
||||
<template>
|
||||
<main class="chat-page">
|
||||
<aside class="chat-sidebar">
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="sidebar-overlay"
|
||||
@click="sidebarOpen = false"
|
||||
></div>
|
||||
<aside class="chat-sidebar" :class="{ open: sidebarOpen }">
|
||||
<button class="btn-new-conv" @click="newConversation">
|
||||
+ New Chat
|
||||
</button>
|
||||
@@ -240,7 +285,10 @@ function excludeAutoNote(noteId: number) {
|
||||
:class="{ active: convId === conv.id }"
|
||||
@click="selectConversation(conv.id)"
|
||||
>
|
||||
<span class="conv-title">{{ conv.title || "Untitled" }}</span>
|
||||
<div class="conv-info">
|
||||
<span class="conv-title">{{ conv.title || "Untitled" }}</span>
|
||||
<span class="conv-date">{{ formatConvDate(conv.updated_at) }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="btn-delete-conv"
|
||||
@click.stop="removeConversation(conv.id)"
|
||||
@@ -258,6 +306,13 @@ function excludeAutoNote(noteId: number) {
|
||||
<section class="chat-main">
|
||||
<template v-if="store.currentConversation">
|
||||
<div class="chat-header">
|
||||
<button class="btn-sidebar-toggle hide-desktop" @click="toggleSidebar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
|
||||
<button
|
||||
v-if="store.currentConversation.messages.length"
|
||||
@@ -383,9 +438,20 @@ function excludeAutoNote(noteId: number) {
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button
|
||||
v-if="store.streaming"
|
||||
class="btn-stop"
|
||||
@click="store.cancelGeneration()"
|
||||
title="Stop generation"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<rect width="14" height="14" rx="2" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-send"
|
||||
@click="sendMessage"
|
||||
:disabled="!messageInput.trim() || store.streaming || !store.chatReady"
|
||||
:disabled="!messageInput.trim() || !store.chatReady"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
@@ -394,6 +460,13 @@ function excludeAutoNote(noteId: number) {
|
||||
</template>
|
||||
|
||||
<div v-else class="no-conversation">
|
||||
<button class="btn-sidebar-toggle no-conv-toggle hide-desktop" @click="toggleSidebar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<p>Select a conversation or start a new chat.</p>
|
||||
<button class="btn-new-conv" @click="newConversation">
|
||||
+ New Chat
|
||||
@@ -455,13 +528,26 @@ function excludeAutoNote(noteId: number) {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.conv-title {
|
||||
.conv-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.conv-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.conv-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.conv-item.active .conv-date {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.btn-delete-conv {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -526,6 +612,9 @@ function excludeAutoNote(noteId: number) {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 848px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.messages-inner {
|
||||
margin-top: auto;
|
||||
@@ -643,7 +732,11 @@ function excludeAutoNote(noteId: number) {
|
||||
|
||||
/* Input wrapper */
|
||||
.input-wrapper {
|
||||
margin: 0 1rem 2rem;
|
||||
max-width: 848px;
|
||||
margin: 0 auto 2rem;
|
||||
padding: 0 1rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Attached note */
|
||||
@@ -789,6 +882,23 @@ function excludeAutoNote(noteId: number) {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-stop {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-danger, #e74c3c);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-stop:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.no-conversation {
|
||||
flex: 1;
|
||||
@@ -807,16 +917,57 @@ function excludeAutoNote(noteId: number) {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.btn-sidebar-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.no-conv-toggle {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
}
|
||||
.no-conversation {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-sidebar {
|
||||
width: 200px;
|
||||
min-width: 160px;
|
||||
position: fixed;
|
||||
top: 49px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
width: 260px;
|
||||
}
|
||||
.chat-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: 49px;
|
||||
background: var(--color-overlay);
|
||||
z-index: 99;
|
||||
}
|
||||
.messages-container {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.input-wrapper {
|
||||
margin: 0 0.5rem 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user