32e4ee12f2
Context sidebar + note title:
- ChatView: replace ephemeral context pills with a persistent right-panel sidebar;
auto-found notes accumulate across turns; attached note shows with pin icon;
× button excludes a note from future auto-search; hidden on mobile
- routes/chat.py: batch-fetch note titles via get_notes_by_ids() and inject
context_note_title into each message dict at conversation load time
- notes.py: add get_notes_by_ids() batch fetch helper
- types/chat.ts: add context_note_title field to Message interface
- stores/chat.ts: sendMessage accepts optional 5th arg contextNoteTitle,
included in optimistic user message
- ChatMessage.vue: context badge shows note title instead of 'Note #N'
Expanded LLM tool suite (all with intent router rules + ToolCallCard display):
- delete_note / delete_task: permanent delete with user confirmation (write tool),
type-safe (refuse to delete wrong type), clears note context cache on success
- get_note: fetch full note body by query (search_notes returns only 200-char preview)
- list_notes: browse notes by recency/keyword/tags with limit; notes only
- update_note: add tags + tag_mode (replace/add/remove) parameters
- search_notes: add optional type filter ("note" | "task")
- search_todos (CalDAV): keyword-filter todos, companion to list_todos
- caldav.py: add search_todos() built on top of list_todos()
- generation_task.py: register new tools in _WRITE_TOOLS, _TOOL_LABELS, _TOOL_ACTIONS
- llm.py: update available actions list and guidance in system prompt
- intent.py: routing rules for all new tools
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
687 lines
52 KiB
Markdown
687 lines
52 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-19 — Phase 12: persistent context sidebar, note title in chat, expanded tool suite
|
||
|
||
## 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)
|
||
|
||
### Invitation Tokens
|
||
- `id` (int PK), `email` (text NOT NULL), `token_hash` (text NOT NULL UNIQUE),
|
||
`invited_by` (int FK users CASCADE), `expires_at` (timestamptz NOT NULL),
|
||
`used` (boolean NOT NULL DEFAULT FALSE), `created_at` (timestamptz)
|
||
- Admin creates invitation → raw token emailed → recipient registers via `/register-invite?token=...`
|
||
- Token stored as SHA256 hash (same pattern as password reset)
|
||
- 7-day expiration, one-time use, previous unused invitations for same email auto-revoked
|
||
- Indexes: `token_hash`, `email`
|
||
|
||
### 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), `tool_calls` (nullable JSONB), `created_at`
|
||
- Index on `conversation_id`
|
||
- `context_note_id` tracks which note was attached as context when message was sent
|
||
- `tool_calls` stores an array of tool call records `[{function, arguments, result, status}]` for assistant messages that used tools
|
||
- `to_dict()` returns: `id`, `conversation_id`, `role`, `content`, `status`, `context_note_id`, `tool_calls`, `created_at`
|
||
- `context_note_title` is NOT a DB column — it is synthesised at query time in `get_conversation_route` via a batch `get_notes_by_ids()` lookup and injected into the message dict so the frontend can display the note title without a separate fetch
|
||
|
||
## Project Structure (Current)
|
||
```
|
||
fabledassistant/
|
||
├── summary.md # This file — canonical project context
|
||
├── pyproject.toml # Python project config (deps include caldav, icalendar)
|
||
├── 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
|
||
│ ├── 0011_add_password_reset_tokens.py # Password reset tokens table
|
||
│ ├── 0012_add_invitation_tokens.py # Invitation tokens table
|
||
│ └── 0013_add_tool_calls_to_messages.py # Add tool_calls JSONB column to messages
|
||
├── 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)
|
||
│ │ └── invitation.py # InvitationToken model (id, email, token_hash, invited_by, expires_at, used, created_at)
|
||
│ ├── routes/
|
||
│ │ ├── __init__.py
|
||
│ │ ├── api.py # /api blueprint with /health endpoint (public)
|
||
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status, password reset, invitation registration
|
||
│ │ ├── admin.py # /api/admin blueprint: backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only)
|
||
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
|
||
│ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks + tag suggestions (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, password reset tokens, invitation tokens (create, validate, register_with_invitation, list_pending, revoke)
|
||
│ │ ├── 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 (stream_chat + stream_chat_with_tools), ChatChunk dataclass, 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, intent routing + tool loop) + run_assist_generation (lightweight, no DB)
|
||
│ │ ├── intent.py # Intent routing: classify_intent() makes fast non-streaming LLM call to detect tool intent before streaming
|
||
│ │ ├── tools.py # LLM tool definitions (create/delete note+task, update_note w/tag management, get_note, list_notes, search_notes w/type filter, list_tasks, full CalDAV suite incl. search_todos) + execute_tool dispatcher
|
||
│ │ ├── tag_suggestions.py # LLM-powered tag suggestions: suggest_tags() builds prompt with existing tags, calls generate_completion, parses JSON response
|
||
│ │ ├── caldav.py # CalDAV integration: full event lifecycle (create/list/search/update/delete), todos (create/list/search/update/complete/delete), list_calendars, timezone (ZoneInfo), reminders (VALARM), attendees, multi-calendar search
|
||
│ │ ├── 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
|
||
│ │ ├── useShortcuts.ts # Shared showShortcuts ref + open/close/toggle helpers (used by App.vue + AppHeader.vue)
|
||
│ │ ├── 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, running models, model warming, updateConversationModel (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 # ToolCallRecord, Message, Conversation, ConversationDetail, ContextMeta, OllamaModel, RunningModel, 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
|
||
│ │ ├── RegisterInviteView.vue # Invitation-based registration: validates token, creates account with pre-set email
|
||
│ │ ├── UserManagementView.vue # Admin user management: registration toggle, invitations (send/revoke), user list with delete
|
||
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, persistent context sidebar (right panel, hidden mobile), model selector in header
|
||
│ │ ├── HomeView.vue # Actionable dashboard: overdue, due today, due this week, high priority, in progress tasks; recent chats with inline chat input; recently edited notes; model warming on mount
|
||
│ │ ├── 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), LLM tag suggestions, Ctrl+S, unsaved guard
|
||
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks, table of contents sidebar
|
||
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination
|
||
│ │ ├── TaskEditorView.vue # Create/edit task: Tiptap editor, sticky toolbar, AI Assist panel, LLM tag suggestions, Ctrl+S, dirty guard
|
||
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note, backlinks, table of contents sidebar
|
||
│ ├── 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
|
||
│ │ ├── ModelSelector.vue # Model dropdown (v-model pattern): fetches installed + running models, hot/cold indicators
|
||
│ │ ├── DashboardChatInput.vue # Inline chat bar: ModelSelector + note picker + textarea + send button; emits submit event
|
||
│ │ ├── ToolCallCard.vue # Compact card for tool call results (created/deleted task/note, note content, notes list, search results, CalDAV events/todos, errors) + suggested tag pills with apply-on-click
|
||
│ │ ├── ChatMessage.vue # Message bubble: markdown rendering, tool call cards, configurable assistant name label, "Save as Note" action on assistant messages
|
||
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit, hover edit button
|
||
│ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags, hover edit button
|
||
│ │ ├── 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
|
||
│ │ ├── TableOfContents.vue # Sticky sidebar TOC: parses markdown headings, smooth-scroll on click, hidden ≤1200px
|
||
│ │ └── ToastNotification.vue # Fixed-position toast container with close button, warning support
|
||
│ └── router/
|
||
│ └── index.ts # Routes: /, /login, /register, /register-invite, /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) |
|
||
| POST | `/api/notes/suggest-tags` | LLM-suggest tags for content (body: `{title, body}`, returns `{suggested_tags: [...]}`) |
|
||
| POST | `/api/notes/:id/append-tag` | Append `#tag` to note body (body: `{tag}`, returns updated note) |
|
||
| 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/auth/invitation/:token` | Validate invitation token, returns `{valid, email?}` (public) |
|
||
| POST | `/api/auth/register-with-invite` | Register with invitation token (body: `{token, username, password}`) (public) |
|
||
| GET | `/api/admin/base-url` | Get application base URL setting (admin only) |
|
||
| PUT | `/api/admin/base-url` | Set application base URL (admin only, body: `{base_url}`) |
|
||
| POST | `/api/admin/invitations` | Create invitation (admin only, body: `{email}`) — sends email with registration link |
|
||
| GET | `/api/admin/invitations` | List pending invitations (admin only) |
|
||
| DELETE | `/api/admin/invitations/:id` | Revoke invitation (admin only) |
|
||
| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `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 or model (body: `{title?, model?}`) |
|
||
| 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/ps` | List currently loaded (hot) Ollama models (`{models: [{name, size, size_vram, expires_at}]}`) |
|
||
| POST | `/api/chat/warm` | Pre-load a model into Ollama memory (body: `{model}`, returns 202) |
|
||
| 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 → 0011_add_password_reset_tokens.py → 0012_add_invitation_tokens.py → 0013_add_tool_calls_to_messages.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
|
||
|
||
## Implemented Features
|
||
|
||
### Notes & Tasks
|
||
- Full CRUD with markdown bodies, Obsidian-style `#tag` extraction (skips code fences), hierarchical tag filtering
|
||
- Unified data model: a task is a note with `status IS NOT NULL` — convert freely between note ↔ task
|
||
- Task attributes: status (todo/in_progress/done), priority (none/low/medium/high), due_date
|
||
- Obsidian-style `[[wikilinks]]` with auto-create on click, backlinks ("what links here")
|
||
- Tiptap WYSIWYG editor with markdown round-trip, tag/wikilink autocomplete and decorations, sticky toolbar
|
||
- AI Assist panel in editor: background LLM generation via SSE with section targeting, accept/reject
|
||
- Table of contents sidebar on note/task viewers (auto-generated from headings, hidden ≤1200px)
|
||
- Inline edit buttons on NoteCard/TaskCard (hover on desktop, always visible on touch)
|
||
- Search (ILIKE), sort, tag filter pills, pagination on list views
|
||
|
||
### Dashboard
|
||
- Actionable home page with 5 task sections: Overdue, Due Today, Due This Week, High Priority, In Progress
|
||
- Priority-aware sorting (priority desc → due date asc) within each section
|
||
- Cascading deduplication — tasks appear only in their highest-priority section
|
||
- `due_before`/`due_after` query params on `/api/tasks` for date-range filtering
|
||
- Recent chats and recently edited notes sections
|
||
- All task sections hidden when empty; marking done removes from all lists
|
||
- **Inline chat widget:** Submitting a message streams the response inline on the dashboard — no
|
||
navigation to `/chat`. Streaming tool calls shown live; final response captured from store after SSE.
|
||
- **Quick action chips:** Pre-defined prompts above the chat input for common queries.
|
||
- **Conversational detection (Option A):** When the response has no tool calls (pure text), the
|
||
"Continue in Chat" link becomes a prominent filled button labeled "Continue this conversation →",
|
||
signalling that the inline response is just a snippet of a longer exchange.
|
||
- **Auto-focus:** Dashboard chat input gains focus on page mount (via `defineExpose({ focus })` on
|
||
`DashboardChatInput` and a template ref in `HomeView`).
|
||
- **Keyboard shortcut — Escape in ChatView:** Cascade close: note picker → mobile sidebar → clear
|
||
textarea if non-empty → navigate to `/` home.
|
||
- **Keyboard shortcuts overlay:** Press `?` (when not in a text input) or click the `?` button in
|
||
the nav bar to open a panel listing all shortcuts. Escape or click-outside closes it. State shared
|
||
via `useShortcuts` composable (`frontend/src/composables/useShortcuts.ts`).
|
||
|
||
### LLM Chat
|
||
- Ollama integration via async HTTP (httpx), default model qwen3 (better tool support than mistral), auto-pull on startup
|
||
- Background generation with `GenerationBuffer` (in-memory SSE fan-out, `Last-Event-ID` reconnect, 60s cleanup)
|
||
- Stop generation with partial content preservation
|
||
- Note-aware context building: current note + keyword search for related notes + URL fetching
|
||
- **Persistent context sidebar:** Right panel in ChatView accumulates auto-found notes across turns.
|
||
Manually attached note appears with 📌 pin, clears after send. × excludes from future auto-search.
|
||
Hidden on mobile (≤768px). Replaces old ephemeral context pills in the message stream.
|
||
- Note picker (paperclip) in chat input; attached note title passed to store for optimistic render.
|
||
`context_note_title` synthesised server-side at conversation load via batch `get_notes_by_ids()`;
|
||
message badge shows note title instead of "Note #N".
|
||
- LLM-generated conversation titles (re-generated every 10th message)
|
||
- Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations
|
||
- Dedicated `/chat` page with responsive sidebar + slide-out chat panel from header
|
||
- Model catalog with installed/available tabs, download progress, select, remove
|
||
- Per-conversation model selection via ModelSelector dropdown in chat header (persisted via PATCH)
|
||
- Dashboard inline chat input: model selector + note picker + textarea; creates conversation and navigates
|
||
- Model warming: default model pre-loaded into Ollama on dashboard mount via fire-and-forget POST
|
||
- **Model load state indicator:** `GET /api/chat/status` now queries `/api/tags` and `/api/ps` in
|
||
parallel. Model status has three values: `"not_found"` (not installed), `"cold"` (installed but
|
||
not in VRAM — first response will be slow), `"loaded"` (hot in VRAM, fast response).
|
||
`chatReady` is true for both `"cold"` and `"loaded"` since cold models still work.
|
||
AppHeader shows five distinct visual states: gray pulse (checking), red (Ollama down), orange
|
||
(model not installed), yellow pulse (cold), green (loaded). Each state has a short inline text
|
||
label ("Cold", "Ready", "Offline", etc.) visible without hovering, plus a tooltip with detail.
|
||
After `warmModel()` is called, a fast 5s polling loop runs for up to 60s until the indicator
|
||
transitions to green (instead of waiting for the 30s poll cycle).
|
||
- Hot/cold model indicators: `/api/chat/ps` proxies Ollama `/api/ps`; ModelSelector shows green/red emoji circles
|
||
- Ollama configured with `OLLAMA_MAX_LOADED_MODELS=2` and `OLLAMA_KEEP_ALIVE=30m`
|
||
- Timeout tuning: connect timeout 30s (cold model loading), warm timeout 300s, pull timeout 1800s
|
||
- SSE reconnection failure shows error toast instead of silently recovering
|
||
- **LLM tool calling:** Models with tool support can create tasks, create/update notes, search and list
|
||
notes/tasks, and manage CalDAV events/todos on behalf of the user during chat. Multi-round tool loop
|
||
(max 5 rounds) allows the LLM to execute tools and then produce a natural language response
|
||
incorporating results. Tool call results are persisted in message `tool_calls` JSONB column and
|
||
rendered as compact ToolCallCard components. SSE emits `tool_call` events for real-time rendering.
|
||
System prompt includes today's date for relative date resolution. Graceful degradation: models without
|
||
tool support respond normally.
|
||
Full tool suite:
|
||
- `create_task` / `create_note` — create new items
|
||
- `update_note` — edit note content (replace/append) AND update task fields: `status`, `priority`,
|
||
`due_date`, `tags` (with `tag_mode`: replace/add/remove); finds by exact title first, falls back to fuzzy search
|
||
- `delete_note` / `delete_task` — permanently delete (require user confirmation via confirm UI;
|
||
validates type so delete_note won't delete a task and vice versa; clears note context cache)
|
||
- `get_note` — retrieve full note body by title/keyword (search_notes only returns 200-char preview)
|
||
- `list_notes` — browse notes by recency/keyword/tags with configurable limit; notes only (use list_tasks for tasks)
|
||
- `list_tasks` — filter tasks by `status`, `priority`, `due_before`, `due_after`, `limit`;
|
||
backed by `list_notes(is_task=True)`; enables "overdue tasks", "high priority", "in progress" queries
|
||
- `search_notes` — keyword search across notes and tasks; optional `type: "note"|"task"` filter
|
||
- Full CalDAV suite: `create_event`, `list_events`, `search_events`, `update_event`, `delete_event`,
|
||
`list_calendars`, `create_todo`, `list_todos`, `search_todos`, `update_todo`, `complete_todo`, `delete_todo`
|
||
(`search_todos` keyword-filters the todo list — companion to `list_todos`)
|
||
- **Streaming status transparency:** The backend emits `status` SSE events at each pipeline stage
|
||
so the user always sees what's happening instead of a blank progress dot. Stages:
|
||
(1) `"Analyzing your request..."` before intent classification; (2) human-readable tool label
|
||
(e.g. `"Creating calendar event..."`) before each tool executes (both intent-routed and native);
|
||
(3) `"Generating response..."` / `"Composing response..."` before each LLM streaming round.
|
||
Frontend: `chat.ts` stores `streamingStatus` ref, cleared on first content chunk or on done/error.
|
||
`ChatView.vue` shows a pulsing dot + italic label above the content while status is active, then
|
||
falls back to the blinking cursor when content streams in. `HomeView.vue` dashboard panel shows
|
||
the status label in place of `...` before any content arrives.
|
||
- **Intent routing:** Before streaming, a fast non-streaming LLM call classifies user intent and
|
||
extracts tool parameters (`services/intent.py`). If a tool call is detected, it executes directly
|
||
— bypassing the model's native (sometimes unreliable) tool calling API. Falls through to normal
|
||
streaming when no tool is detected or classification fails. Only runs on first round of tool loop.
|
||
Supports confidence levels ("high"/"medium"/"low") — low-confidence intents fall through to streaming.
|
||
Passes last 6 user/assistant turns as history for anaphora resolution ("move it", "cancel that").
|
||
Dedicated intent model configurable via `OLLAMA_INTENT_MODEL` env var or per-user `intent_model`
|
||
setting — allows a smaller/faster model for routing while the main model handles responses.
|
||
Intent router rules cover: update/delete events, CalDAV todos, time-period → list_events (not
|
||
search_events), update_note vs create_note disambiguation, reminder_minutes conversion,
|
||
delete_note vs delete_task disambiguation, get_note for "read/show me this note", list_notes for
|
||
"browse/list notes", tag management via update_note (tag_mode add/remove), search_todos.
|
||
- **CalDAV calendar integration:** Per-user CalDAV settings (URL, username, password, calendar name, timezone).
|
||
LLM tools: `create_event` (all_day, recurrence, timezone, reminder_minutes, attendees, calendar_name),
|
||
`list_events`, `search_events`, `update_event`, `delete_event`, `list_calendars`,
|
||
`create_todo`, `list_todos`, `search_todos`, `update_todo`, `complete_todo`, `delete_todo`.
|
||
All-day events use iCalendar DATE values; recurrence uses RRULE (e.g. `FREQ=YEARLY`).
|
||
VALARM components added for reminders. Attendees via mailto: vCalAddress.
|
||
Multi-calendar search: when no specific calendar configured, all calendars are scanned.
|
||
Runs synchronous caldav library calls in asyncio executor. Settings UI for CalDAV config including timezone.
|
||
- **LLM-suggested tags:** Backend service (`tag_suggestions.py`) prompts LLM with existing user tags
|
||
and note content, returns 3-5 relevant tag suggestions. Tags already in body are filtered out.
|
||
Exposed via `POST /api/notes/suggest-tags` and `POST /api/notes/:id/append-tag`. Integrated in:
|
||
(1) Editor views — "Suggest tags" button shows clickable pills that insert `#tag` into body;
|
||
(2) Chat tool calls — `create_note`/`create_task` results include `suggested_tags`, rendered as
|
||
pills in ToolCallCard with one-click apply via append-tag API.
|
||
|
||
### Authentication & User Management
|
||
- Session cookie auth with bcrypt, first-user-is-admin, orphaned data claiming
|
||
- Per-user data isolation across all resources
|
||
- Registration auto-closes after first user; admin toggle; invitation-based registration
|
||
- Email invitation system: admin sends invite → branded email → `/register-invite` token flow
|
||
- Password reset: email-based, SHA256-hashed tokens, 1-hour expiry
|
||
- Session cookie hardening: HttpOnly, SameSite=Lax, optional Secure flag
|
||
- Admin user management: list, delete, invite, revoke
|
||
|
||
### Notifications & Email
|
||
- SMTP email service (aiosmtplib): STARTTLS (587) and implicit TLS (465)
|
||
- Security alerts: login, failed login, logout, password change (fire-and-forget)
|
||
- Task due date reminders: hourly background check, grouped per user, dedup via logs
|
||
- Invitation and password reset emails with branded HTML templates
|
||
- Per-user notification preferences (task reminders, security alerts)
|
||
- Admin SMTP configuration via Settings UI with test email
|
||
|
||
### Logging & Observability
|
||
- Single `app_logs` table: audit (security events), usage (API requests), error (unhandled exceptions)
|
||
- Request logging middleware with timing (skips log endpoints to avoid recursion)
|
||
- Admin log viewer: stats summary, category/search/date filters, paginated table, expandable JSON details
|
||
- Configurable retention via `LOG_RETENTION_DAYS` (default 90), hourly cleanup
|
||
|
||
### Settings & Admin
|
||
- Per-user key-value settings store (assistant name, default model, notification prefs)
|
||
- Admin: backup/restore (full or per-user JSON), SMTP config, base URL, registration toggle, user management, log viewer
|
||
- Configurable base URL for email links (admin setting, env var fallback)
|
||
|
||
### UI & Theming
|
||
- Dark/light theme with CSS custom properties, design tokens, `prefers-color-scheme` detection
|
||
- Responsive design: breakpoints (480/768/1024), hamburger menu, mobile touch targets (44px), sidebar overlays
|
||
- App shell: navbar always visible, 100dvh flex layout, all views fit within viewport
|
||
- Toast notifications (success/error/warning, 4s auto-dismiss)
|
||
- DOMPurify sanitization on all rendered markdown
|
||
|
||
### Infrastructure
|
||
- Multi-stage Dockerfile: Node build → Python runtime, auto-migration on startup
|
||
- Docker Compose (dev) + Docker Swarm production stack with secrets, overlay networks, health checks
|
||
- Quart serves Vue SPA from static + REST API under `/api/`; 404 handler for SPA routing
|
||
- Alembic migrations: 13 migrations, all raw SQL with idempotency guards (IF NOT EXISTS, DO $$ BEGIN...EXCEPTION)
|
||
- Config from env vars + Docker secrets file support (`_read_secret`)
|
||
|
||
## 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`
|
||
|
||
## Backlog
|
||
- Calendar/timeline view for tasks
|
||
- Import/export (Markdown files, JSON)
|
||
- Email integration (read/send emails from chat via IMAP/SMTP tools)
|
||
- Web search support (LLM tool to search the web and summarize results)
|
||
- Application-level rate limiting on auth endpoints
|
||
- Security headers middleware (CSP, X-Frame-Options, etc.) — currently handled at reverse proxy level
|
||
- Session invalidation on user deletion
|