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 @@