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:
+203
-34
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user