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
+133 -79
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-02-11 — Phase 4.8: Backend efficiency & consistency pass
2026-02-11 — Phase 5.1: Chat UX Improvements
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -61,10 +61,15 @@ for AI-assisted features.
- **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown
bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create
missing notes.
- **SSE over WebSockets for LLM streaming:** Uses `POST` with `text/event-stream`
response (not EventSource). Frontend uses `fetch()` + `ReadableStream` since we
need to POST the message body. Simpler than WebSockets; Quart supports async
generators natively for SSE.
- **Background generation architecture:** LLM streaming runs in a detached
`asyncio.Task` that writes into an in-memory `GenerationBuffer`. SSE clients
tail the buffer and can reconnect mid-stream without data loss. Buffer has
`cancel_event` for user-initiated stop. Completed buffers are cleaned up after
60s grace period. Periodic DB flushes every 5s preserve partial content.
- **SSE over WebSockets for LLM streaming:** SSE clients connect via
`GET /api/chat/conversations/:id/generation/stream` with `Last-Event-ID`
reconnection support. Frontend uses `fetch()` + `ReadableStream`. Simpler than
WebSockets; Quart supports async generators natively for SSE.
- **Context building server-side:** Backend fetches URL content and searches notes —
frontend just sends the message text + optional note ID + optional exclude list.
Keyword extraction uses simple word splitting with stopword filtering (no embeddings).
@@ -115,9 +120,18 @@ for AI-assisted features.
## Data Model
### Users
- `id` (int PK), `username` (text UNIQUE NOT NULL), `email` (text nullable),
`password_hash` (text NOT NULL), `role` (text NOT NULL DEFAULT 'user'),
`created_at` (timestamptz)
- First registered user auto-assigned `role='admin'`; claims orphaned data
- Passwords hashed with bcrypt
- `to_dict()` excludes `password_hash`
### Notes (unified — includes tasks)
- `id` (int PK), `title` (str), `body` (markdown str), `tags` (ARRAY[str]),
`parent_id` (nullable FK to self), `status` (nullable str — `todo`/`in_progress`/`done`),
`parent_id` (nullable FK to self), `user_id` (nullable FK to users, CASCADE),
`status` (nullable str — `todo`/`in_progress`/`done`),
`priority` (nullable str — `none`/`low`/`medium`/`high`), `due_date` (nullable date),
`created_at`, `updated_at`
- **A note is a task when `status IS NOT NULL`** — the `is_task` property checks this
@@ -138,24 +152,26 @@ for AI-assisted features.
- Task body field is `body` (not `description`) — standardized with notes
### Settings
- `key` (text PK), `value` (text)
- Key-value store for app-wide settings (assistant name, default model, etc.)
- CRUD via `services/settings.py`: `get_setting(key, default)`, `set_setting(key, value)`, `set_settings_batch(dict)`, `get_all_settings()`
- Composite PK: `(user_id, key)` `user_id` FK to users (CASCADE), `key` (text)
- `value` (text)
- Per-user key-value store for settings (assistant name, default model, etc.)
- CRUD via `services/settings.py`: all functions take `user_id` as first parameter
### Conversations
- `id` (int PK), `title` (str), `model` (str), `created_at`, `updated_at`
- `id` (int PK), `title` (str), `model` (str), `user_id` (nullable FK to users, CASCADE), `created_at`, `updated_at`
- Has many messages (cascade delete)
- Title auto-generated from first user message if not set
- Title auto-generated by LLM on first exchange and re-generated every 10th message
- Index on `updated_at` for list ordering
- `to_dict()` returns: `id`, `title`, `model`, `message_count`, `created_at`, `updated_at`
- `list_conversations()` uses a subquery for `message_count` instead of eager-loading all messages
### Messages
- `id` (int PK), `conversation_id` (FK to conversations, CASCADE), `role` (str: system/user/assistant),
`content` (str), `context_note_id` (nullable FK to notes, SET NULL), `created_at`
`content` (str), `status` (str, default `'complete'``complete`/`generating`/`error`),
`context_note_id` (nullable FK to notes, SET NULL), `created_at`
- Index on `conversation_id`
- `context_note_id` tracks which note was attached as context when message was sent
- `to_dict()` returns: `id`, `conversation_id`, `role`, `content`, `context_note_id`, `created_at`
- `to_dict()` returns: `id`, `conversation_id`, `role`, `content`, `status`, `context_note_id`, `created_at`
## Project Structure (Current)
```
@@ -164,6 +180,7 @@ fabledassistant/
├── pyproject.toml # Python project config
├── Dockerfile # Multi-stage build (Node → Python)
├── docker-compose.yml # Dev compose (app, PostgreSQL, Ollama)
├── docker-compose.prod.yml # Production stack (Docker Swarm, secrets, network isolation)
├── alembic.ini # Alembic config (prepend_sys_path = src)
├── alembic/
│ ├── env.py # Async migration runner
@@ -174,29 +191,39 @@ fabledassistant/
│ ├── 0004_merge_tasks_into_notes.py # Add task columns to notes, migrate data, drop tasks table
│ ├── 0005_add_chat_tables.py # Conversations + messages tables with FKs and indexes
│ ├── 0006_add_settings_table.py # Settings key-value table
── 0007_add_title_and_updated_at_indexes.py # B-tree indexes on notes.title and conversations.updated_at
── 0007_add_title_and_updated_at_indexes.py # B-tree indexes on notes.title and conversations.updated_at
│ ├── 0008_add_users_and_user_id.py # Users table, user_id FKs on notes/conversations/settings, composite PK for settings
│ └── 0009_add_message_status.py # Add status column to messages table (default 'complete')
├── src/
│ └── fabledassistant/
│ ├── __init__.py
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API
│ ├── config.py # Config from env vars
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API, request logging middleware
│ ├── auth.py # Auth decorators: login_required, admin_required, get_current_user_id
│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret)
│ ├── models/
│ │ ├── __init__.py # async_session factory, Base, imports Note + TaskStatus + TaskPriority + Conversation + Message + Setting
│ │ ├── note.py # Note model (unified: id, title, body, tags[], parent_id, status, priority, due_date, timestamps) + TaskStatus/TaskPriority enums + is_task property
│ │ ├── conversation.py # Conversation + Message models with relationships and to_dict()
│ │ ── setting.py # Setting model (key TEXT PK, value TEXT)
│ │ ├── __init__.py # async_session factory, Base, imports all models
│ │ ├── user.py # User model (id, username, email, password_hash, role, created_at)
│ │ ├── note.py # Note model (unified: id, title, body, tags[], parent_id, user_id, status, priority, due_date, timestamps)
│ │ ── conversation.py # Conversation + Message models with user_id
│ │ └── setting.py # Setting model (composite PK: user_id + key, value TEXT)
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming, save/summarize as note, models list/pull/delete, status check
│ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /convert-to-note + /backlinks
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (imports directly from services.notes, accepts body not description)
│ │ ── settings.py # /api/settings GET/PUT — app-wide settings
│ │ ├── api.py # /api blueprint with /health endpoint (public)
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status
│ │ ├── admin.py # /api/admin blueprint: backup (user/full), restore (admin only)
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
│ │ ── notes.py # /api/notes CRUD + wikilinks + backlinks (all @login_required)
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required)
│ │ └── settings.py # /api/settings GET/PUT — per-user settings (@login_required)
│ ├── services/
│ │ ├── notes.py # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks, search_notes_for_context (OR-keyword search), get_all_tags (SQL unnest)
│ │ ├── llm.py # Ollama interaction: get_installed_models (shared helper), ensure_model, stream_chat, generate_completion, fetch_url_content, build_context (keyword extraction, single OR-query note search, exclude_note_ids support, returns (messages, context_meta) tuple, URL fetching, configurable assistant name)
│ │ ├── chat.py # Conversation CRUD (list uses subquery count), add_message, save_response_as_note, summarize_conversation_as_note
│ │ ── settings.py # Settings CRUD: get_setting, set_setting, set_settings_batch, get_all_settings
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id
│ │ ├── backup.py # Backup/restore: export_full_backup, export_user_backup, restore_full_backup
│ │ ├── notes.py # CRUD with user_id isolation, is_task filter, convert, backlinks, search_notes_for_context
│ │ ── llm.py # Ollama interaction: build_context with user_id, streaming, URL fetching
│ │ ├── chat.py # Conversation CRUD with user_id isolation, add_message, save/summarize as note (LLM-titled, chat-tagged)
│ │ ├── generation_buffer.py # In-memory SSE event buffer with cancel_event, reconnect support, auto-cleanup
│ │ ├── generation_task.py # Background asyncio task: streams LLM into buffer, periodic DB flush, LLM title generation
│ │ └── settings.py # Settings CRUD with user_id isolation: get_setting, set_setting, set_settings_batch, get_all_settings
│ ├── utils/
│ │ ├── __init__.py
│ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences
@@ -209,19 +236,21 @@ fabledassistant/
│ ├── App.vue # Shell: AppHeader + router-view + ChatPanel (slide-out) + ToastNotification; starts status polling + loads settings on mount
│ ├── main.ts # App init, imports theme.css
│ ├── assets/
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset, design tokens (radius, semantic colors, focus ring, overlay), global focus-visible styles
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset, design tokens, responsive breakpoints (480/768/1024), utility classes (.hide-mobile/.hide-desktop), mobile touch targets
│ ├── api/
│ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete + apiStreamPost (SSE via fetch + ReadableStream)
│ │ └── client.ts # ApiError class, apiGet/apiPost/apiPut/apiPatch/apiDelete + apiSSEStream (EventSource-based), auto 401→login redirect
│ ├── composables/
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
│ │ └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search
│ ├── stores/
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description)
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming, handles context event, accepts excludeNoteIds), lastContextMeta ref, saveMessageAsNote, summarizeAsNote, fetchModels, status polling (ollamaStatus, modelStatus, chatReady)
│ │ ├── settings.ts # App settings: assistantName, defaultModel, fetchInstalledModels, pullModel (SSE streaming with onProgress callback), deleteModel
│ │ ── toast.ts # Toast notification state, 3s auto-dismiss
│ │ ├── auth.ts # Auth state: user, isAuthenticated, isAdmin, login/register/logout/checkAuth
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags (with toast errors)
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (with toast errors)
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), status polling (with toast errors)
│ │ ── settings.ts # App settings: assistantName, defaultModel, pullModel, deleteModel (with toast errors)
│ │ └── toast.ts # Toast notification state (success/error/warning), 4s auto-dismiss, dismiss(id)
│ ├── types/
│ │ ├── auth.ts # User interface, AuthStatus interface
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
│ │ ├── chat.ts # Message, Conversation, ConversationDetail, ContextMeta, OllamaModel, OllamaStatus interfaces
│ │ ├── settings.ts # AppSettings interface, ModelInfo interface (name, description, size, bestFor, category)
@@ -230,17 +259,19 @@ fabledassistant/
│ │ ├── tags.ts # extractTags(), linkifyTags() (with (?<!&) to skip HTML entities), linkifyWikilinks()
│ │ └── markdown.ts # renderMarkdown() (full) + renderPreview() (strips links/images) — decodes HTML entities before marked, replaces &#39; after sanitization
│ ├── views/
│ │ ├── ChatView.vue # Dedicated /chat page: sidebar + bubble-style messages (user right, assistant left) + floating dark input bar + auto-focus + note picker + context pills (auto-found notes with promote/exclude) + exclude tracking
│ │ ├── HomeView.vue # Landing page: recent notes + tasks + recent chats (3 most recent) + new chat button
│ │ ├── SettingsView.vue # Settings page: assistant name config + model catalog (18 models in 3 categories) with download/select/remove
│ │ ├── LoginView.vue # Login form with error display, link to register
│ │ ├── RegisterView.vue # Register form (username, password, email), first-user setup
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context pills
│ │ ├── HomeView.vue # Landing page: recent notes + tasks + recent chats
│ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin)
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
│ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts, autocomplete
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task (only when !is_task), backlinks
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination, status toggle
│ │ ├── TaskEditorView.vue # Create/edit task: fields (body not description), Ctrl+S, dirty guard, autocomplete
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note button, backlinks
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination
│ │ ├── TaskEditorView.vue # Create/edit task: Ctrl+S, dirty guard, autocomplete
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note, backlinks
│ ├── components/
│ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks + Chat + Settings links, status indicator dot, chat panel toggle, theme toggle
│ │ ├── AppHeader.vue # Nav bar: brand, nav links, status indicator, theme toggle, user info + logout, hamburger menu (mobile)
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop), bubble-style messages, floating dark input, note picker, context pills with promote/exclude
│ │ ├── ChatMessage.vue # Message bubble: markdown rendering, configurable assistant name label, "Save as Note" action on assistant messages
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit
@@ -251,9 +282,9 @@ fabledassistant/
│ │ ├── SearchBar.vue # Debounced search input
│ │ ├── TagPill.vue # Clickable/dismissible tag pill
│ │ ├── PaginationBar.vue # Prev/next + page numbers
│ │ └── ToastNotification.vue # Fixed-position toast container
│ │ └── ToastNotification.vue # Fixed-position toast container with close button, warning support
│ └── router/
│ └── index.ts # Routes: /, /notes/*, /tasks/*, /chat, /chat/:id, /settings
│ └── index.ts # Routes: /, /login, /register, /notes/*, /tasks/*, /chat, /chat/:id, /settings; beforeEach auth guard
└── public/
```
@@ -261,7 +292,14 @@ fabledassistant/
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/health` | Health check |
| GET | `/api/health` | Health check (public) |
| GET | `/api/auth/status` | Check if any users exist (public) |
| POST | `/api/auth/register` | Register new user (first user becomes admin) |
| POST | `/api/auth/login` | Login with username/password |
| POST | `/api/auth/logout` | Logout (clear session) |
| GET | `/api/auth/me` | Get current user info |
| GET | `/api/admin/backup` | Export backup (`?scope=user` for own data, full requires admin) |
| POST | `/api/admin/restore` | Restore from JSON backup (admin only) |
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`; defaults to `is_task=false` — plain notes only; `?is_task=true` for tasks, `?all=true` for everything) |
| POST | `/api/notes` | Create note (body: `{title, body, status?, priority?, due_date?}` — tags auto-extracted) |
| GET | `/api/notes/tags` | List all tags from notes table (param: `q` for filter) |
@@ -284,8 +322,10 @@ fabledassistant/
| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
| PATCH | `/api/chat/conversations/:id` | Update conversation title (body: `{title}`) |
| POST | `/api/chat/conversations/:id/messages` | Send message + stream LLM response via SSE (body: `{content, context_note_id?, exclude_note_ids?}`); emits `{context: ContextMeta}` SSE event before streaming chunks |
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as a new note |
| POST | `/api/chat/conversations/:id/messages` | Start generation: save user message, launch background task, return 202 (body: `{content, context_note_id?, exclude_note_ids?}`) |
| GET | `/api/chat/conversations/:id/generation/stream` | SSE endpoint tailing generation buffer; supports `Last-Event-ID` reconnection; emits `context`, `chunk`, `done`, `error` events |
| POST | `/api/chat/conversations/:id/generation/cancel` | Cancel active generation (sets cancel_event, saves partial content) |
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as a new note (LLM-generated title, tagged `chat`) |
| POST | `/api/chat/conversations/:id/summarize` | Summarize conversation via LLM, save as note |
| GET | `/api/chat/status` | Check Ollama availability and model readiness (`{ollama, model, default_model}`) |
| GET | `/api/chat/models` | List available Ollama models |
@@ -303,7 +343,7 @@ container startup.
### Migration Chain
```
0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py → 0005_add_chat_tables.py → 0006_add_settings_table.py → 0007_add_title_and_updated_at_indexes.py
0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py → 0005_add_chat_tables.py → 0006_add_settings_table.py → 0007_add_title_and_updated_at_indexes.py → 0008_add_users_and_user_id.py → 0009_add_message_status.py
```
### How Migrations Run
@@ -438,13 +478,13 @@ When adding a new migration, follow these conventions:
- [x] **Context building:** System prompt with note-aware context — includes current note (via `context_note_id`), keyword-extracted related note search (top 3, 5 keywords, 2000-char previews), URL content fetching (regex detection, HTML stripping, truncated to 4000 chars), returns `(messages, context_meta)` tuple with auto-found note IDs/titles, accepts `exclude_note_ids` to skip notes during auto-search
- [x] **Conversation persistence:** `conversations` + `messages` tables in PostgreSQL, full CRUD
- [x] **SSE streaming endpoint:** `POST /api/chat/conversations/:id/messages` streams LLM response chunks as SSE events, saves complete response to DB on finish
- [x] **Save as note:** Save any assistant message as a new note (first line as title)
- [x] **Save as note:** Save any assistant message as a new note (LLM-generated title, tagged `chat`)
- [x] **Summarize conversation:** Send full conversation to LLM with summarize prompt, save result as note
- [x] **Model management:** `GET /api/chat/models` lists available Ollama models; auto-pull default model (`llama3.1`) on app startup
- [x] **Dedicated chat page:** `/chat` with sidebar (conversation list, create/delete) + message thread + markdown rendering + streaming indicator
- [x] **Slide-out chat panel:** Toggle from header, receives `contextNoteId` from current route (`/notes/:id` or `/tasks/:id`), auto-creates conversation on first message
- [x] **Frontend SSE client:** `apiStreamPost()` uses fetch + ReadableStream to parse SSE data lines
- [x] **Auto-title:** Conversation title auto-set from first user message content
- [x] **Auto-title:** Conversation title generated by LLM (concise 3-8 words)
### Phase 4.5 — Chat UX + Settings + Model Management ✓
- [x] **Settings infrastructure:** Key-value `settings` table, `GET/PUT /api/settings`, Pinia store with defaults
@@ -495,12 +535,28 @@ When adding a new migration, follow these conventions:
- [x] **Database indexes:** Added B-tree indexes on `notes.title` (for `get_note_by_title` + backlinks) and `conversations.updated_at` (for list ordering)
- [x] **Safe `Conversation.to_dict()`:** Handles case where messages relationship is not loaded
### Phase 5 — Polish & Production Hardening
- [ ] Authentication (single-user or multi-user, TBD)
- [ ] Docker Swarm production stack (secrets, volumes, networking)
- [ ] Backup/restore strategy for data
- [ ] UI/UX refinements, responsive design
- [ ] Error handling, logging, monitoring
### Phase 5 — Polish & Production Hardening
- [x] **Multi-user authentication:** Session cookie auth with bcrypt, first-user-is-admin pattern, orphaned data claiming
- [x] **Per-user data isolation:** All notes, conversations, settings filtered by `user_id`
- [x] **Auth decorators:** `@login_required`, `@admin_required` on all API routes (except health + auth status)
- [x] **Frontend auth flow:** Login/register views, router beforeEach guard, ApiError with auto-redirect on 401
- [x] **Error handling & logging:** Request logging with timing, sanitized 500 errors, LOG_LEVEL config, toast errors on all store actions
- [x] **Toast improvements:** Warning type, dismiss button, 4s auto-dismiss
- [x] **Responsive design:** Breakpoints (480/768/1024), utility classes (.hide-mobile/.hide-desktop), 44px mobile touch targets
- [x] **Responsive header:** Hamburger menu at ≤768px, vertical nav dropdown, close on route change
- [x] **Responsive chat:** Sidebar overlay on mobile with toggle button, slides in/out
- [x] **Backup/restore:** Export user data (any user), full backup (admin), restore from JSON (admin)
- [x] **Docker Swarm production stack:** `docker-compose.prod.yml` with Docker secrets, internal network isolation, health checks, resource limits, restart policies
- [x] **Docker secrets support:** `_read_secret()` helper in config.py for SECRET_KEY_FILE, DATABASE_URL_FILE
### Phase 5.1 — Chat UX Improvements ✓
- [x] **LLM-generated titles:** Conversation titles generated by LLM (3-8 words) on first exchange and re-generated every 10th message to reflect evolved topics; falls back to truncation on failure
- [x] **Background generation architecture:** `GenerationBuffer` (in-memory event buffer with SSE fan-out, reconnect via `Last-Event-ID`, auto-cleanup after 60s) + `run_generation()` asyncio task (streams from Ollama, periodic DB flushes every 5s)
- [x] **Stop generation:** `cancel_event` on `GenerationBuffer`; `POST .../generation/cancel` endpoint; red stop button replaces send button during streaming; partial content saved as complete
- [x] **Date/time in sidebar:** Relative time for recent (`Just now`, `5m ago`, `3h ago` up to 10h), then date (`Jan 15` this year, `Jan 15, 2025` older)
- [x] **Empty chat cleanup:** Navigating away from an empty conversation (message_count === 0) auto-deletes it
- [x] **Save-as-note LLM titles:** `save_response_as_note` generates title via LLM (same pattern as chat titles); falls back to first-line extraction on failure
- [x] **Chat tag on saved notes:** Both `save_response_as_note` and `summarize_conversation_as_note` tag created notes with `chat`
### Future / Stretch
- Tagging/labeling system with LLM-suggested tags
@@ -516,27 +572,25 @@ When adding a new migration, follow these conventions:
Quart serves them
- To reset database: `docker compose down -v && docker compose up --build`
## Open Questions
- Authentication model: single-user (password-only) vs multi-user?
## Current Status
**Phase:** Phase 4.8 complete. Backend efficiency & consistency pass.
**Phase:** Phase 5.1 complete. Chat UX Improvements.
- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags
- Unified note/task model (a task is a note with `status IS NOT NULL`)
- LLM chat via Ollama with SSE streaming responses
- Note-aware context: auto-includes current note + searches related notes by keyword (5 keywords, 2000-char previews)
- Context visibility: auto-found notes shown as pills with title links, promote (+) and exclude (x) controls
- Note picker: attach any note as context from chat input via search dropdown
- Exclude tracking: session-scoped per conversation, excluded notes skipped in future auto-searches
- Multi-word search: splits query into per-term ILIKE with AND logic (words match independently)
- URL content fetching: detects URLs in messages, fetches and includes content
- Save assistant messages as notes; summarize entire conversations as notes
- Dedicated `/chat` page with bubble-style messages (user right, assistant left), floating dark input bar
- Slide-out chat panel accessible from any page, auto-includes note/task context
- Settings page: configurable assistant name (default "Fable"), model catalog with 18 models in 3 categories
- Model management: download (with live SSE progress percentage), select, and remove models from the settings page
- Ollama status indicator in global nav bar (green/yellow/red dot) with 30s polling
- Recent chats section on home page with quick "New Chat" button
- HTML entity rendering fix (apostrophes in marked → DOMPurify pipeline)
- Dark/light theme with CSS custom properties
- Ready for Phase 5: Polish & Production Hardening
- **Multi-user authentication** with session cookies, bcrypt passwords, admin role
- **Per-user data isolation** across notes, conversations, and settings
- **First-user-is-admin** pattern; orphaned pre-auth data claimed by first user
- LLM chat via Ollama with background generation task + SSE streaming
- **LLM-generated titles** on first exchange and every 10th message
- **Stop generation** button with partial content preservation
- **Relative timestamps** in sidebar (5m ago, 3h ago, then dates)
- **Empty chat cleanup** on navigation away
- Note-aware context: auto-includes current note + searches related notes by keyword
- Context pills with promote/exclude controls; note picker in chat input
- Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations as notes
- Dedicated `/chat` page with responsive sidebar (overlay on mobile)
- Settings page: assistant name, model catalog, **data export/restore (admin)**
- **Request logging** with timing, sanitized 500 errors, configurable LOG_LEVEL
- **Responsive design**: hamburger menu, mobile touch targets, responsive breakpoints
- **Toast notifications** with success/error/warning types and dismiss button
- **Docker Swarm production stack** with secrets, network isolation, health checks, resource limits
- Dark/light theme with CSS custom properties and design tokens