The TAG_RE regex in linkifyTags() was matching #39 inside ' as a tag, breaking the HTML entity and preventing apostrophe rendering. Added (?<!&) negative lookbehind so # preceded by & is skipped. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
33 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-10 — Phase 4.5: Chat UX improvements, settings page, model management, entity rendering fix
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. - 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. - SSE over WebSockets for LLM streaming: Uses
POSTwithtext/event-streamresponse (not EventSource). Frontend usesfetch()+ReadableStreamsince 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. Keyword extraction uses simple word splitting with stopword filtering (no embeddings).
- 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
asyncio.create_task()for fire-and-forget work and SSE streaming for incremental responses. Frontend keeps the UI responsive during async operations (loading states, streaming indicators). 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
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— 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
Task ≡ Note with task attributes
- No separate tasks table. The
services/tasks.pymodule is a thin wrapper aroundservices/notes.pythat passesis_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
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_idtracks which note was attached as context when message was sentto_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, 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
│ ├── 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), saveMessageAsNote, summarizeAsNote, fetchModels, status polling (ollamaStatus, modelStatus, chatReady)
│ │ ├── settings.ts # App settings: assistantName, defaultModel, fetchInstalledModels, pullModel, 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, 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 ' after sanitization
│ ├── views/
│ │ ├── ChatView.vue # Dedicated /chat page: sidebar + bubble-style messages (user right, assistant left) + floating dark input bar + auto-focus
│ │ ├── 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
│ │ ├── 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?}) |
| 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 (background, body: {model}) |
| 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 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), URL content fetching (regex detection, HTML stripping, truncated to 4000 chars) - 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 (first line as title)
- 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 auto-set from first user message content
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), 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 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.5 complete. Chat UX improvements, settings page, model management.
- 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
- URL content fetching: detects URLs in messages, fetches and includes content
- Save assistant messages as notes; summarize entire conversations as notes
- Dedicated
/chatpage 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, 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