Replace plain textarea with Tiptap (ProseMirror) WYSIWYG editor for notes and tasks. Headings, bold, lists, and code blocks now render inline while editing. Tags and wikilinks get visual highlighting via decoration plugins, and autocomplete uses @tiptap/suggestion with heading disambiguation. Layout: pin navbar with app-shell flex column (100dvh), sticky editor toolbar above scrolling content, AI Assist panel fixed at bottom 1/3 with full-height section selector and prompt. Increase max-width to 960px across all views. Hide assist controls during streaming/review. Other fixes: markdown paste handling, auto-syncing assist sections via body watcher, trailing newline on AI accept, ChatView height fix. Add README.md describing the app, usage guide, and technical overview. Update summary.md with Phase 5.2 roadmap and current status. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
47 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:
- Update this file (
summary.md) to reflect all architectural, data model, API, file structure, and roadmap changes made during the session.- 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-11 — Phase 5.2: Tiptap Inline-Preview Editor + Layout 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.pyuses@app.errorhandler(404)(not a catch-all route) to serve static files or fall back toindex.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
#tagsyntax (Obsidian-style), not manually entered. Backend is source of truth for tag extraction. TheTAG_REregex uses(?<!&)negative lookbehind to avoid matching HTML entities like'as tags. - Hierarchical tags:
#project/webappstored as"project/webapp". Filtering byprojectmatches bothprojectandproject/*children via SQLunnest+LIKEprefix. - Dark-first theming: CSS custom properties on
:root(light) and[data-theme="dark"], withprefers-color-schemedetection 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" setsstatus='todo'; "convert to note" clearsstatus,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 usesPOST /api/notes/resolve-titleto auto-create missing notes. - Background generation architecture: LLM streaming runs in a detached
asyncio.Taskthat writes into an in-memoryGenerationBuffer. SSE clients tail the buffer and can reconnect mid-stream without data loss. Buffer hascancel_eventfor user-initiated stop. Completed buffers are cleaned up after 60s grace period. Periodic DB flushes every 5s preserve partial content. - SSE over WebSockets for LLM streaming: SSE clients connect via
GET /api/chat/conversations/:id/generation/streamwithLast-Event-IDreconnection support. Frontend usesfetch()+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 SSEcontextevent 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/backlinkssearches all note bodies for[[Title]]patterns referencing the given note. Results includetype: "note"ortype: "task"based on whether the linking note hasstatus IS NOT NULL. - Idempotent raw SQL migrations: All Alembic migrations use raw SQL with
CREATE TABLE IF NOT EXISTS,CREATE INDEX IF NOT EXISTS, andDO $$ BEGIN CREATE TYPE ... EXCEPTION WHEN duplicate_objectto 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
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()excludespassword_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— theis_taskproperty checks this - Tags are auto-extracted from body text on create/update via
#tagregex - 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.pyimports directly fromservices/notes.pywithis_task=Truefor listing and defaultsstatus='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(notdescription) — standardized with notes
Settings
- Composite PK:
(user_id, key)—user_idFK 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 takeuser_idas 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_atfor list ordering to_dict()returns:id,title,model,message_count,created_at,updated_atlist_conversations()uses a subquery formessage_countinstead of eager-loading all messages
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_idtracks which note was attached as context when message was sentto_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')
├── 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)
│ ├── 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)
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint (public)
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status
│ │ ├── admin.py # /api/admin blueprint: backup (user/full), restore (admin only)
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
│ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks (all @login_required)
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required)
│ │ └── settings.py # /api/settings GET/PUT — per-user settings (@login_required)
│ ├── services/
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id
│ │ ├── backup.py # Backup/restore: export_full_backup, export_user_backup, restore_full_backup
│ │ ├── notes.py # CRUD with user_id isolation, is_task filter, convert, backlinks, search_notes_for_context
│ │ ├── llm.py # Ollama interaction: build_context with user_id, streaming, URL fetching
│ │ ├── chat.py # Conversation CRUD with user_id isolation, add_message, save/summarize as note (LLM-titled, chat-tagged)
│ │ ├── generation_buffer.py # In-memory SSE event buffer with cancel_event, reconnect support, auto-cleanup
│ │ ├── generation_task.py # Background asyncio task: streams LLM into buffer, periodic DB flush, LLM title generation
│ │ └── settings.py # Settings CRUD with user_id isolation: get_setting, set_setting, set_settings_batch, get_all_settings
│ ├── utils/
│ │ ├── __init__.py
│ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences
│ └── 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 (EventSource-based), 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, LLM streaming, 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 ' 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 (username, password, email), first-user setup
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context pills
│ │ ├── HomeView.vue # Landing page: recent notes + tasks + recent chats
│ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin)
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
│ │ ├── NoteEditorView.vue # Create/edit: 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
│ │ ├── 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
│ ├── components/
│ │ ├── AppHeader.vue # Nav bar: brand, nav links, status indicator, theme toggle, user info + logout, hamburger menu (mobile)
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop), bubble-style messages, floating dark input, note picker, context pills with promote/exclude
│ │ ├── ChatMessage.vue # Message bubble: markdown rendering, configurable assistant name label, "Save as Note" action on assistant messages
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit
│ │ ├── 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
│ │ └── ToastNotification.vue # Fixed-position toast container with close button, warning support
│ └── router/
│ └── index.ts # Routes: /, /login, /register, /notes/*, /tasks/*, /chat, /chat/:id, /settings; 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 (public) |
| POST | /api/auth/register |
Register new user (first user becomes admin) |
| POST | /api/auth/login |
Login with username/password |
| POST | /api/auth/logout |
Logout (clear session) |
| GET | /api/auth/me |
Get current user info |
| GET | /api/admin/backup |
Export backup (?scope=user for own data, full requires admin) |
| POST | /api/admin/restore |
Restore from JSON backup (admin only) |
| GET | /api/notes |
List notes (params: q, tag, sort, order, limit, offset; defaults to is_task=false — plain notes only; ?is_task=true for tasks, ?all=true for everything) |
| POST | /api/notes |
Create note (body: {title, body, status?, priority?, due_date?} — tags auto-extracted) |
| GET | /api/notes/tags |
List all tags from notes table (param: q for filter) |
| 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 |
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/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
How Migrations Run
The Dockerfile CMD runs:
(alembic stamp --purge base 2>/dev/null || true) && alembic upgrade head && hypercorn ...
alembic stamp --purge baseclears any stale revision stamps (safe no-op if none)alembic upgrade headruns 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:
-
Create the migration file:
alembic/versions/0005_description.py -
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") -
Rebuild and test:
docker compose up --buildTo test from a completely fresh database:
docker compose down -v && docker compose up --build
Important Notes
- Do NOT use
op.create_table()orsa.Enum()— SQLAlchemy's event system can fireCREATE TYPEeven withcreate_type=False, causing failures on re-run - Always write raw SQL with PostgreSQL idempotency guards
- Set
down_revisionto chain from the previous migration'srevision - 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/healthendpoint - 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
#tagextraction from body text (backend regex, skips code fences) - Hierarchical tag filtering (
#projectmatchesprojectandproject/*) - 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
#taglinks)
Phase 3 — Tasks CRUD + Wikilinks ✓
- 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 headbefore 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_datecolumns to notes table, migrated task data from companion notes and orphan tasks, droppedtaskstable - Eliminated companion note system: No more companion note creation, title sync, cascade deletes, or
_skip_cascadeflags - Standardized on
body: Tasks usebodyeverywhere (notdescription); API acceptsdescriptionas 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-noteendpoint clears task attributes - Tasks service as thin wrappers:
services/tasks.pydelegates entirely toservices/notes.py - Frontend unified types:
Taskis a re-export alias forNote;note.tsdefinesTaskStatus,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, acceptsexclude_note_idsto skip notes during auto-search - Conversation persistence:
conversations+messagestables in PostgreSQL, full CRUD - SSE streaming endpoint:
POST /api/chat/conversations/:id/messagesstreams LLM response chunks as SSE events, saves complete response to DB on finish - Save as note: Save any assistant message as a new note (LLM-generated title, tagged
chat) - Summarize conversation: Send full conversation to LLM with summarize prompt, save result as note
- Model management:
GET /api/chat/modelslists available Ollama models; auto-pull default model (llama3.1) on app startup - Dedicated chat page:
/chatwith sidebar (conversation list, create/delete) + message thread + markdown rendering + streaming indicator - Slide-out chat panel: Toggle from header, receives
contextNoteIdfrom current route (/notes/:idor/tasks/:id), auto-creates conversation on first message - Frontend SSE client:
apiStreamPost()uses fetch + ReadableStream to parse SSE data lines - Auto-title: Conversation title generated by LLM (concise 3-8 words)
Phase 4.5 — Chat UX + Settings + Model Management ✓
- Settings infrastructure: Key-value
settingstable,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:
/settingsroute 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:
#1c1c1ebackground, rounded corners, circular send button with arrow - Auto-focus input: Chat input auto-focuses on mount, conversation switch, and after sending
- HTML entity fix:
linkifyTagsregex now uses(?<!&)lookbehind to avoid matching#39inside'as a tag. Additional layers: decode entities before marked, DOMPurify sanitization, explicit'→'replacement after sanitization - New chat navigation fix:
fetchConversation()called beforerouter.push()socurrentConversationis 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_idsin 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-ringto theme.css - Header alignment fix: Removed
max-width: 960pxfrom header nav — now full-width with consistent padding, matching modern app layout - Active nav link state: Added
router-link-activestyling 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 use0.3rem 0.75rem - Settings max-width fixed: Changed from 700px to 720px to match all other views
- Focus-visible states: Global
focus-visiblerule with--focus-ringshadow on inputs, textareas, selects, buttons - Modal overlays: Replaced hardcoded
rgba(0,0,0,0.5)withvar(--color-overlay)across all components
Phase 4.8 — Backend Efficiency & Consistency Pass ✓
get_all_tags()SQL rewrite: Replaced O(n) Python-side tag extraction with singleSELECT DISTINCT unnest(tags)querylist_conversations()subquery count: Replacedselectinload(Conversation.messages)with correlated subquery formessage_count, avoiding loading all messages for list view- Removed
services/tasks.py: Eliminated 64-line pass-through service;routes/tasks.pyimports directly fromservices.notes - Consolidated convert functions:
convert_note_to_task()andconvert_task_to_note()now use single session (was 3 DB round trips) - Batch settings updates: New
set_settings_batch()does all upserts in single transaction (was one transaction per setting) - Shared
get_installed_models(): Extracted duplicate Ollama model-listing logic from 3 locations intoservices/llm.py search_notes_for_context(): New function does single OR-keyword query (was up to 5 sequentiallist_notes()calls each with count query)- Logging: Added
logger = logging.getLogger(__name__)toservices/notes.py,services/chat.py,services/settings.py - Database indexes: Added B-tree indexes on
notes.title(forget_note_by_title+ backlinks) andconversations.updated_at(for list ordering) - Safe
Conversation.to_dict(): Handles case where messages relationship is not loaded
Phase 5 — Polish & Production Hardening ✓
- Multi-user authentication: Session cookie auth with bcrypt, first-user-is-admin pattern, orphaned data claiming
- Per-user data isolation: All notes, conversations, settings filtered by
user_id - Auth decorators:
@login_required,@admin_requiredon all API routes (except health + auth status) - Frontend auth flow: Login/register views, router beforeEach guard, ApiError with auto-redirect on 401
- Error handling & logging: Request logging with timing, sanitized 500 errors, LOG_LEVEL config, toast errors on all store actions
- Toast improvements: Warning type, dismiss button, 4s auto-dismiss
- Responsive design: Breakpoints (480/768/1024), utility classes (.hide-mobile/.hide-desktop), 44px mobile touch targets
- Responsive header: Hamburger menu at ≤768px, vertical nav dropdown, close on route change
- Responsive chat: Sidebar overlay on mobile with toggle button, slides in/out
- Backup/restore: Export user data (any user), full backup (admin), restore from JSON (admin)
- Docker Swarm production stack:
docker-compose.prod.ymlwith Docker secrets, internal network isolation, health checks, resource limits, restart policies - Docker secrets support:
_read_secret()helper in config.py for SECRET_KEY_FILE, DATABASE_URL_FILE
Phase 5.1 — Chat UX Improvements ✓
- 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
- Background generation architecture:
GenerationBuffer(in-memory event buffer with SSE fan-out, reconnect viaLast-Event-ID, auto-cleanup after 60s) +run_generation()asyncio task (streams from Ollama, periodic DB flushes every 5s) - Stop generation:
cancel_eventonGenerationBuffer;POST .../generation/cancelendpoint; red stop button replaces send button during streaming; partial content saved as complete - Date/time in sidebar: Relative time for recent (
Just now,5m ago,3h agoup to 10h), then date (Jan 15this year,Jan 15, 2025older) - Empty chat cleanup: Navigating away from an empty conversation (message_count === 0) auto-deletes it
- Save-as-note LLM titles:
save_response_as_notegenerates title via LLM (same pattern as chat titles); falls back to first-line extraction on failure - Chat tag on saved notes: Both
save_response_as_noteandsummarize_conversation_as_notetag created notes withchat
Phase 5.2 — Tiptap Inline-Preview Editor + Layout Improvements ✓
- Tiptap WYSIWYG editor: Replaced plain textarea with Tiptap (ProseMirror-based) inline-preview editor — headings, bold, italic, lists, code blocks render inline while editing
- Markdown round-trip: Load markdown → HTML (via
marked) → Tiptap editing → markdown (via customserializeToMarkdown()) on every change - Tag/wikilink decorations: ProseMirror decoration plugins visually highlight
#tagand[[wikilink]]as colored badges without custom node types - Autocomplete migration: Moved from textarea-based
useAutocompleteto@tiptap/suggestionframework with sharedSuggestionDropdowncomponent - Heading vs tag disambiguation: Tag suggestion suppressed at start of paragraphs (where
#is likely a heading prefix) - Markdown paste handling: Pasted markdown text auto-detected and converted to formatted content
- Toolbar refactor:
MarkdownToolbaruses Tiptap commands with active state highlighting instead of textarea text insertion - Sticky toolbar: Toolbar, title, tabs pinned above scrolling editor content
- App shell layout: Navbar always visible —
App.vueuses flex column at 100dvh with header + content areas; all views fit below header - 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)
- Auto-syncing sections:
useAssistwatchesbodyref to keep section list in sync on load, typing, and AI accept - Accept trailing newline: Accepted AI suggestions always end with a newline for clean separation
- Wider content area: Max-width increased from 720px to 960px across all views
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.2 complete. Tiptap Inline-Preview Editor + Layout 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/suggestionwith 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
- 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
/chatpage with responsive sidebar (overlay on mobile) - Settings page: assistant name, model catalog, data export/restore (admin)
- 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
- Dark/light theme with CSS custom properties and design tokens