Files
FabledScribe/summary.md
T
bvandeusen 953eaf2feb Add model selection, dashboard chat input, and model warming
- Add GET /api/chat/ps and POST /api/chat/warm endpoints for hot model
  visibility and pre-loading
- Extend PATCH /api/chat/conversations/:id to accept model in addition
  to title
- Add ModelSelector component with hot/cold indicators from Ollama /api/ps
- Add DashboardChatInput component (model selector + note picker + textarea)
  replacing the simple "New Chat" button on the dashboard
- Add model selector dropdown to ChatView header, persisted per-conversation
- Warm default model on dashboard mount via fire-and-forget background task
- Configure Ollama with OLLAMA_MAX_LOADED_MODELS=2 and OLLAMA_KEEP_ALIVE=30m
- Always-visible edit buttons on NoteCard/TaskCard (remove hover-only behavior)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 18:49:06 -05:00

42 KiB

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-14 — Phase 6.0: Model selection, dashboard chat input, model warming

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, running models, model warming, updateConversationModel (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, RunningModel, 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, model selector in header
    │   │   ├── HomeView.vue     # Actionable dashboard: overdue, due today, due this week, high priority, in progress tasks; recent chats with inline chat input; recently edited notes; model warming on mount
    │   │   ├── 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
    │   │   ├── ModelSelector.vue # Model dropdown (v-model pattern): fetches installed + running models, hot/cold indicators
    │   │   ├── DashboardChatInput.vue # Inline chat bar: ModelSelector + note picker + textarea + send button; emits submit event
    │   │   ├── 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, hover edit button
    │   │   ├── TaskCard.vue     # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags, hover edit button
    │   │   ├── 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 or model (body: {title?, model?})
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/ps List currently loaded (hot) Ollama models ({models: [{name, size, size_vram, expires_at}]})
POST /api/chat/warm Pre-load a model into Ollama memory (body: {model}, returns 202)
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:

(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:

    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:

    docker compose up --build
    

    To test from a completely fresh database:

    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

Implemented Features

Notes & Tasks

  • Full CRUD with markdown bodies, Obsidian-style #tag extraction (skips code fences), hierarchical tag filtering
  • Unified data model: a task is a note with status IS NOT NULL — convert freely between note ↔ task
  • Task attributes: status (todo/in_progress/done), priority (none/low/medium/high), due_date
  • Obsidian-style [[wikilinks]] with auto-create on click, backlinks ("what links here")
  • Tiptap WYSIWYG editor with markdown round-trip, tag/wikilink autocomplete and decorations, sticky toolbar
  • AI Assist panel in editor: background LLM generation via SSE with section targeting, accept/reject
  • Table of contents sidebar on note/task viewers (auto-generated from headings, hidden ≤1200px)
  • Inline edit buttons on NoteCard/TaskCard (hover on desktop, always visible on touch)
  • Search (ILIKE), sort, tag filter pills, pagination on list views

Dashboard

  • Actionable home page with 5 task sections: Overdue, Due Today, Due This Week, High Priority, In Progress
  • Priority-aware sorting (priority desc → due date asc) within each section
  • Cascading deduplication — tasks appear only in their highest-priority section
  • due_before/due_after query params on /api/tasks for date-range filtering
  • Recent chats and recently edited notes sections
  • All task sections hidden when empty; marking done removes from all lists

LLM Chat

  • Ollama integration via async HTTP (httpx), auto-pull default model on startup
  • Background generation with GenerationBuffer (in-memory SSE fan-out, Last-Event-ID reconnect, 60s cleanup)
  • Stop generation with partial content preservation
  • Note-aware context building: current note + keyword search for related notes + URL fetching
  • Context pills with promote/exclude controls; note picker (paperclip) in chat input
  • LLM-generated conversation titles (re-generated every 10th message)
  • Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations
  • Dedicated /chat page with responsive sidebar + slide-out chat panel from header
  • Model catalog with installed/available tabs, download progress, select, remove
  • Per-conversation model selection via ModelSelector dropdown in chat header (persisted via PATCH)
  • Dashboard inline chat input: model selector + note picker + textarea; creates conversation and navigates
  • Model warming: default model pre-loaded into Ollama on dashboard mount via fire-and-forget POST
  • Hot/cold model indicators: /api/chat/ps proxies Ollama /api/ps; ModelSelector shows filled/empty circles
  • Ollama configured with OLLAMA_MAX_LOADED_MODELS=2 and OLLAMA_KEEP_ALIVE=30m

Authentication & User Management

  • Session cookie auth with bcrypt, first-user-is-admin, orphaned data claiming
  • Per-user data isolation across all resources
  • Registration auto-closes after first user; admin toggle; invitation-based registration
  • Email invitation system: admin sends invite → branded email → /register-invite token flow
  • Password reset: email-based, SHA256-hashed tokens, 1-hour expiry
  • Session cookie hardening: HttpOnly, SameSite=Lax, optional Secure flag
  • Admin user management: list, delete, invite, revoke

Notifications & Email

  • SMTP email service (aiosmtplib): STARTTLS (587) and implicit TLS (465)
  • Security alerts: login, failed login, logout, password change (fire-and-forget)
  • Task due date reminders: hourly background check, grouped per user, dedup via logs
  • Invitation and password reset emails with branded HTML templates
  • Per-user notification preferences (task reminders, security alerts)
  • Admin SMTP configuration via Settings UI with test email

Logging & Observability

  • Single app_logs table: audit (security events), usage (API requests), error (unhandled exceptions)
  • Request logging middleware with timing (skips log endpoints to avoid recursion)
  • Admin log viewer: stats summary, category/search/date filters, paginated table, expandable JSON details
  • Configurable retention via LOG_RETENTION_DAYS (default 90), hourly cleanup

Settings & Admin

  • Per-user key-value settings store (assistant name, default model, notification prefs)
  • Admin: backup/restore (full or per-user JSON), SMTP config, base URL, registration toggle, user management, log viewer
  • Configurable base URL for email links (admin setting, env var fallback)

UI & Theming

  • Dark/light theme with CSS custom properties, design tokens, prefers-color-scheme detection
  • Responsive design: breakpoints (480/768/1024), hamburger menu, mobile touch targets (44px), sidebar overlays
  • App shell: navbar always visible, 100dvh flex layout, all views fit within viewport
  • Toast notifications (success/error/warning, 4s auto-dismiss)
  • DOMPurify sanitization on all rendered markdown

Infrastructure

  • Multi-stage Dockerfile: Node build → Python runtime, auto-migration on startup
  • Docker Compose (dev) + Docker Swarm production stack with secrets, overlay networks, health checks
  • Quart serves Vue SPA from static + REST API under /api/; 404 handler for SPA routing
  • Alembic migrations: 12 migrations, all raw SQL with idempotency guards (IF NOT EXISTS, DO $$ BEGIN...EXCEPTION)
  • Config from env vars + Docker secrets file support (_read_secret)

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

Backlog

  • Tagging/labeling system with LLM-suggested tags
  • Calendar/timeline view for tasks
  • Import/export (Markdown files, JSON)
  • Application-level rate limiting on auth endpoints
  • Security headers middleware (CSP, X-Frame-Options, etc.) — currently handled at reverse proxy level
  • Session invalidation on user deletion