diff --git a/Dockerfile b/Dockerfile
index 555038b..092586e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,4 +22,4 @@ COPY alembic/ alembic/
ENV PYTHONPATH=/app/src
EXPOSE 5000
-CMD ["sh", "-c", "alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000"]
+CMD ["sh", "-c", "alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
diff --git a/alembic/versions/0008_add_users_and_user_id.py b/alembic/versions/0008_add_users_and_user_id.py
new file mode 100644
index 0000000..3c0293c
--- /dev/null
+++ b/alembic/versions/0008_add_users_and_user_id.py
@@ -0,0 +1,121 @@
+"""add users table and user_id columns
+
+Revision ID: 0008
+Revises: 0007
+Create Date: 2026-02-11 00:00:00.000000
+"""
+
+from alembic import op
+
+revision = "0008"
+down_revision = "0007"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create users table
+ op.execute("""
+ CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ username TEXT UNIQUE NOT NULL,
+ email TEXT,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'user',
+ created_at TIMESTAMPTZ DEFAULT now()
+ )
+ """)
+ op.execute("CREATE INDEX IF NOT EXISTS ix_users_username ON users (username)")
+
+ # Add user_id columns WITHOUT FK constraints first (so we can assign existing rows)
+ op.execute("""
+ DO $$ BEGIN
+ ALTER TABLE notes ADD COLUMN user_id INTEGER;
+ EXCEPTION WHEN duplicate_column THEN NULL;
+ END $$
+ """)
+ op.execute("""
+ DO $$ BEGIN
+ ALTER TABLE conversations ADD COLUMN user_id INTEGER;
+ EXCEPTION WHEN duplicate_column THEN NULL;
+ END $$
+ """)
+ op.execute("""
+ DO $$ BEGIN
+ ALTER TABLE settings ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1;
+ EXCEPTION WHEN duplicate_column THEN NULL;
+ END $$
+ """)
+
+ # Assign all existing data to user_id=1 (the first registered user gets SERIAL id=1)
+ op.execute("UPDATE notes SET user_id = 1 WHERE user_id IS NULL")
+ op.execute("UPDATE conversations SET user_id = 1 WHERE user_id IS NULL")
+ op.execute("UPDATE settings SET user_id = 1 WHERE user_id != 1")
+
+ # Restructure settings PK: drop old single-column PK, create composite (user_id, key)
+ op.execute("""
+ DO $$ BEGIN
+ ALTER TABLE settings DROP CONSTRAINT settings_pkey;
+ EXCEPTION WHEN undefined_object THEN NULL;
+ END $$
+ """)
+ op.execute("""
+ DO $$ BEGIN
+ ALTER TABLE settings ADD CONSTRAINT settings_pkey PRIMARY KEY (user_id, key);
+ EXCEPTION WHEN duplicate_object THEN NULL;
+ END $$
+ """)
+
+ # Now add FK constraints with NOT VALID (skips validation of existing rows
+ # where user_id=1 has no matching user yet — becomes valid once first user registers)
+ op.execute("""
+ DO $$ BEGIN
+ ALTER TABLE notes ADD CONSTRAINT notes_user_id_fkey
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE NOT VALID;
+ EXCEPTION WHEN duplicate_object THEN NULL;
+ END $$
+ """)
+ op.execute("""
+ DO $$ BEGIN
+ ALTER TABLE conversations ADD CONSTRAINT conversations_user_id_fkey
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE NOT VALID;
+ EXCEPTION WHEN duplicate_object THEN NULL;
+ END $$
+ """)
+ op.execute("""
+ DO $$ BEGIN
+ ALTER TABLE settings ADD CONSTRAINT settings_user_id_fkey
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE NOT VALID;
+ EXCEPTION WHEN duplicate_object THEN NULL;
+ END $$
+ """)
+
+ op.execute("CREATE INDEX IF NOT EXISTS ix_notes_user_id ON notes (user_id)")
+ op.execute("CREATE INDEX IF NOT EXISTS ix_conversations_user_id ON conversations (user_id)")
+
+
+def downgrade() -> None:
+ # Reverse settings changes
+ op.execute("""
+ DO $$ BEGIN
+ ALTER TABLE settings DROP CONSTRAINT settings_pkey;
+ EXCEPTION WHEN undefined_object THEN NULL;
+ END $$
+ """)
+ op.execute("ALTER TABLE settings ALTER COLUMN user_id DROP NOT NULL")
+ op.execute("""
+ DO $$ BEGIN
+ ALTER TABLE settings ADD CONSTRAINT settings_pkey PRIMARY KEY (key);
+ EXCEPTION WHEN duplicate_object THEN NULL;
+ END $$
+ """)
+ op.execute("ALTER TABLE settings DROP COLUMN IF EXISTS user_id")
+
+ op.execute("DROP INDEX IF EXISTS ix_conversations_user_id")
+ op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS user_id")
+
+ op.execute("DROP INDEX IF EXISTS ix_notes_user_id")
+ op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS user_id")
+
+ op.execute("DROP INDEX IF EXISTS ix_users_username")
+ op.execute("DROP TABLE IF EXISTS users")
diff --git a/alembic/versions/0009_add_message_status.py b/alembic/versions/0009_add_message_status.py
new file mode 100644
index 0000000..7569ce1
--- /dev/null
+++ b/alembic/versions/0009_add_message_status.py
@@ -0,0 +1,26 @@
+"""add message status column
+
+Revision ID: 0009
+Revises: 0008
+Create Date: 2026-02-11 00:00:00.000000
+"""
+
+from alembic import op
+
+revision = "0009"
+down_revision = "0008"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute("""
+ DO $$ BEGIN
+ ALTER TABLE messages ADD COLUMN status TEXT NOT NULL DEFAULT 'complete';
+ EXCEPTION WHEN duplicate_column THEN NULL;
+ END $$
+ """)
+
+
+def downgrade() -> None:
+ op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS status")
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..a889e12
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,109 @@
+services:
+ app:
+ build: .
+ ports:
+ - "5000:5000"
+ depends_on:
+ db:
+ condition: service_healthy
+ ollama:
+ condition: service_started
+ environment:
+ DATABASE_URL_FILE: /run/secrets/fabled_assistant_db_url
+ SECRET_KEY_FILE: /run/secrets/fabled_assistant_secret_key
+ OLLAMA_URL: "http://ollama:11434"
+ OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
+ LOG_LEVEL: "${LOG_LEVEL:-INFO}"
+ secrets:
+ - fabled_assistant_db_url
+ - fabled_assistant_secret_key
+ networks:
+ - frontend
+ - backend
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 30s
+ deploy:
+ resources:
+ limits:
+ memory: 512M
+ restart_policy:
+ condition: on-failure
+ max_attempts: 5
+
+ db:
+ image: postgres:16-alpine
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ environment:
+ POSTGRES_USER: fabled
+ POSTGRES_PASSWORD_FILE: /run/secrets/fabled_assistant_db_password
+ POSTGRES_DB: fabledassistant
+ secrets:
+ - fabled_assistant_db_password
+ networks:
+ - backend
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U fabled"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ deploy:
+ resources:
+ limits:
+ memory: 256M
+ restart_policy:
+ condition: on-failure
+ max_attempts: 5
+
+ ollama:
+ image: ollama/ollama
+ volumes:
+ - ollama_models:/root/.ollama
+ networks:
+ - backend
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:11434/"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 15s
+ deploy:
+ placement:
+ constraints:
+ - node.role == worker
+ resources:
+ limits:
+ memory: 8G
+ restart_policy:
+ condition: on-failure
+ max_attempts: 3
+ # To enable GPU support, uncomment the section below
+ # (requires nvidia-container-toolkit)
+ # deploy:
+ # resources:
+ # reservations:
+ # devices:
+ # - driver: nvidia
+ # count: all
+ # capabilities: [gpu]
+
+volumes:
+ pgdata:
+ ollama_models:
+
+networks:
+ frontend:
+ backend:
+ internal: true
+
+secrets:
+ fabled_assistant_secret_key:
+ external: true
+ fabled_assistant_db_password:
+ external: true
+ fabled_assistant_db_url:
+ external: true
diff --git a/frontend/index.html b/frontend/index.html
index 72096c6..1915d1d 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,6 +3,7 @@
+
Fabled Assistant
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
new file mode 100644
index 0000000..4dadbff
--- /dev/null
+++ b/frontend/public/favicon.svg
@@ -0,0 +1,33 @@
+
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index f0397ee..119497b 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,16 +1,18 @@
-
-
-
+
+
+
+
+
+
+
+
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 24fddf1..2c655c0 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -1,21 +1,55 @@
-export async function apiGet(path: string): Promise {
- const res = await fetch(path);
+export class ApiError extends Error {
+ status: number;
+ body: Record;
+
+ constructor(status: number, body: Record) {
+ const msg = (body.error as string) || `API error: ${status}`;
+ super(msg);
+ this.name = "ApiError";
+ this.status = status;
+ this.body = body;
+ }
+}
+
+async function handleResponse(res: Response, path: string): Promise {
if (!res.ok) {
- throw new Error(`API error: ${res.status}`);
+ let body: Record = {};
+ 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;
}
+export async function apiGet(path: string): Promise {
+ const res = await fetch(path);
+ return handleResponse(res, path);
+}
+
export async function apiPost(path: string, body: unknown): Promise {
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;
+ return handleResponse(res, path);
}
export async function apiPut(path: string, body: unknown): Promise {
@@ -24,10 +58,7 @@ export async function apiPut(path: string, body: unknown): Promise {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
- if (!res.ok) {
- throw new Error(`API error: ${res.status}`);
- }
- return res.json() as Promise;
+ return handleResponse(res, path);
}
export async function apiPatch(path: string, body: unknown): Promise {
@@ -36,17 +67,132 @@ export async function apiPatch(path: string, body: unknown): Promise {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
- if (!res.ok) {
- throw new Error(`API error: ${res.status}`);
- }
- return res.json() as Promise;
+ return handleResponse(res, path);
}
export async function apiDelete(path: string): Promise {
const res = await fetch(path, { method: "DELETE" });
- if (!res.ok) {
- throw new Error(`API error: ${res.status}`);
+ return handleResponse(res, path);
+}
+
+export interface SSEStreamHandle {
+ close(): void;
+ /** Resolves when the stream closes (normally or via error/abort). */
+ done: Promise;
+}
+
+export interface SSENamedEvent {
+ id: number;
+ event: string;
+ data: Record;
+}
+
+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 = {};
+ 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 = {};
+ 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;
+ 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 = {};
+ 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);
}
}
diff --git a/frontend/src/assets/theme.css b/frontend/src/assets/theme.css
index ba2b8a1..f809ee1 100644
--- a/frontend/src/assets/theme.css
+++ b/frontend/src/assets/theme.css
@@ -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;
+ }
+}
diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue
index e2a7c9b..d129876 100644
--- a/frontend/src/components/AppHeader.vue
+++ b/frontend/src/components/AppHeader.vue
@@ -1,10 +1,16 @@