diff --git a/docker-compose.yml b/docker-compose.yml index 3d0d465..3308f82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,9 @@ services: image: ollama/ollama volumes: - ollama_models:/root/.ollama + environment: + OLLAMA_MAX_LOADED_MODELS: "2" + OLLAMA_KEEP_ALIVE: "30m" # To enable GPU support, uncomment the deploy section below # (requires nvidia-container-toolkit) # deploy: diff --git a/frontend/src/components/DashboardChatInput.vue b/frontend/src/components/DashboardChatInput.vue new file mode 100644 index 0000000..a7018e3 --- /dev/null +++ b/frontend/src/components/DashboardChatInput.vue @@ -0,0 +1,358 @@ + + + + + diff --git a/frontend/src/components/ModelSelector.vue b/frontend/src/components/ModelSelector.vue new file mode 100644 index 0000000..64c3178 --- /dev/null +++ b/frontend/src/components/ModelSelector.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index 82aba99..e2c7a41 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -68,28 +68,19 @@ function goEdit() { } .btn-edit { flex-shrink: 0; - padding: 0.2rem 0.5rem; - font-size: 0.75rem; - background: transparent; - color: var(--color-text-muted); + padding: 0.25rem 0.6rem; + font-size: 0.8rem; + background: var(--color-bg-card); + color: var(--color-text-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-sm); cursor: pointer; - opacity: 0; - transition: opacity 0.15s, color 0.15s, border-color 0.15s; -} -.note-card:hover .btn-edit { - opacity: 1; + transition: color 0.15s, border-color 0.15s, background 0.15s; } .btn-edit:hover { color: var(--color-primary); border-color: var(--color-primary); } -@media (hover: none) { - .btn-edit { - opacity: 1; - } -} .note-preview { margin: 0 0 0.5rem; color: var(--color-text-secondary); diff --git a/frontend/src/components/TaskCard.vue b/frontend/src/components/TaskCard.vue index 2a14c37..4515be9 100644 --- a/frontend/src/components/TaskCard.vue +++ b/frontend/src/components/TaskCard.vue @@ -93,28 +93,19 @@ function isOverdue(): boolean { } .btn-edit { flex-shrink: 0; - padding: 0.2rem 0.5rem; - font-size: 0.75rem; - background: transparent; - color: var(--color-text-muted); + padding: 0.25rem 0.6rem; + font-size: 0.8rem; + background: var(--color-bg-card); + color: var(--color-text-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-sm); cursor: pointer; - opacity: 0; - transition: opacity 0.15s, color 0.15s, border-color 0.15s; -} -.task-card:hover .btn-edit { - opacity: 1; + transition: color 0.15s, border-color 0.15s, background 0.15s; } .btn-edit:hover { color: var(--color-primary); border-color: var(--color-primary); } -@media (hover: none) { - .btn-edit { - opacity: 1; - } -} .task-preview { margin: 0 0 0.5rem; color: var(--color-text-secondary); diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index 1a35689..a038e85 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -15,6 +15,7 @@ import type { Message, OllamaModel, OllamaStatus, + RunningModel, SendMessageResponse, } from "@/types/chat"; @@ -27,6 +28,7 @@ export const useChatStore = defineStore("chat", () => { const streamingContent = ref(""); const lastContextMeta = ref(null); const models = ref([]); + const runningModels = ref([]); const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking"); const modelStatus = ref<"checking" | "ready" | "not_found">("checking"); const defaultModel = ref(""); @@ -308,6 +310,42 @@ export const useChatStore = defineStore("chat", () => { } } + async function fetchRunningModels() { + try { + const data = await apiGet<{ models: RunningModel[] }>("/api/chat/ps"); + runningModels.value = data.models; + } catch { + runningModels.value = []; + } + } + + async function warmModel(model: string) { + try { + await apiPost("/api/chat/warm", { model }); + } catch { + // Warming is best-effort + } + } + + async function updateConversationModel(id: number, model: string) { + try { + const updated = await apiPatch( + `/api/chat/conversations/${id}`, + { model } + ); + const idx = conversations.value.findIndex((c) => c.id === id); + if (idx !== -1) { + conversations.value[idx] = { ...conversations.value[idx], ...updated }; + } + if (currentConversation.value?.id === id) { + currentConversation.value.model = updated.model; + } + } catch (e) { + useToastStore().show("Failed to update model", "error"); + throw e; + } + } + return { conversations, currentConversation, @@ -317,6 +355,7 @@ export const useChatStore = defineStore("chat", () => { streamingContent, lastContextMeta, models, + runningModels, ollamaStatus, modelStatus, defaultModel, @@ -331,6 +370,9 @@ export const useChatStore = defineStore("chat", () => { saveMessageAsNote, summarizeAsNote, fetchModels, + fetchRunningModels, + warmModel, + updateConversationModel, fetchStatus, startStatusPolling, stopStatusPolling, diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts index 0c35180..969cf1d 100644 --- a/frontend/src/types/chat.ts +++ b/frontend/src/types/chat.ts @@ -31,6 +31,13 @@ export interface OllamaModel { size: number; } +export interface RunningModel { + name: string; + size: number; + size_vram: number; + expires_at: string; +} + export interface ContextMeta { context_note_id: number | null; context_note_title: string | null; diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index 962e19c..e088f7b 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings"; import { apiGet } from "@/api/client"; import { renderMarkdown } from "@/utils/markdown"; import ChatMessage from "@/components/ChatMessage.vue"; +import ModelSelector from "@/components/ModelSelector.vue"; import type { Note } from "@/types/note"; const route = useRoute(); @@ -28,6 +29,9 @@ const noteSearchResults = ref<{ id: number; title: string }[]>([]); const noteSearchLoading = ref(false); let noteSearchTimer: ReturnType | null = null; +// Model selection +const selectedModel = ref(""); + // Exclude tracking (session-scoped per conversation) const excludedNoteIds = ref>(new Set()); @@ -71,6 +75,12 @@ onMounted(async () => { await store.fetchConversations(); if (convId.value) { await store.fetchConversation(convId.value); + if (store.currentConversation?.model) { + selectedModel.value = store.currentConversation.model; + } + } + if (!selectedModel.value) { + selectedModel.value = store.defaultModel; } nextTick(() => inputEl.value?.focus()); }); @@ -101,6 +111,26 @@ watch(convId, async (newId) => { nextTick(() => inputEl.value?.focus()); }); +// Sync selectedModel from loaded conversation +watch( + () => store.currentConversation?.model, + (model) => { + if (model) selectedModel.value = model; + } +); + +// Persist model changes to backend +watch(selectedModel, (newModel, oldModel) => { + if ( + newModel && + oldModel && + newModel !== oldModel && + store.currentConversation + ) { + store.updateConversationModel(store.currentConversation.id, newModel); + } +}); + watch( () => store.streamingContent, () => scrollToBottom() @@ -124,7 +154,7 @@ async function selectConversation(id: number) { } async function newConversation() { - const conv = await store.createConversation(); + const conv = await store.createConversation("", selectedModel.value); await store.fetchConversation(conv.id); router.push(`/chat/${conv.id}`); } @@ -313,6 +343,10 @@ function excludeAutoNote(noteId: number) { +

