Files
FabledScribe/summary.md
T
bvandeusen e02b681e91 Add invitation system, table of contents, actionable dashboard, and settings improvements
Invitation system (Phase 5.7):
- invitation_tokens table (migration 0012) with SHA256-hashed tokens, 7-day expiry
- Admin CRUD endpoints: POST/GET/DELETE /api/admin/invitations
- Branded invitation email with registration link
- /register-invite frontend view with token validation and account creation
- Admin UI: invite form + pending invitations table with revoke
- Configurable base URL setting for email links (replaces hardcoded Config.BASE_URL)

Table of contents + markdown improvements (Phase 5.8):
- TableOfContents component: sticky sidebar, heading parsing, smooth-scroll
- Custom marked renderer adds id attributes to headings for anchor links
- stripFirstLineTags() prevents leading #tags from rendering as headings
- NoteViewerView/TaskViewerView flex layout with TOC sidebar (hidden ≤1200px)
- Model catalog refresh: added llama3.2/3.3, gemma3, qwen3, phi4, deepseek-r1,
  qwen2.5-coder, dolphin3; added Reasoning category; removed discontinued models
- Settings model section split into Installed/Available tabs

Actionable dashboard (Phase 5.9):
- due_before/due_after query params on /api/tasks (exclusive < / inclusive >=)
- HomeView rewritten: overdue (red accent), due today, in progress, chats, notes
- 5 parallel API calls via Promise.allSettled
- Client-side done filtering and in-progress deduplication
- Task sections hidden when empty; status toggle removes done tasks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 08:46:51 -05:00

761 lines
61 KiB
Markdown

