Files
FabledScribe/summary.md
T
bvandeusen 834fd80640 Add Ollama health check status indicator to chat views
Adds visibility into whether Ollama is reachable and the configured model
is ready before allowing chat. Prevents silent failures when the model is
still downloading or Ollama is unavailable.

- Backend: GET /api/chat/status endpoint checks Ollama /api/tags (5s timeout)
  Returns ollama availability + model readiness + default model name
- Frontend: OllamaStatus type, chat store polling (30s interval)
- ChatView: status dot + label in header (green/yellow/red), disabled send
- ChatPanel: compact status indicator in panel header, disabled send
- summary.md: updated with new endpoint, store changes, and component descriptions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:33:42 -05:00

460 lines
29 KiB
Markdown

# Fabled Assistant - Project Context
> **Purpose:** This file is the canonical reference for re-initializing Claude Code
> context on this project. **Update this file after every change session.**
## Session Checklist
> **IMPORTANT:** At the end of every change session, you MUST:
> 1. **Update this file** (`summary.md`) to reflect all architectural, data model,
> API, file structure, and roadmap changes made during the session.
> 2. **Commit all changes** with a descriptive commit message summarizing what was
> done (e.g., "Merge tasks into notes: single table with task attributes").
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-02-10 — Phase 4 + Ollama health check (status indicator on chat views showing Ollama/model readiness)
## 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.
- **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.
- **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. 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/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
### 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
├── 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
│ │ ├── 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()
│ ├── 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, 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)
│ ├── 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)
│ │ └── chat.py # Conversation CRUD, add_message, save_response_as_note, summarize_conversation_as_note
│ ├── 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
│ ├── 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)
│ │ └── 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
│ │ └── task.ts # Task = re-export of Note; TaskListResponse
│ ├── utils/
│ │ ├── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks()
│ │ └── markdown.ts # renderMarkdown() (full), renderPreview() (strips links/images for cards)
│ ├── views/
│ │ ├── ChatView.vue # Dedicated /chat page: sidebar (conversation list) + message thread + input + summarize + Ollama status indicator
│ │ ├── HomeView.vue # Landing page: recent notes + tasks (independent error handling)
│ │ ├── 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 links, chat panel toggle, theme toggle
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop) + Ollama status indicator
│ │ ├── ChatMessage.vue # Message bubble: markdown rendering, role 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
└── 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 |
## 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
```
### How Migrations Run
The Dockerfile CMD runs:
```sh
(alembic stamp --purge base 2>/dev/null || true) && alembic upgrade head && hypercorn ...
```
- `alembic stamp --purge base` clears any stale revision stamps (safe no-op if none)
- `alembic upgrade head` runs all pending migrations in order
- Migrations are idempotent so re-running is safe
### Creating a New Migration
When adding a new migration, follow these conventions:
1. **Create the migration file:**
```
alembic/versions/0005_description.py
```
2. **Use raw SQL for idempotency:**
```python
from alembic import op
revision = "0005"
down_revision = "0004"
def upgrade() -> None:
# For new enums:
op.execute("""
DO $$ BEGIN
CREATE TYPE my_enum AS ENUM ('a', 'b');
EXCEPTION WHEN duplicate_object THEN null;
END $$
""")
# For new tables:
op.execute("""
CREATE TABLE IF NOT EXISTS my_table (...)
""")
# For new indexes:
op.execute("CREATE INDEX IF NOT EXISTS ix_name ON table (col)")
# For adding columns:
op.execute("""
DO $$ BEGIN
ALTER TABLE my_table ADD COLUMN new_col TEXT DEFAULT '';
EXCEPTION WHEN duplicate_column THEN null;
END $$
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_name")
op.execute("DROP TABLE IF EXISTS my_table")
op.execute("DROP TYPE IF EXISTS my_enum")
```
3. **Rebuild and test:**
```bash
docker compose up --build
```
To test from a completely fresh database:
```bash
docker compose down -v && docker compose up --build
```
### Important Notes
- Do NOT use `op.create_table()` or `sa.Enum()` — SQLAlchemy's event system
can fire `CREATE TYPE` even with `create_type=False`, causing failures on re-run
- Always write raw SQL with PostgreSQL idempotency guards
- Set `down_revision` to chain from the previous migration's `revision`
- Data migrations (like 0003) should use `op.get_bind()` + `sa.text()` for queries
## Phased Roadmap
### Phase 1 — Skeleton & Dev Environment ✓
- [x] Initialize Python project (pyproject.toml, Quart app scaffold)
- [x] Initialize Vue.js project (Vite-based, inside `frontend/`)
- [x] Set up Dockerfile (multi-stage: build Vue, serve with Quart)
- [x] Docker Compose stack with Ollama service
- [x] Quart serves Vue static build + `/api/health` endpoint
- [x] Database setup (PostgreSQL 16, asyncpg, SQLAlchemy 2.0, Alembic)
### Phase 2 — Notes CRUD + UX ✓
- [x] Database model for notes (title, body, tags[], parent_id, timestamps)
- [x] REST API: create, read, update, delete, list notes with pagination
- [x] Vue views: note list, note editor (markdown), note viewer (rendered)
- [x] Search (ILIKE on title/body) and tag filtering (hierarchical via unnest)
- [x] Inline `#tag` extraction from body text (backend regex, skips code fences)
- [x] Hierarchical tag filtering (`#project` matches `project` and `project/*`)
- [x] Dark/light theming with CSS custom properties + toggle + localStorage
- [x] App header with navigation and theme toggle
- [x] Tag pills (clickable + dismissible) on cards, viewer, and list filter bar
- [x] Pagination bar with prev/next and page numbers
- [x] Sort controls (field + asc/desc)
- [x] Toast notifications (success/error, 3s auto-dismiss)
- [x] Ctrl+S save shortcut in editor
- [x] Unsaved changes guard (route leave + beforeunload)
- [x] DOMPurify sanitization on rendered markdown
- [x] Inline tag linkification in rendered markdown (clickable `#tag` links)
### Phase 3 — Tasks CRUD + Wikilinks ✓
- [x] Task model with status (todo/in_progress/done) and priority (none/low/medium/high) enums
- [x] Alembic migration for tasks table with PG enums and indexes
- [x] REST API: full CRUD + PATCH status toggle + filter by status/priority/tags
- [x] Vue views: task list (search, status/priority filters, sort, pagination), editor (Ctrl+S, dirty guard), viewer (rendered markdown)
- [x] StatusBadge (clickable, cycles status), PriorityBadge, TaskCard components
- [x] Obsidian-style wikilinks: `[[Title]]` and `[[Title|Display]]` in rendered markdown
- [x] Wikilink click handling resolves notes by title via `/api/notes/by-title`
### Phase 3.5 — Note-Task Integration + Bug Fixes ✓
- [x] **Wikilink auto-create:** Clicking `[[New Page]]` creates the note if it doesn't exist
- [x] **Backlinks system:** "What links here" section on note and task viewers
- [x] **Rendered markdown previews:** NoteCard/TaskCard show rendered markdown (links/images stripped)
- [x] **Tag autocomplete Tab cycling:** Tab cycles through suggestions, single match accepts immediately
- [x] **HomeView error isolation:** Notes and tasks load independently (one failure doesn't break both)
- [x] **SPA routing fix:** Replaced catch-all route with 404 error handler to prevent API interception
- [x] **500 error handler:** JSON error responses for API routes, traceback logging
- [x] **Idempotent migrations:** All migrations rewritten to raw SQL with IF NOT EXISTS guards
- [x] **Auto-migration on startup:** Dockerfile runs `alembic upgrade head` before starting app
### Phase 3.6 — Merge Tasks into Notes ✓
- [x] **Unified note/task model:** Task is just a note with `status IS NOT NULL`
- [x] **Migration 0004:** Added `status`, `priority`, `due_date` columns to notes table, migrated task data from companion notes and orphan tasks, dropped `tasks` table
- [x] **Eliminated companion note system:** No more companion note creation, title sync, cascade deletes, or `_skip_cascade` flags
- [x] **Standardized on `body`:** Tasks use `body` everywhere (not `description`); API accepts `description` as fallback
- [x] **Convert to task:** Simple `update_note(id, status='todo', priority='none')` — same ID preserved
- [x] **Convert to note:** New `POST /api/notes/:id/convert-to-note` endpoint clears task attributes
- [x] **Tasks service as thin wrappers:** `services/tasks.py` delegates entirely to `services/notes.py`
- [x] **Frontend unified types:** `Task` is a re-export alias for `Note`; `note.ts` defines `TaskStatus`, `TaskPriority`
- [x] **Simplified views:** Removed companion note UI from TaskEditorView/TaskViewerView/NoteViewerView; added Convert to Note button on TaskViewerView
### Phase 4 — LLM Chat Integration ✓
- [x] **Ollama client:** async HTTP via httpx — `ensure_model()` (auto-pull on startup), `stream_chat()` (streaming NDJSON), `generate_completion()` (non-streaming)
- [x] **Context building:** System prompt with note-aware context — includes current note (via `context_note_id`), keyword-extracted related note search (top 3), URL content fetching (regex detection, HTML stripping, truncated to 4000 chars)
- [x] **Conversation persistence:** `conversations` + `messages` tables in PostgreSQL, full CRUD
- [x] **SSE streaming endpoint:** `POST /api/chat/conversations/:id/messages` streams LLM response chunks as SSE events, saves complete response to DB on finish
- [x] **Save as note:** Save any assistant message as a new note (first line as title)
- [x] **Summarize conversation:** Send full conversation to LLM with summarize prompt, save result as note
- [x] **Model management:** `GET /api/chat/models` lists available Ollama models; auto-pull default model (`llama3.1`) on app startup
- [x] **Dedicated chat page:** `/chat` with sidebar (conversation list, create/delete) + message thread + markdown rendering + streaming indicator
- [x] **Slide-out chat panel:** Toggle from header, receives `contextNoteId` from current route (`/notes/:id` or `/tasks/:id`), auto-creates conversation on first message
- [x] **Frontend SSE client:** `apiStreamPost()` uses fetch + ReadableStream to parse SSE data lines
- [x] **Auto-title:** Conversation title auto-set from first user message content
### 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 complete. LLM chat integration with streaming responses.
- 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 `/chat` page with conversation sidebar + message thread
- Slide-out chat panel accessible from any page, auto-includes note/task context
- Auto-pull default model (`llama3.1`) on startup
- Ollama health check: status indicator on chat views (green/yellow/red dot) with 30s polling; disables send when not ready
- Dark/light theme with CSS custom properties
- Ready for Phase 5: Polish & Production Hardening