a89d25f5d6
The assist flow previously tied the entire LLM generation to a single
POST request with no keepalives, causing NS_ERROR_NET_PARTIAL_TRANSFER
in Firefox when Hypercorn closed the connection during gaps between
chunks. This refactor decouples generation into a background task with
a buffer and a separate SSE stream — the same pattern used by chat.
- generation_buffer.py: Widen _buffers to support string keys, add
create/get/remove_assist_buffer() using "assist:{user_id}" keys,
fix cleanup log format for string keys
- generation_task.py: Add run_assist_generation() — lightweight
background task with no DB persistence or title generation
- notes.py: Replace single POST SSE route with POST /api/notes/assist
(returns 202) + GET /api/notes/assist/stream (SSE with 15s keepalives
and Last-Event-ID reconnection); 409 if already running
- useAssist.ts: Switch from apiStreamPost to apiPost + apiSSEStream
two-step pattern with named event mapping and stream handle cleanup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
710 lines
56 KiB
Markdown
710 lines
56 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-13 — Phase 5.6: Assist Background-Task + Buffer Architecture
|
|
|
|
## Project Overview
|
|
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
|
integrated LLM capabilities. It is designed to run on container infrastructure
|
|
(Docker Swarm) and connect to Ollama or any self-hostable LLM-compatible system
|
|
for AI-assisted features.
|
|
|
|
## Core Architecture
|
|
|
|
### Stack
|
|
| Layer | Technology | Notes |
|
|
|-------------|-----------|-------|
|
|
| Frontend | Vue 3 + TypeScript + Vite + Pinia + Vue Router | SPA served from the same container as the API |
|
|
| Backend/API | Quart (Python 3.12) | Async framework; serves both API and built frontend static files |
|
|
| LLM | Ollama | Or any OpenAI-compatible self-hosted LLM API |
|
|
| Database | PostgreSQL 16 | asyncpg driver, SQLAlchemy 2.0 async ORM, Alembic migrations |
|
|
| Deployment | Docker Compose | Single-container app + separate DB + LLM service |
|
|
|
|
### Key Design Decisions
|
|
- **Single container for frontend + API:** Quart serves the Vue.js production
|
|
build as static files and exposes the REST API under `/api/`.
|
|
- **Quart chosen for familiarity:** The maintainer (bvandeusen) knows Quart well.
|
|
- **SPA routing via 404 handler:** `app.py` uses `@app.errorhandler(404)` (not a
|
|
catch-all route) to serve static files or fall back to `index.html`. API routes
|
|
(`/api/*`) get JSON 404 responses. This avoids a catch-all `/<path:path>` route
|
|
intercepting API GETs.
|
|
- **LLM integration is a separate service:** The app communicates with Ollama (or
|
|
compatible) over HTTP.
|
|
- **Inline tag extraction:** Tags are extracted from note/task body text using
|
|
`#tag` syntax (Obsidian-style), not manually entered. Backend is source of truth
|
|
for tag extraction. The `TAG_RE` regex uses `(?<!&)` negative lookbehind to avoid
|
|
matching HTML entities like `'` as tags.
|
|
- **Hierarchical tags:** `#project/webapp` stored as `"project/webapp"`. Filtering
|
|
by `project` matches both `project` and `project/*` children via SQL `unnest` +
|
|
`LIKE` prefix.
|
|
- **Dark-first theming:** CSS custom properties on `:root` (light) and
|
|
`[data-theme="dark"]`, with `prefers-color-scheme` detection defaulting to dark.
|
|
- **Design tokens:** Standardized CSS custom properties for border-radius
|
|
(`--radius-sm: 6px`, `--radius-md: 8px`, `--radius-lg: 12px`), semantic colors
|
|
(`--color-success`, `--color-warning`, `--color-overlay`), and focus ring
|
|
(`--focus-ring`). All components reference tokens instead of hardcoded values.
|
|
- **Unified note/task model:** A task is just a note with task attributes enabled.
|
|
A note has `status IS NOT NULL` ⟹ it's a task. "Convert to task" sets
|
|
`status='todo'`; "convert to note" clears `status`, `priority`, `due_date`.
|
|
No companion notes, no cascade deletes, no bidirectional sync needed.
|
|
- **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown
|
|
bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create
|
|
missing notes.
|
|
- **Background generation architecture:** LLM streaming runs in a detached
|
|
`asyncio.Task` that writes into an in-memory `GenerationBuffer`. SSE clients
|
|
tail the buffer and can reconnect mid-stream without data loss. Buffer has
|
|
`cancel_event` for user-initiated stop. Completed buffers are cleaned up after
|
|
60s grace period. Periodic DB flushes every 5s preserve partial content.
|
|
Both chat and AI Assist use this architecture — assist buffers are keyed by
|
|
`"assist:{user_id}"` (string keys) instead of conversation ID (int keys).
|
|
Assist generation has no DB persistence, no title generation, no cancellation.
|
|
- **SSE over WebSockets for LLM streaming:** SSE clients connect via
|
|
`GET /api/chat/conversations/:id/generation/stream` with `Last-Event-ID`
|
|
reconnection support. Frontend uses `fetch()` + `ReadableStream`. Simpler than
|
|
WebSockets; Quart supports async generators natively for SSE.
|
|
- **Context building server-side:** Backend fetches URL content and searches notes —
|
|
frontend just sends the message text + optional note ID + optional exclude list.
|
|
Keyword extraction uses simple word splitting with stopword filtering (no embeddings).
|
|
`build_context()` returns `(messages, context_meta)` tuple; metadata includes
|
|
auto-found note IDs/titles sent to frontend via SSE `context` event before streaming.
|
|
Multi-word search splits terms into per-word ILIKE with AND logic (not adjacent match).
|
|
- **Reverse proxy required for production:** The app does not terminate TLS. A
|
|
reverse proxy (Nginx/Traefik/Caddy) must sit in front of port 5000. Do not
|
|
expose the app directly to the internet.
|
|
- **Registration auto-closes:** After the first user registers (admin), registration
|
|
is closed by default. The admin can toggle it from `/admin/users`. The setting is
|
|
stored as the admin user's `registration_open` setting key.
|
|
- **No blocking long-running operations:** Any potentially slow operation (model
|
|
pulls, LLM calls, URL fetching, etc.) must never block app startup or freeze the
|
|
UI. Backend uses SSE streaming for incremental responses (chat messages and model
|
|
pull progress). Frontend keeps the UI responsive during async operations (loading
|
|
states, streaming indicators, progress percentages). This applies to all future
|
|
features as well.
|
|
- **Auto-pull model on startup:** Non-blocking background task, logs warning on
|
|
failure so the app still starts even if Ollama isn't ready.
|
|
- **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies for
|
|
`[[Title]]` patterns referencing the given note. Results include `type: "note"` or
|
|
`type: "task"` based on whether the linking note has `status IS NOT NULL`.
|
|
- **Idempotent raw SQL migrations:** All Alembic migrations use raw SQL with
|
|
`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN
|
|
CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs and
|
|
fresh database creation.
|
|
- **Session cookie security:** `HttpOnly` and `SameSite=Lax` always set. `Secure`
|
|
flag controlled by `SECURE_COOKIES` env var (enable when behind TLS).
|
|
- **Default SECRET_KEY warning:** App logs a WARNING on startup if the default
|
|
`dev-secret-change-me` key is in use.
|
|
|
|
### High-Level Component Diagram
|
|
```
|
|
┌─────────────────────────────────────────────┐
|
|
│ Docker Compose │
|
|
│ │
|
|
│ ┌──────────────────────┐ ┌────────────┐ │
|
|
│ │ fabledassistant │ │ ollama │ │
|
|
│ │ ┌────────────────┐ │ │ │ │
|
|
│ │ │ Quart Server │ │ │ LLM API │ │
|
|
│ │ │ ┌──────────┐ │ │ │ │ │
|
|
│ │ │ │ Vue SPA │ │ │ └────────────┘ │
|
|
│ │ │ │ (static) │ │ │ ▲ │
|
|
│ │ │ └──────────┘ │ │ │ │
|
|
│ │ │ ┌──────────┐ │ │ HTTP/REST │
|
|
│ │ │ │ /api/* │──┼──┼─────────┘ │
|
|
│ │ │ └──────────┘ │ │ │
|
|
│ │ │ │ │ │ ┌────────────┐ │
|
|
│ │ │ ▼ │ │ │ PostgreSQL │ │
|
|
│ │ │ ┌──────────┐ │ │ │ 16 │ │
|
|
│ │ │ │ asyncpg │──┼──┼──▶ │ │
|
|
│ │ │ └──────────┘ │ │ └────────────┘ │
|
|
│ │ └────────────────┘ │ │
|
|
│ └──────────────────────┘ │
|
|
└─────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Data Model
|
|
|
|
### Users
|
|
- `id` (int PK), `username` (text UNIQUE NOT NULL), `email` (text nullable),
|
|
`password_hash` (text NOT NULL), `role` (text NOT NULL DEFAULT 'user'),
|
|
`created_at` (timestamptz)
|
|
- First registered user auto-assigned `role='admin'`; claims orphaned data
|
|
- Passwords hashed with bcrypt
|
|
- `to_dict()` excludes `password_hash`
|
|
|
|
### Notes (unified — includes tasks)
|
|
- `id` (int PK), `title` (str), `body` (markdown str), `tags` (ARRAY[str]),
|
|
`parent_id` (nullable FK to self), `user_id` (nullable FK to users, CASCADE),
|
|
`status` (nullable str — `todo`/`in_progress`/`done`),
|
|
`priority` (nullable str — `none`/`low`/`medium`/`high`), `due_date` (nullable date),
|
|
`created_at`, `updated_at`
|
|
- **A note is a task when `status IS NOT NULL`** — the `is_task` property checks this
|
|
- Tags are auto-extracted from body text on create/update via `#tag` regex
|
|
- Supports hierarchical organization via `parent_id`
|
|
- Lookup by exact title via `get_note_by_title()` for wikilink resolution
|
|
- Auto-create via `get_or_create_note_by_title()` for wikilink clicks
|
|
- `to_dict()` returns: `id`, `title`, `body`, `tags`, `parent_id`, `status`,
|
|
`priority`, `due_date`, `is_task`, `created_at`, `updated_at`
|
|
- Indexes: GIN on tags, B-tree on status, B-tree on title
|
|
|
|
### Task ≡ Note with task attributes
|
|
- No separate tasks table. `routes/tasks.py` imports directly from
|
|
`services/notes.py` with `is_task=True` for listing and
|
|
defaults `status='todo'`, `priority='none'` for creation.
|
|
- "Convert to task" = `update_note(id, status='todo', priority='none')`
|
|
- "Convert to note" = `update_note(id, status=None, priority=None, due_date=None)`
|
|
- Task body field is `body` (not `description`) — standardized with notes
|
|
|
|
### Settings
|
|
- Composite PK: `(user_id, key)` — `user_id` FK to users (CASCADE), `key` (text)
|
|
- `value` (text)
|
|
- Per-user key-value store for settings (assistant name, default model, etc.)
|
|
- CRUD via `services/settings.py`: all functions take `user_id` as first parameter
|
|
|
|
### Conversations
|
|
- `id` (int PK), `title` (str), `model` (str), `user_id` (nullable FK to users, CASCADE), `created_at`, `updated_at`
|
|
- Has many messages (cascade delete)
|
|
- Title auto-generated by LLM on first exchange and re-generated every 10th message
|
|
- Index on `updated_at` for list ordering
|
|
- `to_dict()` returns: `id`, `title`, `model`, `message_count`, `created_at`, `updated_at`
|
|
- `list_conversations()` uses a subquery for `message_count` instead of eager-loading all messages
|
|
|
|
### App Logs
|
|
- `id` (int PK), `category` (text NOT NULL — `audit`/`usage`/`error`),
|
|
`user_id` (int FK users ON DELETE SET NULL), `username` (text — denormalized),
|
|
`action` (text), `endpoint` (text), `method` (text), `status_code` (int),
|
|
`duration_ms` (real), `ip_address` (text), `details` (text — JSON-serialized),
|
|
`created_at` (timestamptz DEFAULT now())
|
|
- Single table with `category` field for all log types
|
|
- Denormalized `username` preserves attribution after user deletion
|
|
- `details` stores flexible JSON data (error tracebacks, audit metadata, etc.)
|
|
- Indexes: `category`, `user_id`, `created_at`, composite `(category, created_at DESC)`
|
|
- Automatic retention cleanup via hourly asyncio task (configurable `LOG_RETENTION_DAYS`, default 90)
|
|
|
|
### Messages
|
|
- `id` (int PK), `conversation_id` (FK to conversations, CASCADE), `role` (str: system/user/assistant),
|
|
`content` (str), `status` (str, default `'complete'` — `complete`/`generating`/`error`),
|
|
`context_note_id` (nullable FK to notes, SET NULL), `created_at`
|
|
- Index on `conversation_id`
|
|
- `context_note_id` tracks which note was attached as context when message was sent
|
|
- `to_dict()` returns: `id`, `conversation_id`, `role`, `content`, `status`, `context_note_id`, `created_at`
|
|
|
|
## Project Structure (Current)
|
|
```
|
|
fabledassistant/
|
|
├── summary.md # This file — canonical project context
|
|
├── pyproject.toml # Python project config
|
|
├── Dockerfile # Multi-stage build (Node → Python)
|
|
├── docker-compose.yml # Dev compose (app, PostgreSQL, Ollama)
|
|
├── docker-compose.prod.yml # Production stack (Docker Swarm, secrets, network isolation)
|
|
├── alembic.ini # Alembic config (prepend_sys_path = src)
|
|
├── alembic/
|
|
│ ├── env.py # Async migration runner
|
|
│ └── versions/
|
|
│ ├── 0001_create_notes_table.py # Notes table (raw SQL, idempotent)
|
|
│ ├── 0002_create_tasks_table.py # Tasks table + enums (raw SQL, idempotent)
|
|
│ ├── 0003_task_note_companion.py # Data migration: create companion notes for existing tasks
|
|
│ ├── 0004_merge_tasks_into_notes.py # Add task columns to notes, migrate data, drop tasks table
|
|
│ ├── 0005_add_chat_tables.py # Conversations + messages tables with FKs and indexes
|
|
│ ├── 0006_add_settings_table.py # Settings key-value table
|
|
│ ├── 0007_add_title_and_updated_at_indexes.py # B-tree indexes on notes.title and conversations.updated_at
|
|
│ ├── 0008_add_users_and_user_id.py # Users table, user_id FKs on notes/conversations/settings, composite PK for settings
|
|
│ ├── 0009_add_message_status.py # Add status column to messages table (default 'complete')
|
|
│ └── 0010_add_app_logs_table.py # App logs table for audit, usage, and error logging
|
|
├── src/
|
|
│ └── fabledassistant/
|
|
│ ├── __init__.py
|
|
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API, request logging middleware
|
|
│ ├── auth.py # Auth decorators: login_required, admin_required, get_current_user_id
|
|
│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret) + SECURE_COOKIES flag
|
|
│ ├── models/
|
|
│ │ ├── __init__.py # async_session factory, Base, imports all models
|
|
│ │ ├── user.py # User model (id, username, email, password_hash, role, created_at)
|
|
│ │ ├── note.py # Note model (unified: id, title, body, tags[], parent_id, user_id, status, priority, due_date, timestamps)
|
|
│ │ ├── conversation.py # Conversation + Message models with user_id
|
|
│ │ ├── setting.py # Setting model (composite PK: user_id + key, value TEXT)
|
|
│ │ └── app_log.py # AppLog model (id, category, user_id, username, action, endpoint, method, status_code, duration_ms, ip_address, details, created_at)
|
|
│ ├── 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, restore, user management, registration toggle (admin only)
|
|
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
|
|
│ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks (all @login_required)
|
|
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required)
|
|
│ │ └── settings.py # /api/settings GET/PUT — per-user settings (@login_required)
|
|
│ ├── services/
|
|
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id, is_registration_open, list_users, delete_user, set_registration_open
|
|
│ │ ├── backup.py # Backup/restore: export_full_backup, export_user_backup, restore_full_backup
|
|
│ │ ├── notes.py # CRUD with user_id isolation, is_task filter, convert, backlinks, search_notes_for_context
|
|
│ │ ├── llm.py # Ollama interaction: build_context with user_id, streaming, URL fetching
|
|
│ │ ├── chat.py # Conversation CRUD with user_id isolation, add_message, save/summarize as note (LLM-titled, chat-tagged)
|
|
│ │ ├── generation_buffer.py # In-memory SSE event buffer with cancel_event, reconnect support, auto-cleanup; supports chat (int keys) and assist (string keys)
|
|
│ │ ├── generation_task.py # Background asyncio tasks: run_generation (chat, DB flush, titles) + run_assist_generation (lightweight, no DB)
|
|
│ │ ├── settings.py # Settings CRUD with user_id isolation: get_setting, set_setting, set_settings_batch, get_all_settings
|
|
│ │ ├── logging.py # App logging: log_audit, log_usage, log_error, get_logs, get_log_stats, delete_old_logs, start_log_retention_loop
|
|
│ │ ├── email.py # SMTP email service: get_smtp_config, is_smtp_configured, send_email, send_test_email
|
|
│ │ └── notifications.py # Notification service: notify_security_event, check_due_tasks, start_notification_loop
|
|
│ ├── utils/
|
|
│ │ ├── __init__.py
|
|
│ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences
|
|
│ └── static/ # Vue production build (generated by Dockerfile)
|
|
└── frontend/
|
|
├── package.json # deps: vue, pinia, vue-router, marked, dompurify
|
|
├── vite.config.ts
|
|
├── tsconfig.json
|
|
├── src/
|
|
│ ├── App.vue # Shell: .app-shell (100dvh flex column) with AppHeader + .app-content (flex:1, scrollable) wrapping router-view; starts status polling + loads settings on mount
|
|
│ ├── main.ts # App init, imports theme.css
|
|
│ ├── assets/
|
|
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset, design tokens, responsive breakpoints (480/768/1024), utility classes (.hide-mobile/.hide-desktop), mobile touch targets
|
|
│ ├── api/
|
|
│ │ └── client.ts # ApiError class, apiGet/apiPost/apiPut/apiPatch/apiDelete + apiSSEStream (fetch+ReadableStream), apiStreamPost (legacy), auto 401→login redirect
|
|
│ ├── composables/
|
|
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
|
|
│ │ ├── useAutocomplete.ts # Legacy textarea autocomplete (replaced by Tiptap suggestion extensions)
|
|
│ │ └── useAssist.ts # AI Assist composable: section parsing, target selection, two-step POST+SSE streaming (apiPost → apiSSEStream), accept/reject; watches body ref for auto-sync
|
|
│ ├── stores/
|
|
│ │ ├── auth.ts # Auth state: user, isAuthenticated, isAdmin, login/register/logout/checkAuth
|
|
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags (with toast errors)
|
|
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (with toast errors)
|
|
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), status polling (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 with password confirmation; shows "closed" message when registration disabled
|
|
│ │ ├── UserManagementView.vue # Admin user management: registration toggle, user list with delete
|
|
│ │ ├── 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/
|
|
│ │ ├── LogsView.vue # Admin log viewer: stats summary, category/search/date filters, paginated table with expandable detail rows
|
|
│ │ ├── AppHeader.vue # Nav bar: brand, nav links (incl. admin Logs), status indicator, theme toggle, user info + logout, hamburger menu (mobile)
|
|
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop), bubble-style messages, floating dark input, note picker, context pills with promote/exclude
|
|
│ │ ├── 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, /admin/users, /admin/logs; beforeEach auth guard
|
|
└── public/
|
|
```
|
|
|
|
## API Endpoints (Current)
|
|
|
|
| Method | Path | Description |
|
|
|--------|------|-------------|
|
|
| GET | `/api/health` | Health check (public) |
|
|
| GET | `/api/auth/status` | Check if any users exist + registration open status (public) |
|
|
| POST | `/api/auth/register` | Register new user (first user becomes admin; returns 403 if registration closed) |
|
|
| POST | `/api/auth/login` | Login with username/password |
|
|
| POST | `/api/auth/logout` | Logout (clear session) |
|
|
| GET | `/api/auth/me` | Get current user info |
|
|
| PUT | `/api/auth/password` | Change password (body: `{current_password, new_password}`) |
|
|
| GET | `/api/admin/backup` | Export backup (`?scope=user` for own data, full requires admin) |
|
|
| POST | `/api/admin/restore` | Restore from JSON backup (admin only) |
|
|
| GET | `/api/admin/users` | List all users (admin only) |
|
|
| DELETE | `/api/admin/users/:id` | Delete a user (admin only, cannot delete self) |
|
|
| GET | `/api/admin/registration` | Get registration open/closed status (admin only) |
|
|
| PUT | `/api/admin/registration` | Toggle registration open/closed (admin only, body: `{open: bool}`) |
|
|
| GET | `/api/admin/logs` | List log entries (admin only, params: `category`, `user_id`, `search`, `date_from`, `date_to`, `limit`, `offset`) |
|
|
| GET | `/api/admin/logs/stats` | Get log category counts (admin only) |
|
|
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`; defaults to `is_task=false` — plain notes only; `?is_task=true` for tasks, `?all=true` for everything) |
|
|
| POST | `/api/notes` | Create note (body: `{title, body, status?, priority?, due_date?}` — tags auto-extracted) |
|
|
| GET | `/api/notes/tags` | List all tags from notes table (param: `q` for filter) |
|
|
| GET | `/api/notes/by-title?title=...` | Resolve note by exact title (case-insensitive) |
|
|
| POST | `/api/notes/resolve-title` | Get-or-create note by title (for wikilink clicks) |
|
|
| GET | `/api/notes/:id` | Get single note (response includes `is_task`, `status`, `priority`, `due_date`) |
|
|
| PUT | `/api/notes/:id` | Update note (body: `{title?, body?, status?, priority?, due_date?}` — tags re-extracted if body changes) |
|
|
| DELETE | `/api/notes/:id` | Delete note (simple delete, no cascade) |
|
|
| POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` on note (returns 200) |
|
|
| POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` from note (returns 200) |
|
|
| GET | `/api/notes/:id/backlinks` | List notes/tasks that reference this note via wikilinks |
|
|
| POST | `/api/notes/assist` | Launch background assist generation, return 202 (body: `{body, target_section, instruction}`; 409 if already running) |
|
|
| GET | `/api/notes/assist/stream` | SSE endpoint tailing assist generation buffer; supports `Last-Event-ID` reconnection; emits `chunk`, `done`, `error` events with 15s keepalives |
|
|
| GET | `/api/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/admin/smtp` | Get SMTP config (password masked) (admin only) |
|
|
| PUT | `/api/admin/smtp` | Save SMTP config to admin settings (admin only, body: `{smtp_host, smtp_port, ...}`) |
|
|
| POST | `/api/admin/smtp/test` | Send test email (admin only, body: `{recipient}`) |
|
|
| GET | `/api/settings` | Get all app settings as `{key: value}` dict |
|
|
| PUT | `/api/settings` | Update settings (body: `{key: value, ...}`) |
|
|
|
|
## Alembic Migrations
|
|
|
|
### Overview
|
|
Migrations use raw SQL for full idempotency — safe to run on a fresh database
|
|
or re-run on an existing one. The Dockerfile runs migrations automatically on
|
|
container startup.
|
|
|
|
### Migration Chain
|
|
```
|
|
0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py → 0005_add_chat_tables.py → 0006_add_settings_table.py → 0007_add_title_and_updated_at_indexes.py → 0008_add_users_and_user_id.py → 0009_add_message_status.py → 0010_add_app_logs_table.py
|
|
```
|
|
|
|
### 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, 5 keywords, 2000-char previews), URL content fetching (regex detection, HTML stripping, truncated to 4000 chars), returns `(messages, context_meta)` tuple with auto-found note IDs/titles, accepts `exclude_note_ids` to skip notes during auto-search
|
|
- [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 (LLM-generated title, tagged `chat`)
|
|
- [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 generated by LLM (concise 3-8 words)
|
|
|
|
### Phase 4.5 — Chat UX + Settings + Model Management ✓
|
|
- [x] **Settings infrastructure:** Key-value `settings` table, `GET/PUT /api/settings`, Pinia store with defaults
|
|
- [x] **Configurable assistant name:** Default "Fable", editable in settings, injected into LLM system prompt and chat message labels
|
|
- [x] **Settings page:** `/settings` route with assistant name form + model catalog
|
|
- [x] **Model catalog:** 18 models across 3 categories (General Purpose, Coding, Uncensored / Creative Writing) with descriptions, sizes, and best-for labels
|
|
- [x] **Model management:** Download (pull with SSE progress streaming + live percentage in UI), select (set as default), and remove (delete from Ollama) with confirmation UI
|
|
- [x] **Status indicator in nav bar:** Moved from per-component (ChatView/ChatPanel) to global AppHeader; green/yellow/red dot with label
|
|
- [x] **Chat bubble layout:** User messages right-aligned (primary color bg), assistant messages left-aligned (card bg), speech bubble tails
|
|
- [x] **Floating dark input bar:** `#1c1c1e` background, rounded corners, circular send button with arrow
|
|
- [x] **Auto-focus input:** Chat input auto-focuses on mount, conversation switch, and after sending
|
|
- [x] **HTML entity fix:** `linkifyTags` regex now uses `(?<!&)` lookbehind to avoid matching `#39` inside `'` as a tag. Additional layers: decode entities before marked, DOMPurify sanitization, explicit `'` → `'` replacement after sanitization
|
|
- [x] **New chat navigation fix:** `fetchConversation()` called before `router.push()` so `currentConversation` is set immediately
|
|
- [x] **Recent chats on home page:** 3 most recent conversations shown as clickable cards + "New Chat" button
|
|
|
|
### Phase 4.6 — Improved Note Context in Chat ✓
|
|
- [x] **Multi-word search:** `list_notes()` splits multi-word queries into per-term ILIKE filters with AND logic (each word matched independently, not adjacent)
|
|
- [x] **Context metadata:** `build_context()` returns `(messages, context_meta)` tuple with auto-found note IDs/titles and attached note info
|
|
- [x] **Fuller auto-context:** Auto-found note previews increased from 300 to 2000 chars; uses all 5 extracted keywords instead of 3
|
|
- [x] **Context SSE event:** Backend emits `{context: ContextMeta}` SSE event before streaming LLM response chunks
|
|
- [x] **Exclude note IDs:** Backend accepts `exclude_note_ids` in request body, skips those notes during auto-search
|
|
- [x] **Context pills:** ChatView and ChatPanel show auto-found notes as pills above streaming response with note title as router-link
|
|
- [x] **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
|
|
- [x] **Note picker:** Paperclip button in chat input opens dropdown with debounced search, selecting a note attaches it as context (shown as pill above input)
|
|
- [x] **ContextMeta type:** New TypeScript interface for context metadata (`context_note_id`, `context_note_title`, `auto_notes`)
|
|
- [x] **Session-scoped excludes:** Excluded note IDs tracked per conversation session, cleared on conversation switch
|
|
|
|
### Phase 4.7 — Styling Consistency Pass ✓
|
|
- [x] **Design tokens:** Added `--radius-sm/md/lg/pill`, `--color-success/warning/overlay`, `--focus-ring` to theme.css
|
|
- [x] **Header alignment fix:** Removed `max-width: 960px` from header nav — now full-width with consistent padding, matching modern app layout
|
|
- [x] **Active nav link state:** Added `router-link-active` styling with primary color + card background
|
|
- [x] **Hardcoded colors replaced:** Status indicator dots, settings saved message, remove/confirm-delete buttons now use CSS variables
|
|
- [x] **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
|
|
- [x] **Button padding standardized:** All primary/CTA buttons use `0.45rem 1rem`; small buttons use `0.3rem 0.75rem`
|
|
- [x] **Settings max-width fixed:** Changed from 700px to 720px to match all other views
|
|
- [x] **Focus-visible states:** Global `focus-visible` rule with `--focus-ring` shadow on inputs, textareas, selects, buttons
|
|
- [x] **Modal overlays:** Replaced hardcoded `rgba(0,0,0,0.5)` with `var(--color-overlay)` across all components
|
|
|
|
### Phase 4.8 — Backend Efficiency & Consistency Pass ✓
|
|
- [x] **`get_all_tags()` SQL rewrite:** Replaced O(n) Python-side tag extraction with single `SELECT DISTINCT unnest(tags)` query
|
|
- [x] **`list_conversations()` subquery count:** Replaced `selectinload(Conversation.messages)` with correlated subquery for `message_count`, avoiding loading all messages for list view
|
|
- [x] **Removed `services/tasks.py`:** Eliminated 64-line pass-through service; `routes/tasks.py` imports directly from `services.notes`
|
|
- [x] **Consolidated convert functions:** `convert_note_to_task()` and `convert_task_to_note()` now use single session (was 3 DB round trips)
|
|
- [x] **Batch settings updates:** New `set_settings_batch()` does all upserts in single transaction (was one transaction per setting)
|
|
- [x] **Shared `get_installed_models()`:** Extracted duplicate Ollama model-listing logic from 3 locations into `services/llm.py`
|
|
- [x] **`search_notes_for_context()`:** New function does single OR-keyword query (was up to 5 sequential `list_notes()` calls each with count query)
|
|
- [x] **Logging:** Added `logger = logging.getLogger(__name__)` to `services/notes.py`, `services/chat.py`, `services/settings.py`
|
|
- [x] **Database indexes:** Added B-tree indexes on `notes.title` (for `get_note_by_title` + backlinks) and `conversations.updated_at` (for list ordering)
|
|
- [x] **Safe `Conversation.to_dict()`:** Handles case where messages relationship is not loaded
|
|
|
|
### Phase 5 — Polish & Production Hardening ✓
|
|
- [x] **Multi-user authentication:** Session cookie auth with bcrypt, first-user-is-admin pattern, orphaned data claiming
|
|
- [x] **Per-user data isolation:** All notes, conversations, settings filtered by `user_id`
|
|
- [x] **Auth decorators:** `@login_required`, `@admin_required` on all API routes (except health + auth status)
|
|
- [x] **Frontend auth flow:** Login/register views, router beforeEach guard, ApiError with auto-redirect on 401
|
|
- [x] **Error handling & logging:** Request logging with timing, sanitized 500 errors, LOG_LEVEL config, toast errors on all store actions
|
|
- [x] **Toast improvements:** Warning type, dismiss button, 4s auto-dismiss
|
|
- [x] **Responsive design:** Breakpoints (480/768/1024), utility classes (.hide-mobile/.hide-desktop), 44px mobile touch targets
|
|
- [x] **Responsive header:** Hamburger menu at ≤768px, vertical nav dropdown, close on route change
|
|
- [x] **Responsive chat:** Sidebar overlay on mobile with toggle button, slides in/out
|
|
- [x] **Backup/restore:** Export user data (any user), full backup (admin), restore from JSON (admin)
|
|
- [x] **Docker Swarm production stack:** `docker-compose.prod.yml` with Docker secrets, internal network isolation, health checks, resource limits, restart policies
|
|
- [x] **Docker secrets support:** `_read_secret()` helper in config.py for SECRET_KEY_FILE, DATABASE_URL_FILE
|
|
|
|
### Phase 5.1 — Chat UX Improvements ✓
|
|
- [x] **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
|
|
- [x] **Background generation architecture:** `GenerationBuffer` (in-memory event buffer with SSE fan-out, reconnect via `Last-Event-ID`, auto-cleanup after 60s) + `run_generation()` asyncio task (streams from Ollama, periodic DB flushes every 5s)
|
|
- [x] **Stop generation:** `cancel_event` on `GenerationBuffer`; `POST .../generation/cancel` endpoint; red stop button replaces send button during streaming; partial content saved as complete
|
|
- [x] **Date/time in sidebar:** Relative time for recent (`Just now`, `5m ago`, `3h ago` up to 10h), then date (`Jan 15` this year, `Jan 15, 2025` older)
|
|
- [x] **Empty chat cleanup:** Navigating away from an empty conversation (message_count === 0) auto-deletes it
|
|
- [x] **Save-as-note LLM titles:** `save_response_as_note` generates title via LLM (same pattern as chat titles); falls back to first-line extraction on failure
|
|
- [x] **Chat tag on saved notes:** Both `save_response_as_note` and `summarize_conversation_as_note` tag created notes with `chat`
|
|
|
|
### Phase 5.2 — Tiptap Inline-Preview Editor + Layout Improvements ✓
|
|
- [x] **Tiptap WYSIWYG editor:** Replaced plain textarea with Tiptap (ProseMirror-based) inline-preview editor — headings, bold, italic, lists, code blocks render inline while editing
|
|
- [x] **Markdown round-trip:** Load markdown → HTML (via `marked`) → Tiptap editing → markdown (via custom `serializeToMarkdown()`) on every change
|
|
- [x] **Tag/wikilink decorations:** ProseMirror decoration plugins visually highlight `#tag` and `[[wikilink]]` as colored badges without custom node types
|
|
- [x] **Autocomplete migration:** Moved from textarea-based `useAutocomplete` to `@tiptap/suggestion` framework with shared `SuggestionDropdown` component
|
|
- [x] **Heading vs tag disambiguation:** Tag suggestion suppressed at start of paragraphs (where `#` is likely a heading prefix)
|
|
- [x] **Markdown paste handling:** Pasted markdown text auto-detected and converted to formatted content
|
|
- [x] **Toolbar refactor:** `MarkdownToolbar` uses Tiptap commands with active state highlighting instead of textarea text insertion
|
|
- [x] **Sticky toolbar:** Toolbar, title, tabs pinned above scrolling editor content
|
|
- [x] **App shell layout:** Navbar always visible — `App.vue` uses flex column at 100dvh with header + content areas; all views fit below header
|
|
- [x] **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)
|
|
- [x] **Auto-syncing sections:** `useAssist` watches `body` ref to keep section list in sync on load, typing, and AI accept
|
|
- [x] **Accept trailing newline:** Accepted AI suggestions always end with a newline for clean separation
|
|
- [x] **Wider content area:** Max-width increased from 720px to 960px across all views
|
|
|
|
### Phase 5.3 — Registration Control, User Management, Security Hardening ✓
|
|
- [x] **Registration auto-closes:** After the first user registers (admin), registration is closed by default
|
|
- [x] **Registration toggle:** Admin can open/close registration from `/admin/users`
|
|
- [x] **Password confirmation:** Register form requires password confirmation with inline validation
|
|
- [x] **Admin user management:** `/admin/users` view with user list and delete (admin only)
|
|
- [x] **Session cookie hardening:** `HttpOnly`, `SameSite=Lax` always on; `Secure` via `SECURE_COOKIES` env var
|
|
- [x] **Default SECRET_KEY warning:** Logs WARNING on startup if default key is in use
|
|
- [x] **Password change:** `PUT /api/auth/password` endpoint + Settings UI section
|
|
- [x] **Production deployment docs:** README documents reverse proxy, rate limiting, CSP headers, SECRET_KEY, registration behavior
|
|
|
|
### Phase 5.4 — Application Logging System ✓
|
|
- [x] **Single `app_logs` table:** Unified audit/usage/error logging with `category` field
|
|
- [x] **Usage logging middleware:** `after_request` logs all `/api/*` requests with user, endpoint, method, status, duration (skips `/api/admin/logs` to avoid recursion)
|
|
- [x] **Error logging:** `handle_500` captures error type, message, and traceback
|
|
- [x] **Audit logging:** Security events logged in auth routes (register, login, login_failed, logout, password_change) and admin routes (backup, restore, user_delete, registration_toggle)
|
|
- [x] **Denormalized username:** Preserves attribution after user deletion (`user_id` FK uses `ON DELETE SET NULL`)
|
|
- [x] **Admin log viewer:** `/admin/logs` with stats summary, category/search/date filters, paginated table with expandable JSON detail rows
|
|
- [x] **Log stats endpoint:** `GET /api/admin/logs/stats` returns category counts
|
|
- [x] **Configurable retention:** `LOG_RETENTION_DAYS` env var (default 90), hourly asyncio cleanup task
|
|
- [x] **Config:** `LOG_RETENTION_DAYS` added to `Config` class
|
|
|
|
### Phase 5.5 — SMTP Email Notifications ✓
|
|
- [x] **SMTP email service:** `aiosmtplib` for async email sending, supports STARTTLS (587) and implicit TLS (465)
|
|
- [x] **SMTP config in DB:** Admin configures SMTP via Settings UI, stored in `settings` table; env var fallbacks for Docker Swarm bootstrap
|
|
- [x] **Admin SMTP endpoints:** `GET/PUT /api/admin/smtp` for config management, `POST /api/admin/smtp/test` for sending test emails
|
|
- [x] **Security alert notifications:** Fire-and-forget `asyncio.create_task()` on login, failed login, logout, password change
|
|
- [x] **Task due date reminders:** Hourly background loop checks for due/overdue tasks, sends grouped email per user; dedup via `app_logs` check
|
|
- [x] **Per-user notification preferences:** `notify_task_reminders` and `notify_security_alerts` settings (default enabled)
|
|
- [x] **Settings UI:** Notifications section for all users (checkbox toggles), SMTP section for admin (2-column grid + test email)
|
|
|
|
### Phase 5.6 — Assist Background-Task + Buffer Architecture ✓
|
|
- [x] **Assist buffer helpers:** `create_assist_buffer()`, `get_assist_buffer()`, `remove_assist_buffer()` using `"assist:{user_id}"` string keys in shared `_buffers` registry
|
|
- [x] **`run_assist_generation()`:** Lightweight background task — streams from Ollama into buffer, no DB persistence, no title generation, no cancellation
|
|
- [x] **Two-step assist API:** `POST /api/notes/assist` returns 202 + launches background task; `GET /api/notes/assist/stream` SSE endpoint tails buffer with 15s keepalives and `Last-Event-ID` reconnection
|
|
- [x] **409 conflict guard:** Rejects concurrent assist requests per user
|
|
- [x] **Frontend two-step pattern:** `useAssist.ts` calls `apiPost()` then `apiSSEStream()` with named event mapping (`chunk`/`done`/`error`); tracks stream handle for cleanup
|
|
- [x] **Fixes `NS_ERROR_NET_PARTIAL_TRANSFER`:** Keepalive pings prevent browser/Hypercorn from closing connection during gaps between LLM chunks
|
|
|
|
### 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.6 complete. Assist Background-Task + Buffer Architecture.
|
|
- 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/suggestion` with 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; uses background-task + buffer architecture with keepalive SSE (same pattern as chat)
|
|
- 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 `/chat` page with responsive sidebar (overlay on mobile)
|
|
- Settings page: assistant name, model catalog, password change, data export/restore (admin)
|
|
- **Registration control**: auto-closes after first user, admin toggle, password confirmation
|
|
- **Admin user management**: `/admin/users` with user list + delete
|
|
- **Session cookie hardening**: HttpOnly, SameSite=Lax, optional Secure flag
|
|
- **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
|
|
- **Application logging**: audit (security events), usage (API requests), error (unhandled exceptions)
|
|
- **Admin log viewer**: `/admin/logs` with stats, filters, pagination, expandable detail rows
|
|
- **Automatic log retention**: configurable via `LOG_RETENTION_DAYS` (default 90 days)
|
|
- **SMTP email notifications**: security alerts (login/logout/failed login/password change), task due date reminders
|
|
- **Admin SMTP configuration**: Settings UI with test email, DB-stored config with env var fallbacks
|
|
- **Per-user notification preferences**: toggle task reminders and security alerts independently
|
|
- Dark/light theme with CSS custom properties and design tokens
|