From 012eb1d46ba929ed6ba22224898f7f563d709930 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 2 Mar 2026 20:52:21 -0500 Subject: [PATCH] Add Projects, Milestones, RAG auto-inject, push notifications, PWA, tag normalisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Projects & Milestones (Phases A + G) - New models: Project, Milestone (Project → Milestone → Task hierarchy) - notes table: project_id + milestone_id FKs; parent_id FK constraint activated - Migrations: 0017 (projects), 0018 (push_subscriptions), 0019 (events), 0020 (milestones) - Services: projects.py, milestones.py (CRUD + progress tracking) - Routes: /api/projects + /api/projects//milestones - LLM tools: create/list/get/update project; create/list milestone; project + milestone + parent_task params on note/task tools - Frontend: ProjectListView (stacked milestone bars), ProjectView (milestone-grouped kanban), ProjectSelector, MilestoneSelector, NoteEditorView + TaskEditorView updated ## RAG Auto-injection (Phase B) - Notes ≥0.60 cosine similarity auto-injected into system prompt (max 3, 800 chars each) - excluded_note_ids param; ChatView "Auto-included" sidebar section ## Summarisation improvements (Phase C) - Threshold 20→30, keep-recent 6→8, max_tokens 200→400 - Two-pass summarisation for histories >50 messages ## Browser push notifications (Phase E) - PushSubscription model + migration; pywebpush dependency - /api/push routes; VAPID config; fire-and-forget on generation complete - Frontend: sw.js, push store, Settings toggle ## PWA manifest (Phase F) - manifest.json, Apple meta tags, service worker registration in main.ts ## Tag normalisation - All tags lowercased + deduplicated at backend (create_note/update_note) and frontend (TagInput sanitize) - Note/Task types gain project_id + milestone_id fields; store signatures updated ## CalDAV - Radicale embedded server reverted; back to user-configured external CalDAV Co-Authored-By: Claude Sonnet 4.6 --- alembic/versions/0017_add_projects.py | 48 + .../versions/0018_add_push_subscriptions.py | 28 + alembic/versions/0019_add_events.py | 37 + alembic/versions/0020_add_milestones.py | 38 + docker-compose.yml | 4 + frontend/index.html | 6 + frontend/public/manifest.json | 17 + frontend/public/sw.js | 35 + frontend/src/components/AppHeader.vue | 1 + frontend/src/components/MilestoneSelector.vue | 86 ++ frontend/src/components/ProjectSelector.vue | 67 ++ frontend/src/components/TagInput.vue | 2 +- frontend/src/main.ts | 9 + frontend/src/router/index.ts | 10 + frontend/src/stores/chat.ts | 2 + frontend/src/stores/notes.ts | 3 +- frontend/src/stores/push.ts | 108 ++ frontend/src/stores/tasks.ts | 5 +- frontend/src/types/chat.ts | 3 +- frontend/src/types/note.ts | 2 + frontend/src/views/ChatView.vue | 94 +- frontend/src/views/NoteEditorView.vue | 44 +- frontend/src/views/ProjectListView.vue | 581 +++++++++ frontend/src/views/ProjectView.vue | 1062 +++++++++++++++++ frontend/src/views/SettingsView.vue | 93 ++ frontend/src/views/TaskEditorView.vue | 184 ++- pyproject.toml | 1 + src/fabledassistant/app.py | 9 +- src/fabledassistant/config.py | 5 + src/fabledassistant/models/__init__.py | 4 + src/fabledassistant/models/event.py | 51 + src/fabledassistant/models/milestone.py | 39 + src/fabledassistant/models/note.py | 12 +- src/fabledassistant/models/project.py | 36 + .../models/push_subscription.py | 20 + src/fabledassistant/routes/chat.py | 2 + src/fabledassistant/routes/milestones.py | 124 ++ src/fabledassistant/routes/notes.py | 4 +- src/fabledassistant/routes/projects.py | 114 ++ src/fabledassistant/routes/push.py | 44 + src/fabledassistant/services/caldav.py | 16 +- src/fabledassistant/services/calendar_sync.py | 304 +++++ src/fabledassistant/services/embeddings.py | 5 +- .../services/generation_task.py | 20 +- src/fabledassistant/services/intent.py | 1 + src/fabledassistant/services/llm.py | 129 +- src/fabledassistant/services/milestones.py | 145 +++ src/fabledassistant/services/notes.py | 30 +- src/fabledassistant/services/projects.py | 146 +++ src/fabledassistant/services/push.py | 149 +++ src/fabledassistant/services/tools.py | 276 +++++ summary.md | 126 +- 52 files changed, 4319 insertions(+), 62 deletions(-) create mode 100644 alembic/versions/0017_add_projects.py create mode 100644 alembic/versions/0018_add_push_subscriptions.py create mode 100644 alembic/versions/0019_add_events.py create mode 100644 alembic/versions/0020_add_milestones.py create mode 100644 frontend/public/manifest.json create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/components/MilestoneSelector.vue create mode 100644 frontend/src/components/ProjectSelector.vue create mode 100644 frontend/src/stores/push.ts create mode 100644 frontend/src/views/ProjectListView.vue create mode 100644 frontend/src/views/ProjectView.vue create mode 100644 src/fabledassistant/models/event.py create mode 100644 src/fabledassistant/models/milestone.py create mode 100644 src/fabledassistant/models/project.py create mode 100644 src/fabledassistant/models/push_subscription.py create mode 100644 src/fabledassistant/routes/milestones.py create mode 100644 src/fabledassistant/routes/projects.py create mode 100644 src/fabledassistant/routes/push.py create mode 100644 src/fabledassistant/services/calendar_sync.py create mode 100644 src/fabledassistant/services/milestones.py create mode 100644 src/fabledassistant/services/projects.py create mode 100644 src/fabledassistant/services/push.py diff --git a/alembic/versions/0017_add_projects.py b/alembic/versions/0017_add_projects.py new file mode 100644 index 0000000..251129f --- /dev/null +++ b/alembic/versions/0017_add_projects.py @@ -0,0 +1,48 @@ +"""Add projects table and project_id to notes.""" + +from alembic import op + +revision = "0017" +down_revision = "0016" + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS projects ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + goal TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + color TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_projects_user_id ON projects(user_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_projects_status ON projects(status)") + op.execute("ALTER TABLE notes ADD COLUMN IF NOT EXISTS project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL") + op.execute("CREATE INDEX IF NOT EXISTS ix_notes_project_id ON notes(project_id)") + # Add FK constraint to existing parent_id column (was nullable with no FK) + # Only add if constraint doesn't already exist + op.execute(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'notes_parent_id_fkey' + ) THEN + ALTER TABLE notes ADD CONSTRAINT notes_parent_id_fkey + FOREIGN KEY (parent_id) REFERENCES notes(id) ON DELETE SET NULL; + END IF; + END $$; + """) + + +def downgrade() -> None: + op.execute("ALTER TABLE notes DROP CONSTRAINT IF EXISTS notes_parent_id_fkey") + op.execute("DROP INDEX IF EXISTS ix_notes_project_id") + op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS project_id") + op.execute("DROP INDEX IF EXISTS ix_projects_status") + op.execute("DROP INDEX IF EXISTS ix_projects_user_id") + op.execute("DROP TABLE IF EXISTS projects") diff --git a/alembic/versions/0018_add_push_subscriptions.py b/alembic/versions/0018_add_push_subscriptions.py new file mode 100644 index 0000000..152c5c0 --- /dev/null +++ b/alembic/versions/0018_add_push_subscriptions.py @@ -0,0 +1,28 @@ +"""Add push_subscriptions table for web push notifications.""" + +from alembic import op + +revision = "0018" +down_revision = "0017" + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_used TIMESTAMP WITH TIME ZONE, + UNIQUE(user_id, endpoint) + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_push_subscriptions_user_id ON push_subscriptions(user_id)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_push_subscriptions_user_id") + op.execute("DROP TABLE IF EXISTS push_subscriptions") diff --git a/alembic/versions/0019_add_events.py b/alembic/versions/0019_add_events.py new file mode 100644 index 0000000..5e28170 --- /dev/null +++ b/alembic/versions/0019_add_events.py @@ -0,0 +1,37 @@ +"""Add events table for internal CalDAV (Radicale) linkage.""" + +from alembic import op + +revision = "0019" +down_revision = "0018" + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS events ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL, + uid TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + start_dt TIMESTAMP WITH TIME ZONE NOT NULL, + end_dt TIMESTAMP WITH TIME ZONE, + all_day BOOLEAN NOT NULL DEFAULT FALSE, + description TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + recurrence TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id, uid) + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_events_user_id ON events(user_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_events_project_id ON events(project_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_events_start_dt ON events(start_dt)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_events_start_dt") + op.execute("DROP INDEX IF EXISTS ix_events_project_id") + op.execute("DROP INDEX IF EXISTS ix_events_user_id") + op.execute("DROP TABLE IF EXISTS events") diff --git a/alembic/versions/0020_add_milestones.py b/alembic/versions/0020_add_milestones.py new file mode 100644 index 0000000..0af72ed --- /dev/null +++ b/alembic/versions/0020_add_milestones.py @@ -0,0 +1,38 @@ +"""Add milestones table and milestone_id to notes.""" + +from alembic import op + +revision = "0020" +down_revision = "0019" + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS milestones ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + description TEXT, + status TEXT NOT NULL DEFAULT 'active', + order_index INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_milestones_user_id ON milestones(user_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_milestones_project_id ON milestones(project_id)") + + op.execute(""" + ALTER TABLE notes + ADD COLUMN IF NOT EXISTS milestone_id INTEGER REFERENCES milestones(id) ON DELETE SET NULL + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_notes_milestone_id ON notes(milestone_id)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_notes_milestone_id") + op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS milestone_id") + op.execute("DROP INDEX IF EXISTS ix_milestones_project_id") + op.execute("DROP INDEX IF EXISTS ix_milestones_user_id") + op.execute("DROP TABLE IF EXISTS milestones") diff --git a/docker-compose.yml b/docker-compose.yml index 1aadce2..1879389 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,10 @@ services: # SEARXNG_URL: "http://searxng:8080" # IMAGE_CACHE_DIR: /data/images # default, change if using a different mount path # IMAGE_MAX_BYTES: "5242880" # 5 MB per image, adjust if needed + # Push notifications (VAPID keys - generate with: python -c "from py_vapid import Vapid01; v=Vapid01(); v.generate_keys(); print(v.private_key, v.public_key)") + VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}" + VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}" + VAPID_CLAIMS_SUB: "${VAPID_CLAIMS_SUB:-mailto:admin@fabledassistant.local}" healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] interval: 10s diff --git a/frontend/index.html b/frontend/index.html index 1915d1d..3cde003 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,12 @@ + + + + + + Fabled Assistant diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..463e6d0 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "Fabled Assistant", + "short_name": "Fabled", + "description": "Your self-hosted second brain with AI assistance", + "start_url": "/", + "display": "standalone", + "background_color": "#1a1a2e", + "theme_color": "#6c63ff", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "any", + "type": "image/x-icon" + } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..cd54d9c --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,35 @@ +// Service Worker for push notifications and PWA support +self.addEventListener('push', event => { + if (!event.data) return; + const data = event.data.json(); + event.waitUntil( + self.registration.showNotification(data.title || 'Fabled Assistant', { + body: data.body || '', + icon: '/favicon.ico', + badge: '/favicon.ico', + data: { url: data.url || '/' }, + }) + ); +}); + +self.addEventListener('notificationclick', event => { + event.notification.close(); + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => { + const url = event.notification.data.url; + for (const client of clientList) { + if (client.url.includes(url) && 'focus' in client) { + return client.focus(); + } + } + if (clients.openWindow) { + return clients.openWindow(url); + } + }) + ); +}); + +// PWA: serve cached assets for offline support (minimal) +self.addEventListener('fetch', event => { + // Pass-through — no offline caching in v1 +}); diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 6c5a707..1ad4dca 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -73,6 +73,7 @@ router.afterEach(() => { -