Files
FabledScribe/summary.md
T
bvandeusen fb18d2c41d Add note context visibility in chat and standardize UI design tokens
Improve chat context: build_context() now returns metadata about auto-found
notes, emitted as an SSE event so the frontend can display context pills
showing which notes influenced the response. Users can promote notes for
deeper context (+) or exclude irrelevant ones (x). A note picker lets users
manually attach notes. Multi-word search uses per-term AND matching, and
auto-search iterates keywords individually for broader OR-style coverage.

Standardize styling: introduce CSS design tokens (--radius-sm/md/lg/pill,
--color-success/warning/overlay, --focus-ring) and migrate all components
to use them. Fix header alignment to full-width, add active nav link state,
replace hardcoded colors with CSS variables, and normalize button padding.

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

37 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-10 — Phase 4.7: Styling consistency pass — design tokens, header alignment, standardized UI

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.
  • SSE over WebSockets for LLM streaming: Uses POST with text/event-stream response (not EventSource). Frontend uses fetch() + ReadableStream since we need to POST the message body. Simpler than WebSockets; Quart supports async generators natively for SSE.
  • 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).
  • 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.

High-Level Component Diagram

┌─────────────────────────────────────────────┐
│              Docker Compose                  │
│                                              │
│  ┌──────────────────────┐   ┌────────────┐  │
│  │  fabledassistant     │   │  ollama     │  │
│  │  ┌────────────────┐  │   │            │  │
│  │  │  Quart Server   │  │   │  LLM API   │  │
│  │  │  ┌──────────┐  │  │   │            │  │
│  │  │  │ Vue SPA  │  │  │   └────────────┘  │
│  │  │  │ (static) │  │  │         ▲         │
│  │  │  └──────────┘  │  │         │         │
│  │  │  ┌──────────┐  │  │    HTTP/REST      │
│  │  │  │ /api/*   │──┼──┼─────────┘         │
│  │  │  └──────────┘  │  │                   │
│  │  │       │        │  │  ┌────────────┐   │
│  │  │       ▼        │  │  │ PostgreSQL │   │
│  │  │  ┌──────────┐  │  │  │    16      │   │
│  │  │  │ asyncpg  │──┼──┼──▶            │   │
│  │  │  └──────────┘  │  │  └────────────┘   │
│  │  └────────────────┘  │                   │
│  └──────────────────────┘                   │
└─────────────────────────────────────────────┘

Data Model

Notes (unified — includes tasks)

  • id (int PK), title (str), body (markdown str), tags (ARRAY[str]), parent_id (nullable FK to self), status (nullable str — todo/in_progress/done), 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

Task ≡ Note with task attributes

  • No separate tasks table. The services/tasks.py module is a thin wrapper around services/notes.py that passes 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

  • key (text PK), value (text)
  • Key-value store for app-wide settings (assistant name, default model, etc.)
  • CRUD via services/settings.py: get_setting(key, default), set_setting(key, value), get_all_settings()

Conversations

  • id (int PK), title (str), model (str), created_at, updated_at
  • Has many messages (cascade delete)
  • Title auto-generated from first user message if not set
  • to_dict() returns: id, title, model, message_count, created_at, updated_at

Messages

  • id (int PK), conversation_id (FK to conversations, CASCADE), role (str: system/user/assistant), content (str), context_note_id (nullable FK to notes, SET NULL), created_at
  • Index on conversation_id
  • context_note_id tracks which note was attached as context when message was sent
  • to_dict() returns: id, conversation_id, role, content, context_note_id, created_at

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)
├── 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
├── src/
│   └── fabledassistant/
│       ├── __init__.py
│       ├── app.py               # Quart app factory: SPA via 404 handler, JSON 404/500 for API
│       ├── config.py            # Config from env vars
│       ├── models/
│       │   ├── __init__.py      # async_session factory, Base, imports Note + TaskStatus + TaskPriority + Conversation + Message + Setting
│       │   ├── note.py          # Note model (unified: id, title, body, tags[], parent_id, status, priority, due_date, timestamps) + TaskStatus/TaskPriority enums + is_task property
│       │   ├── conversation.py  # Conversation + Message models with relationships and to_dict()
│       │   └── setting.py       # Setting model (key TEXT PK, value TEXT)
│       ├── routes/
│       │   ├── __init__.py
│       │   ├── api.py           # /api blueprint with /health endpoint
│       │   ├── chat.py          # /api/chat blueprint: conversations CRUD, SSE message streaming, save/summarize as note, models list/pull/delete, status check
│       │   ├── notes.py         # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /convert-to-note + /backlinks
│       │   ├── tasks.py         # /api/tasks CRUD + PATCH status (thin wrappers, accepts body not description)
│       │   └── settings.py      # /api/settings GET/PUT — app-wide settings
│       ├── services/
│       │   ├── notes.py         # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks
│       │   ├── tasks.py         # Thin wrappers around notes.py (create_task, list_tasks with is_task=True, etc.)
│       │   ├── llm.py           # Ollama interaction: ensure_model, stream_chat, generate_completion, fetch_url_content, build_context (keyword extraction, note search with 5 keywords + 2000-char previews, exclude_note_ids support, returns (messages, context_meta) tuple, URL fetching, configurable assistant name)
│       │   ├── chat.py          # Conversation CRUD, add_message, save_response_as_note, summarize_conversation_as_note
│       │   └── settings.py      # Settings CRUD: get_setting, set_setting, get_all_settings
│       ├── 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: AppHeader + router-view + ChatPanel (slide-out) + ToastNotification; starts status polling + loads settings on mount
    │   ├── main.ts              # App init, imports theme.css
    │   ├── assets/
    │   │   └── theme.css        # CSS custom properties: light/dark themes, body reset, design tokens (radius, semantic colors, focus ring, overlay), global focus-visible styles
    │   ├── api/
    │   │   └── client.ts        # apiGet/apiPost/apiPut/apiPatch/apiDelete + apiStreamPost (SSE via fetch + ReadableStream)
    │   ├── composables/
    │   │   ├── useTheme.ts      # Theme toggle, localStorage, prefers-color-scheme
    │   │   └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search
    │   ├── stores/
    │   │   ├── notes.ts         # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags
    │   │   ├── tasks.ts         # CRUD + status/priority filter, patchStatus (uses body not description)
    │   │   ├── chat.ts          # Conversation CRUD, sendMessage (SSE streaming, handles context event, accepts excludeNoteIds), lastContextMeta ref, saveMessageAsNote, summarizeAsNote, fetchModels, status polling (ollamaStatus, modelStatus, chatReady)
    │   │   ├── settings.ts      # App settings: assistantName, defaultModel, fetchInstalledModels, pullModel (SSE streaming with onProgress callback), deleteModel
    │   │   └── toast.ts         # Toast notification state, 3s auto-dismiss
    │   ├── types/
    │   │   ├── 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
    │   ├── 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
    │   ├── views/
    │   │   ├── ChatView.vue     # Dedicated /chat page: sidebar + bubble-style messages (user right, assistant left) + floating dark input bar + auto-focus + note picker + context pills (auto-found notes with promote/exclude) + exclude tracking
    │   │   ├── HomeView.vue     # Landing page: recent notes + tasks + recent chats (3 most recent) + new chat button
    │   │   ├── SettingsView.vue # Settings page: assistant name config + model catalog (18 models in 3 categories) with download/select/remove
    │   │   ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
    │   │   ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts, autocomplete
    │   │   ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task (only when !is_task), backlinks
    │   │   ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination, status toggle
    │   │   ├── TaskEditorView.vue # Create/edit task: fields (body not description), Ctrl+S, dirty guard, autocomplete
    │   │   └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note button, backlinks
    │   ├── components/
    │   │   ├── AppHeader.vue    # Nav bar: brand, Notes + Tasks + Chat + Settings links, status indicator dot, chat panel toggle, theme toggle
    │   │   ├── 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 # Bold/italic/link/list/heading toolbar for editor
    │   │   ├── SearchBar.vue    # Debounced search input
    │   │   ├── TagPill.vue      # Clickable/dismissible tag pill
    │   │   ├── PaginationBar.vue # Prev/next + page numbers
    │   │   └── ToastNotification.vue # Fixed-position toast container
    │   └── router/
    │       └── index.ts         # Routes: /, /notes/*, /tasks/*, /chat, /chat/:id, /settings
    └── public/

API Endpoints (Current)

Method Path Description
GET /api/health Health check
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
GET /api/tasks List tasks (params: q, tag, status, priority, 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 Send message + stream LLM response via SSE (body: {content, context_note_id?, exclude_note_ids?}); emits {context: ContextMeta} SSE event before streaming chunks
POST /api/chat/messages/:id/save-as-note Save assistant message as a new note
POST /api/chat/conversations/:id/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/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

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

Phased Roadmap

Phase 1 — Skeleton & Dev Environment ✓

  • Initialize Python project (pyproject.toml, Quart app scaffold)
  • Initialize Vue.js project (Vite-based, inside frontend/)
  • Set up Dockerfile (multi-stage: build Vue, serve with Quart)
  • Docker Compose stack with Ollama service
  • Quart serves Vue static build + /api/health endpoint
  • Database setup (PostgreSQL 16, asyncpg, SQLAlchemy 2.0, Alembic)

Phase 2 — Notes CRUD + UX ✓

  • Database model for notes (title, body, tags[], parent_id, timestamps)
  • REST API: create, read, update, delete, list notes with pagination
  • Vue views: note list, note editor (markdown), note viewer (rendered)
  • Search (ILIKE on title/body) and tag filtering (hierarchical via unnest)
  • Inline #tag extraction from body text (backend regex, skips code fences)
  • Hierarchical tag filtering (#project matches project and project/*)
  • Dark/light theming with CSS custom properties + toggle + localStorage
  • App header with navigation and theme toggle
  • Tag pills (clickable + dismissible) on cards, viewer, and list filter bar
  • Pagination bar with prev/next and page numbers
  • Sort controls (field + asc/desc)
  • Toast notifications (success/error, 3s auto-dismiss)
  • Ctrl+S save shortcut in editor
  • Unsaved changes guard (route leave + beforeunload)
  • DOMPurify sanitization on rendered markdown
  • Inline tag linkification in rendered markdown (clickable #tag links)
  • Task model with status (todo/in_progress/done) and priority (none/low/medium/high) enums
  • Alembic migration for tasks table with PG enums and indexes
  • REST API: full CRUD + PATCH status toggle + filter by status/priority/tags
  • Vue views: task list (search, status/priority filters, sort, pagination), editor (Ctrl+S, dirty guard), viewer (rendered markdown)
  • StatusBadge (clickable, cycles status), PriorityBadge, TaskCard components
  • Obsidian-style wikilinks: [[Title]] and [[Title|Display]] in rendered markdown
  • Wikilink click handling resolves notes by title via /api/notes/by-title

Phase 3.5 — Note-Task Integration + Bug Fixes ✓

  • Wikilink auto-create: Clicking [[New Page]] creates the note if it doesn't exist
  • Backlinks system: "What links here" section on note and task viewers
  • Rendered markdown previews: NoteCard/TaskCard show rendered markdown (links/images stripped)
  • Tag autocomplete Tab cycling: Tab cycles through suggestions, single match accepts immediately
  • HomeView error isolation: Notes and tasks load independently (one failure doesn't break both)
  • SPA routing fix: Replaced catch-all route with 404 error handler to prevent API interception
  • 500 error handler: JSON error responses for API routes, traceback logging
  • Idempotent migrations: All migrations rewritten to raw SQL with IF NOT EXISTS guards
  • Auto-migration on startup: Dockerfile runs alembic upgrade head before starting app

Phase 3.6 — Merge Tasks into Notes ✓

  • Unified note/task model: Task is just a note with status IS NOT NULL
  • Migration 0004: Added status, priority, due_date columns to notes table, migrated task data from companion notes and orphan tasks, dropped tasks table
  • Eliminated companion note system: No more companion note creation, title sync, cascade deletes, or _skip_cascade flags
  • Standardized on body: Tasks use body everywhere (not description); API accepts description as fallback
  • Convert to task: Simple update_note(id, status='todo', priority='none') — same ID preserved
  • Convert to note: New POST /api/notes/:id/convert-to-note endpoint clears task attributes
  • Tasks service as thin wrappers: services/tasks.py delegates entirely to services/notes.py
  • Frontend unified types: Task is a re-export alias for Note; note.ts defines TaskStatus, TaskPriority
  • Simplified views: Removed companion note UI from TaskEditorView/TaskViewerView/NoteViewerView; added Convert to Note button on TaskViewerView

Phase 4 — LLM Chat Integration ✓

  • Ollama client: async HTTP via httpx — ensure_model() (auto-pull on startup), stream_chat() (streaming NDJSON), generate_completion() (non-streaming)
  • 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
  • Conversation persistence: conversations + messages tables in PostgreSQL, full CRUD
  • SSE streaming endpoint: POST /api/chat/conversations/:id/messages streams LLM response chunks as SSE events, saves complete response to DB on finish
  • Save as note: Save any assistant message as a new note (first line as title)
  • Summarize conversation: Send full conversation to LLM with summarize prompt, save result as note
  • Model management: GET /api/chat/models lists available Ollama models; auto-pull default model (llama3.1) on app startup
  • Dedicated chat page: /chat with sidebar (conversation list, create/delete) + message thread + markdown rendering + streaming indicator
  • Slide-out chat panel: Toggle from header, receives contextNoteId from current route (/notes/:id or /tasks/:id), auto-creates conversation on first message
  • Frontend SSE client: apiStreamPost() uses fetch + ReadableStream to parse SSE data lines
  • Auto-title: Conversation title auto-set from first user message content

Phase 4.5 — Chat UX + Settings + Model Management ✓

  • Settings infrastructure: Key-value settings table, GET/PUT /api/settings, Pinia store with defaults
  • Configurable assistant name: Default "Fable", editable in settings, injected into LLM system prompt and chat message labels
  • Settings page: /settings route with assistant name form + model catalog
  • Model catalog: 18 models across 3 categories (General Purpose, Coding, Uncensored / Creative Writing) with descriptions, sizes, and best-for labels
  • Model management: Download (pull with SSE progress streaming + live percentage in UI), select (set as default), and remove (delete from Ollama) with confirmation UI
  • Status indicator in nav bar: Moved from per-component (ChatView/ChatPanel) to global AppHeader; green/yellow/red dot with label
  • Chat bubble layout: User messages right-aligned (primary color bg), assistant messages left-aligned (card bg), speech bubble tails
  • Floating dark input bar: #1c1c1e background, rounded corners, circular send button with arrow
  • Auto-focus input: Chat input auto-focuses on mount, conversation switch, and after sending
  • 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
  • New chat navigation fix: fetchConversation() called before router.push() so currentConversation is set immediately
  • Recent chats on home page: 3 most recent conversations shown as clickable cards + "New Chat" button

Phase 4.6 — Improved Note Context in Chat ✓

  • Multi-word search: list_notes() splits multi-word queries into per-term ILIKE filters with AND logic (each word matched independently, not adjacent)
  • Context metadata: build_context() returns (messages, context_meta) tuple with auto-found note IDs/titles and attached note info
  • Fuller auto-context: Auto-found note previews increased from 300 to 2000 chars; uses all 5 extracted keywords instead of 3
  • Context SSE event: Backend emits {context: ContextMeta} SSE event before streaming LLM response chunks
  • Exclude note IDs: Backend accepts exclude_note_ids in request body, skips those notes during auto-search
  • Context pills: ChatView and ChatPanel show auto-found notes as pills above streaming response with note title as router-link
  • 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
  • Note picker: Paperclip button in chat input opens dropdown with debounced search, selecting a note attaches it as context (shown as pill above input)
  • ContextMeta type: New TypeScript interface for context metadata (context_note_id, context_note_title, auto_notes)
  • Session-scoped excludes: Excluded note IDs tracked per conversation session, cleared on conversation switch

Phase 4.7 — Styling Consistency Pass ✓

  • Design tokens: Added --radius-sm/md/lg/pill, --color-success/warning/overlay, --focus-ring to theme.css
  • Header alignment fix: Removed max-width: 960px from header nav — now full-width with consistent padding, matching modern app layout
  • Active nav link state: Added router-link-active styling with primary color + card background
  • Hardcoded colors replaced: Status indicator dots, settings saved message, remove/confirm-delete buttons now use CSS variables
  • 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
  • Button padding standardized: All primary/CTA buttons use 0.45rem 1rem; small buttons use 0.3rem 0.75rem
  • Settings max-width fixed: Changed from 700px to 720px to match all other views
  • Focus-visible states: Global focus-visible rule with --focus-ring shadow on inputs, textareas, selects, buttons
  • Modal overlays: Replaced hardcoded rgba(0,0,0,0.5) with var(--color-overlay) across all components

Phase 5 — Polish & Production Hardening

  • Authentication (single-user or multi-user, TBD)
  • Docker Swarm production stack (secrets, volumes, networking)
  • Backup/restore strategy for data
  • UI/UX refinements, responsive design
  • Error handling, logging, monitoring

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

Open Questions

  • Authentication model: single-user (password-only) vs multi-user?

Current Status

Phase: Phase 4.7 complete. Styling consistency pass with design tokens and standardized UI.

  • Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags
  • Unified note/task model (a task is a note with status IS NOT NULL)
  • LLM chat via Ollama with SSE streaming responses
  • Note-aware context: auto-includes current note + searches related notes by keyword (5 keywords, 2000-char previews)
  • Context visibility: auto-found notes shown as pills with title links, promote (+) and exclude (x) controls
  • Note picker: attach any note as context from chat input via search dropdown
  • Exclude tracking: session-scoped per conversation, excluded notes skipped in future auto-searches
  • Multi-word search: splits query into per-term ILIKE with AND logic (words match independently)
  • URL content fetching: detects URLs in messages, fetches and includes content
  • Save assistant messages as notes; summarize entire conversations as notes
  • Dedicated /chat page with bubble-style messages (user right, assistant left), floating dark input bar
  • Slide-out chat panel accessible from any page, auto-includes note/task context
  • Settings page: configurable assistant name (default "Fable"), model catalog with 18 models in 3 categories
  • Model management: download (with live SSE progress percentage), select, and remove models from the settings page
  • Ollama status indicator in global nav bar (green/yellow/red dot) with 30s polling
  • Recent chats section on home page with quick "New Chat" button
  • HTML entity rendering fix (apostrophes in marked → DOMPurify pipeline)
  • Dark/light theme with CSS custom properties
  • Ready for Phase 5: Polish & Production Hardening