# Fabled Assistant - Project Context
> **Purpose:** This file is the canonical reference for re-initializing Claude Code
> context on this project. **Update this file after every change session.**
## Session Checklist
> **IMPORTANT:** At the end of every change session, you MUST:
> 1. **Update this file** (`summary.md`) to reflect all architectural, data model,
> API, file structure, and roadmap changes made during the session.
> 2. **Commit all changes** with a descriptive commit message summarizing what was
> done (e.g., "Merge tasks into notes: single table with task attributes").
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-02-13 — Phase 5.9: Invitation system, table of contents, actionable dashboard, settings improvements
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
integrated LLM capabilities. It is designed to run on container infrastructure
(Docker Swarm) and connect to Ollama or any self-hostable LLM-compatible system
for AI-assisted features.
## Core Architecture
### Stack
| Layer | Technology | Notes |
|-------------|-----------|-------|
| Frontend | Vue 3 + TypeScript + Vite + Pinia + Vue Router | SPA served from the same container as the API |
| Backend/API | Quart (Python 3.12) | Async framework; serves both API and built frontend static files |
| LLM | Ollama | Or any OpenAI-compatible self-hosted LLM API |
| Database | PostgreSQL 16 | asyncpg driver, SQLAlchemy 2.0 async ORM, Alembic migrations |
| Deployment | Docker Compose | Single-container app + separate DB + LLM service |
### Key Design Decisions
- **Single container for frontend + API:** Quart serves the Vue.js production
build as static files and exposes the REST API under `/api/`.
- **Quart chosen for familiarity:** The maintainer (bvandeusen) knows Quart well.
- **SPA routing via 404 handler:** `app.py` uses `@app.errorhandler(404)` (not a
catch-all route) to serve static files or fall back to `index.html`. API routes
(`/api/*`) get JSON 404 responses. This avoids a catch-all `/<path:path>` route
intercepting API GETs.
- **LLM integration is a separate service:** The app communicates with Ollama (or
compatible) over HTTP.
- **Inline tag extraction:** Tags are extracted from note/task body text using
`#tag` syntax (Obsidian-style), not manually entered. Backend is source of truth
for tag extraction. The `TAG_RE` regex uses `(?<!&)` negative lookbehind to avoid
matching HTML entities like `&#39;` as tags.
- **Hierarchical tags:** `#project/webapp` stored as `"project/webapp"`. Filtering
by `project` matches both `project` and `project/*` children via SQL `unnest` +
`LIKE` prefix.
- **Dark-first theming:** CSS custom properties on `:root` (light) and
`[data-theme="dark"]`, with `prefers-color-scheme` detection defaulting to dark.
- **Design tokens:** Standardized CSS custom properties for border-radius
(`--radius-sm: 6px`, `--radius-md: 8px`, `--radius-lg: 12px`), semantic colors
(`--color-success`, `--color-warning`, `--color-overlay`), and focus ring
(`--focus-ring`). All components reference tokens instead of hardcoded values.
- **Unified note/task model:** A task is just a note with task attributes enabled.
A note has `status IS NOT NULL` ⟹ it's a task. "Convert to task" sets
`status='todo'`; "convert to note" clears `status`, `priority`, `due_date`.
No companion notes, no cascade deletes, no bidirectional sync needed.
- **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.
- **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.
Both chat and AI Assist use this architecture — assist buffers are keyed by
`"assist:{user_id}"` (string keys) instead of conversation ID (int keys).
Assist generation has no DB persistence, no title generation, no cancellation.
- **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).
`build_context()` returns `(messages, context_meta)` tuple; metadata includes
auto-found note IDs/titles sent to frontend via SSE `context` event before streaming.
Multi-word search splits terms into per-word ILIKE with AND logic (not adjacent match).
- **Reverse proxy required for production:** The app does not terminate TLS. A
reverse proxy (Nginx/Traefik/Caddy) must sit in front of port 5000. Do not
expose the app directly to the internet.
- **Registration auto-closes:** After the first user registers (admin), registration
is closed by default. The admin can toggle it from `/admin/users`. The setting is
stored as the admin user's `registration_open` setting key.
- **No blocking long-running operations:** Any potentially slow operation (model
pulls, LLM calls, URL fetching, etc.) must never block app startup or freeze the
UI. Backend uses SSE streaming for incremental responses (chat messages and model
pull progress). Frontend keeps the UI responsive during async operations (loading
states, streaming indicators, progress percentages). This applies to all future
features as well.
- **Auto-pull model on startup:** Non-blocking background task, logs warning on
failure so the app still starts even if Ollama isn't ready.
- **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies for
`[[Title]]` patterns referencing the given note. Results include `type: "note"` or
`type: "task"` based on whether the linking note has `status IS NOT NULL`.
- **Idempotent raw SQL migrations:** All Alembic migrations use raw SQL with
`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN
CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs and
fresh database creation.
- **Session cookie security:** `HttpOnly` and `SameSite=Lax` always set. `Secure`
flag controlled by `SECURE_COOKIES` env var (enable when behind TLS).
- **Default SECRET_KEY warning:** App logs a WARNING on startup if the default
`dev-secret-change-me` key is in use.
### High-Level Component Diagram
```
┌─────────────────────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────────────┐ ┌────────────┐ │
│ │ fabledassistant │ │ ollama │ │
│ │ ┌────────────────┐ │ │ │ │
│ │ │ Quart Server │ │ │ LLM API │ │
│ │ │ ┌──────────┐ │ │ │ │ │
│ │ │ │ Vue SPA │ │ │ └────────────┘ │
│ │ │ │ (static) │ │ │ ▲ │
│ │ │ └──────────┘ │ │ │ │
│ │ │ ┌──────────┐ │ │ HTTP/REST │
│ │ │ │ /api/* │──┼──┼─────────┘ │
│ │ │ └──────────┘ │ │ │
│ │ │ │ │ │ ┌────────────┐ │
│ │ │ ▼ │ │ │ PostgreSQL │ │
│ │ │ ┌──────────┐ │ │ │ 16 │ │
│ │ │ │ asyncpg │──┼──┼──▶ │ │
│ │ │ └──────────┘ │ │ └────────────┘ │
│ │ └────────────────┘ │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────┘
```
## 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), `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
- Tags are auto-extracted from body text on create/update via `#tag` regex
- Supports hierarchical organization via `parent_id`
- Lookup by exact title via `get_note_by_title()` for wikilink resolution
- Auto-create via `get_or_create_note_by_title()` for wikilink clicks
- `to_dict()` returns: `id`, `title`, `body`, `tags`, `parent_id`, `status`,
`priority`, `due_date`, `is_task`, `created_at`, `updated_at`
- Indexes: GIN on tags, B-tree on status, B-tree on title
### Task ≡ Note with task attributes
- No separate tasks table. `routes/tasks.py` imports directly from
`services/notes.py` with `is_task=True` for listing and
defaults `status='todo'`, `priority='none'` for creation.
- "Convert to task" = `update_note(id, status='todo', priority='none')`
- "Convert to note" = `update_note(id, status=None, priority=None, due_date=None)`
- Task body field is `body` (not `description`) — standardized with notes
### 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), `user_id` (nullable FK to users, CASCADE), `created_at`, `updated_at`
- Has many messages (cascade delete)
- 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
### App Logs
- `id` (int PK), `category` (text NOT NULL — `audit`/`usage`/`error`),
`user_id` (int FK users ON DELETE SET NULL), `username` (text — denormalized),
`action` (text), `endpoint` (text), `method` (text), `status_code` (int),
`duration_ms` (real), `ip_address` (text), `details` (text — JSON-serialized),
`created_at` (timestamptz DEFAULT now())
- Single table with `category` field for all log types
- Denormalized `username` preserves attribution after user deletion
- `details` stores flexible JSON data (error tracebacks, audit metadata, etc.)
- Indexes: `category`, `user_id`, `created_at`, composite `(category, created_at DESC)`
- Automatic retention cleanup via hourly asyncio task (configurable `LOG_RETENTION_DAYS`, default 90)
### Invitation Tokens
- `id` (int PK), `email` (text NOT NULL), `token_hash` (text NOT NULL UNIQUE),
`invited_by` (int FK users CASCADE), `expires_at` (timestamptz NOT NULL),
`used` (boolean NOT NULL DEFAULT FALSE), `created_at` (timestamptz)
- Admin creates invitation → raw token emailed → recipient registers via `/register-invite?token=...`
- Token stored as SHA256 hash (same pattern as password reset)
- 7-day expiration, one-time use, previous unused invitations for same email auto-revoked
- Indexes: `token_hash`, `email`
### Messages
- `id` (int PK), `conversation_id` (FK to conversations, CASCADE), `role` (str: system/user/assistant),
`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`, `status`, `context_note_id`, `created_at`
## Project Structure (Current)
```
fabledassistant/
├── summary.md # This file — canonical project context
├── 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
│ └── versions/
│ ├── 0001_create_notes_table.py # Notes table (raw SQL, idempotent)
│ ├── 0002_create_tasks_table.py # Tasks table + enums (raw SQL, idempotent)
│ ├── 0003_task_note_companion.py # Data migration: create companion notes for existing tasks
│ ├── 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
│ ├── 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')
│ ├── 0010_add_app_logs_table.py # App logs table for audit, usage, and error logging
│ ├── 0011_add_password_reset_tokens.py # Password reset tokens table
│ └── 0012_add_invitation_tokens.py # Invitation tokens table
├── src/
│ └── fabledassistant/
│ ├── __init__.py
│ ├── 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) + SECURE_COOKIES flag
│ ├── models/
│ │ ├── __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)
│ │ ├── app_log.py # AppLog model (id, category, user_id, username, action, endpoint, method, status_code, duration_ms, ip_address, details, created_at)
│ │ └── invitation.py # InvitationToken model (id, email, token_hash, invited_by, expires_at, used, created_at)
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint (public)
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status, password reset, invitation registration
│ │ ├── admin.py # /api/admin blueprint: backup, restore, user management, registration toggle, invitations, base URL, SMTP (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/
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id, is_registration_open, list_users, delete_user, set_registration_open, password reset tokens, invitation tokens (create, validate, register_with_invitation, list_pending, revoke)
│ │ ├── 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; supports chat (int keys) and assist (string keys)
│ │ ├── generation_task.py # Background asyncio tasks: run_generation (chat, DB flush, titles) + run_assist_generation (lightweight, no DB)
│ │ ├── settings.py # Settings CRUD with user_id isolation: get_setting, set_setting, set_settings_batch, get_all_settings
│ │ ├── logging.py # App logging: log_audit, log_usage, log_error, get_logs, get_log_stats, delete_old_logs, start_log_retention_loop
│ │ ├── email.py # SMTP email service: get_smtp_config, is_smtp_configured, send_email, send_test_email
│ │ └── notifications.py # Notification service: notify_security_event, check_due_tasks, start_notification_loop
│ ├── utils/
│ │ ├── __init__.py
│ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences
│ └── static/ # Vue production build (generated by Dockerfile)
└── frontend/
├── package.json # deps: vue, pinia, vue-router, marked, dompurify
├── vite.config.ts
├── tsconfig.json
├── src/
│ ├── App.vue # Shell: .app-shell (100dvh flex column) with AppHeader + .app-content (flex:1, scrollable) wrapping router-view; 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, responsive breakpoints (480/768/1024), utility classes (.hide-mobile/.hide-desktop), mobile touch targets
│ ├── api/
│ │ └── client.ts # ApiError class, apiGet/apiPost/apiPut/apiPatch/apiDelete + apiSSEStream (fetch+ReadableStream), apiStreamPost (legacy), auto 401→login redirect
│ ├── composables/
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
│ │ ├── useAutocomplete.ts # Legacy textarea autocomplete (replaced by Tiptap suggestion extensions)
│ │ └── useAssist.ts # AI Assist composable: section parsing, target selection, two-step POST+SSE streaming (apiPost → apiSSEStream), accept/reject; watches body ref for auto-sync
│ ├── stores/
│ │ ├── 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)
│ │ └── task.ts # Task = re-export of Note; TaskListResponse
│ ├── extensions/
│ │ ├── TagDecoration.ts # ProseMirror decoration plugin: highlights #tags with .inline-tag class
│ │ ├── WikilinkDecoration.ts # ProseMirror decoration plugin: highlights [[wikilinks]] with .wikilink class
│ │ ├── TagSuggestion.ts # @tiptap/suggestion extension for #tag autocomplete (suppressed at line start for headings)
│ │ ├── WikilinkSuggestion.ts # @tiptap/suggestion extension for [[wikilink]] autocomplete
│ │ └── suggestionRenderer.ts # Shared Vue renderer for suggestion dropdowns (creates/positions SuggestionDropdown)
│ ├── utils/
│ │ ├── 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
│ │ └── markdownSerializer.ts # Tiptap JSON → markdown serializer: handles all StarterKit nodes + marks
│ ├── views/
│ │ ├── LoginView.vue # Login form with error display, link to register
│ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled
│ │ ├── RegisterInviteView.vue # Invitation-based registration: validates token, creates account with pre-set email
│ │ ├── UserManagementView.vue # Admin user management: registration toggle, invitations (send/revoke), user list with delete
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context pills
│ │ ├── HomeView.vue # Actionable dashboard: overdue tasks, due today, in progress, recent chats, recently edited notes
│ │ ├── 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: Tiptap editor, sticky toolbar, AI Assist panel (bottom 1/3), Ctrl+S, unsaved guard
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks, table of contents sidebar
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination
│ │ ├── TaskEditorView.vue # Create/edit task: Tiptap editor, sticky toolbar, AI Assist panel, Ctrl+S, dirty guard
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note, backlinks, table of contents sidebar
│ ├── components/
│ │ ├── LogsView.vue # Admin log viewer: stats summary, category/search/date filters, paginated table with expandable detail rows
│ │ ├── AppHeader.vue # Nav bar: brand, nav links (incl. admin Logs), 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
│ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags
│ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
│ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none")
│ │ ├── MarkdownToolbar.vue # Tiptap command-based toolbar: bold/italic/link/list/heading with active state highlighting
│ │ ├── TiptapEditor.vue # Tiptap wrapper: markdown↔HTML round-trip, paste handling, selection change emit, expose editor
│ │ ├── SuggestionDropdown.vue # Shared autocomplete dropdown for tag/wikilink suggestions
│ │ ├── SearchBar.vue # Debounced search input
│ │ ├── TagPill.vue # Clickable/dismissible tag pill
│ │ ├── PaginationBar.vue # Prev/next + page numbers
│ │ ├── TableOfContents.vue # Sticky sidebar TOC: parses markdown headings, smooth-scroll on click, hidden ≤1200px
│ │ └── ToastNotification.vue # Fixed-position toast container with close button, warning support
│ └── router/
│ └── index.ts # Routes: /, /login, /register, /register-invite, /notes/*, /tasks/*, /chat, /chat/:id, /settings, /admin/users, /admin/logs; beforeEach auth guard
└── public/
```
## API Endpoints (Current)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/health` | Health check (public) |
| GET | `/api/auth/status` | Check if any users exist + registration open status (public) |
| POST | `/api/auth/register` | Register new user (first user becomes admin; returns 403 if registration closed) |
| POST | `/api/auth/login` | Login with username/password |
| POST | `/api/auth/logout` | Logout (clear session) |
| GET | `/api/auth/me` | Get current user info |
| PUT | `/api/auth/password` | Change password (body: `{current_password, new_password}`) |
| 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/admin/users` | List all users (admin only) |
| DELETE | `/api/admin/users/:id` | Delete a user (admin only, cannot delete self) |
| GET | `/api/admin/registration` | Get registration open/closed status (admin only) |
| PUT | `/api/admin/registration` | Toggle registration open/closed (admin only, body: `{open: bool}`) |
| GET | `/api/admin/logs` | List log entries (admin only, params: `category`, `user_id`, `search`, `date_from`, `date_to`, `limit`, `offset`) |
| GET | `/api/admin/logs/stats` | Get log category counts (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) |
| GET | `/api/notes/by-title?title=...` | Resolve note by exact title (case-insensitive) |
| POST | `/api/notes/resolve-title` | Get-or-create note by title (for wikilink clicks) |
| GET | `/api/notes/:id` | Get single note (response includes `is_task`, `status`, `priority`, `due_date`) |
| PUT | `/api/notes/:id` | Update note (body: `{title?, body?, status?, priority?, due_date?}` — tags re-extracted if body changes) |
| DELETE | `/api/notes/:id` | Delete note (simple delete, no cascade) |
| POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` on note (returns 200) |
| POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` from note (returns 200) |
| GET | `/api/notes/:id/backlinks` | List notes/tasks that reference this note via wikilinks |
| POST | `/api/notes/assist` | Launch background assist generation, return 202 (body: `{body, target_section, instruction}`; 409 if already running) |
| GET | `/api/notes/assist/stream` | SSE endpoint tailing assist generation buffer; supports `Last-Event-ID` reconnection; emits `chunk`, `done`, `error` events with 15s keepalives |
| GET | `/api/auth/invitation/:token` | Validate invitation token, returns `{valid, email?}` (public) |
| POST | `/api/auth/register-with-invite` | Register with invitation token (body: `{token, username, password}`) (public) |
| GET | `/api/admin/base-url` | Get application base URL setting (admin only) |
| PUT | `/api/admin/base-url` | Set application base URL (admin only, body: `{base_url}`) |
| POST | `/api/admin/invitations` | Create invitation (admin only, body: `{email}`) — sends email with registration link |
| GET | `/api/admin/invitations` | List pending invitations (admin only) |
| DELETE | `/api/admin/invitations/:id` | Revoke invitation (admin only) |
| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `sort`, `order`, `limit`, `offset`) — queries notes where `status IS NOT NULL` |
| POST | `/api/tasks` | Create task (body: `{title, body, status?, priority?, due_date?}` — accepts `description` as fallback for `body`) |
| GET | `/api/tasks/:id` | Get single task |
| PUT | `/api/tasks/:id` | Update task (accepts `body` or `description`, prefers `body`) |
| PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) |
| DELETE | `/api/tasks/:id` | Delete task (simple delete) |
| GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) |
| POST | `/api/chat/conversations` | Create conversation (body: `{title?, model?}`) |
| 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` | 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 |
| POST | `/api/chat/models/pull` | Pull/download a model from Ollama via SSE streaming progress (body: `{model}`, response: SSE with `{status, completed, total}` events) |
| POST | `/api/chat/models/delete` | Delete a model from Ollama (body: `{model}`) |
| GET | `/api/admin/smtp` | Get SMTP config (password masked) (admin only) |
| PUT | `/api/admin/smtp` | Save SMTP config to admin settings (admin only, body: `{smtp_host, smtp_port, ...}`) |
| POST | `/api/admin/smtp/test` | Send test email (admin only, body: `{recipient}`) |
| GET | `/api/settings` | Get all app settings as `{key: value}` dict |
| PUT | `/api/settings` | Update settings (body: `{key: value, ...}`) |
## Alembic Migrations
### Overview
Migrations use raw SQL for full idempotency — safe to run on a fresh database
or re-run on an existing one. The Dockerfile runs migrations automatically on
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 → 0008_add_users_and_user_id.py → 0009_add_message_status.py → 0010_add_app_logs_table.py → 0011_add_password_reset_tokens.py → 0012_add_invitation_tokens.py
```
### How Migrations Run
The Dockerfile CMD runs:
```sh
(alembic stamp --purge base 2>/dev/null || true) && alembic upgrade head && hypercorn ...
```
- `alembic stamp --purge base` clears any stale revision stamps (safe no-op if none)
- `alembic upgrade head` runs all pending migrations in order
- Migrations are idempotent so re-running is safe
### Creating a New Migration
When adding a new migration, follow these conventions:
1. **Create the migration file:**
```
alembic/versions/0005_description.py
```
2. **Use raw SQL for idempotency:**
```python
from alembic import op
revision = "0005"
down_revision = "0004"
def upgrade() -> None:
# For new enums:
op.execute("""
DO $$ BEGIN
CREATE TYPE my_enum AS ENUM ('a', 'b');
EXCEPTION WHEN duplicate_object THEN null;
END $$
""")
# For new tables:
op.execute("""
CREATE TABLE IF NOT EXISTS my_table (...)
""")
# For new indexes:
op.execute("CREATE INDEX IF NOT EXISTS ix_name ON table (col)")
# For adding columns:
op.execute("""
DO $$ BEGIN
ALTER TABLE my_table ADD COLUMN new_col TEXT DEFAULT '';
EXCEPTION WHEN duplicate_column THEN null;
END $$
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_name")
op.execute("DROP TABLE IF EXISTS my_table")
op.execute("DROP TYPE IF EXISTS my_enum")
```
3. **Rebuild and test:**
```bash
docker compose up --build
```
To test from a completely fresh database:
```bash
docker compose down -v && docker compose up --build
```
### Important Notes
- Do NOT use `op.create_table()` or `sa.Enum()` — SQLAlchemy's event system
can fire `CREATE TYPE` even with `create_type=False`, causing failures on re-run
- Always write raw SQL with PostgreSQL idempotency guards
- Set `down_revision` to chain from the previous migration's `revision`
- Data migrations (like 0003) should use `op.get_bind()` + `sa.text()` for queries
## Phased Roadmap
### Phase 1 — Skeleton & Dev Environment ✓
- [x] Initialize Python project (pyproject.toml, Quart app scaffold)
- [x] Initialize Vue.js project (Vite-based, inside `frontend/`)
- [x] Set up Dockerfile (multi-stage: build Vue, serve with Quart)
- [x] Docker Compose stack with Ollama service
- [x] Quart serves Vue static build + `/api/health` endpoint
- [x] Database setup (PostgreSQL 16, asyncpg, SQLAlchemy 2.0, Alembic)
### Phase 2 — Notes CRUD + UX ✓
- [x] Database model for notes (title, body, tags[], parent_id, timestamps)
- [x] REST API: create, read, update, delete, list notes with pagination
- [x] Vue views: note list, note editor (markdown), note viewer (rendered)
- [x] Search (ILIKE on title/body) and tag filtering (hierarchical via unnest)
- [x] Inline `#tag` extraction from body text (backend regex, skips code fences)
- [x] Hierarchical tag filtering (`#project` matches `project` and `project/*`)
- [x] Dark/light theming with CSS custom properties + toggle + localStorage
- [x] App header with navigation and theme toggle
- [x] Tag pills (clickable + dismissible) on cards, viewer, and list filter bar
- [x] Pagination bar with prev/next and page numbers
- [x] Sort controls (field + asc/desc)
- [x] Toast notifications (success/error, 3s auto-dismiss)
- [x] Ctrl+S save shortcut in editor
- [x] Unsaved changes guard (route leave + beforeunload)
- [x] DOMPurify sanitization on rendered markdown
- [x] Inline tag linkification in rendered markdown (clickable `#tag` links)
### Phase 3 — Tasks CRUD + Wikilinks ✓
- [x] Task model with status (todo/in_progress/done) and priority (none/low/medium/high) enums
- [x] Alembic migration for tasks table with PG enums and indexes
- [x] REST API: full CRUD + PATCH status toggle + filter by status/priority/tags
- [x] Vue views: task list (search, status/priority filters, sort, pagination), editor (Ctrl+S, dirty guard), viewer (rendered markdown)
- [x] StatusBadge (clickable, cycles status), PriorityBadge, TaskCard components
- [x] Obsidian-style wikilinks: `[[Title]]` and `[[Title|Display]]` in rendered markdown
- [x] Wikilink click handling resolves notes by title via `/api/notes/by-title`
### Phase 3.5 — Note-Task Integration + Bug Fixes ✓
- [x] **Wikilink auto-create:** Clicking `[[New Page]]` creates the note if it doesn't exist
- [x] **Backlinks system:** "What links here" section on note and task viewers
- [x] **Rendered markdown previews:** NoteCard/TaskCard show rendered markdown (links/images stripped)
- [x] **Tag autocomplete Tab cycling:** Tab cycles through suggestions, single match accepts immediately
- [x] **HomeView error isolation:** Notes and tasks load independently (one failure doesn't break both)
- [x] **SPA routing fix:** Replaced catch-all route with 404 error handler to prevent API interception
- [x] **500 error handler:** JSON error responses for API routes, traceback logging
- [x] **Idempotent migrations:** All migrations rewritten to raw SQL with IF NOT EXISTS guards
- [x] **Auto-migration on startup:** Dockerfile runs `alembic upgrade head` before starting app
### Phase 3.6 — Merge Tasks into Notes ✓
- [x] **Unified note/task model:** Task is just a note with `status IS NOT NULL`
- [x] **Migration 0004:** Added `status`, `priority`, `due_date` columns to notes table, migrated task data from companion notes and orphan tasks, dropped `tasks` table
- [x] **Eliminated companion note system:** No more companion note creation, title sync, cascade deletes, or `_skip_cascade` flags
- [x] **Standardized on `body`:** Tasks use `body` everywhere (not `description`); API accepts `description` as fallback
- [x] **Convert to task:** Simple `update_note(id, status='todo', priority='none')` — same ID preserved
- [x] **Convert to note:** New `POST /api/notes/:id/convert-to-note` endpoint clears task attributes
- [x] **Tasks service as thin wrappers:** `services/tasks.py` delegates entirely to `services/notes.py`
- [x] **Frontend unified types:** `Task` is a re-export alias for `Note`; `note.ts` defines `TaskStatus`, `TaskPriority`
- [x] **Simplified views:** Removed companion note UI from TaskEditorView/TaskViewerView/NoteViewerView; added Convert to Note button on TaskViewerView
### Phase 4 — LLM Chat Integration ✓
- [x] **Ollama client:** async HTTP via httpx — `ensure_model()` (auto-pull on startup), `stream_chat()` (streaming NDJSON), `generate_completion()` (non-streaming)
- [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 (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 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
- [x] **Configurable assistant name:** Default "Fable", editable in settings, injected into LLM system prompt and chat message labels
- [x] **Settings page:** `/settings` route with assistant name form + model catalog
- [x] **Model catalog:** 18 models across 3 categories (General Purpose, Coding, Uncensored / Creative Writing) with descriptions, sizes, and best-for labels
- [x] **Model management:** Download (pull with SSE progress streaming + live percentage in UI), select (set as default), and remove (delete from Ollama) with confirmation UI
- [x] **Status indicator in nav bar:** Moved from per-component (ChatView/ChatPanel) to global AppHeader; green/yellow/red dot with label
- [x] **Chat bubble layout:** User messages right-aligned (primary color bg), assistant messages left-aligned (card bg), speech bubble tails
- [x] **Floating dark input bar:** `#1c1c1e` background, rounded corners, circular send button with arrow
- [x] **Auto-focus input:** Chat input auto-focuses on mount, conversation switch, and after sending
- [x] **HTML entity fix:** `linkifyTags` regex now uses `(?<!&)` lookbehind to avoid matching `#39` inside `&#39;` as a tag. Additional layers: decode entities before marked, DOMPurify sanitization, explicit `&#39;` → `'` replacement after sanitization
- [x] **New chat navigation fix:** `fetchConversation()` called before `router.push()` so `currentConversation` is set immediately
- [x] **Recent chats on home page:** 3 most recent conversations shown as clickable cards + "New Chat" button
### Phase 4.6 — Improved Note Context in Chat ✓
- [x] **Multi-word search:** `list_notes()` splits multi-word queries into per-term ILIKE filters with AND logic (each word matched independently, not adjacent)
- [x] **Context metadata:** `build_context()` returns `(messages, context_meta)` tuple with auto-found note IDs/titles and attached note info
- [x] **Fuller auto-context:** Auto-found note previews increased from 300 to 2000 chars; uses all 5 extracted keywords instead of 3
- [x] **Context SSE event:** Backend emits `{context: ContextMeta}` SSE event before streaming LLM response chunks
- [x] **Exclude note IDs:** Backend accepts `exclude_note_ids` in request body, skips those notes during auto-search
- [x] **Context pills:** ChatView and ChatPanel show auto-found notes as pills above streaming response with note title as router-link
- [x] **Promote/exclude controls:** "+" button on context pills promotes note to attached context for next message; "x" button excludes note from future auto-searches in the session
- [x] **Note picker:** Paperclip button in chat input opens dropdown with debounced search, selecting a note attaches it as context (shown as pill above input)
- [x] **ContextMeta type:** New TypeScript interface for context metadata (`context_note_id`, `context_note_title`, `auto_notes`)
- [x] **Session-scoped excludes:** Excluded note IDs tracked per conversation session, cleared on conversation switch
### Phase 4.7 — Styling Consistency Pass ✓
- [x] **Design tokens:** Added `--radius-sm/md/lg/pill`, `--color-success/warning/overlay`, `--focus-ring` to theme.css
- [x] **Header alignment fix:** Removed `max-width: 960px` from header nav — now full-width with consistent padding, matching modern app layout
- [x] **Active nav link state:** Added `router-link-active` styling with primary color + card background
- [x] **Hardcoded colors replaced:** Status indicator dots, settings saved message, remove/confirm-delete buttons now use CSS variables
- [x] **Border-radius standardized:** All components migrated from mixed 3px/4px/6px/8px to `var(--radius-sm)` (6px) for controls, `var(--radius-md)` (8px) for cards/containers
- [x] **Button padding standardized:** All primary/CTA buttons use `0.45rem 1rem`; small buttons use `0.3rem 0.75rem`
- [x] **Settings max-width fixed:** Changed from 700px to 720px to match all other views
- [x] **Focus-visible states:** Global `focus-visible` rule with `--focus-ring` shadow on inputs, textareas, selects, buttons
- [x] **Modal overlays:** Replaced hardcoded `rgba(0,0,0,0.5)` with `var(--color-overlay)` across all components
### Phase 4.8 — Backend Efficiency & Consistency Pass ✓
- [x] **`get_all_tags()` SQL rewrite:** Replaced O(n) Python-side tag extraction with single `SELECT DISTINCT unnest(tags)` query
- [x] **`list_conversations()` subquery count:** Replaced `selectinload(Conversation.messages)` with correlated subquery for `message_count`, avoiding loading all messages for list view
- [x] **Removed `services/tasks.py`:** Eliminated 64-line pass-through service; `routes/tasks.py` imports directly from `services.notes`
- [x] **Consolidated convert functions:** `convert_note_to_task()` and `convert_task_to_note()` now use single session (was 3 DB round trips)
- [x] **Batch settings updates:** New `set_settings_batch()` does all upserts in single transaction (was one transaction per setting)
- [x] **Shared `get_installed_models()`:** Extracted duplicate Ollama model-listing logic from 3 locations into `services/llm.py`
- [x] **`search_notes_for_context()`:** New function does single OR-keyword query (was up to 5 sequential `list_notes()` calls each with count query)
- [x] **Logging:** Added `logger = logging.getLogger(__name__)` to `services/notes.py`, `services/chat.py`, `services/settings.py`
- [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 ✓
- [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`
### Phase 5.2 — Tiptap Inline-Preview Editor + Layout Improvements ✓
- [x] **Tiptap WYSIWYG editor:** Replaced plain textarea with Tiptap (ProseMirror-based) inline-preview editor — headings, bold, italic, lists, code blocks render inline while editing
- [x] **Markdown round-trip:** Load markdown → HTML (via `marked`) → Tiptap editing → markdown (via custom `serializeToMarkdown()`) on every change
- [x] **Tag/wikilink decorations:** ProseMirror decoration plugins visually highlight `#tag` and `[[wikilink]]` as colored badges without custom node types
- [x] **Autocomplete migration:** Moved from textarea-based `useAutocomplete` to `@tiptap/suggestion` framework with shared `SuggestionDropdown` component
- [x] **Heading vs tag disambiguation:** Tag suggestion suppressed at start of paragraphs (where `#` is likely a heading prefix)
- [x] **Markdown paste handling:** Pasted markdown text auto-detected and converted to formatted content
- [x] **Toolbar refactor:** `MarkdownToolbar` uses Tiptap commands with active state highlighting instead of textarea text insertion
- [x] **Sticky toolbar:** Toolbar, title, tabs pinned above scrolling editor content
- [x] **App shell layout:** Navbar always visible — `App.vue` uses flex column at 100dvh with header + content areas; all views fit below header
- [x] **AI Assist panel:** Fixed at bottom 1/3 of viewport; section selector and prompt fill the panel; controls hidden during streaming/review (only output + accept/reject shown)
- [x] **Auto-syncing sections:** `useAssist` watches `body` ref to keep section list in sync on load, typing, and AI accept
- [x] **Accept trailing newline:** Accepted AI suggestions always end with a newline for clean separation
- [x] **Wider content area:** Max-width increased from 720px to 960px across all views
### Phase 5.3 — Registration Control, User Management, Security Hardening ✓
- [x] **Registration auto-closes:** After the first user registers (admin), registration is closed by default
- [x] **Registration toggle:** Admin can open/close registration from `/admin/users`
- [x] **Password confirmation:** Register form requires password confirmation with inline validation
- [x] **Admin user management:** `/admin/users` view with user list and delete (admin only)
- [x] **Session cookie hardening:** `HttpOnly`, `SameSite=Lax` always on; `Secure` via `SECURE_COOKIES` env var
- [x] **Default SECRET_KEY warning:** Logs WARNING on startup if default key is in use
- [x] **Password change:** `PUT /api/auth/password` endpoint + Settings UI section
- [x] **Production deployment docs:** README documents reverse proxy, rate limiting, CSP headers, SECRET_KEY, registration behavior
### Phase 5.4 — Application Logging System ✓
- [x] **Single `app_logs` table:** Unified audit/usage/error logging with `category` field
- [x] **Usage logging middleware:** `after_request` logs all `/api/*` requests with user, endpoint, method, status, duration (skips `/api/admin/logs` to avoid recursion)
- [x] **Error logging:** `handle_500` captures error type, message, and traceback
- [x] **Audit logging:** Security events logged in auth routes (register, login, login_failed, logout, password_change) and admin routes (backup, restore, user_delete, registration_toggle)
- [x] **Denormalized username:** Preserves attribution after user deletion (`user_id` FK uses `ON DELETE SET NULL`)
- [x] **Admin log viewer:** `/admin/logs` with stats summary, category/search/date filters, paginated table with expandable JSON detail rows
- [x] **Log stats endpoint:** `GET /api/admin/logs/stats` returns category counts
- [x] **Configurable retention:** `LOG_RETENTION_DAYS` env var (default 90), hourly asyncio cleanup task
- [x] **Config:** `LOG_RETENTION_DAYS` added to `Config` class
### Phase 5.5 — SMTP Email Notifications ✓
- [x] **SMTP email service:** `aiosmtplib` for async email sending, supports STARTTLS (587) and implicit TLS (465)
- [x] **SMTP config in DB:** Admin configures SMTP via Settings UI, stored in `settings` table; env var fallbacks for Docker Swarm bootstrap
- [x] **Admin SMTP endpoints:** `GET/PUT /api/admin/smtp` for config management, `POST /api/admin/smtp/test` for sending test emails
- [x] **Security alert notifications:** Fire-and-forget `asyncio.create_task()` on login, failed login, logout, password change
- [x] **Task due date reminders:** Hourly background loop checks for due/overdue tasks, sends grouped email per user; dedup via `app_logs` check
- [x] **Per-user notification preferences:** `notify_task_reminders` and `notify_security_alerts` settings (default enabled)
- [x] **Settings UI:** Notifications section for all users (checkbox toggles), SMTP section for admin (2-column grid + test email)
### Phase 5.6 — Assist Background-Task + Buffer Architecture ✓
- [x] **Assist buffer helpers:** `create_assist_buffer()`, `get_assist_buffer()`, `remove_assist_buffer()` using `"assist:{user_id}"` string keys in shared `_buffers` registry
- [x] **`run_assist_generation()`:** Lightweight background task — streams from Ollama into buffer, no DB persistence, no title generation, no cancellation
- [x] **Two-step assist API:** `POST /api/notes/assist` returns 202 + launches background task; `GET /api/notes/assist/stream` SSE endpoint tails buffer with 15s keepalives and `Last-Event-ID` reconnection
- [x] **409 conflict guard:** Rejects concurrent assist requests per user
- [x] **Frontend two-step pattern:** `useAssist.ts` calls `apiPost()` then `apiSSEStream()` with named event mapping (`chunk`/`done`/`error`); tracks stream handle for cleanup
- [x] **Fixes `NS_ERROR_NET_PARTIAL_TRANSFER`:** Keepalive pings prevent browser/Hypercorn from closing connection during gaps between LLM chunks
### Phase 5.7 — Invitation System ✓
- [x] **Invitation tokens table:** `invitation_tokens` (migration 0012) — SHA256-hashed tokens, 7-day expiry, one-time use
- [x] **Admin invitation management:** `POST/GET/DELETE /api/admin/invitations` for create/list/revoke
- [x] **Invitation email:** Branded HTML email with accept link, sent via fire-and-forget asyncio task
- [x] **Invitation registration flow:** `GET /api/auth/invitation/:token` validates, `POST /api/auth/register-with-invite` creates account
- [x] **Frontend:** `/register-invite` view with token validation, email pre-fill, username/password form
- [x] **Admin UI:** Invite User section in UserManagementView with email input, pending invitations table, revoke action
- [x] **Base URL admin setting:** Configurable via Settings UI; used in invitation and password reset email links (replaces `Config.BASE_URL` hardcoded fallback)
### Phase 5.8 — Table of Contents + Markdown Improvements ✓
- [x] **TableOfContents component:** Sticky sidebar on note/task viewer — parses markdown headings, slugified IDs, smooth-scroll click, hidden on screens ≤1200px
- [x] **Heading IDs in rendered markdown:** Custom `marked` renderer adds `id` attributes to headings for TOC anchor links
- [x] **First-line tag stripping:** `stripFirstLineTags()` removes leading `#tag` lines from markdown before rendering (prevents tags from rendering as headings)
- [x] **Viewer layout:** NoteViewerView and TaskViewerView use flex layout with main content + TOC sidebar (max-width 1200px)
- [x] **Model catalog refresh:** Updated to current models (llama3.2/3.3, gemma3, qwen3, phi4, deepseek-r1, qwen2.5-coder, dolphin3), added Reasoning category, removed discontinued models
- [x] **Settings model tabs:** Split model section into "Installed" and "Available" tabs
### Phase 5.9 — Actionable "Today" Dashboard ✓
- [x] **Due date filtering API:** Added `due_before` (exclusive `<`) and `due_after` (inclusive `>=`) params to `list_notes()` and `/api/tasks` endpoint
- [x] **Dashboard rewrite:** HomeView now shows Overdue (red left-border accent), Due Today, In Progress, Recent Chats, Recently Edited sections
- [x] **Parallel fetching:** 5 API calls via `Promise.allSettled` — notes, overdue tasks, due-today tasks, in-progress tasks, chats
- [x] **Client-side filtering:** Filters out `done` tasks from overdue/due-today, deduplicates in-progress against other lists
- [x] **Task sections hidden when empty:** Only shown if there are matching tasks
- [x] **Status toggle:** Marking a task `done` removes it from all dashboard lists
### Future / Stretch
- Tagging/labeling system with LLM-suggested tags
- Calendar/timeline view for tasks
- Import/export (Markdown files, JSON)
- Plugin/extension system
## Development Workflow
- All development and testing done via Docker: `docker compose up --build`
- No local dependency installation — everything containerized
- Frontend dev: Vite dev server with proxy to Quart (via Docker)
- Production build: Dockerfile multi-stage — Vite builds Vue into static files,
Quart serves them
- To reset database: `docker compose down -v && docker compose up --build`
## Current Status
**Phase:** Phase 5.9 complete. Invitation system, table of contents, actionable dashboard, settings improvements.
- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags
- **Tiptap WYSIWYG editor** with inline formatting preview, markdown round-trip, paste handling
- **Tag/wikilink autocomplete** via `@tiptap/suggestion` with heading disambiguation
- Unified note/task model (a task is a note with `status IS NOT NULL`)
- **Multi-user authentication** with session cookies, bcrypt passwords, admin role
- **Per-user data isolation** across notes, conversations, and settings
- LLM chat via Ollama with background generation task + SSE streaming
- **AI Assist panel** pinned to bottom 1/3 of editor viewport with auto-syncing sections; uses background-task + buffer architecture with keepalive SSE (same pattern as chat)
- 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 (installed/available tabs), password change, data export/restore (admin)
- **Invitation system**: admin sends email invitations, recipients register via token-based `/register-invite` flow
- **Registration control**: auto-closes after first user, admin toggle, invitation-based registration
- **Admin user management**: `/admin/users` with user list, delete, invite, revoke
- **Session cookie hardening**: HttpOnly, SameSite=Lax, optional Secure flag
- **Actionable dashboard**: HomeView shows overdue/due-today/in-progress task sections (hidden when empty), recent chats, recently edited notes
- **Table of contents**: Sticky sidebar on note/task viewers, auto-generated from markdown headings
- **App-wide layout fix**: navbar always visible, all views fit within viewport
- **Responsive design**: hamburger menu, mobile touch targets, responsive breakpoints
- **Docker Swarm production stack** with secrets, network isolation, health checks, resource limits
- **Application logging**: audit (security events), usage (API requests), error (unhandled exceptions)
- **Admin log viewer**: `/admin/logs` with stats, filters, pagination, expandable detail rows
- **Automatic log retention**: configurable via `LOG_RETENTION_DAYS` (default 90 days)
- **SMTP email notifications**: security alerts (login/logout/failed login/password change), task due date reminders, invitation emails
- **Admin SMTP configuration**: Settings UI with test email, DB-stored config with env var fallbacks
- **Admin base URL setting**: Configurable public URL used in email links
- **Per-user notification preferences**: toggle task reminders and security alerts independently
- **Password reset flow**: Email-based with SHA256-hashed tokens, 1-hour expiry
- Dark/light theme with CSS custom properties and design tokens