{{ store.currentConversation.title || "New Chat" }}

+
@@ -374,9 +388,6 @@ async function newChat() { color: var(--color-text-muted); white-space: nowrap; } -.btn-new-chat { - margin-top: 0.75rem; -} .empty-state { text-align: center; padding: 1.5rem 0; diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py index 189dfdd..dc41e01 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -15,7 +15,7 @@ from fabledassistant.services.chat import ( list_conversations, save_response_as_note, summarize_conversation_as_note, - update_conversation_title, + update_conversation, ) from fabledassistant.services.generation_buffer import ( GenerationState, @@ -83,9 +83,10 @@ async def update_conversation_route(conv_id: int): uid = get_current_user_id() data = await request.get_json() title = data.get("title") - if title is None: - return jsonify({"error": "title is required"}), 400 - conv = await update_conversation_title(uid, conv_id, title) + model = data.get("model") + if title is None and model is None: + return jsonify({"error": "title or model is required"}), 400 + conv = await update_conversation(uid, conv_id, title=title, model=model) if conv is None: return jsonify({"error": "Conversation not found"}), 404 return jsonify(conv.to_dict()) @@ -233,6 +234,54 @@ async def summarize_conversation_route(conv_id: int): return jsonify({"error": str(e)}), 400 +@chat_bp.route("/ps", methods=["GET"]) +@login_required +async def running_models_route(): + """Return currently loaded (hot) models from Ollama.""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(f"{Config.OLLAMA_URL}/api/ps") + resp.raise_for_status() + data = resp.json() + models = [ + { + "name": m["name"], + "size": m.get("size", 0), + "size_vram": m.get("size_vram", 0), + "expires_at": m.get("expires_at", ""), + } + for m in data.get("models", []) + ] + return jsonify({"models": models}) + except Exception as e: + logger.debug("Failed to query Ollama /api/ps: %s", e) + return jsonify({"models": []}) + + +@chat_bp.route("/warm", methods=["POST"]) +@login_required +async def warm_model_route(): + """Pre-load a model into Ollama memory.""" + data = await request.get_json(force=True, silent=True) or {} + model = data.get("model", "").strip() + if not model: + return jsonify({"error": "model is required"}), 400 + + async def _warm(): + try: + async with httpx.AsyncClient(timeout=120.0) as client: + await client.post( + f"{Config.OLLAMA_URL}/api/generate", + json={"model": model, "prompt": "", "keep_alive": "30m"}, + ) + logger.info("Warmed model %s", model) + except Exception as e: + logger.warning("Failed to warm model %s: %s", model, e) + + asyncio.create_task(_warm()) + return jsonify({"status": "warming"}), 202 + + @chat_bp.route("/status", methods=["GET"]) @login_required async def chat_status_route(): diff --git a/src/fabledassistant/services/chat.py b/src/fabledassistant/services/chat.py index 9e4fe67..9ef0fa8 100644 --- a/src/fabledassistant/services/chat.py +++ b/src/fabledassistant/services/chat.py @@ -103,8 +103,11 @@ async def delete_conversation(user_id: int, conversation_id: int) -> bool: return True -async def update_conversation_title( - user_id: int, conversation_id: int, title: str +async def update_conversation( + user_id: int, + conversation_id: int, + title: str | None = None, + model: str | None = None, ) -> Conversation | None: async with async_session() as session: result = await session.execute( @@ -116,13 +119,22 @@ async def update_conversation_title( conv = result.scalars().first() if conv is None: return None - conv.title = title + if title is not None: + conv.title = title + if model is not None: + conv.model = model conv.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(conv) return conv +async def update_conversation_title( + user_id: int, conversation_id: int, title: str +) -> Conversation | None: + return await update_conversation(user_id, conversation_id, title=title) + + async def add_message( conversation_id: int, role: str, diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 70255a0..a3bb415 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -80,7 +80,7 @@ async def stream_chat( payload: dict = {"model": model, "messages": messages, "stream": True} if options: payload["options"] = options - async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client: + async with httpx.AsyncClient(timeout=httpx.Timeout(1800.0, connect=10.0, read=300.0)) as client: async with client.stream( "POST", f"{Config.OLLAMA_URL}/api/chat", @@ -100,7 +100,7 @@ async def stream_chat( async def generate_completion(messages: list[dict], model: str) -> str: """Non-streaming chat completion, returns full response text.""" - async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client: + async with httpx.AsyncClient(timeout=httpx.Timeout(1800.0, connect=10.0, read=300.0)) as client: resp = await client.post( f"{Config.OLLAMA_URL}/api/chat", json={"model": model, "messages": messages, "stream": False}, diff --git a/summary.md b/summary.md index b790f8c..ddbb965 100644 --- a/summary.md +++ b/summary.md @@ -12,7 +12,7 @@ > Include file-level details in the commit body when the change is non-trivial. ## Last Updated -2026-02-13 — Phase 5.9: Invitation system, table of contents, actionable dashboard, settings improvements +2026-02-14 — Phase 6.0: Model selection, dashboard chat input, model warming ## Project Overview Fabled Assistant is a self-hosted note-taking and task-tracking application with @@ -289,13 +289,13 @@ fabledassistant/ │ │ ├── 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) + │ │ ├── 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 # Message, Conversation, ConversationDetail, ContextMeta, OllamaModel, OllamaStatus interfaces + │ │ ├── chat.ts # 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/ @@ -313,8 +313,8 @@ fabledassistant/ │ │ ├── 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, context pills - │ │ ├── HomeView.vue # Actionable dashboard: overdue, due today, due this week, high priority, in progress tasks; recent chats; recently edited notes + │ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context pills, 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), Ctrl+S, unsaved guard @@ -326,6 +326,8 @@ fabledassistant/ │ │ ├── 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 │ │ ├── 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, hover edit button │ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags, hover edit button @@ -393,12 +395,14 @@ fabledassistant/ | 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}`) | +| 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) | @@ -489,236 +493,84 @@ When adding a new migration, follow these conventions: - 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 +## Implemented Features -### 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) +### 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 -### 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) +### 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 -### 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` +### LLM Chat +- Ollama integration via async HTTP (httpx), auto-pull default model 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 +- Context pills with promote/exclude controls; note picker (paperclip) in chat input +- 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 +- Hot/cold model indicators: `/api/chat/ps` proxies Ollama `/api/ps`; ModelSelector shows filled/empty circles +- Ollama configured with `OLLAMA_MAX_LOADED_MODELS=2` and `OLLAMA_KEEP_ALIVE=30m` -### 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 +### 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 -### 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 +### 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 -### 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) +### 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 -### 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 `(?=`) params to `list_notes()` and `/api/tasks` endpoint -- [x] **Dashboard rewrite:** HomeView shows 5 task sections: Overdue (red accent), Due Today, Due This Week (next 7 days), High Priority (amber accent, catches important tasks not already shown by date), In Progress (deduplicated catch-all) -- [x] **Priority-aware sorting:** All task sections sort by priority desc then due date asc — highest priority items surface first within each section -- [x] **Parallel fetching:** 6 API calls via `Promise.allSettled` — notes, overdue, due-soon (split client-side into today/week), high priority, in-progress, chats -- [x] **Cascading deduplication:** Each section builds on a shared `seen` set so tasks only appear in their highest-priority section -- [x] **Client-side filtering:** Filters out `done` tasks from all date/priority sections -- [x] **Task sections hidden when empty:** Only shown if there are matching tasks -- [x] **Status toggle:** Marking a task `done` removes it from all dashboard lists -- [x] **Inline edit buttons:** NoteCard and TaskCard show an "Edit" button on hover (always visible on touch devices) that navigates directly to the edit view - -### Future / Stretch -- Tagging/labeling system with LLM-suggested tags -- Calendar/timeline view for tasks -- Import/export (Markdown files, JSON) -- Plugin/extension system +### 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: 12 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` @@ -728,37 +580,10 @@ When adding a new migration, follow these conventions: Quart serves them - To reset database: `docker compose down -v && docker compose up --build` -## Current Status -**Phase:** Phase 5.9 complete. Invitation system, table of contents, actionable dashboard, settings improvements. -- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags -- **Tiptap WYSIWYG editor** with inline formatting preview, markdown round-trip, paste handling -- **Tag/wikilink autocomplete** via `@tiptap/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 (installed/available tabs), password change, data export/restore (admin) -- **Invitation system**: admin sends email invitations, recipients register via token-based `/register-invite` flow -- **Registration control**: auto-closes after first user, admin toggle, invitation-based registration -- **Admin user management**: `/admin/users` with user list, delete, invite, revoke -- **Session cookie hardening**: HttpOnly, SameSite=Lax, optional Secure flag -- **Actionable dashboard**: HomeView shows overdue/due-today/due-this-week/high-priority/in-progress task sections (priority-sorted, cascading dedup, hidden when empty), recent chats, recently edited notes -- **Inline edit buttons**: NoteCard and TaskCard show hover edit button for direct navigation to edit view -- **Table of contents**: Sticky sidebar on note/task viewers, auto-generated from markdown headings -- **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, invitation emails -- **Admin SMTP configuration**: Settings UI with test email, DB-stored config with env var fallbacks -- **Admin base URL setting**: Configurable public URL used in email links -- **Per-user notification preferences**: toggle task reminders and security alerts independently -- **Password reset flow**: Email-based with SHA256-hashed tokens, 1-hour expiry -- Dark/light theme with CSS custom properties and design tokens +## Backlog +- Tagging/labeling system with LLM-suggested tags +- Calendar/timeline view for tasks +- Import/export (Markdown files, JSON) +- 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