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:
2026-02-11 14:36:30 -05:00
parent db01106714
commit cbfdf5289e
49 changed files with 3105 additions and 369 deletions
+140 -3
View File
@@ -1,10 +1,16 @@
<script setup lang="ts">
import { computed } from "vue";
import { ref, computed } from "vue";
import { useRouter } from "vue-router";
import { useTheme } from "@/composables/useTheme";
import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import AppLogo from "@/components/AppLogo.vue";
const { theme, toggleTheme } = useTheme();
const authStore = useAuthStore();
const chatStore = useChatStore();
const router = useRouter();
const mobileMenuOpen = ref(false);
const emit = defineEmits<{
toggleChatPanel: [];
@@ -23,13 +29,38 @@ const statusClass = computed(() => {
if (chatStore.chatReady) return "status-green";
return "status-yellow";
});
function toggleMobileMenu() {
mobileMenuOpen.value = !mobileMenuOpen.value;
}
async function handleLogout() {
await authStore.logout();
mobileMenuOpen.value = false;
router.push("/login");
}
// Close mobile menu on route change
router.afterEach(() => {
mobileMenuOpen.value = false;
});
</script>
<template>
<header class="app-header">
<nav class="nav">
<router-link to="/" class="nav-brand">Fabled Assistant</router-link>
<div class="nav-links">
<router-link to="/" class="nav-brand">
<AppLogo />
Fabled Assistant
</router-link>
<button class="hamburger" @click="toggleMobileMenu" aria-label="Toggle menu">
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<div class="nav-links" :class="{ open: mobileMenuOpen }">
<router-link to="/notes" class="nav-link">Notes</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" class="nav-link">Chat</router-link>
@@ -43,6 +74,13 @@ const statusClass = computed(() => {
<button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
{{ theme === "dark" ? "\u2600" : "\u263E" }}
</button>
<div class="user-info">
<span class="username">{{ authStore.user?.username }}</span>
<span v-if="authStore.isAdmin" class="admin-badge">admin</span>
<button class="btn-logout" @click="handleLogout" title="Sign out">
Sign out
</button>
</div>
</div>
</nav>
</header>
@@ -60,11 +98,30 @@ const statusClass = computed(() => {
justify-content: space-between;
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 700;
font-size: 1.1rem;
color: var(--color-primary);
text-decoration: none;
}
.hamburger {
display: none;
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
flex-direction: column;
gap: 4px;
}
.hamburger-line {
display: block;
width: 20px;
height: 2px;
background: var(--color-text);
border-radius: 1px;
}
.nav-links {
display: flex;
align-items: center;
@@ -139,4 +196,84 @@ const statusClass = computed(() => {
.theme-toggle:hover {
background: var(--color-bg-card);
}
.user-info {
display: flex;
align-items: center;
gap: 0.4rem;
margin-left: 0.5rem;
}
.username {
font-size: 0.85rem;
color: var(--color-text-secondary);
font-weight: 500;
}
.admin-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
padding: 0.1rem 0.35rem;
border-radius: var(--radius-sm);
}
.btn-logout {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.2rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.btn-logout:hover {
color: var(--color-danger);
border-color: var(--color-danger);
}
@media (max-width: 768px) {
.hamburger {
display: flex;
}
.nav-links {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
flex-direction: column;
padding: 0.75rem 1rem;
gap: 0.25rem;
z-index: 100;
}
.nav-links.open {
display: flex;
}
.nav-link {
padding: 0.5rem 0.75rem;
min-height: 44px;
display: flex;
align-items: center;
}
.status-indicator {
margin-left: 0;
}
.user-info {
margin-left: 0;
margin-top: 0.25rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border);
width: 100%;
}
.btn-chat-panel,
.theme-toggle,
.btn-logout {
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
defineProps<{ size?: number }>();
</script>
<template>
<svg
class="app-logo"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
:width="size ?? 24"
:height="size ?? 24"
aria-hidden="true"
>
<!-- Book body -->
<path class="logo-book" d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z" stroke-width="0.5"/>
<!-- Book spine -->
<line class="logo-spine" x1="16" y1="6" x2="16" y2="25" stroke-width="0.8"/>
<!-- Left page lines -->
<line class="logo-lines" x1="7" y1="11" x2="14" y2="10.5" stroke-width="0.6" stroke-linecap="round"/>
<line class="logo-lines" x1="7" y1="14" x2="14" y2="13.5" stroke-width="0.6" stroke-linecap="round"/>
<line class="logo-lines" x1="7" y1="17" x2="14" y2="16.5" stroke-width="0.6" stroke-linecap="round"/>
<line class="logo-lines" x1="7" y1="20" x2="12" y2="19.5" stroke-width="0.6" stroke-linecap="round"/>
<!-- Right page lines -->
<line class="logo-lines" x1="18" y1="10.5" x2="25" y2="11" stroke-width="0.6" stroke-linecap="round"/>
<line class="logo-lines" x1="18" y1="13.5" x2="25" y2="14" stroke-width="0.6" stroke-linecap="round"/>
<line class="logo-lines" x1="18" y1="16.5" x2="25" y2="17" stroke-width="0.6" stroke-linecap="round"/>
<!-- Magic spark -->
<g transform="translate(24, 5)">
<path d="M0 -4 L1 -1 L4 0 L1 1 L0 4 L-1 1 L-4 0 L-1 -1 Z" fill="#f6ad55"/>
<path d="M0 -2.5 L0.6 -0.6 L2.5 0 L0.6 0.6 L0 2.5 L-0.6 0.6 L-2.5 0 L-0.6 -0.6 Z" fill="#fbd38d"/>
</g>
<!-- Secondary sparkles -->
<circle cx="20" cy="3" r="0.7" fill="#f6ad55" opacity="0.7"/>
<circle cx="27" cy="8" r="0.5" fill="#fbd38d" opacity="0.6"/>
</svg>
</template>
<style scoped>
.logo-book {
fill: var(--color-text);
stroke: var(--color-text-secondary);
}
.logo-spine {
stroke: var(--color-text-secondary);
}
.logo-lines {
stroke: var(--color-text-muted);
}
</style>
+31 -2
View File
@@ -13,7 +13,8 @@ const toastStore = useToastStore();
class="toast"
:class="`toast--${toast.type}`"
>
{{ toast.message }}
<span class="toast-msg">{{ toast.message }}</span>
<button class="toast-close" @click="toastStore.dismiss(toast.id)">&times;</button>
</div>
</transition-group>
</div>
@@ -30,19 +31,47 @@ const toastStore = useToastStore();
gap: 0.5rem;
}
.toast {
padding: 0.75rem 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
box-shadow: 0 2px 8px var(--color-shadow);
min-width: 200px;
}
.toast-msg {
flex: 1;
}
.toast-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
padding: 0 0.15rem;
}
.toast-close:hover {
color: #fff;
}
.toast--success {
background: var(--color-toast-success);
}
.toast--error {
background: var(--color-toast-error);
}
.toast--warning {
background: var(--color-warning);
color: #1a1a1a;
}
.toast--warning .toast-close {
color: rgba(0, 0, 0, 0.5);
}
.toast--warning .toast-close:hover {
color: #1a1a1a;
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;