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
+39 -10
View File
@@ -1,16 +1,18 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute } from "vue-router";
import AppHeader from "@/components/AppHeader.vue";
import ChatPanel from "@/components/ChatPanel.vue";
import ToastNotification from "@/components/ToastNotification.vue";
import { useTheme } from "@/composables/useTheme";
import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
useTheme();
const route = useRoute();
const authStore = useAuthStore();
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const chatPanelOpen = ref(false);
@@ -29,23 +31,50 @@ function toggleChatPanel() {
chatPanelOpen.value = !chatPanelOpen.value;
}
onMounted(() => {
function startAppServices() {
chatStore.startStatusPolling();
settingsStore.fetchSettings();
}
function stopAppServices() {
chatStore.stopStatusPolling();
}
onMounted(async () => {
await authStore.checkAuth();
if (authStore.isAuthenticated) {
startAppServices();
}
});
watch(
() => authStore.isAuthenticated,
(authenticated) => {
if (authenticated) {
startAppServices();
} else {
stopAppServices();
}
}
);
onUnmounted(() => {
chatStore.stopStatusPolling();
stopAppServices();
});
</script>
<template>
<AppHeader @toggle-chat-panel="toggleChatPanel" />
<router-view />
<ChatPanel
v-if="chatPanelOpen"
:context-note-id="contextNoteId"
@close="chatPanelOpen = false"
/>
<template v-if="authStore.isAuthenticated">
<AppHeader @toggle-chat-panel="toggleChatPanel" />
<router-view />
<ChatPanel
v-if="chatPanelOpen"
:context-note-id="contextNoteId"
@close="chatPanelOpen = false"
/>
</template>
<template v-else>
<router-view />
</template>
<ToastNotification />
</template>
+203 -34
View File
@@ -1,21 +1,55 @@
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path);
export class ApiError extends Error {
status: number;
body: Record<string, unknown>;
constructor(status: number, body: Record<string, unknown>) {
const msg = (body.error as string) || `API error: ${status}`;
super(msg);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
async function handleResponse<T>(res: Response, path: string): Promise<T> {
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
let body: Record<string, unknown> = {};
try {
body = await res.json();
} catch {
body = { error: `API error: ${res.status}` };
}
// Redirect to login on 401 (except for auth endpoints)
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
const currentPath = window.location.pathname;
if (currentPath !== "/login" && currentPath !== "/register") {
router.push({ name: "login", query: { redirect: currentPath } });
}
}
throw new ApiError(res.status, body);
}
// Handle 204 No Content
if (res.status === 204) {
return undefined as T;
}
return res.json() as Promise<T>;
}
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path);
return handleResponse<T>(res, path);
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
return handleResponse<T>(res, path);
}
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
@@ -24,10 +58,7 @@ export async function apiPut<T>(path: string, body: unknown): Promise<T> {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
return handleResponse<T>(res, path);
}
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
@@ -36,17 +67,132 @@ export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
return handleResponse<T>(res, path);
}
export async function apiDelete(path: string): Promise<void> {
const res = await fetch(path, { method: "DELETE" });
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
return handleResponse<void>(res, path);
}
export interface SSEStreamHandle {
close(): void;
/** Resolves when the stream closes (normally or via error/abort). */
done: Promise<void>;
}
export interface SSENamedEvent {
id: number;
event: string;
data: Record<string, unknown>;
}
export function apiSSEStream(
path: string,
onEvent: (event: SSENamedEvent) => void,
options?: { lastEventId?: number; signal?: AbortSignal },
): SSEStreamHandle {
const controller = new AbortController();
const combinedSignal = options?.signal
? AbortSignal.any([controller.signal, options.signal])
: controller.signal;
const headers: Record<string, string> = {};
if (options?.lastEventId !== undefined) {
headers["Last-Event-ID"] = String(options.lastEventId);
}
const done = (async () => {
const res = await fetch(path, { headers, signal: combinedSignal });
if (!res.ok) {
let body: Record<string, unknown> = {};
try {
body = await res.json();
} catch {
body = { error: `API error: ${res.status}` };
}
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
router.push({ name: "login" });
}
throw new ApiError(res.status, body);
}
const reader = res.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
// SSE field parsing state
let currentId = -1;
let currentEvent = "message";
let currentData = "";
function dispatch() {
if (!currentData) return;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(currentData);
} catch {
return;
}
onEvent({ id: currentId, event: currentEvent, data: parsed });
// Reset for next event
currentEvent = "message";
currentData = "";
}
function processLine(line: string) {
if (line === "") {
// Empty line = end of event
dispatch();
return;
}
if (line.startsWith(":")) return; // comment / keepalive
const colonIdx = line.indexOf(":");
let field: string;
let value: string;
if (colonIdx === -1) {
field = line;
value = "";
} else {
field = line.slice(0, colonIdx);
value = line.slice(colonIdx + 1);
if (value.startsWith(" ")) value = value.slice(1);
}
switch (field) {
case "id":
currentId = parseInt(value, 10);
break;
case "event":
currentEvent = value;
break;
case "data":
currentData += value;
break;
}
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
processLine(line);
}
}
// Process remaining buffer
if (buffer) {
processLine(buffer);
}
dispatch();
})().catch(() => {
// Stream closed or aborted — handled by caller via reconnection
});
return { close: () => controller.abort(), done };
}
export async function apiStreamPost(
@@ -60,7 +206,19 @@ export async function apiStreamPost(
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
let errBody: Record<string, unknown> = {};
try {
errBody = await res.json();
} catch {
errBody = { error: `API error: ${res.status}` };
}
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
router.push({ name: "login" });
}
throw new ApiError(res.status, errBody);
}
const reader = res.body?.getReader();
if (!reader) throw new Error("No response body");
@@ -68,35 +226,46 @@ export async function apiStreamPost(
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
function processLines(text: string) {
const lines = text.split("\n");
// Keep the last (possibly incomplete) line in the buffer
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("data: ")) {
let data;
try {
const data = JSON.parse(trimmed.slice(6));
onChunk(data);
data = JSON.parse(trimmed.slice(6));
} catch {
// Skip malformed JSON lines
continue; // Skip malformed JSON lines
}
onChunk(data);
}
}
}
// Process any remaining buffer
if (buffer.trim().startsWith("data: ")) {
try {
const data = JSON.parse(buffer.trim().slice(6));
onChunk(data);
} catch {
// Skip malformed JSON
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
processLines(buffer);
}
} catch {
// Stream may close with a network error after all data was sent.
// Process whatever remains in the buffer before deciding to throw.
}
// Process any remaining buffer
const remaining = buffer.trim();
if (remaining.startsWith("data: ")) {
let data;
try {
data = JSON.parse(remaining.slice(6));
} catch {
return; // Skip malformed JSON
}
onChunk(data);
}
}
+18
View File
@@ -110,3 +110,21 @@ button:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
/* Responsive breakpoints: 480px (phone), 768px (tablet), 1024px (desktop) */
@media (max-width: 768px) {
.hide-mobile {
display: none !important;
}
button,
[role="button"],
.btn-new-conv,
.btn-send {
min-height: 44px;
}
}
@media (min-width: 769px) {
.hide-desktop {
display: none !important;
}
}
+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;
+28
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "@/views/HomeView.vue";
import { useAuthStore } from "@/stores/auth";
const router = createRouter({
history: createWebHistory(),
@@ -9,6 +10,18 @@ const router = createRouter({
name: "home",
component: HomeView,
},
{
path: "/login",
name: "login",
component: () => import("@/views/LoginView.vue"),
meta: { public: true },
},
{
path: "/register",
name: "register",
component: () => import("@/views/RegisterView.vue"),
meta: { public: true },
},
{
path: "/notes",
name: "notes",
@@ -67,4 +80,19 @@ const router = createRouter({
],
});
router.beforeEach(async (to) => {
if (to.meta.public) return;
const authStore = useAuthStore();
// Wait for initial auth check if still loading
if (authStore.loading && !authStore.isAuthenticated) {
await authStore.checkAuth();
}
if (!authStore.isAuthenticated) {
return { name: "login", query: { redirect: to.fullPath } };
}
});
export default router;
+63
View File
@@ -0,0 +1,63 @@
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost } from "@/api/client";
import type { User, AuthStatus } from "@/types/auth";
export const useAuthStore = defineStore("auth", () => {
const user = ref<User | null>(null);
const loading = ref(true);
const hasUsers = ref(true);
const isAuthenticated = computed(() => user.value !== null);
const isAdmin = computed(() => user.value?.role === "admin");
async function checkAuth() {
loading.value = true;
try {
user.value = await apiGet<User>("/api/auth/me");
} catch {
user.value = null;
} finally {
loading.value = false;
}
}
async function checkHasUsers() {
try {
const data = await apiGet<AuthStatus>("/api/auth/status");
hasUsers.value = data.has_users;
} catch {
hasUsers.value = true;
}
}
async function login(username: string, password: string) {
user.value = await apiPost<User>("/api/auth/login", { username, password });
}
async function register(username: string, password: string, email?: string) {
user.value = await apiPost<User>("/api/auth/register", {
username,
password,
email: email || undefined,
});
}
async function logout() {
await apiPost("/api/auth/logout", {});
user.value = null;
}
return {
user,
loading,
hasUsers,
isAuthenticated,
isAdmin,
checkAuth,
checkHasUsers,
login,
register,
logout,
};
});
+153 -58
View File
@@ -5,8 +5,9 @@ import {
apiPost,
apiPatch,
apiDelete,
apiStreamPost,
apiSSEStream,
} from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type {
Conversation,
ConversationDetail,
@@ -14,6 +15,7 @@ import type {
Message,
OllamaModel,
OllamaStatus,
SendMessageResponse,
} from "@/types/chat";
export const useChatStore = defineStore("chat", () => {
@@ -42,6 +44,9 @@ export const useChatStore = defineStore("chat", () => {
);
conversations.value = data.conversations;
total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load conversations", "error");
throw e;
} finally {
loading.value = false;
}
@@ -51,12 +56,17 @@ export const useChatStore = defineStore("chat", () => {
title = "",
model = ""
): Promise<Conversation> {
const conv = await apiPost<Conversation>("/api/chat/conversations", {
title,
model,
});
conversations.value.unshift(conv);
return conv;
try {
const conv = await apiPost<Conversation>("/api/chat/conversations", {
title,
model,
});
conversations.value.unshift(conv);
return conv;
} catch (e) {
useToastStore().show("Failed to create conversation", "error");
throw e;
}
}
async function fetchConversation(id: number) {
@@ -65,30 +75,43 @@ export const useChatStore = defineStore("chat", () => {
currentConversation.value = await apiGet<ConversationDetail>(
`/api/chat/conversations/${id}`
);
} catch (e) {
useToastStore().show("Failed to load conversation", "error");
throw e;
} finally {
loading.value = false;
}
}
async function deleteConversation(id: number) {
await apiDelete(`/api/chat/conversations/${id}`);
conversations.value = conversations.value.filter((c) => c.id !== id);
if (currentConversation.value?.id === id) {
currentConversation.value = null;
try {
await apiDelete(`/api/chat/conversations/${id}`);
conversations.value = conversations.value.filter((c) => c.id !== id);
if (currentConversation.value?.id === id) {
currentConversation.value = null;
}
} catch (e) {
useToastStore().show("Failed to delete conversation", "error");
throw e;
}
}
async function updateTitle(id: number, title: string) {
const updated = await apiPatch<Conversation>(
`/api/chat/conversations/${id}`,
{ title }
);
const idx = conversations.value.findIndex((c) => c.id === id);
if (idx !== -1) {
conversations.value[idx] = { ...conversations.value[idx], ...updated };
}
if (currentConversation.value?.id === id) {
currentConversation.value.title = updated.title;
try {
const updated = await apiPatch<Conversation>(
`/api/chat/conversations/${id}`,
{ title }
);
const idx = conversations.value.findIndex((c) => c.id === id);
if (idx !== -1) {
conversations.value[idx] = { ...conversations.value[idx], ...updated };
}
if (currentConversation.value?.id === id) {
currentConversation.value.title = updated.title;
}
} catch (e) {
useToastStore().show("Failed to update title", "error");
throw e;
}
}
@@ -116,53 +139,124 @@ export const useChatStore = defineStore("chat", () => {
streaming.value = true;
streamingContent.value = "";
// Phase 1: POST to start generation
let assistantMessageId: number;
try {
await apiStreamPost(
const resp = await apiPost<SendMessageResponse>(
`/api/chat/conversations/${convId}/messages`,
{
content,
context_note_id: contextNoteId,
exclude_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
},
(data) => {
if (data.context) {
lastContextMeta.value = data.context as ContextMeta;
}
if (data.chunk) {
streamingContent.value += data.chunk as string;
}
if (data.done) {
// Add the final assistant message
const assistantMsg: Message = {
id: data.message_id as number,
conversation_id: convId,
role: "assistant",
content: streamingContent.value,
context_note_id: null,
created_at: new Date().toISOString(),
};
if (currentConversation.value?.id === convId) {
currentConversation.value.messages.push(assistantMsg);
}
streamingContent.value = "";
streaming.value = false;
// Update conversation in list
const idx = conversations.value.findIndex((c) => c.id === convId);
if (idx !== -1) {
conversations.value[idx].message_count += 2;
conversations.value[idx].updated_at = new Date().toISOString();
}
}
if (data.error) {
streaming.value = false;
streamingContent.value = "";
}
}
);
} catch {
assistantMessageId = resp.assistant_message_id;
} catch (e) {
streaming.value = false;
streamingContent.value = "";
useToastStore().show("Failed to send message", "error");
return;
}
// Phase 2: Connect to SSE stream with reconnection
await _streamGeneration(convId, assistantMessageId);
}
async function _streamGeneration(
convId: number,
assistantMessageId: number,
initialLastEventId = -1,
attempt = 0,
) {
const MAX_RETRIES = 5;
let lastEventId = initialLastEventId;
let gotDone = false;
const handle = apiSSEStream(
`/api/chat/conversations/${convId}/generation/stream` +
(lastEventId >= 0 ? `?last_event_id=${lastEventId}` : ""),
(event) => {
lastEventId = event.id;
switch (event.event) {
case "context":
lastContextMeta.value = event.data.context as ContextMeta;
break;
case "chunk":
streamingContent.value += event.data.chunk as string;
break;
case "done":
gotDone = true;
{
const assistantMsg: Message = {
id: event.data.message_id as number,
conversation_id: convId,
role: "assistant",
content: streamingContent.value,
context_note_id: null,
created_at: new Date().toISOString(),
};
if (currentConversation.value?.id === convId) {
currentConversation.value.messages.push(assistantMsg);
}
streamingContent.value = "";
streaming.value = false;
// Update conversation in list
const idx = conversations.value.findIndex((c) => c.id === convId);
if (idx !== -1) {
conversations.value[idx].message_count += 2;
conversations.value[idx].updated_at = new Date().toISOString();
}
}
break;
case "error":
gotDone = true;
streaming.value = false;
streamingContent.value = "";
useToastStore().show(
"Chat error: " + (event.data.error as string),
"error",
);
break;
}
},
);
// Wait for the stream to close (events are processed via callback above)
await handle.done;
// If stream ended without done/error, attempt reconnection
if (!gotDone && attempt < MAX_RETRIES) {
const delay = Math.min(1000 * 2 ** attempt, 10_000);
await new Promise((r) => setTimeout(r, delay));
if (currentConversation.value?.id === convId) {
return _streamGeneration(convId, assistantMessageId, lastEventId, attempt + 1);
}
}
// Recovery fallback: re-fetch conversation from DB
if (!gotDone && currentConversation.value?.id === convId) {
try {
await fetchConversation(convId);
} catch {
// Re-fetch failed — partial content visible on next page load
}
}
streaming.value = false;
streamingContent.value = "";
}
async function cancelGeneration() {
if (!currentConversation.value) return;
try {
await apiPost(
`/api/chat/conversations/${currentConversation.value.id}/generation/cancel`,
{}
);
} catch {
// Generation may have already finished — ignore
}
}
@@ -233,6 +327,7 @@ export const useChatStore = defineStore("chat", () => {
deleteConversation,
updateTitle,
sendMessage,
cancelGeneration,
saveMessageAsNote,
summarizeAsNote,
fetchModels,
+55 -25
View File
@@ -1,6 +1,7 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { Note, NoteListResponse } from "@/types/note";
export const useNotesStore = defineStore("notes", () => {
@@ -36,6 +37,9 @@ export const useNotesStore = defineStore("notes", () => {
);
notes.value = data.notes;
total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load notes", "error");
throw e;
} finally {
loading.value = false;
}
@@ -62,6 +66,9 @@ export const useNotesStore = defineStore("notes", () => {
loading.value = true;
try {
currentNote.value = await apiGet<Note>(`/api/notes/${id}`);
} catch (e) {
useToastStore().show("Failed to load note", "error");
throw e;
} finally {
loading.value = false;
}
@@ -71,26 +78,40 @@ export const useNotesStore = defineStore("notes", () => {
title: string;
body: string;
}): Promise<Note> {
const note = await apiPost<Note>("/api/notes", data);
return note;
try {
return await apiPost<Note>("/api/notes", data);
} catch (e) {
useToastStore().show("Failed to create note", "error");
throw e;
}
}
async function updateNote(
id: number,
data: Partial<Pick<Note, "title" | "body">>
): Promise<Note> {
const note = await apiPut<Note>(`/api/notes/${id}`, data);
if (currentNote.value?.id === id) {
currentNote.value = note;
try {
const note = await apiPut<Note>(`/api/notes/${id}`, data);
if (currentNote.value?.id === id) {
currentNote.value = note;
}
return note;
} catch (e) {
useToastStore().show("Failed to update note", "error");
throw e;
}
return note;
}
async function deleteNote(id: number) {
await apiDelete(`/api/notes/${id}`);
notes.value = notes.value.filter((n) => n.id !== id);
if (currentNote.value?.id === id) {
currentNote.value = null;
try {
await apiDelete(`/api/notes/${id}`);
notes.value = notes.value.filter((n) => n.id !== id);
if (currentNote.value?.id === id) {
currentNote.value = null;
}
} catch (e) {
useToastStore().show("Failed to delete note", "error");
throw e;
}
}
@@ -143,26 +164,35 @@ export const useNotesStore = defineStore("notes", () => {
}
async function convertToTask(noteId: number): Promise<Note> {
const note = await apiPost<Note>(
`/api/notes/${noteId}/convert-to-task`,
{}
);
// Update local state — the note is now a task
if (currentNote.value?.id === noteId) {
currentNote.value = note;
try {
const note = await apiPost<Note>(
`/api/notes/${noteId}/convert-to-task`,
{}
);
if (currentNote.value?.id === noteId) {
currentNote.value = note;
}
return note;
} catch (e) {
useToastStore().show("Failed to convert to task", "error");
throw e;
}
return note;
}
async function convertToNote(noteId: number): Promise<Note> {
const note = await apiPost<Note>(
`/api/notes/${noteId}/convert-to-note`,
{}
);
if (currentNote.value?.id === noteId) {
currentNote.value = note;
try {
const note = await apiPost<Note>(
`/api/notes/${noteId}/convert-to-note`,
{}
);
if (currentNote.value?.id === noteId) {
currentNote.value = note;
}
return note;
} catch (e) {
useToastStore().show("Failed to convert to note", "error");
throw e;
}
return note;
}
async function fetchBacklinks(
+11 -2
View File
@@ -1,6 +1,7 @@
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPut, apiPost, apiStreamPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { AppSettings } from "@/types/settings";
export const useSettingsStore = defineStore("settings", () => {
@@ -31,6 +32,9 @@ export const useSettingsStore = defineStore("settings", () => {
loading.value = true;
try {
settings.value = await apiPut<AppSettings>("/api/settings", updates);
} catch (e) {
useToastStore().show("Failed to save settings", "error");
throw e;
} finally {
loading.value = false;
}
@@ -55,8 +59,13 @@ export const useSettingsStore = defineStore("settings", () => {
}
async function deleteModel(model: string) {
await apiPost("/api/chat/models/delete", { model });
await fetchInstalledModels();
try {
await apiPost("/api/chat/models/delete", { model });
await fetchInstalledModels();
} catch (e) {
useToastStore().show("Failed to delete model", "error");
throw e;
}
}
return {
+44 -18
View File
@@ -1,6 +1,7 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { Task, TaskListResponse, TaskStatus, TaskPriority } from "@/types/task";
export const useTasksStore = defineStore("tasks", () => {
@@ -41,6 +42,9 @@ export const useTasksStore = defineStore("tasks", () => {
);
tasks.value = data.tasks;
total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load tasks", "error");
throw e;
} finally {
loading.value = false;
}
@@ -50,6 +54,9 @@ export const useTasksStore = defineStore("tasks", () => {
loading.value = true;
try {
currentTask.value = await apiGet<Task>(`/api/tasks/${id}`);
} catch (e) {
useToastStore().show("Failed to load task", "error");
throw e;
} finally {
loading.value = false;
}
@@ -62,7 +69,12 @@ export const useTasksStore = defineStore("tasks", () => {
priority?: TaskPriority;
due_date?: string | null;
}): Promise<Task> {
return await apiPost<Task>("/api/tasks", data);
try {
return await apiPost<Task>("/api/tasks", data);
} catch (e) {
useToastStore().show("Failed to create task", "error");
throw e;
}
}
async function updateTask(
@@ -71,31 +83,45 @@ export const useTasksStore = defineStore("tasks", () => {
Pick<Task, "title" | "body" | "status" | "priority" | "due_date">
>
): Promise<Task> {
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
if (currentTask.value?.id === id) {
currentTask.value = task;
try {
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
if (currentTask.value?.id === id) {
currentTask.value = task;
}
return task;
} catch (e) {
useToastStore().show("Failed to update task", "error");
throw e;
}
return task;
}
async function patchStatus(id: number, status: TaskStatus): Promise<Task> {
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
// Update in list if present
const idx = tasks.value.findIndex((t) => t.id === id);
if (idx !== -1) {
tasks.value[idx] = task;
try {
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
const idx = tasks.value.findIndex((t) => t.id === id);
if (idx !== -1) {
tasks.value[idx] = task;
}
if (currentTask.value?.id === id) {
currentTask.value = task;
}
return task;
} catch (e) {
useToastStore().show("Failed to update task status", "error");
throw e;
}
if (currentTask.value?.id === id) {
currentTask.value = task;
}
return task;
}
async function deleteTask(id: number) {
await apiDelete(`/api/tasks/${id}`);
tasks.value = tasks.value.filter((t) => t.id !== id);
if (currentTask.value?.id === id) {
currentTask.value = null;
try {
await apiDelete(`/api/tasks/${id}`);
tasks.value = tasks.value.filter((t) => t.id !== id);
if (currentTask.value?.id === id) {
currentTask.value = null;
}
} catch (e) {
useToastStore().show("Failed to delete task", "error");
throw e;
}
}
+8 -4
View File
@@ -4,7 +4,7 @@ import { defineStore } from "pinia";
export interface Toast {
id: number;
message: string;
type: "success" | "error";
type: "success" | "error" | "warning";
}
let nextId = 0;
@@ -12,13 +12,17 @@ let nextId = 0;
export const useToastStore = defineStore("toast", () => {
const toasts = ref<Toast[]>([]);
function show(message: string, type: "success" | "error" = "success") {
function show(message: string, type: "success" | "error" | "warning" = "success") {
const id = nextId++;
toasts.value.push({ id, message, type });
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id);
}, 3000);
}, 4000);
}
return { toasts, show };
function dismiss(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id);
}
return { toasts, show, dismiss };
});
+11
View File
@@ -0,0 +1,11 @@
export interface User {
id: number;
username: string;
email: string | null;
role: string;
created_at: string;
}
export interface AuthStatus {
has_users: boolean;
}
+6
View File
@@ -3,10 +3,16 @@ export interface Message {
conversation_id: number;
role: "system" | "user" | "assistant";
content: string;
status?: "complete" | "generating" | "error";
context_note_id: number | null;
created_at: string;
}
export interface SendMessageResponse {
assistant_message_id: number;
status: string;
}
export interface Conversation {
id: number;
title: string;
+161 -10
View File
@@ -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"
>
&uarr;
</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>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const username = ref("");
const password = ref("");
const error = ref("");
const submitting = ref(false);
onMounted(async () => {
await authStore.checkHasUsers();
});
async function handleSubmit() {
error.value = "";
submitting.value = true;
try {
await authStore.login(username.value, password.value);
const redirect = (route.query.redirect as string) || "/";
router.push(redirect);
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Login failed";
} else {
error.value = "Login failed";
}
} finally {
submitting.value = false;
}
}
</script>
<template>
<main class="auth-page">
<div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Sign In</h1></div>
<p v-if="!authStore.hasUsers" class="auth-hint">
No accounts yet.
<router-link to="/register">Create the first account</router-link>
to get started.
</p>
<form @submit.prevent="handleSubmit">
<div class="field">
<label for="username">Username</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
required
class="input"
/>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
required
class="input"
/>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="submitting">
{{ submitting ? "Signing in..." : "Sign In" }}
</button>
</form>
<p class="auth-footer">
Don't have an account?
<router-link to="/register">Register</router-link>
</p>
</div>
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-bottom: 1rem;
}
.auth-hint a {
color: var(--color-primary);
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.btn-submit {
width: 100%;
padding: 0.6rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: default;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--color-primary);
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
const router = useRouter();
const authStore = useAuthStore();
const username = ref("");
const password = ref("");
const email = ref("");
const error = ref("");
const submitting = ref(false);
async function handleSubmit() {
error.value = "";
submitting.value = true;
try {
await authStore.register(username.value, password.value, email.value || undefined);
router.push("/");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Registration failed";
} else {
error.value = "Registration failed";
}
} finally {
submitting.value = false;
}
}
</script>
<template>
<main class="auth-page">
<div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Create Account</h1></div>
<form @submit.prevent="handleSubmit">
<div class="field">
<label for="username">Username</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
required
class="input"
/>
</div>
<div class="field">
<label for="email">Email (optional)</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
class="input"
/>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="new-password"
required
minlength="8"
class="input"
/>
<p class="field-hint">Must be at least 8 characters</p>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="submitting">
{{ submitting ? "Creating account..." : "Create Account" }}
</button>
</form>
<p class="auth-footer">
Already have an account?
<router-link to="/login">Sign in</router-link>
</p>
</div>
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.btn-submit {
width: 100%;
padding: 0.6rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: default;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--color-primary);
}
</style>
+147
View File
@@ -1,15 +1,22 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import type { ModelInfo } from "@/types/settings";
const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
const assistantName = ref("");
const saving = ref(false);
const saved = ref(false);
const pullProgress = ref<{ model: string; percent: number } | null>(null);
const deleting = ref<string | null>(null);
const confirmDelete = ref<string | null>(null);
const exporting = ref(false);
const restoring = ref(false);
const restoreFileInput = ref<HTMLInputElement | null>(null);
const MODEL_CATALOG: ModelInfo[] = [
// — General Purpose —
@@ -244,6 +251,63 @@ async function removeModel(name: string) {
function cancelDelete() {
confirmDelete.value = null;
}
async function exportData(scope: "user" | "full") {
exporting.value = true;
try {
const url = scope === "full" ? "/api/admin/backup" : "/api/admin/backup?scope=user";
const res = await fetch(url);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
}
const data = await res.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `fabledassistant-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(a.href);
toastStore.show("Backup downloaded");
} catch (e) {
toastStore.show("Export failed: " + (e as Error).message, "error");
} finally {
exporting.value = false;
}
}
function triggerRestoreUpload() {
restoreFileInput.value?.click();
}
async function handleRestoreFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
restoring.value = true;
try {
const text = await file.text();
const data = JSON.parse(text);
const res = await fetch("/api/admin/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
}
const result = await res.json();
toastStore.show(
`Restored ${result.stats?.users ?? 0} users, ${result.stats?.notes ?? 0} notes, ${result.stats?.conversations ?? 0} conversations`
);
} catch (e) {
toastStore.show("Restore failed: " + (e as Error).message, "error");
} finally {
restoring.value = false;
// Reset file input
if (restoreFileInput.value) restoreFileInput.value.value = "";
}
}
</script>
<template>
@@ -345,6 +409,45 @@ function cancelDelete() {
</div>
</div>
</section>
<section class="settings-section">
<h2>Data</h2>
<p class="section-desc">Export your data or restore from a backup.</p>
<div class="data-actions">
<button
class="btn-export"
@click="exportData('user')"
:disabled="exporting"
>
{{ exporting ? "Exporting..." : "Export My Data" }}
</button>
<template v-if="authStore.isAdmin">
<button
class="btn-export"
@click="exportData('full')"
:disabled="exporting"
>
{{ exporting ? "Exporting..." : "Export Full Backup" }}
</button>
<button
class="btn-restore"
@click="triggerRestoreUpload"
:disabled="restoring"
>
{{ restoring ? "Restoring..." : "Restore from Backup" }}
</button>
<input
ref="restoreFileInput"
type="file"
accept=".json"
class="hidden-file-input"
@change="handleRestoreFile"
/>
</template>
</div>
</section>
</main>
</template>
@@ -598,4 +701,48 @@ function cancelDelete() {
color: var(--color-text);
border-color: var(--color-text-muted);
}
/* Data section */
.data-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.btn-export {
padding: 0.45rem 1rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
}
.btn-export:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-export:disabled {
opacity: 0.6;
cursor: default;
}
.btn-restore {
padding: 0.45rem 1rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
}
.btn-restore:hover:not(:disabled) {
border-color: var(--color-warning);
color: var(--color-warning);
}
.btn-restore:disabled {
opacity: 0.6;
cursor: default;
}
.hidden-file-input {
display: none;
}
</style>