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:
+153
-58
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user