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
+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 };
});