Compare commits

...

80 Commits

Author SHA1 Message Date
bvandeusen 190664366d Merge pull request 'dev → main' (#12) from dev into main
dev → main
2026-03-26 22:08:03 +00:00
bvandeusen 08d738ddfb feat: silent background polling for dashboard and briefing views
HomeView: setInterval every 90s refreshes events, orphan tasks/notes,
and hero next-up task. Never touches loading ref, so the page content
stays stable — only the data silently swaps when the fetch completes.
Skips when document.hidden or initial load is still in progress.

BriefingView: setInterval every 60s refetches today's messages, but
only when viewing today's conversation and not currently streaming.
Compares message count and last message content before updating to
avoid unnecessary re-renders.

Both timers are cleared on unmount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:43:21 -04:00
bvandeusen a63e498067 fix: weather card spacing and sizing consistency
- Increase temp size to 2rem and add explicit font-size to condition
- Uniform 0.5rem spacing below current temp (was 0.35rem)
- Today row font-size explicit at 0.85rem
- Forecast gap 0.75rem (was 0.5rem), min-width 4.5rem (was 3.5rem)
- Padding-top on forecast strip 0.75rem to match overall rhythm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:37:27 -04:00
bvandeusen 48d1d9e64f fix: remove npm self-update in Docker build; use npm ci
npm install -g npm@latest corrupts npm's own module tree inside Alpine,
breaking subsequent installs. Use npm ci instead (faster, deterministic).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:19:01 -04:00
bvandeusen 538b67e57d docs(fable-mcp): add server instructions and detailed tool docstrings
Adds a comprehensive instructions block to the FastMCP server covering
the data model hierarchy, valid enum values, tag format, integer-or-none
conventions, when to use fable_send_message vs direct CRUD tools, and
admin key requirements.

All tool docstrings expanded with full argument descriptions, valid
values, and return shape notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:03:31 -04:00
bvandeusen 383a4430f1 fix: convert calendar event times to user timezone in briefing
The briefing was formatting event start_dt directly in UTC instead of
converting to the user's local timezone. Also, the day_start/day_end
query window was naive (UTC), so events at the edges of the user's day
could be missed or included incorrectly.

Now reads user_timezone setting, uses it for today's date boundary and
for converting each event's start_dt before formatting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 06:34:22 -04:00
bvandeusen 4aacd093e5 Merge pull request 'fix: decode HTML entities in wikilinks before re-escaping' (#11) from dev into main 2026-03-26 02:50:00 +00:00
bvandeusen aab478359b fix: decode HTML entities in wikilinks before re-escaping
marked encodes " to &quot; in text nodes, causing linkifyWikilinks to
double-escape it (& → &amp;) so the visible link text showed &quot;
instead of the actual character.

Decode marked's entities on the matched title/label before running
escapeHtmlAttr so the output is correct in both the href attribute
and the visible link text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 22:40:25 -04:00
bvandeusen 6214666942 Merge pull request 'refactor: centralise user timezone as standalone setting' (#10) from dev into main 2026-03-26 00:38:46 +00:00
bvandeusen 62dbb8d496 refactor: centralise user timezone as a standalone setting
Browser timezone is now synced to user_settings["user_timezone"] on
every login/page load (App.vue). The briefing scheduler and LLM context
both read from this single source, falling back to the legacy
briefing_config.timezone for existing users during migration.

- App.vue: PUT /api/settings with browser IANA timezone on startAppServices
- routes/chat.py: fall back to stored user_timezone when not sent in request
- briefing_scheduler: read user_timezone setting; briefing_config.timezone
  kept as fallback only
- routes/briefing.py: pass tz_override from user_timezone to live-patched scheduler
- Remove timezone field from BriefingConfig interface and all briefing UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 20:17:51 -04:00
bvandeusen fd05c65018 fix(calendar): correct event timezone handling
- Frontend sends user_timezone (IANA, from Intl.DateTimeFormat) with
  every message POST; threaded through route → generation_task → build_context
- System prompt now tells the LLM the user's timezone so it creates
  events with the correct UTC offset (e.g. 15:00+01:00 not 15:00Z)
- Calendar tool guidance updated to require UTC offset in all event
  datetimes
- EventSlideOver: dateFromIso/timeFromIso now use JS Date to convert
  stored UTC times to local time for display; toIso includes local
  timezone offset when saving so the correct UTC time is stored

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 20:06:09 -04:00
bvandeusen c8aa5834fa feat: internal calendar, RAG scoping, and project summarization 2026-03-25 22:45:40 +00:00
bvandeusen 87c55691fb docs: update architecture and features for calendar + RAG scoping
- architecture.md: add Event model, services/events.py, routes/events.py,
  CalendarView.vue, EventSlideOver.vue; update Conversation and Project
  data model tables with new columns; update RAG Pipeline section with
  three-value scope system, search_projects/set_rag_scope tools, and
  project summary background job; fix dead-code note for models/event.py;
  update execute_tool() signature docs; add services/projects.py entry
- features.md: replace CalDAV section with full Calendar section covering
  internal store, AI tools, HomeView widget, and optional CalDAV sync;
  update AI Chat scope chip description; remove done "Calendar view" item
  from roadmap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:16:41 -04:00
bvandeusen ebc79b34f9 feat(rag): RAG scoping and context isolation controls
- Migration 0030: add conversations.rag_project_id (NULL=orphan-only,
  -1=all notes, positive=project), projects.auto_summary and
  projects.summary_updated_at
- Three-value scope semantics thread from build_context() → semantic
  search and keyword fallback via orphan_only + effective_project_id
- Project summarization background job (generate_project_summary,
  backfill_project_summaries) called via Ollama; triggered on project
  update and note saves (debounced 1h); runs at startup
- New LLM tools: search_projects (SequenceMatcher scoring on
  title+description+auto_summary) and set_rag_scope (persists to DB,
  workspace-guarded, emits new_rag_scope in SSE done event)
- execute_tool() accepts conv_id + workspace_project_id; generation_task
  passes both and captures scope changes for SSE done enrichment
- Frontend: Conversation type gets rag_project_id; chat store adds
  ragProjectId computed + updateRagScope(); SSE done handler syncs scope
- ChatView: replace sidebar ProjectSelector with a scope chip pill above
  the input bar, animated dropdown, pulse on model-driven scope change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:44:39 -04:00
bvandeusen 1e0d11c907 docs: update RAG scoping spec with explicit wiring details
Add exact code snippets for orphan_only logic, conv_id threading,
SSE new_rag_scope wiring, workspace guard, and frontend store changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:01:30 -04:00
bvandeusen 17db511119 docs: add RAG scoping and context isolation design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 16:56:25 -04:00
bvandeusen e0c3836fab feat: event cards in chat/briefing, upcoming events on dashboard, briefing uses internal store
- ToolCallCard: event list items replaced with rich clickable cards (color dot,
  title, time, location); clicking opens EventSlideOver for edit/delete; single
  create/update events in header are also clickable; updated all event types to
  use start_dt/end_dt fields from internal store
- HomeView: new upcoming events widget shows today + next 7 days as a card grid
  above the hero project; clicking any card opens EventSlideOver inline
- briefing_pipeline: _gather_internal now queries the internal events store for
  today's events; CalDAV events are still appended (deduped) if configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 15:39:13 -04:00
bvandeusen df1194ee96 fix(tools): remove duplicate datetime import in get_weather handler
The local `from datetime import datetime, timezone as _tz` inside
execute_tool() shadowed the module-level datetime import for the entire
function scope, causing UnboundLocalError in all calendar tool handlers
(list_events, create_event, update_event). Fixed by importing only
timezone as _tz — datetime is already available at module level.

Also removes the now-unnecessary noqa: F823 suppression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:59:05 -04:00
bvandeusen 0277f5744f fix(events): use g.user.id instead of g.user_id in route helper
auth.py sets g.user (the User object), not g.user_id directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:12:12 -04:00
bvandeusen 809d8e0008 fix(lint): suppress ruff F823 false positive in create_event handler
Ruff 0.15.7 incorrectly flags datetime.fromisoformat() as a
"local variable referenced before assignment" inside a try block
within execute_tool(). datetime is imported at module level and
is not a local variable. Added noqa comment at the specific line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:45:25 -04:00
bvandeusen c3e201d26a feat(calendar): internal event store with FullCalendar UI and CalDAV push sync
- AI calendar tools now always available (moved from _CALDAV_TOOLS to _CORE_TOOLS);
  create/list/search/update/delete events go through the internal DB store first,
  with fire-and-forget CalDAV push sync when the user has CalDAV configured
- Add EventEntry interface and typed API helpers (listEvents, createEvent,
  getEvent, updateEvent, deleteEvent) to client.ts
- Install @fullcalendar/vue3, daygrid, timegrid, interaction, core packages
- Add EventSlideOver.vue: create/edit/delete slide-over with title, start/end,
  all-day toggle, location, description, color picker, and project selector
- Add CalendarView.vue: month/week/day FullCalendar with drag-drop and resize
  wired to PATCH /api/events/:id; click empty date opens create slide-over
- Wire /calendar route, Calendar nav link in AppHeader, g+l keyboard shortcut

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:38:09 -04:00
bvandeusen 8d330afc6d feat(calendar): events REST API blueprint and registration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 12:53:52 -04:00
bvandeusen 90ca667df2 feat(calendar): implement events service with CalDAV push
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 12:49:57 -04:00
bvandeusen b547f47f54 feat(calendar): update Event model and patch caldav.create_event with uid param
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 12:48:40 -04:00
bvandeusen 57f837984c feat(calendar): migration 0029 — add caldav_uid and color to events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 12:42:32 -04:00
bvandeusen da55e32a1a docs(calendar): implementation plan for internal calendar with CalDAV sync
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 12:16:25 -04:00
bvandeusen 651bc1ba7b docs: revise internal calendar spec based on reviewer feedback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 11:47:52 -04:00
bvandeusen 0e27be5b63 docs: add internal calendar with CalDAV sync design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 11:41:14 -04:00
bvandeusen fe6afbad17 docs: update architecture and development docs with recent additions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:56:44 -04:00
bvandeusen e57ac26749 docs: add quickstart compose file; condense README features section
Adds docker-compose.quickstart.yml that pulls the pre-built image from
the registry so users can get started without a local build. Updates
README Quick Start to use the new file as the default path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:55:18 -04:00
bvandeusen 940dd0c08e feat(fable-mcp): add RSS feed management tools (list/add/remove)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:48:57 -04:00
bvandeusen 5f6107bbf8 feat(briefing): add News Preferences section with topic include/exclude inputs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:46:59 -04:00
bvandeusen 06cb7cc86d feat(briefing): render WeatherCard and RSS reaction buttons from message metadata
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:45:16 -04:00
bvandeusen a691fc043d feat(briefing): add WeatherCard.vue component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:44:02 -04:00
bvandeusen aa46551ccf feat(briefing): add RSS reaction and fable-mcp info API helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:42:39 -04:00
bvandeusen 0c2d9c2f6c feat(briefing): add POST/DELETE /api/briefing/rss-reactions endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:39:55 -04:00
bvandeusen 359b5f0545 feat(briefing): trigger RSS classification after new items are stored
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:39:08 -04:00
bvandeusen dc93e0d39f feat(briefing): wire pre-processing pipeline; run_compilation returns (text, metadata)
- Task change detection via snapshot diff
- RSS scoring/filtering via briefing_preferences
- Weather card via parse_weather_card_data (staleness-gated)
- News card markdown format with ordering constraint
- Metadata stored on Message record via post_message()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:38:28 -04:00
bvandeusen e3c1e97cfa feat(briefing): add task change detection helpers and task_id to _gather_internal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:36:52 -04:00
bvandeusen 3b71549b91 feat(briefing): add briefing_preferences service for RSS scoring and filtering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:35:17 -04:00
bvandeusen 2ad07b5e06 feat(briefing): add rss_classifier service for LLM-based topic tagging
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:34:37 -04:00
bvandeusen 9e1615bd32 feat(briefing): add past_days/current_weather to Open-Meteo; add parse_weather_card_data()
Also adds get_cached_weather_rows() for parallel gather in briefing pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:32:59 -04:00
bvandeusen e44eb185d5 feat(briefing): extend post_message() to accept optional metadata dict
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:31:25 -04:00
bvandeusen 5665f484ed feat(briefing): add Message.msg_metadata and RssItem.topics/classified_at columns
msg_metadata maps to the 'metadata' DB column ('metadata' is reserved
by SQLAlchemy Declarative API). to_dict() exposes the key as 'metadata'
for frontend compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:30:37 -04:00
bvandeusen 8fa850534c feat(briefing): add migration 0028 — briefing improvements schema
Also comments out nvidia GPU reservation in docker-compose.yml
(no nvidia-container-toolkit on this host).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 09:59:20 -04:00
bvandeusen fa200fd528 docs(briefing): add implementation plan for briefing improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 09:48:41 -04:00
bvandeusen 24bd80b5d7 docs(briefing): add briefing improvements design spec
Covers task deduplication, RSS classification and preference filtering,
weather card with staleness gate, news cards with reactions, topic
preference settings UI, and Fable MCP RSS feed tools.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 09:31:39 -04:00
bvandeusen bf292e6019 docs: remove extraneous content — pipeline internals moved to architecture, changelog removed
- features.md: remove SQL impl detail from tasks section, sw.js reference from PWA section,
  and entire "LLM Chat — Internal Pipeline" section (moved to architecture.md)
- architecture.md: add "LLM Pipeline Internals" section (intent routing, tool loop, duplicate
  guards, context window, research pipeline, image cache)
- development.md: remove site-specific NFS path from custom runner instructions
- Remove changelog.md (duplicates git history)
- README.md: remove changelog link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:38:44 -04:00
bvandeusen e2133529a0 docs: add missing content from summary.md (API reference, Android app, file structure, LLM internals)
- docs/api-reference.md: complete REST API endpoint reference (~60+ routes)
- docs/android-app.md: Flutter companion app stack, architecture, feature status
- docs/architecture.md: detailed file-by-file reference for all backend services and frontend components
- docs/features.md: LLM pipeline internals (intent routing, tool loop, duplicate guards, image search, research pipeline), roadmap
- docs/development.md: full migration chain (0001–0026) with naming and caveats
- README.md: link to new api-reference and android-app docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:25:12 -04:00
bvandeusen 47e248d9ac docs: restructure documentation into docs/ directory, slim README, add project .mcp.json
- README.md: reduced to overview + quick start + links to docs/
- docs/architecture.md: stack, design decisions, data models, key services
- docs/configuration.md: all env vars, docker-compose setup, production + security
- docs/development.md: dev workflow, CI/CD, migrations, release process
- docs/features.md: detailed feature breakdown + keyboard shortcuts
- docs/api-keys-and-mcp.md: API key management + Fable MCP install guide
- docs/sso-oauth.md: OAuth/OIDC setup (replaces docs/oauth-setup.md)
- docs/changelog.md: development history from summary.md
- Remove summary.md (content distributed across docs/)
- Remove docs/oauth-setup.md (superseded by docs/sso-oauth.md)
- .gitignore: add .mcp.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:03:15 -04:00
bvandeusen 553c38200a feat(fable-mcp): build wheel in Docker image, serve download, add admin log tool, and Settings install UI
- Dockerfile: build fable-mcp wheel into /app/dist/ during image build
- routes/fable_mcp_dist.py: GET /api/fable-mcp/info + /download endpoints
- app.py: register fable_mcp_dist_bp
- fable_mcp/tools/admin.py: get_app_logs() hitting /api/admin/logs
- fable_mcp/server.py: fable_get_app_logs MCP tool
- SettingsView: "Fable MCP" section in API Keys tab with download button and install instructions
- client.ts: getFableMcpInfo() helper
- ci.yml: add fable-mcp/** to trigger paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:26:05 -04:00
bvandeusen 6564a03c0e feat(settings): model management UI — pull, delete, VRAM status
Backend:
- Enrich GET /api/chat/models to also hit /api/ps and return loaded:bool
  and modified_at alongside name/size, using parallel gather

Frontend (Settings → General):
- Model list: each row shows name (monospace), size (GB/MB), 'in VRAM' badge
  if currently loaded, 'default' badge if it's the configured default
- Delete button per row; disabled while deletion in progress
- Pull form: text input (Enter submits) + Pull button
- Suggestion chips for qwen3:7b/14b/4b, llama3.1:8b, nomic-embed-text;
  disabled if already installed
- Progress display during pull: status text + determinate bar when
  Ollama reports total/completed, indeterminate animation otherwise
- Refresh button reloads the list; list auto-refreshes after pull/delete
- Link to ollama.com/library for model discovery

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 02:52:31 -04:00
bvandeusen 9cd0de3883 fix(briefing): deadlock in scheduler startup
start_briefing_scheduler was called from before_serving (event loop thread)
and used run_coroutine_threadsafe(...).result() which blocks the calling
thread waiting for the coroutine to complete — but since the calling thread
IS the event loop, the coroutine could never run, causing a 10s timeout and
zero jobs scheduled.

Fix: make start_briefing_scheduler async and await _get_briefing_enabled_users()
directly. Also use asyncio.create_task for the catch-up rather than
run_coroutine_threadsafe. The background thread jobs (_run_user_slot_sync)
continue to use run_coroutine_threadsafe correctly since they run on the
APScheduler thread, not the event loop thread.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 02:18:30 -04:00
bvandeusen 0db5dd126c fix(task-viewer): esc capture phase to prevent App.vue handler conflict
Register the Esc keydown listener in capture phase (useCapture=true) and
call stopPropagation() so App.vue's document-level handler never fires.
Without this, both handlers ran: App.vue pushed "/" and the component
pushed "/projects/:id", with non-deterministic winner. Also fixes the
blur-then-navigate issue where App.vue blurring an input caused the
component's handler to see body as the active element and navigate
immediately instead of stopping at the blur step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:05:12 -04:00
bvandeusen a2ba90160c feat: kanban status buttons, task back-nav, RSS UI, weather search, briefing fixes
Project view:
- Add inline status advance buttons on kanban task cards (todo→in_progress,
  in_progress→done); buttons reveal on hover, stop link navigation

Task viewer:
- Back button navigates to task's project instead of /tasks when project_id set
- Esc key navigates to project (or /tasks); blurs focused element first

Quick capture:
- Use user's configured model instead of hardcoded Config.OLLAMA_MODEL
- Remove create_project from classifier prompt (tool not offered, caused
  task-shaped inputs to silently fall through to note fallback)

Briefing scheduler:
- Fix get_event_loop() → get_running_loop() so background thread uses the
  correct hypercorn event loop (jobs were scheduling but never executing)
- Suppress bare greeting when both LLM synthesis lanes return empty

RSS feed UI (SettingsView):
- Show last-fetched age, category badge, and feed URL per row
- Category input field when adding a feed
- Refresh all button: fetches latest items, reloads list, toasts with count
- Enter key submits add-feed form; better empty-state hint with example feeds

Weather tool:
- Accept any city/region name in addition to 'home'/'work'/'all'
- Geocodes via Nominatim + fetches live from Open-Meteo for arbitrary queries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:42:01 -04:00
bvandeusen a9414cf949 chore: merge main into dev, resolve tools.py conflict
Accepted main's semantic duplicate threshold (>= 80 chars) over dev's
(>= 200 chars) for both note and task body checks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:16:09 -04:00
bvandeusen e407043713 Merge pull request 'feat: API key auth, fable-mcp package, and semantic search endpoint' (#8) from dev into main
feat: API key auth, fable-mcp package, and semantic search endpoint
2026-03-24 03:05:16 +00:00
bvandeusen 00dee5ea0e ci: remove redundant pull_request trigger
All code goes through dev first where CI runs on push. PR runs
(dev→main) were duplicating already-validated checks and causing
queue contention. Build job was already gated to push-only events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:53:32 -04:00
bvandeusen 55e260b392 fix(settings): fallback clipboard copy for http dev environments
navigator.clipboard.writeText() requires a secure context (HTTPS) and
silently fails on http://. Add an execCommand fallback so the API key
copy button works on non-secure dev instances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:39:55 -04:00
bvandeusen 359cb24d9a feat(fable-mcp): implement FableClient, all tool modules, and server.py
- client.py: async httpx wrapper, FableAPIError, stream_get() SSE generator,
  singleton init_client()/get_client() and _reset_client() for tests
- tools/: notes, tasks, projects, milestones, search, chat — thin async
  functions that accept FableClient and call Fable REST endpoints
- server.py: FastMCP entry point with 20 tools registered via @mcp.tool(),
  each opening a fresh FableClient context per call; validates env vars at startup
- tests: 34 tests covering client HTTP behaviour, error handling, singleton,
  SSE streaming, and all tool modules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:51:32 -04:00
bvandeusen 6180ee65c3 feat: add .env and Claude config downloads to API key reveal
After creating a key, two download buttons appear:
- 'Download .env' — pre-filled FABLE_URL + FABLE_API_KEY
- 'Download Claude config' — ready-to-paste mcpServers JSON block

Also adds Future Task A (in-app install instructions) and Future Task B
(Forgejo MCP) to the implementation plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:44:34 -04:00
bvandeusen 66571e16c1 feat: scaffold fable-mcp Python package
pyproject.toml with mcp[cli]/httpx/python-dotenv deps, entry point
fable-mcp=fable_mcp.server:main, dev deps for testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:21:55 -04:00
bvandeusen 73eb34d722 feat: add API Keys tab to Settings UI
Create/revoke API keys with read/write scope selector. One-time key
reveal with copy button. Keys table showing prefix, scope badge, last
used. Inline revoke confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:15:24 -04:00
bvandeusen 47b4af281b feat: API key routes, search endpoint, conversation type wiring
- GET/POST/DELETE /api/api-keys blueprint registered
- GET /api/search?q=&content_type=&limit= semantic search endpoint
- create_conversation gains conversation_type param (default "chat")
- cleanup_old_conversations excludes mcp type from retention sweep
- POST /api/chat/conversations accepts conversation_type body field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:01:04 -04:00
bvandeusen e8a4ca915a feat: add bearer token auth to _check_auth, falls back to session
Checks Authorization: Bearer header first, hashes token, looks up in
api_keys. Read-only keys get 403 on non-GET. Admin routes inaccessible
to non-admin key owners. Session path unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:57:26 -04:00
bvandeusen 583a140485 feat: add ApiKey service with create/list/revoke/lookup
SHA-256 hashed keys, fmcp_ prefix, soft-delete revocation. Tests cover
pure logic (hash, prefix, format, uniqueness, scope validation) and
auth middleware bearer token path (pending auth.py update).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:56:51 -04:00
bvandeusen 7358909432 feat: add ApiKey model and migration 0027
Adds api_keys table with user_id FK, key_hash, key_prefix, scope
(read/write), last_used_at, revoked_at. SHA-256 hashed, soft-delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:52:25 -04:00
bvandeusen c24b21f259 docs: add Fable MCP implementation plan
17-task TDD plan covering Phase 1 (API key auth, search endpoint,
conversation type wiring, Settings UI) and Phase 2 (fable-mcp Python
package with all tool modules, tests, and Claude Code registration).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:24:51 -04:00
bvandeusen e54d172487 docs: fix 8 spec review issues in Fable MCP design
- Add migration down_revision = "0026"
- Document create_conversation service + route changes for conversation_type
- Document SSE wire format (endpoints, event schema, termination event)
- Add retention sweep exclusion for mcp conversation type
- Rename type→content_type in search, document is_task mapping
- Clarify API keys cannot access admin-protected routes
- Document POST /api/chat/conversations accepting conversation_type body field
- Add delete_milestone to milestones tool list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:03:53 -04:00
bvandeusen f9973e22f1 docs: add Fable MCP design spec
Design for two sub-projects: API key auth support in fabledassistant
(bearer tokens, read/write scope, Settings UI) and a standalone Python
MCP server at fable-mcp/ exposing notes, tasks, projects, milestones,
semantic search, and chat tools to Claude Code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:00:26 -04:00
bvandeusen e9ddab7368 Merge pull request 'fix: remove registry build cache — Forgejo rejects large layer blobs' (#7) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#7
2026-03-16 17:37:43 +00:00
Bryan Van Deusen 16b2b5c68e fix: remove registry build cache — Forgejo rejects large layer blobs
Even mode=min still hits the registry's blob size limit (400 Bad Request).
The local runner's Docker daemon layer cache is sufficient for fast
incremental builds without needing a separate registry cache tag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:53:02 -04:00
bvandeusen ce5b2ffaeb Merge pull request 'fix: downgrade buildx cache export from mode=max to mode=min' (#6) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#6
2026-03-16 16:41:38 +00:00
Bryan Van Deusen c232a7d079 fix: downgrade buildx cache export from mode=max to mode=min
mode=max exports all intermediate layer blobs which exceeded the Forgejo
registry's blob upload size limit (400 Bad Request). mode=min only exports
the final image layers, keeping cache entries small enough for the registry.
The actual image build and push was succeeding; only the cache write failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:32:06 -04:00
bvandeusen 1c3f0ab7f7 Merge pull request 'Dev to Main: bug fixes' (#5) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#5
2026-03-16 16:00:47 +00:00
bvandeusen 33f52081e5 docs: update summary.md for 2026-03-16 session
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 08:29:04 -04:00
bvandeusen a58dbe79f2 fix: fuzzy project resolution and guard against accidental project creation
- _resolve_project now uses reverse-substring + SequenceMatcher (≥0.55)
  so partial names like "Famous Supply" match "Famous Supply Co." reliably
- create_project runs similar-project checks before the confirmed gate so
  confirmed=true can't bypass them; threshold lowered to 0.55 to catch
  abbreviated names
- Error messages on project-not-found no longer suggest create_project,
  directing the model to list_projects first
- Tool description updated to explicitly prohibit calling create_project
  in response to a lookup failure
- tasks.py: fire-and-forget embedding update on create/update
- briefing_pipeline.py: await is_caldav_configured (was sync call)
- notes.py: remove erroneous duplicate count_query filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 07:44:27 -04:00
bvandeusen 0d41b8be34 Fix briefing settings not loading when tab is active on mount
The watch(activeTab) only fires on changes, not the initial value.
When localStorage had settings_tab="briefing", onMounted skipped
loadBriefingTab() so the form always showed default empty values.
Follows the same pattern already used for the groups panel (line 338).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 23:05:10 -04:00
bvandeusen 46672725a1 Skip semantic duplicate check for bare-title tasks/notes
The semantic similarity check was flagging unrelated short-title tasks
as duplicates (e.g. "Lore: Shell 0" matching "Lore: Reinitialization 0"
at 91%) because with no body, the embedding is purely title-based and
co-domain tasks in the same project share a tight embedding neighborhood.

Only run the semantic check when the body is ≥ 80 chars — enough
content to make a meaningful comparison. The fuzzy title check already
covers exact/near-exact title duplicates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:42:07 -04:00
bvandeusen 4d55d9d82a Fix scanner probes returning 200 via SPA catch-all
The 404 handler was unconditionally serving index.html (200) for all
non-API, non-static paths, including scanner probes for .php, .asp, .cgi
etc. Added _SPA_EXTENSIONS set so paths with unknown extensions get a
real 404 instead of a misleading 200.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:28:25 -04:00
112 changed files with 16656 additions and 1882 deletions
+4 -10
View File
@@ -2,7 +2,7 @@
#
# Push to dev: typecheck + lint + test → build :dev + :<sha>
# Tag v* (release): typecheck + lint + test → build :latest + :<sha> + :<version>
# Pull request: typecheck + lint + test only (no build)
# Pull request: no CI run — code is validated on dev push before any PR is opened
#
# To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name.
@@ -26,14 +26,10 @@ on:
- "alembic.ini"
- "Dockerfile"
- "assets/**"
- "fable-mcp/**"
- ".forgejo/workflows/ci.yml"
pull_request:
paths:
- "src/**"
- "frontend/**"
- "tests/**"
- "pyproject.toml"
- ".forgejo/workflows/ci.yml"
# pull_request trigger intentionally omitted — all changes go through dev
# first, where CI already runs on push. PR runs would be redundant duplication.
env:
REGISTRY: git.fabledsword.com
@@ -142,5 +138,3 @@ jobs:
push: true
tags: ${{ steps.tags.outputs.value }}
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max
+1
View File
@@ -34,3 +34,4 @@ docker-compose.override.yml
*.log
.DS_Store
.superpowers/
.mcp.json
+8 -1
View File
@@ -2,7 +2,7 @@
FROM node:22-alpine AS build-frontend
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install -g npm@latest --quiet && npm install
RUN npm ci --quiet
COPY frontend/ .
RUN npm run build
@@ -14,6 +14,13 @@ COPY pyproject.toml .
COPY src/ src/
RUN pip install --no-cache-dir .
# Build the fable-mcp wheel so it can be served for download
COPY fable-mcp/ fable-mcp/
RUN pip install --no-cache-dir build hatchling \
&& python -m build --wheel ./fable-mcp --outdir /app/dist/ \
&& pip uninstall -y build \
&& rm -rf fable-mcp/ /root/.cache/pip
COPY --from=build-frontend /build/dist/ src/fabledassistant/static/
COPY alembic.ini .
COPY alembic/ alembic/
+25 -194
View File
@@ -2,210 +2,41 @@
A self-hosted second brain and project management application with integrated LLM capabilities. Write, organise, and act on your notes and tasks with the help of a local AI assistant — all running on your own hardware.
## What It Does
## Features
**Notes with inline formatting** — Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold, italic, lists, code blocks, and task checklists render inline. A slash-command menu (`/`) inserts common blocks without leaving the keyboard.
Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, an MCP server for external AI clients, and an Android companion app.
**Task tracking** — Notes convert freely to tasks (and back). Tasks carry status (`todo``in_progress``done`), priority, due date, sub-tasks, milestone assignment, and work logs with time tracking.
## Quick Start
**Projects and milestones** — Group related notes and tasks into projects. Milestones give projects a timeline and show completion progress. A kanban-style project view groups tasks by milestone.
**Prerequisites:** Docker and Docker Compose. 8 GB+ RAM recommended for LLM inference.
**Project Workspace**`/workspace/:projectId` opens a three-panel environment (tasks / chat / notes) locked to a project. The AI assistant creates and updates content directly in the workspace; new notes auto-load in the editor and the task list refreshes automatically.
**Wikilinks and backlinks** — Link notes with `[[Title]]` syntax. Click a wikilink to navigate to (or create) the referenced note. Each note shows what links to it. The editor suggests existing note titles as candidate links.
**Tag organisation** — Tags are first-class columns (stored as a PostgreSQL array, not extracted from body text). Tag autocomplete, tag-based filtering, and a force-directed graph view show how notes cluster.
**Knowledge graph**`/graph` renders all notes, tasks, and tags as a D3 force-directed graph. Tag nodes cluster notes that share tags; project hub nodes (invisible) attract project members. Click any node to open a slide-in peek panel.
**AI chat** — Full conversation history with SSE streaming. The assistant automatically retrieves semantically relevant notes (RAG) and injects them as context. Attach specific notes by paperclip for focused discussions. Useful replies can be saved directly as notes.
**AI writing assistant** — Select a passage in the editor, give an instruction ("make this more concise", "add examples"), and stream a diff-style suggestion you can accept or reject.
**Web research** — The assistant can search the web (SearXNG), fetch pages, and synthesise findings. Research results are saved as notes. A lightweight `search_web` tool answers quick questions inline.
**Collaboration and sharing** — Share any project or note with other users or groups. Three permission levels: `viewer`, `editor`, `admin`. A "Shared with me" page lists all incoming shares. In-app notifications (with push) alert recipients when items are shared or when they are added to a group.
**Groups** — Admins can create platform-wide groups, assign users roles (`member` / `owner`), and share resources with the group in one action.
**In-app notifications** — A bell icon in the nav shows unread notification count with a 60-second polling interval. Clicking a notification navigates to the relevant resource and marks it read.
**CalDAV calendar** — Connect an external CalDAV server (Nextcloud, Radicale, etc.) and have the assistant create, list, search, update, and delete calendar events via natural language.
**Push notifications** — Web Push (VAPID) notifies you when AI generation completes, even in another tab. Configurable per-user from the Notifications settings tab.
**PWA** — Installable as a desktop or mobile app. Service worker caches the shell; push is handled by `public/sw.js`.
**Data export and backup** — Export your data as a Markdown ZIP (with YAML frontmatter) or a JSON array from the Data settings tab. Admins can export/restore full application backups (version 2 includes projects, milestones, task logs, AI drafts, note versions, push subscriptions) with proper ID remapping on restore.
**OAuth / OIDC login** — Supports PKCE-based OIDC in addition to local username/password auth. `LOCAL_AUTH_ENABLED` can disable local login entirely for SSO-only deployments.
**Multi-user with isolation** — All data is scoped to the owning user. Access to shared resources is resolved through `services/access.py` using the permission rank system. The first registered user becomes admin.
**Daily Briefing** — A scheduled, dialogue-based morning briefing accessible at `/briefing`. The assistant compiles your tasks, calendar events, projects, weather forecast (Open-Meteo), and RSS feed digest at 4am, then checks in at 8am, 12pm, and 4pm. You can reply interactively — the briefing is a real conversation, not a widget. The assistant learns your preferences over time via a profile note it maintains. Configure locations, work schedule, RSS feeds, and active slots from Settings → Briefing.
**Dark and light themes** — Defaults to dark (slate-indigo palette). One-click toggle in the header.
**Keyboard shortcuts**`g`+`h/n/t/p/c/g` navigate sections; `n` new note; `t` new task; `?` shows the shortcut panel. Full list available in-app.
---
## Getting Started
### Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
- A machine with enough RAM to run an LLM (8 GB+ recommended for smaller models like `llama3.2`)
### Quick Start
1. Clone the repository:
```bash
git clone https://github.com/your-username/fabledassistant.git
cd fabledassistant
```
2. Start the application:
```bash
docker compose up --build
```
3. Open `http://localhost:5000` in your browser.
4. Register the first user account — this account becomes the admin.
5. Go to **Settings → General** to pull an LLM model (`llama3.2` at 2 GB is a good starting point).
### Day-to-Day Usage
- **Create a note** from the Notes page. Use Markdown — formatting renders live.
- **Link notes** by typing `[[` for a wikilink autocomplete dropdown.
- **Tag your notes** — add tags in the sidebar tag input; autocomplete suggests existing tags.
- **Use the AI writing assistant** — select text in the editor, write an instruction, stream a suggestion.
- **Chat with the AI** — the assistant finds relevant notes automatically. Attach specific notes for focused context.
- **Convert notes ↔ tasks** from the viewer toolbar.
- **Open a project workspace** from the project page — chat, tasks, and note editor in one view.
- **Share a project or note** — click Share in the project/note/task viewer toolbar, search for users or pick a group.
- **Manage groups** (admins) — Settings → Groups tab.
- **Backup your data** — Settings → Data tab for personal export; admin section for full application backup.
---
## Configuration
Configuration is via environment variables. See `docker-compose.yml` for defaults.
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL connection string |
| `SECRET_KEY` | (required) | Session signing key |
| `OLLAMA_BASE_URL` | `http://ollama:11434` | Ollama API endpoint |
| `DEFAULT_MODEL` | `llama3.1` | LLM model to warm on startup |
| `EMBEDDING_MODEL` | `nomic-embed-text` | Model used for semantic search / RAG |
| `SECURE_COOKIES` | `false` | Set `true` behind TLS |
| `LOG_LEVEL` | `INFO` | Logging verbosity |
| `OIDC_ISSUER` | — | OIDC issuer URL for SSO login |
| `OIDC_CLIENT_ID` | — | OIDC client ID |
| `OIDC_CLIENT_SECRET` | — | OIDC client secret |
| `LOCAL_AUTH_ENABLED` | `true` | Set `false` to disable local login |
| `SEARXNG_URL` | — | SearXNG base URL for web search |
| `BASE_URL` | — | Public URL (used in email links and OIDC redirect) |
For production, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits.
---
## Production Deployment
### Reverse Proxy (Required)
Fabled Assistant does **not** handle SSL/TLS. Run it behind a reverse proxy:
- **Nginx**, **Traefik**, or **Caddy** in front of the app container
- Terminate TLS at the proxy; forward to port 5000
- **Do not expose port 5000 directly to the internet**
- **Rate-limit auth endpoints** — recommended: ≤5 req/min per IP on `/api/auth/login` and `/api/auth/register`
- **Set CSP headers** — recommended: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'`
### Security Checklist
- **Strong `SECRET_KEY`** — generate with `python -c "import secrets; print(secrets.token_hex(32))"` or use Docker secrets via `SECRET_KEY_FILE`
- **Registration** — auto-closes after the first user (admin). Re-enable from Settings → Users or send invite links.
- **Session invalidation** — changing or resetting a password bumps `session_version`, evicting all other active sessions.
- **Keep Ollama on an internal network** — both compose files keep it off the host network.
---
## Technical Overview
### Stack
| Layer | Technology |
|-------|------------|
| Frontend | Vue 3, TypeScript, Vite, Pinia, Vue Router |
| Editor | Tiptap (ProseMirror) with custom slash-command extension |
| Backend | Python 3.12, Quart (async ASGI) |
| Database | PostgreSQL 16, SQLAlchemy 2.0 async, Alembic |
| LLM | Ollama (local) — any OpenAI-compatible API |
| Search | SearXNG (optional, self-hosted) |
| Push | Web Push / VAPID (pywebpush 2.x) |
| Deployment | Docker Compose |
### Architecture
The app runs as a single container serving the Vue SPA and REST API (`/api/`). The frontend is built by Vite during the Docker image build and served as static files by Quart.
LLM interactions stream via Server-Sent Events (SSE). Chat generation runs in background `asyncio` tasks with an in-memory event buffer supporting client reconnection without data loss. An abort mechanism lets users cancel in-flight generations.
Semantic search (RAG) uses `nomic-embed-text` via Ollama to generate embeddings stored in PostgreSQL. Notes above 0.60 cosine similarity are auto-injected into the system prompt; notes between 0.450.60 appear as sidebar suggestions.
Permission resolution is centralised in `services/access.py`. All resource access goes through `get_project_permission` / `get_note_permission`, which check ownership, direct shares, group membership shares, and note→project inheritance in order, returning the highest applicable permission.
### Database
PostgreSQL with SQLAlchemy 2.0 async. Tasks are notes with non-null `status` (unified `Note` model). Tags are stored as a `ARRAY[text]` column. Migrations run automatically on startup via Alembic.
### Project Structure
```
fabledassistant/
├── docker-compose.yml # Development stack
├── docker-compose.prod.yml # Production stack (Docker Swarm)
├── Dockerfile # Multi-stage build (Node → Python)
├── alembic/ # Database migrations
├── src/fabledassistant/
│ ├── app.py # Quart app factory + blueprint registration
│ ├── models/ # SQLAlchemy models
│ ├── routes/ # API blueprints
│ ├── services/ # Business logic (access, sharing, groups, …)
│ └── static/ # Built frontend (generated at build time)
└── frontend/
└── src/
├── views/ # Page-level components
├── components/ # Reusable UI components
├── composables/ # Vue composables (autosave, shortcuts, …)
├── stores/ # Pinia stores (auth, chat, notes, notifications, …)
└── api/ # Typed API client (client.ts)
```
### Development
All development is done via Docker. No local dependency installation required.
Download [`docker-compose.quickstart.yml`](docker-compose.quickstart.yml) from this repo, then:
```bash
# Start the dev stack (hot-reload not included — rebuild on changes)
docker compose up --build
# Optional but recommended — set a secret key
export SECRET_KEY=your-random-secret-here
# Reset the database
docker compose down -v && docker compose up --build
# Lint, format, typecheck, test (runs inside Docker via Makefile)
make check
docker compose -f docker-compose.quickstart.yml up -d
```
CI runs on Forgejo Actions with a custom runner image (`py3.12-node22`) that has Python 3.12 and Node 22 pre-installed. Pushes to `dev` build a `:dev` image; merges to `main` build `:latest`.
Open `http://localhost:5000`. The first user to register becomes admin. Go to **Settings → General** to pull an LLM model — `qwen3:8b` or `llama3.1:8b` are good starting points.
---
> **GPU:** Ollama runs CPU-only by default. See the comments in `docker-compose.quickstart.yml` to enable NVIDIA GPU passthrough.
> **Development:** To build from source, see [Development](docs/development.md).
## Documentation
| Doc | Contents |
|-----|----------|
| [Architecture](docs/architecture.md) | Stack, design decisions, data models, key services |
| [Configuration](docs/configuration.md) | Environment variables, Docker Compose, production setup, security |
| [Features](docs/features.md) | Detailed feature breakdown and keyboard shortcuts |
| [Development](docs/development.md) | Dev workflow, CI/CD, migrations, release process |
| [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide |
| [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers |
| [API Reference](docs/api-reference.md) | All REST API endpoints |
| [Android App](docs/android-app.md) | Flutter companion app architecture and feature status |
## License
+34
View File
@@ -0,0 +1,34 @@
"""add api_keys table
Revision ID: 0027
Revises: 0026
Create Date: 2026-03-23
"""
from alembic import op
import sqlalchemy as sa
revision = "0027"
down_revision = "0026"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"api_keys",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("key_hash", sa.Text(), nullable=False, unique=True),
sa.Column("key_prefix", sa.Text(), nullable=False),
sa.Column("scope", sa.Text(), nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"])
def downgrade() -> None:
op.drop_table("api_keys")
@@ -0,0 +1,55 @@
"""Add briefing improvements: rss_items topics/classified_at, messages metadata,
rss_item_reactions, briefing_task_snapshot."""
from alembic import op
revision = "0028"
down_revision = "0027"
def upgrade() -> None:
op.execute("""
ALTER TABLE rss_items
ADD COLUMN IF NOT EXISTS topics TEXT[] DEFAULT '{}',
ADD COLUMN IF NOT EXISTS classified_at TIMESTAMPTZ
""")
op.execute("""
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS metadata JSONB
""")
op.execute("""
CREATE TABLE IF NOT EXISTS rss_item_reactions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rss_item_id INTEGER NOT NULL REFERENCES rss_items(id) ON DELETE CASCADE,
reaction TEXT NOT NULL CHECK (reaction IN ('up', 'down')),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, rss_item_id)
)
""")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_rss_item_reactions_user_id "
"ON rss_item_reactions(user_id)"
)
op.execute("""
CREATE TABLE IF NOT EXISTS briefing_task_snapshot (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
task_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
snapshot_hash TEXT NOT NULL,
last_briefed TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, task_id)
)
""")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_briefing_task_snapshot_user_id "
"ON briefing_task_snapshot(user_id)"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS briefing_task_snapshot")
op.execute("DROP TABLE IF EXISTS rss_item_reactions")
op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS metadata")
op.execute("ALTER TABLE rss_items DROP COLUMN IF EXISTS classified_at")
op.execute("ALTER TABLE rss_items DROP COLUMN IF EXISTS topics")
@@ -0,0 +1,19 @@
"""Add caldav_uid and color columns to events table."""
from alembic import op
revision = "0029"
down_revision = "0028"
def upgrade() -> None:
op.execute("""
ALTER TABLE events
ADD COLUMN IF NOT EXISTS caldav_uid TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS color TEXT DEFAULT ''
""")
def downgrade() -> None:
op.execute("ALTER TABLE events DROP COLUMN IF EXISTS color")
op.execute("ALTER TABLE events DROP COLUMN IF EXISTS caldav_uid")
+23
View File
@@ -0,0 +1,23 @@
"""Add rag_project_id to conversations; auto_summary columns to projects."""
from alembic import op
revision = "0030"
down_revision = "0029"
def upgrade() -> None:
op.execute("""
ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS rag_project_id INTEGER DEFAULT NULL
""")
op.execute("""
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS auto_summary TEXT DEFAULT NULL,
ADD COLUMN IF NOT EXISTS summary_updated_at TIMESTAMPTZ DEFAULT NULL
""")
def downgrade() -> None:
op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS rag_project_id")
op.execute("ALTER TABLE projects DROP COLUMN IF EXISTS auto_summary, DROP COLUMN IF EXISTS summary_updated_at")
+82
View File
@@ -0,0 +1,82 @@
# Fabled Assistant — Quick Start
#
# No build required. Pulls the latest pre-built image from the registry.
#
# Usage:
# 1. Download this file
# 2. docker compose -f docker-compose.quickstart.yml up -d
# 3. Open http://localhost:5000 — the first account registered becomes admin
# 4. Go to Settings → General to pull an LLM model (qwen3:8b or llama3.1:8b are good starting points)
#
# Set SECRET_KEY via environment variable or a .env file alongside this file:
# SECRET_KEY=your-random-secret-here
services:
app:
image: git.fabledsword.com/bvandeusen/fabledassistant:latest
ports:
- "5000:5000"
environment:
DATABASE_URL: "postgresql+asyncpg://fabled:fabled@db:5432/fabledassistant"
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
OLLAMA_URL: "http://ollama:11434"
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1:8b}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
volumes:
- app_data:/data
depends_on:
db:
condition: service_healthy
ollama:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: fabled
POSTGRES_PASSWORD: fabled
POSTGRES_DB: fabledassistant
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fabled"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
ollama:
image: ollama/ollama
volumes:
- ollama_models:/root/.ollama
environment:
OLLAMA_MAX_LOADED_MODELS: "2"
OLLAMA_KEEP_ALIVE: "30m"
OLLAMA_FLASH_ATTENTION: "1"
healthcheck:
test: ["CMD-SHELL", "ollama list > /dev/null 2>&1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 15s
restart: unless-stopped
# Uncomment to enable NVIDIA GPU passthrough (requires nvidia-container-toolkit):
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
volumes:
app_data:
pgdata:
ollama_models:
+8 -7
View File
@@ -55,13 +55,14 @@ services:
OLLAMA_NUM_PARALLEL: "2"
OLLAMA_KEEP_ALIVE: "30m"
OLLAMA_FLASH_ATTENTION: "1"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
# GPU reservation commented out — no nvidia-container-toolkit on this host
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
volumes:
pgdata:
+271
View File
@@ -0,0 +1,271 @@
# Fable MCP — Design Spec
**Date:** 2026-03-23
**Status:** Approved
**Author:** bvandeusen + Claude
---
## Overview
A Python MCP (Model Context Protocol) server that lets Claude directly interface with a running Fabled Assistant instance. The goal is two-fold: Claude's stronger reasoning handles planning, documentation, and analysis while Fable remains the authoritative store for notes, tasks, projects, and milestones; and direct API access enables process improvement by letting Claude observe and operate on real data rather than working from descriptions.
The work is split into two sub-projects:
1. **Fable API Key Feature** — additions to the main `fabledassistant` project to support bearer token authentication
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
---
## Sub-project 1: Fable API Key Feature
### Motivation
All existing Fable routes use session-based authentication (`session["user_id"]`). The MCP server runs as a local process and cannot maintain a browser session, so a stateless bearer token mechanism is required. API keys are user-scoped and carry a read/write permission level.
### Database
New table `api_keys` via migration `0027_add_api_keys.py`:
- `down_revision = "0026"` (the briefing tables migration)
- `revision = "0027"`
| Column | Type | Notes |
|--------|------|-------|
| `id` | Integer PK | |
| `user_id` | Integer FK → users | CASCADE delete |
| `name` | Text | Human-readable label |
| `key_hash` | Text | SHA-256 of the full key, used for lookup |
| `key_prefix` | Text | First 8 chars (e.g. `fmcp_ab12`), shown in UI |
| `scope` | Text | `"read"` or `"write"` |
| `last_used_at` | Timestamp | Nullable, updated on each authenticated request |
| `created_at` | Timestamp | |
| `revoked_at` | Timestamp | Nullable — soft delete |
Full keys are generated as `fmcp_<32 random url-safe chars>`, returned once at creation, never stored. Only the SHA-256 hash is persisted.
### Auth Middleware (`auth.py`)
`_check_auth` is updated to check the `Authorization: Bearer <token>` header before falling back to session auth:
1. If header present: hash the token, look up in `api_keys` where `revoked_at IS NULL`
2. If found: update `last_used_at`, set `g.user` and `g.api_key`
3. Scope enforcement: if `g.api_key.scope == "read"` and `request.method` is not `GET`, return 403
4. If header absent: existing session logic runs unchanged
Session auth is untouched — no regression risk for the web UI.
**Admin routes and API keys:** Routes decorated with `admin_required` check `user.role == "admin"` after auth resolves. API keys authenticate as the key owner — so a write-scoped key for a non-admin user will fail admin-protected routes with 403 (role check, not scope check). This is intentional: API keys cannot elevate privilege beyond the user's role.
### Routes (`/api/api-keys`)
New blueprint `api_keys_bp`, registered in `app.py`:
- `GET /api/api-keys` — list caller's keys (prefix, name, scope, last_used_at, created_at — never the hash or full key)
- `POST /api/api-keys` — create key; body: `{name, scope}`. Returns full key in response **once only**
- `DELETE /api/api-keys/:id` — revoke by setting `revoked_at = now()`
### Settings UI
New "API Keys" tab in `SettingsView.vue` (added to `VALID_TABS`):
- Create form: name input + read/write radio/toggle + "Generate Key" button
- After creation: one-time modal displaying the full key with a copy button and a warning that it will not be shown again
- Keys table: columns for name, scope badge, prefix, last used, revoke button
- Revoke shows inline confirmation before calling DELETE
### New Search Endpoint
`GET /api/search?q=<query>&content_type=note|task|all&limit=N`
Calls the existing `semantic_search_notes()` service (already in `services/embeddings.py`). The `content_type` parameter maps to the service's `is_task` argument:
- `content_type=note``is_task=False`
- `content_type=task``is_task=True`
- `content_type=all` (default) → `is_task=None`
Returns:
```json
{
"results": [
{"id": 1, "title": "...", "body": "...", "is_task": false, "similarity": 0.87, "tags": [...]}
],
"total": 5
}
```
This endpoint is needed by the MCP's `search.py` tool module. It reuses existing infrastructure with no new ML work.
### Conversation Type: `"mcp"`
A new conversation type `"mcp"` is added alongside `"chat"` and `"briefing"`. This requires changes in two places in the main app:
**`services/chat.py`** — `create_conversation(user_id, title, model)` gains an optional `conversation_type: str = "chat"` parameter, passed through to the `Conversation` constructor.
**`routes/chat.py`** — `POST /api/chat/conversations` accepts an optional `conversation_type` body field (defaults to `"chat"`), passed to `create_conversation`. `GET /api/chat/conversations` continues to default-filter to `conversation_type="chat"` (excluding `"mcp"` and `"briefing"`); pass `?type=mcp` to retrieve MCP conversations.
**Retention:** `cleanup_old_conversations` in `routes/chat.py` currently deletes conversations regardless of type. It must be updated to exclude `conversation_type="mcp"` from the sweep, so MCP audit-trail conversations are not automatically pruned.
MCP conversations:
- Are created with a caller-supplied name (e.g., `"MCP Session 2026-03-23"`) or auto-named
- Are excluded from the default chat list
- Are accessible via `GET /api/chat/conversations?type=mcp` for inspection
- Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions
### Chat SSE Wire Format
The MCP's `chat.py` tool must consume the existing SSE stream. The relevant endpoints and event schema:
- **Create conversation:** `POST /api/chat/conversations``{id, title, ...}`
- **Post message + start generation:** `POST /api/chat/conversations/:id/messages``{message_id, ...}`; then connect to:
- **Stream:** `GET /api/chat/conversations/:id/stream` (SSE)
SSE event format (each line is `data: <json>`):
- `{"type": "token", "content": "..."}` — streaming token
- `{"type": "tool_call", "name": "...", "result": {...}}` — tool fired
- `{"type": "done", "content": "...", "tools_used": [...]}` — generation complete; this is the termination event
The MCP tool reads tokens until it receives `type: "done"`, then returns `response` (full content) and `tools_used` (list of tool names).
---
## Sub-project 2: Fable MCP Server
### Location
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `fabledassistant`. It will be extracted to its own Forgejo repo once stable.
### Package Structure
```
fable-mcp/
pyproject.toml # entry point: fable-mcp = "fable_mcp.server:main"
README.md
.env.example # FABLE_URL=http://localhost:8080, FABLE_API_KEY=fmcp_...
fable_mcp/
__init__.py
server.py # FastMCP instance, imports + registers all tool modules
client.py # FableClient: async httpx wrapper, env var config, FableAPIError
tools/
__init__.py
notes.py # list_notes, get_note, create_note, update_note, delete_note
tasks.py # list_tasks, get_task, create_task, update_task, delete_task,
# patch_task_status, add_task_log
projects.py # list_projects, get_project, create_project, update_project,
# delete_project, get_project_summary
milestones.py # list_milestones, get_milestone, create_milestone,
# update_milestone, delete_milestone
search.py # semantic_search — calls GET /api/search
chat.py # send_message — creates/continues conversation, returns response
```
### Dependencies
```toml
[project]
dependencies = [
"mcp[cli]>=1.0",
"httpx>=0.27",
"python-dotenv>=1.0",
]
```
### `client.py`
`FableClient` is an async context manager wrapping `httpx.AsyncClient`. It reads `FABLE_URL` and `FABLE_API_KEY` from environment variables (with `python-dotenv` fallback to `.env`). All methods are `async def` and return parsed dicts. A `FableAPIError(status_code, message)` exception is raised for any non-2xx response.
A module-level singleton `_client: FableClient` is initialized at server startup and shared across all tool modules.
### `server.py`
Uses `mcp[cli]`'s `FastMCP` class. Imports all tool modules, which register their tools via decorators against the shared `FastMCP` instance. Entry point `main()` calls `mcp.run()` (stdio transport).
### Tool Modules
Each module imports `_client` from `client.py` and defines tools as `async def` functions decorated with `@mcp.tool()`. Tool docstrings serve as the MCP tool descriptions visible to Claude.
**`notes.py`** tools:
- `list_notes(query, tags, project_id, limit, offset)`
- `get_note(note_id)`
- `create_note(title, body, tags, project_id)`
- `update_note(note_id, title, body, tags, project_id)`
- `delete_note(note_id)`
**`tasks.py`** tools:
- `list_tasks(query, project_id, milestone_id, status, priority, limit, offset)`
- `get_task(task_id)`
- `create_task(title, body, project_id, milestone_id, priority, status)`
- `update_task(task_id, title, body, project_id, milestone_id, priority, status)`
- `delete_task(task_id)`
- `patch_task_status(task_id, status)`
- `add_task_log(task_id, content)`
**`projects.py`** tools:
- `list_projects()`
- `get_project(project_id)` — includes milestone summary
- `create_project(title, description, goal, color)`
- `update_project(project_id, title, description, goal, status, color)`
- `delete_project(project_id)`
**`milestones.py`** tools:
- `list_milestones(project_id, status)`
- `get_milestone(project_id, milestone_id)`
- `create_milestone(project_id, title, description, order_index)`
- `update_milestone(project_id, milestone_id, title, description, status, order_index)`
- `delete_milestone(project_id, milestone_id)`
**`search.py`** tools:
- `semantic_search(query, content_type, limit)``content_type` is `"note"`, `"task"`, or `"all"` (avoids shadowing Python's `type` builtin). Calls `GET /api/search`, returns ranked results with similarity scores.
**`chat.py`** tools:
- `send_message(message, conversation_name)`:
- Looks up or creates a Fable conversation with the given name and `conversation_type="mcp"`
- Posts the message via `POST /api/chat/conversations/:id/messages`
- Consumes SSE stream from `GET /api/chat/conversations/:id/stream` until `type: "done"`
- Returns: `{response: str, tools_used: [str], conversation_id: int}`
- MCP conversations are excluded from the normal chat UI list
### Error Handling
`FableAPIError` is caught at each tool boundary and returned as a descriptive string rather than propagating as an exception. This ensures Claude receives a readable error message (e.g., `"Fable API error 403: Read-only key cannot perform write operations."`) rather than a stack trace. Network errors (`httpx.RequestError`) are similarly caught and surfaced as strings.
Scope enforcement lives entirely in Fable's auth middleware — the MCP does not duplicate it.
### Claude Code Registration
After `pip install -e fable-mcp/` (or `uv tool install ./fable-mcp`), add to `~/.claude/settings.json`:
```json
{
"mcpServers": {
"fable": {
"command": "fable-mcp",
"env": {
"FABLE_URL": "http://localhost:8080",
"FABLE_API_KEY": "fmcp_your_key_here"
}
}
}
}
```
Claude Code spawns the process over stdio automatically. No Docker, no daemon.
---
## Build & Repo Plan
1. Implement and test within `fabledassistant/fable-mcp/`
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
---
## Out of Scope (v1)
- MCP resources (browsable URI tree) — tools cover all use cases in Claude Code
- Forgejo MCP — follow-on spec
- Rate limiting on API key endpoints
- Key expiry / TTL
- Per-resource scope (e.g., key restricted to one project)
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
# Android Companion App
The Android companion app lives in a separate repository at `/home/bvandeusen/Nextcloud/Projects/fabled_app`.
## Stack
- Flutter + Dart
- Riverpod (state management)
- GoRouter (navigation)
- Dio (HTTP client)
- PersistCookieJar (session persistence)
- SSE streaming via `fetch` + `ReadableStream` bridge
## Architecture
```
lib/
app.dart # GoRouter + _Shell + _QuickCaptureBar
core/constants.dart # Routes.*
data/
models/ # note.dart, task.dart, project.dart
api/ # notes_api.dart, tasks_api.dart, projects_api.dart
repositories/ # notes, tasks, projects repositories
providers/
api_client_provider.dart # all API + repository providers
notes_provider.dart # NotesNotifier
tasks_provider.dart # TasksNotifier
projects_provider.dart # ProjectsNotifier
screens/
notes/note_edit_screen.dart # chip tag input + ProjectSelector
tasks/task_edit_screen.dart # ProjectSelector
projects/project_list_screen.dart
widgets/
project_selector.dart # reusable DropdownButtonFormField
```
## Navigation
4-tab shell (Notes · Tasks · Projects · Chat):
- Phone: bottom `NavigationBar`
- Tablet/landscape: `NavigationRail`
Quick Capture bar persists across all tabs. Settings accessible from top-right icon.
## Feature Status
| Feature | Status | Notes |
|---------|--------|-------|
| Notes CRUD | ✅ | Tags chip input; project selector in editor |
| Tasks CRUD | ✅ | Project selector in editor |
| Projects list | ✅ | Active/archived sections; long-press status change; create dialog |
| Chat + SSE | ✅ | Full streaming |
| Quick Capture | ✅ | Offline queue with retry |
| Tags | ✅ | Chip input in NoteEditScreen; typed as `List<String>` |
| Project assignment | ✅ | `ProjectSelector` dropdown in Note + Task editors |
| Milestones | ❌ deferred | Too granular for mobile; web UI handles it |
| Push notifications | ❌ incompatible | Backend uses browser VAPID; Flutter needs FCM/APNs — separate implementation required |
| CalDAV settings | ❌ intentional | Server-side config only; not exposed in mobile app |
## API Compatibility Notes
- `GET /api/projects/:id` returns a flat JSON object (not `{project: ...}` wrapper); includes `summary` field.
- `POST /api/projects` returns the project dict directly (201).
- `PATCH /api/projects/:id` returns the updated project dict.
- Task body field is `body` (not `description`) — the app maps `description``body` on serialize.
## Self-Update
The app supports self-update via the Forgejo release API (`update_provider.dart`). It checks the latest release tag and prompts the user to download and install a new APK when one is available.
## CI
Builds are triggered from the Forgejo Actions pipeline in the `fabled_app` repository. The APK is attached to the release as a downloadable artifact.
+144
View File
@@ -0,0 +1,144 @@
# API Keys and Fable MCP
## API Keys
API keys let external tools access your Fable data without a browser session. Each key is scoped to a single user — it can only access data that user owns or has been shared with them.
### Scopes
| Scope | Permissions |
|-------|-------------|
| `read` | GET endpoints only — list, search, fetch content |
| `write` | Full read + create, update, delete |
Admin-level operations (log access, user management) require a `write`-scoped key from an admin account.
### Creating a Key
1. Go to **Settings → API Keys**
2. Enter a name (e.g. "Claude MCP", "Home Server")
3. Choose scope
4. Click **Generate Key**
5. Copy the key immediately — it is shown only once
After creation you can download:
- **`.env` file** — `FABLE_URL` + `FABLE_API_KEY` ready to paste
- **Claude config JSON** — `mcpServers` block ready to merge into `~/.claude.json`
### Revoking a Key
Click **Revoke** next to the key in the API Keys table and confirm. Revoked keys are deleted immediately.
---
## Fable MCP Server
The Fable MCP server (`fable-mcp`) exposes Fable as a set of MCP tools that Claude (and other MCP clients) can use to read and write your notes, tasks, projects, and more.
### Download
The wheel is bundled into the Docker image at build time and available for download from **Settings → API Keys → Fable MCP** when you are logged in.
You can also download it directly:
```
GET /api/fable-mcp/download
```
(Requires login — authenticated browser session or API key in `Authorization: Bearer <key>` header.)
### Installation
```bash
# Install the wheel
pip install fable_mcp-*.whl
# Verify
fable-mcp --help
```
### Configuration
The server reads two environment variables:
| Variable | Description |
|----------|-------------|
| `FABLE_URL` | Base URL of your Fable instance (e.g. `https://notes.example.com`) |
| `FABLE_API_KEY` | API key generated from Settings → API Keys |
Create a `.env` file in your working directory, or set them in your shell / MCP config.
### Claude Code (Global)
Add to `~/.claude.json`:
```json
{
"mcpServers": {
"fable": {
"type": "stdio",
"command": "fable-mcp",
"env": {
"FABLE_URL": "https://your-fable-instance.example.com",
"FABLE_API_KEY": "your-api-key"
}
}
}
}
```
### Claude Code (Project-scoped)
Add a `.mcp.json` at the project root (same format as the global config). Project-scoped config takes precedence over global when the same server name is defined in both. This is useful for using a dev instance or admin key within a specific project.
```json
{
"mcpServers": {
"fable": {
"type": "stdio",
"command": "fable-mcp",
"env": {
"FABLE_URL": "http://localhost:5000",
"FABLE_API_KEY": "your-dev-api-key"
}
}
}
}
```
Note: `.mcp.json` contains an API key and should be added to `.gitignore`.
### Available Tools
| Tool | Description |
|------|-------------|
| `fable_list_notes` | List notes, filter by tag or search text |
| `fable_get_note` | Fetch a note by ID |
| `fable_create_note` | Create a new note |
| `fable_update_note` | Update a note |
| `fable_delete_note` | Delete a note |
| `fable_list_tasks` | List tasks, filter by status or project |
| `fable_get_task` | Fetch a task by ID |
| `fable_create_task` | Create a new task |
| `fable_update_task` | Update a task |
| `fable_add_task_log` | Append a work log entry to a task |
| `fable_list_projects` | List all projects |
| `fable_get_project` | Fetch a project with milestone summary |
| `fable_create_project` | Create a project |
| `fable_update_project` | Update a project |
| `fable_list_milestones` | List milestones for a project |
| `fable_create_milestone` | Create a milestone |
| `fable_update_milestone` | Update a milestone |
| `fable_search` | Semantic search over notes and tasks |
| `fable_list_conversations` | List MCP chat conversations |
| `fable_send_message` | Send a message to Fable's LLM |
| `fable_get_app_logs` | Fetch application logs (admin key required) |
### Development Notes
The `fable-mcp` package lives in `fable-mcp/` in this repository. The Docker build compiles it into a wheel at `/app/dist/` so it can be served for download without requiring the source tree at runtime.
To build the wheel locally:
```bash
cd fable-mcp
pip install build hatchling
python -m build --wheel .
```
+244
View File
@@ -0,0 +1,244 @@
# API Reference
All endpoints require login (session cookie or `Authorization: Bearer <api-key>`) unless marked **(public)**.
## Health
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/health` | Health check **(public)** |
## Auth
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/auth/status` | `{has_users, registration_open, oauth_enabled, local_auth_enabled}` **(public)** |
| POST | `/api/auth/register` | Register new user (first user becomes admin; 403 if registration closed or local auth disabled) |
| POST | `/api/auth/login` | Login with username/password (403 if local auth disabled) |
| POST | `/api/auth/logout` | Clear session |
| GET | `/api/auth/me` | Current user info (includes `has_password: bool`) |
| PUT | `/api/auth/password` | Change password `{current_password, new_password}` |
| PUT | `/api/auth/email` | Change email `{email, password?}` (password required only for local-auth users) |
| POST | `/api/auth/invalidate-sessions` | Bump `session_version` — evicts all other sessions, keeps current alive |
| POST | `/api/auth/forgot-password` | Send password reset email `{email}` |
| POST | `/api/auth/reset-password` | Reset password with token `{token, new_password}` |
| GET | `/api/auth/oauth/login` | Initiate OIDC PKCE flow → redirect to provider |
| GET | `/api/auth/oauth/callback` | OIDC callback — exchange code, find/create user, redirect to `/` |
| GET | `/api/auth/invitation/:token` | Validate invitation token **(public)** |
| POST | `/api/auth/register-with-invite` | Register with token `{token, username, password}` **(public)** |
## Notes
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/notes` | List notes. Params: `q`, `tag`, `sort`, `order`, `limit`, `offset`, `project_id`, `milestone_id`, `parent_id`, `type` (`note`/`task`/`all`) |
| POST | `/api/notes` | Create note `{title, body, tags?, status?, priority?, due_date?, project_id?, milestone_id?, parent_id?}` |
| GET | `/api/notes/tags` | All tags (param: `q` for filter) |
| POST | `/api/notes/suggest-tags` | LLM tag suggestions `{title, body, current_tags?}``{suggested_tags}` |
| POST | `/api/notes/link-suggestions` | Detect note titles as plain text in body `{body, project_id, exclude_note_id}``[{note_id, title, count}]` |
| GET | `/api/notes/by-title` | Resolve note by exact title (param: `title`) |
| POST | `/api/notes/resolve-title` | Get-or-create note by title `{title}` (wikilink click) |
| GET | `/api/notes/:id` | Get single note |
| PUT | `/api/notes/:id` | Full update |
| PATCH | `/api/notes/:id` | Partial update (same fields as PUT) |
| DELETE | `/api/notes/:id` | Delete note |
| POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` |
| POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` |
| POST | `/api/notes/:id/append-tag` | Add tag `{tag}` → updated note |
| GET | `/api/notes/:id/backlinks` | Notes/tasks with `[[Title]]` references to this note |
| GET | `/api/notes/:id/versions` | List note version history |
| GET | `/api/notes/:id/versions/:vid` | Get a specific version |
| GET | `/api/notes/:id/draft` | Get current AI draft |
| PUT | `/api/notes/:id/draft` | Save AI draft |
| DELETE | `/api/notes/:id/draft` | Delete AI draft |
| POST | `/api/notes/assist` | Launch AI assist generation → 202 `{body, target_section?, instruction, whole_doc?}` |
| GET | `/api/notes/assist/stream` | SSE stream for assist (Last-Event-ID reconnect; events: `chunk`, `done`, `error`) |
## Tasks
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/tasks` | List tasks. Params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `sort`, `order`, `limit`, `offset` |
| POST | `/api/tasks` | Create task (accepts `project` name string → resolved to `project_id`) |
| GET | `/api/tasks/:id` | Get task (includes `parent_title`) |
| PUT | `/api/tasks/:id` | Full update |
| PATCH | `/api/tasks/:id/status` | Quick status update `{status}` |
| DELETE | `/api/tasks/:id` | Delete task |
| GET | `/api/tasks/:id/logs` | List work logs |
| POST | `/api/tasks/:id/logs` | Create log `{content, duration_minutes?}` |
| PATCH | `/api/tasks/:id/logs/:log_id` | Update log |
| DELETE | `/api/tasks/:id/logs/:log_id` | Delete log |
## Projects & Milestones
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/projects` | List projects (owned + shared) |
| POST | `/api/projects` | Create project |
| GET | `/api/projects/:id` | Get project with `milestone_summary` |
| PATCH | `/api/projects/:id` | Update project |
| DELETE | `/api/projects/:id` | Delete project |
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
| GET | `/api/projects/:id/milestones` | List milestones |
| POST | `/api/projects/:id/milestones` | Create milestone |
| PATCH | `/api/projects/:id/milestones/:mid` | Update milestone |
| DELETE | `/api/projects/:id/milestones/:mid` | Delete milestone |
## Sharing
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/projects/:id/shares` | List project shares |
| POST | `/api/projects/:id/shares` | Create project share `{user_id?, group_id?, permission}` |
| PATCH | `/api/projects/:id/shares/:sid` | Update permission |
| DELETE | `/api/projects/:id/shares/:sid` | Remove share |
| GET | `/api/notes/:id/shares` | List note shares |
| POST | `/api/notes/:id/shares` | Create note share |
| PATCH | `/api/notes/:id/shares/:sid` | Update permission |
| DELETE | `/api/notes/:id/shares/:sid` | Remove share |
| GET | `/api/shared-with-me` | All resources shared with the current user |
## Groups
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/groups` | List all groups (admin only) |
| POST | `/api/groups` | Create group `{name, description?}` |
| PATCH | `/api/groups/:id` | Update group |
| DELETE | `/api/groups/:id` | Delete group |
| GET | `/api/groups/:id/members` | List members |
| POST | `/api/groups/:id/members` | Add member `{user_id, role}` |
| PATCH | `/api/groups/:id/members/:uid` | Update member role |
| DELETE | `/api/groups/:id/members/:uid` | Remove member |
## Chat
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) |
| POST | `/api/chat/conversations` | Create conversation `{title?, model?}` |
| POST | `/api/chat/conversations/bulk-delete` | Delete multiple conversations `{ids: number[]}` |
| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
| PATCH | `/api/chat/conversations/:id` | Update title or model |
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
| POST | `/api/chat/conversations/:id/messages` | Start generation → 202. Body: `{content, context_note_id?, include_note_ids?, rag_project_id?, workspace_project_id?, think?}` |
| GET | `/api/chat/conversations/:id/generation/stream` | SSE stream (Last-Event-ID reconnect; events: `context`, `chunk`, `tool_call`, `status`, `done`, `error`) |
| POST | `/api/chat/conversations/:id/generation/cancel` | Cancel active generation |
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as note |
| POST | `/api/chat/conversations/:id/summarize` | Summarize conversation → note |
| GET | `/api/chat/status` | Ollama availability + model state `{ollama, model, default_model}` |
| GET | `/api/chat/models` | List installed Ollama models (includes `loaded: bool`, `modified_at`) |
| POST | `/api/chat/models/pull` | Pull model (SSE NDJSON progress) `{model}` |
| POST | `/api/chat/models/delete` | Delete model `{model}` |
| GET | `/api/chat/ps` | Currently loaded (hot) models |
| POST | `/api/chat/warm` | Pre-load model into VRAM `{model}` → 202 |
## Quick Capture
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/quick-capture` | Classify + create item from natural language `{text}``{success, type, message, data}` |
## Search
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/search` | Semantic + keyword search across notes and tasks. Params: `q`, `type` (`note`/`task`/`all`), `limit` |
## Briefing
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/briefing/config` | Get briefing configuration |
| PUT | `/api/briefing/config` | Save briefing configuration |
| GET | `/api/briefing/feeds` | List RSS feeds |
| POST | `/api/briefing/feeds` | Add RSS feed `{url, name?, category?}` |
| DELETE | `/api/briefing/feeds/:id` | Delete feed |
| POST | `/api/briefing/feeds/refresh` | Trigger immediate feed refresh → `{feeds_refreshed, new_items}` |
| GET | `/api/briefing/weather` | Get weather configuration |
| PUT | `/api/briefing/weather` | Save weather locations |
| POST | `/api/briefing/weather/geocode` | Geocode address `{query}``{lat, lon, label}` |
| POST | `/api/briefing/trigger` | Manually fire a briefing slot `{slot}` |
| GET | `/api/briefing/conversations` | List past briefing conversations |
| GET | `/api/briefing/conversations/today` | Get/create today's briefing conversation |
| GET | `/api/briefing/conversations/:id/messages` | Get messages for a briefing conversation |
## Settings
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/settings` | All settings as `{key: value}` |
| PUT | `/api/settings` | Update settings `{key: value, ...}` |
| GET | `/api/settings/models` | Installed models + defaults |
| GET | `/api/settings/search` | Proxy SearXNG search (params: `q`) |
## API Keys
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/api-keys` | List user's API keys |
| POST | `/api/api-keys` | Create key `{name, scope}``{key, ...}` (key shown once) |
| DELETE | `/api/api-keys/:id` | Revoke key |
## Fable MCP Distribution
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/fable-mcp/info` | `{available: bool, filename: string\|null}` |
| GET | `/api/fable-mcp/download` | Download wheel file |
## Notifications
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/notifications` | List notifications |
| GET | `/api/notifications/count` | Unread count |
| POST | `/api/notifications/:id/read` | Mark read |
| POST | `/api/notifications/read-all` | Mark all read |
## Push
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/push/vapid-public-key` | VAPID public key for subscription |
| POST | `/api/push/subscribe` | Register push subscription |
| DELETE | `/api/push/subscribe` | Unregister push subscription |
## Images
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/images/:id` | Serve cached image **(no auth required — IDs are opaque SHA-256)** |
## Users
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/users/search` | Search users by username/email prefix (param: `q`, min 2 chars, excludes self) |
## Export
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/export` | Export data. Params: `format=markdown` (ZIP with `.md` + YAML frontmatter) or `format=json` |
## Admin
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/admin/backup` | Export backup (`?scope=user` for own data; full requires admin) |
| POST | `/api/admin/restore` | Restore from JSON backup |
| GET | `/api/admin/users` | List all users |
| DELETE | `/api/admin/users/:id` | Delete user (cannot delete self) |
| GET | `/api/admin/registration` | Get registration open/closed state |
| PUT | `/api/admin/registration` | Toggle registration `{open: bool}` |
| POST | `/api/admin/invitations` | Create invitation `{email}` → sends email |
| GET | `/api/admin/invitations` | List pending invitations |
| DELETE | `/api/admin/invitations/:id` | Revoke invitation |
| GET | `/api/admin/logs` | Log entries. Params: `category`, `user_id`, `search`, `date_from`, `date_to`, `limit`, `offset` |
| GET | `/api/admin/logs/stats` | Log category counts |
| GET | `/api/admin/base-url` | Get base URL setting |
| PUT | `/api/admin/base-url` | Set base URL `{base_url}` |
| GET | `/api/admin/smtp` | Get SMTP config (password masked) |
| PUT | `/api/admin/smtp` | Save SMTP config |
| POST | `/api/admin/smtp/test` | Send test email `{recipient}` |
+384
View File
@@ -0,0 +1,384 @@
# Architecture
## Stack
| Layer | Technology | Notes |
|-------|-----------|-------|
| Frontend | Vue 3 + TypeScript + Vite + Pinia + Vue Router | SPA served from the same container as the API |
| Editor | Tiptap (ProseMirror) with custom slash-command extension | |
| Backend | Python 3.12, Quart (async ASGI) | Serves both API and built frontend static files |
| Database | PostgreSQL 16, SQLAlchemy 2.0 async, Alembic | asyncpg driver |
| LLM | Ollama (local) | Any OpenAI-compatible API also works |
| Search | SearXNG (optional, self-hosted) | Web search + image search |
| Push | Web Push / VAPID (pywebpush 2.x) | |
| Deployment | Docker Compose | Single-container app + separate DB + LLM service |
## High-Level Component Diagram
```
┌─────────────────────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────────────┐ ┌────────────┐ │
│ │ fabledassistant │ │ ollama │ │
│ │ ┌────────────────┐ │ │ │ │
│ │ │ Quart Server │ │ │ LLM API │ │
│ │ │ ┌──────────┐ │ │ │ │ │
│ │ │ │ Vue SPA │ │ │ └────────────┘ │
│ │ │ │ (static) │ │ │ ▲ │
│ │ │ └──────────┘ │ │ │ │
│ │ │ ┌──────────┐ │ │ HTTP/REST │
│ │ │ │ /api/* │──┼──┼─────────┘ │
│ │ │ └──────────┘ │ │ │
│ │ │ │ │ │ ┌────────────┐ │
│ │ │ ▼ │ │ │ PostgreSQL │ │
│ │ │ ┌──────────┐ │ │ │ 16 │ │
│ │ │ │ asyncpg │──┼──┼──▶ │ │
│ │ │ └──────────┘ │ │ └────────────┘ │
│ │ └────────────────┘ │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────┘
```
## Project Structure
```
fabledassistant/
├── docker-compose.yml # Development stack
├── docker-compose.prod.yml # Production stack (Docker Swarm)
├── Dockerfile # Multi-stage build (Node → Python)
├── alembic/ # Database migrations
│ └── versions/ # Migration files (idempotent raw SQL)
├── fable-mcp/ # Fable MCP server package
│ └── fable_mcp/
│ ├── server.py # FastMCP tool registrations
│ ├── client.py # FableClient (httpx wrapper)
│ └── tools/ # Tool modules (notes, tasks, projects, …)
├── src/fabledassistant/
│ ├── app.py # Quart app factory + blueprint registration
│ ├── config.py # Config class (reads env vars)
│ ├── auth.py # login_required decorator, session checks
│ ├── models/ # SQLAlchemy models
│ ├── routes/ # API blueprints (one file per resource)
│ ├── services/ # Business logic (access, llm, tools, sharing, …)
│ └── static/ # Built Vue SPA (generated at Docker build time)
└── frontend/
└── src/
├── views/ # Page-level Vue components
├── components/ # Reusable UI components
├── composables/ # Vue composables (autosave, shortcuts, …)
├── stores/ # Pinia stores (auth, chat, notes, notifications, …)
└── api/ # Typed API client (client.ts)
```
## 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/`. The SPA is built by Vite during the Docker image build.
**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/*`) always return JSON 404. This avoids a catch-all `/<path:path>` route intercepting API GETs.
**Unified note/task model.** A task is just a note with task attributes enabled. `status IS NOT NULL` means it's a task. "Convert to task" sets `status='todo'`; "convert to note" clears `status`, `priority`, `due_date`. No separate table, no cascade complexity.
**First-class tag column.** Tags live in a `tags ARRAY[text]` column and are explicitly set by the client — not auto-extracted from body text. Hierarchical tags (`project/webapp`) supported via SQL `unnest + LIKE` prefix matching.
**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 a `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.
**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`.
**Context building is server-side.** Backend fetches URL content and searches notes. Frontend sends the message text + optional context note IDs. `build_context()` returns `(messages, context_meta)`; metadata includes auto-found note IDs/titles sent to frontend via a `context` SSE event before streaming begins.
**No blocking long-running operations.** Any slow operation (model pulls, LLM calls, URL fetching) must never block app startup or freeze the UI. Backend uses SSE streaming for incremental responses. Model pulls stream NDJSON progress to the frontend.
**SSRF protection.** `services/llm.py` blocks requests to loopback, private, link-local, reserved, and multicast addresses before fetching. `follow_redirects=False` prevents redirect-based bypasses.
**Session cookie security.** `HttpOnly` and `SameSite=Lax` always set. `Secure` flag controlled by `SECURE_COOKIES` env var.
**Rate limiting.** In-memory sliding-window rate limiter (`rate_limit.py`) applied to auth endpoints: login (10/60s), register (5/300s), forgot-password (5/300s), reset-password (10/60s). Keys are per-IP. `TRUST_PROXY_HEADERS` env var enables `X-Forwarded-For` / `X-Real-IP` when behind a reverse proxy.
**Idempotent 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.
## Data Model
### Users
| Column | Type | Notes |
|--------|------|-------|
| `id` | int PK | |
| `username` | text UNIQUE NOT NULL | |
| `email` | text nullable | |
| `password_hash` | text nullable | NULL for OAuth-only accounts |
| `oauth_sub` | text UNIQUE nullable | OIDC subject identifier |
| `role` | text NOT NULL DEFAULT 'user' | First user auto-assigned 'admin' |
| `session_version` | int NOT NULL DEFAULT 1 | Bumped on password change to evict sessions |
| `created_at` | timestamptz | |
### Notes (unified — includes tasks)
| Column | Type | Notes |
|--------|------|-------|
| `id` | int PK | |
| `title` | text | |
| `body` | text | Markdown |
| `tags` | ARRAY[text] | GIN indexed; explicitly set by client |
| `parent_id` | int FK self nullable | Sub-tasks / sub-notes |
| `user_id` | int FK users nullable | CASCADE |
| `project_id` | int FK projects nullable | |
| `milestone_id` | int FK milestones nullable | |
| `status` | text nullable | `todo`/`in_progress`/`done` — non-null = task |
| `priority` | text nullable | `none`/`low`/`medium`/`high` |
| `due_date` | date nullable | |
| `created_at`, `updated_at` | timestamptz | |
Indexes: GIN on `tags`, B-tree on `status`, B-tree on `title`.
### Settings
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `briefing_enabled`, `briefing_locations`, `office_days`, etc.
### Conversations / Messages
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`briefing`/`mcp`), `briefing_date`, `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
`messages`: `id`, `conversation_id` FK CASCADE, `role` (`user`/`assistant`), `content`, `status` (`done`/`generating`), `created_at`.
Title auto-generated by LLM on first exchange, re-generated every 10th message.
### Projects / Milestones
`projects`: `id`, `user_id`, `title`, `description`, `goal`, `status` (`active`/`completed`/`archived`), `color`, `auto_summary` (nullable text — LLM-generated summary for `search_projects` scoring), `summary_updated_at` (nullable timestamptz), timestamps.
`milestones`: `id`, `user_id`, `project_id` FK CASCADE, `title`, `description`, `status`, `order_index`, timestamps.
### Sharing & Access
`project_shares`, `note_shares`: each has `shared_with_user_id` OR `shared_with_group_id` (exclusive), `permission` (`viewer`/`editor`/`admin`), `invited_by`.
`groups`, `group_memberships`: platform-wide groups with `member`/`owner` roles.
Permission resolution is centralised in `services/access.py`. `get_project_permission(uid, project_id)` checks ownership → direct share → group-based share → note→project inheritance, returning the highest applicable permission.
### Briefing-Related Tables
`rss_feeds`: `id`, `user_id`, `url`, `name`, `category`, `last_fetched_at`.
`rss_items`: `id`, `feed_id` FK, `guid`, `title`, `url`, `summary`, `pub_date`.
`weather_cache`: per-user cache with `lat`, `lon`, `location_name`, `forecast_json`, `fetched_at`.
### API Keys
`api_keys`: `id`, `user_id` FK CASCADE, `prefix` (first 8 chars, displayed in UI), `key_hash` (SHA-256 of full key — full key never stored), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at`.
### App Logs
`category` (`audit`/`usage`/`error`), `user_id` FK nullable (SET NULL on delete), `username` (denormalised), `action`, `endpoint`, `method`, `status_code`, `duration_ms`, `error_type`, `error_message`, `traceback`, `details` JSONB.
## Detailed File Reference
### Backend (`src/fabledassistant/`)
| File | Responsibility |
|------|---------------|
| `app.py` | Quart app factory; SPA via 404 handler; JSON 404/500 for API; request logging; security headers in `after_request` |
| `auth.py` | `login_required`, `admin_required`, `get_current_user_id` — shared `_check_auth()` helper; accepts session cookie or `Authorization: Bearer <key>` |
| `config.py` | All config from env vars + Docker secret file support (`_read_secret`); `SECURE_COOKIES`, `TRUST_PROXY_HEADERS`, `OLLAMA_NUM_CTX`; `oidc_enabled()`, `searxng_enabled()` classmethods |
| `rate_limit.py` | In-memory sliding-window rate limiter (`asyncio.Lock` + `defaultdict`); `is_rate_limited(key, max, window)` |
| `models/note.py` | Unified Note model (notes + tasks) |
| `models/user.py` | `id`, `username`, `email`, `password_hash` (nullable — NULL for OAuth-only), `oauth_sub` (unique nullable), `role`, `session_version` |
| `models/conversation.py` | `Conversation` + `Message` models |
| `models/app_log.py` | `AppLog` with `category`, denormalised `username`, `details` JSONB |
| `models/api_key.py` | `ApiKey`: `id`, `user_id`, `prefix`, `key_hash` (SHA-256), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at` |
| `models/event.py` | Internal events store (`Event` model: `id`, `user_id`, `title`, `description`, `start_dt`, `end_dt`, `all_day`, `location`, `caldav_uid` nullable, `color`, timestamps) |
| `routes/api.py` | `/api` blueprint; `GET /api/health` (public) |
| `routes/auth.py` | Register, login, logout, me, password/email change, password reset, invite registration, OAuth login+callback; rate limiting; `LOCAL_AUTH_ENABLED` guards |
| `routes/admin.py` | Backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only) |
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm; briefing conversation routes |
| `routes/notes.py` | Notes CRUD + wikilinks + backlinks + assist + link suggestions + version history + drafts |
| `routes/tasks.py` | Tasks CRUD; `POST` accepts `project` name string (resolved to `project_id`) |
| `routes/task_logs.py` | Task work log CRUD (`GET/POST/DELETE /api/tasks/:id/logs`) |
| `routes/projects.py` | Projects CRUD + summary endpoint |
| `routes/milestones.py` | Milestones CRUD under `/api/projects/:id/milestones` |
| `routes/settings.py` | Per-user settings key-value (`GET/PUT /api/settings/:key`) |
| `routes/briefing.py` | Briefing conversation + reply + history; RSS feed management |
| `routes/groups.py` | Group CRUD + membership management (admin) |
| `routes/shares.py` | Share project/note with user or group; revoke; list incoming shares |
| `routes/in_app_notifications.py` | In-app notification list + mark-read + count |
| `routes/push.py` | Web Push subscription subscribe/unsubscribe; VAPID public key |
| `routes/users.py` | User profile; admin user list + delete |
| `routes/images.py` | Serve cached images at `/api/images/<id>` |
| `routes/export.py` | `GET /api/export` — personal Markdown ZIP or JSON array download |
| `routes/api_keys.py` | API key CRUD (`GET/POST/DELETE /api/api-keys`) |
| `routes/fable_mcp_dist.py` | `GET /api/fable-mcp/info` + `GET /api/fable-mcp/download` — package distribution |
| `routes/quick_capture.py` | `POST /api/quick-capture` — single-shot natural language item creation |
| `routes/search.py` | `GET /api/search` — semantic + keyword hybrid search |
| `services/auth.py` | `create_user`, `authenticate`, user lookups, password reset tokens, invitation tokens |
| `services/oauth.py` | OIDC discovery (cached), PKCE auth URL, code exchange, `find_or_create_oauth_user` |
| `services/api_keys.py` | `generate_key()`, `create_api_key()`, `list_api_keys()`, `revoke_api_key()`, `lookup_key()` (SHA-256 hash lookup) |
| `services/llm.py` | `build_context()`, RAG injection, history summarisation, `stream_chat_with_tools()`, URL fetching, SSRF guard |
| `services/generation_task.py` | `run_generation()` — full chat pipeline: intent routing, tool loop, SSE fan-out, push notification; `run_assist_generation()` |
| `services/intent.py` | `classify_intent()` — fast non-streaming LLM call; intent skip heuristic; `_PRIOR_WORK_REFS` fast-path |
| `services/tools.py` | All LLM tool definitions + `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` dispatcher; duplicate guards; `_resolve_project()` 4-step lookup; `search_projects` and `set_rag_scope` tools |
| `services/projects.py` | Project CRUD + `generate_project_summary()` (Ollama, fire-and-forget) + `backfill_project_summaries()` (startup) |
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes(orphan_only=False)` (pgvector cosine similarity) |
| `services/generation_buffer.py` | In-memory SSE event buffer; `cancel_event`; 60s cleanup; supports both chat (int keys) and assist (string keys) |
| `services/notes.py` | Note CRUD, wikilink resolution, backlink queries, tag management |
| `services/note_versions.py` | Version snapshot on save; restore-from-version; diff metadata |
| `services/note_drafts.py` | Per-user per-note draft persistence (AI Assist pending state) |
| `services/settings.py` | `get_setting()`, `set_setting()`, `get_all_settings()` — key-value per user |
| `services/tag_suggestions.py` | `/api/notes/suggest-tags` — LLM-generated tag suggestions for note body |
| `services/access.py` | Permission resolution for all shared resources |
| `services/sharing.py` | Create/revoke shares; list shares; `get_shared_with_me()` |
| `services/groups.py` | Group CRUD; membership management |
| `services/notifications.py` | Create/read/mark-read in-app notifications |
| `services/task_logs.py` | Append/list/delete work log entries on tasks |
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → `GenerationBuffer` stream |
| `services/briefing_scheduler.py` | APScheduler `BackgroundScheduler`; slots with catch-up logic; async-safe via `asyncio.create_task` |
| `services/briefing_conversations.py` | Briefing conversation persistence and history queries |
| `services/briefing_profile.py` | Per-user profile note that the assistant updates over time |
| `services/research.py` | SearXNG research pipeline: 5 sub-queries → parallel fetch → synthesis; `search_images` for image category |
| `services/events.py` | Internal events CRUD: `list_events`, `create_event`, `update_event`, `delete_event`, `get_event`; source of truth for all event LLM tools |
| `routes/events.py` | `/api/events` — event CRUD routes |
| `services/caldav.py` | Optional CalDAV sync — user-configured external server; syncs to/from internal store via `caldav_uid` FK; `is_caldav_configured()` guards tool activation |
| `services/calendar_sync.py` | Dead code — Radicale sync service; was trialled and removed |
| `services/images.py` | `fetch_and_store_image()` — SHA-256 dedup, content-type validation, 5 MB cap |
| `services/backup.py` | `export_full_backup()`, `export_user_backup()`, `restore_full_backup()` (version 2 with ID maps) |
| `services/push.py` | VAPID key auto-generation; `send_push_notification()` fire-and-forget; 410 Gone cleanup |
| `services/logging.py` | `log_audit`, `log_usage`, `log_error`, `start_log_retention_loop` (hourly cleanup) |
| `services/email.py` | SMTP email sending for password reset; reads `SMTP_*` env vars |
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast + per-user DB cache |
| `services/rss.py` | feedparser fetch; per-feed DB cache; prune-to-100 items |
| `services/assist.py` | `build_assist_messages()` — section/whole-doc modes; project context injection |
### Frontend (`frontend/src/`)
| File | Responsibility |
|------|---------------|
| `App.vue` | App shell (`100dvh` flex column); global keyboard shortcuts (`g`+key nav, `n/t/c/e///?`); starts status polling + loads settings on mount |
| `api/client.ts` | `ApiError`, `apiGet/Post/Put/Patch/Delete`, `apiSSEStream` (fetch + ReadableStream), auto 401→login redirect |
| `stores/auth.ts` | User, `isAuthenticated`, `isAdmin`, `oauthEnabled`, `localAuthEnabled`, login/register/logout/checkAuth |
| `stores/chat.ts` | Conversation CRUD; `sendMessage()` SSE streaming; `reconnectIfGenerating()`; message queue (localStorage); `streamingStatus` |
| `stores/notes.ts` | CRUD + tag filter; `resolveTitle`; `convertToTask/Note`; `fetchBacklinks`; `fetchAllTags` |
| `stores/tasks.ts` | CRUD + status/priority filter; `patchStatus` |
| `stores/settings.ts` | `assistantName`, `defaultModel`, `installedModels`; `pullModel()`, `deleteModel()` |
| `stores/push.ts` | `isSupported`, `permission`, `isSubscribed`, `subscribe/unsubscribe` |
| `stores/notifications.ts` | `count`, `items`, `fetchCount`, `fetchAll`, `markRead`, `markAll` |
| `views/HomeView.vue` | Chat-first dashboard; 6 task sections (Overdue → Other); 8 recent notes; inline streaming response |
| `views/ChatView.vue` | `/chat` — SSE streaming; context sidebar (Suggested/In Context); note picker; scope chip (RAG project scope pill above input); message queue; bulk delete |
| `views/CalendarView.vue` | `/calendar` — FullCalendar v6 month/week/day views; click to create event; click event to open EventSlideOver |
| `components/EventSlideOver.vue` | Reusable slide-over for event create/edit/delete; used in ToolCallCard, HomeView, CalendarView |
| `views/WorkspaceView.vue` | `/workspace/:id` — 3-panel (tasks/chat/notes); SSE tool-call watcher; conv persisted to localStorage |
| `views/GraphView.vue` | `/graph` — D3 force-directed; tag/note/project-hub nodes; physics panel; peek panel |
| `views/ProjectView.vue` | Kanban grouped by milestone; advance buttons; milestone management |
| `views/SettingsView.vue` | Tabbed settings (11 tabs); tab state in localStorage |
| `views/NoteEditorView.vue` | Tiptap editor; 2-column layout; AI assist panel; diff view; version history; autosave |
| `views/TaskEditorView.vue` | Tiptap editor; 2-column layout; AI assist panel; task log section; sub-tasks |
| `components/ToolCallCard.vue` | Tool call results; `requires_confirmation` inline confirm/deny; direct POST on "Create anyway" |
| `components/ChatMessage.vue` | Message bubble; markdown rendering; tool call cards; "Save as Note" |
| `components/TagInput.vue` | Chip-based tag input; Enter/comma to confirm; autocomplete from `/api/notes/tags` |
| `components/TiptapEditor.vue` | Tiptap wrapper; markdown↔HTML round-trip; selection change emit; WikilinkDecoration; TagDecoration |
| `components/ShareDialog.vue` | `<Teleport>` modal; user tab + group tab; current shares list |
| `components/WorkspaceTaskPanel.vue` | Milestone-grouped task list; detail slide-over |
| `components/WorkspaceNoteEditor.vue` | List ↔ TipTap editor with autosave |
| `composables/useAssist.ts` | AI assist: section parsing, SSE streaming, accept/reject, LCS diff, persistent draft |
| `composables/useAutoSave.ts` | Interval-based autosave (5 min) with dirty/saving guards |
| `composables/useEditorGuards.ts` | Ctrl+S, `beforeunload` warning, route-leave confirm |
| `composables/useTagSuggestions.ts` | `/api/notes/suggest-tags` call + suggestion state |
| `composables/useListKeyboardNavigation.ts` | j/k/Enter list navigation; focus-in-input guard |
| `extensions/WikilinkDecoration.ts` | ProseMirror decoration plugin highlighting `[[wikilinks]]` |
| `extensions/TagDecoration.ts` | ProseMirror decoration plugin highlighting `#tags` |
| `extensions/WikilinkSuggestion.ts` | `@tiptap/suggestion` extension for `[[` autocomplete |
## Key Services
| Service | Responsibility |
|---------|---------------|
| `services/access.py` | Permission resolution for all shared resources |
| `services/llm.py` | `build_context()`, RAG injection, history summarisation |
| `services/generation_task.py` | SSE streaming, tool-call loop, GenerationBuffer management |
| `services/tools.py` | All LLM tool implementations (`create_note`, `search_notes`, `get_weather`, …) |
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` |
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → briefing output |
| `services/briefing_scheduler.py` | APScheduler integration, catch-up logic for missed slots |
| `services/backup.py` | Full and per-user backup export/restore (version 2 format) |
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast fetch + DB cache |
| `services/rss.py` | feedparser-based fetch, per-feed DB cache, prune-to-100 |
## Authentication
**Session cookies**`HttpOnly`, `SameSite=Lax`, optionally `Secure` (`SECURE_COOKIES` env var). Session includes `session_version`; mismatch with DB value (after password change) results in 401 and session clear.
**Bearer token / API keys**`Authorization: Bearer <key>` accepted by `_check_auth()` as an alternative to session cookies. The raw key is SHA-256 hashed and looked up via `services/api_keys.py`. Keys with `scope=read` are rejected on non-safe methods (`POST`/`PATCH`/`DELETE`). Used by Fable MCP and any external API consumers.
**Local auth + OIDC/OAuth (PKCE)**`services/oauth.py` handles discovery and `find_or_create_oauth_user`. On OAuth login: checks existing `oauth_sub` → matching email → creates new user.
See [sso-oauth.md](sso-oauth.md) for provider-specific setup instructions.
## LLM Pipeline Internals
### Intent Routing
Before the main model runs, a lightweight intent classifier (`services/intent.py`) runs concurrently with `build_context()`. It makes a fast non-streaming call using a smaller dedicated model (`OLLAMA_INTENT_MODEL`, default `qwen2.5:7b`) to determine if the message requires a tool call.
**Skip heuristic** — Intent classification is skipped entirely for short messages (≤10 words) with no action/object keywords, saving 400800ms on conversational replies.
**Prior-work fast-path**`_PRIOR_WORK_REFS` regex detects phrases like "research you did", "note you made", "using your research" and returns no-tool immediately, preventing `search_web` from firing when the user references existing notes.
If a tool is detected, the intent's one-sentence `ack` field is streamed as the first chunk (TTFT), the tool executes, then the main model generates a follow-up with the tool result. For chat-only responses the main model streams directly.
### Tool Loop
Multi-round tool loop (max 5 rounds). All implementations in `services/tools.py`; `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` is the dispatcher. `conv_id` and `workspace_project_id` are threaded in from `run_generation()` so tools like `set_rag_scope` can write to the current conversation.
**Duplicate protection on `create_note` / `create_task`:**
1. Exact title match (case-insensitive) → hard block, redirect to `update_note`
2. Fuzzy title match (SequenceMatcher ≥ 82%; punctuation stripped before candidate search) → hard block
3. Semantic content similarity (threshold 0.90, body ≥ 200 chars) → soft block with `requires_confirmation: true`
**Project resolution** (`_resolve_project`): 4-step lookup — (1) exact DB match, (2) `query in title` substring, (3) `title in query` reverse substring, (4) SequenceMatcher ≥ 0.55.
### Context Window and Summarisation
`OLLAMA_NUM_CTX` (default 16384) controls the context window for all generation calls. Intent classification always uses `num_ctx=4096` to reduce VRAM pressure.
History summarisation threshold: 30 messages. Keeps 8 recent messages. Summary max 400 tokens.
### Web Research Pipeline
`services/research.py` implements a full autonomous research pipeline:
1. Intent model generates 5 focused sub-queries
2. All 5 SearXNG queries run in parallel (200ms stagger to avoid rate limiter)
3. Up to 15 unique URLs fetched in parallel
4. Up to 12 sources passed to synthesis LLM
5. Result saved as a note with `tags=["research"]`
SearXNG tip: add the app server IP to `botdetection.ip_lists.pass_ip` in SearXNG `settings.yml` to bypass the rate limiter for trusted backend requests.
### Image Cache
`search_images` tool fetches images server-side via SearXNG, stores them on disk (SHA-256 dedup, content-type validation, 5 MB cap), and serves from `/api/images/<id>`. The user's browser never contacts the original image host.
Config: `IMAGE_CACHE_DIR` (default `/data/images`), `IMAGE_MAX_BYTES` (default 5 MB).
## RAG Pipeline
1. `semantic_search_notes()` — cosine similarity via pgvector, threshold configurable per call; accepts `orphan_only` and `project_id` scope flags.
2. Notes ≥ 0.60 similarity auto-injected into system prompt (up to 3, 800 chars each).
3. Notes 0.450.60 surfaced in chat sidebar as "Suggested" (user clicks to include).
4. Explicitly included notes delivered full-body.
5. `excluded_note_ids` prevents the current note from being injected as its own context.
### RAG Scope (three-value system)
`conversations.rag_project_id` controls which notes are eligible for retrieval:
| Value | Behaviour |
|-------|-----------|
| `NULL` (default) | Orphan notes only — notes with `project_id IS NULL` |
| `-1` | All notes — opt-in to global search |
| Positive int | That project's notes only |
`build_context()` derives `orphan_only` + `effective_project_id` from this value before calling both the semantic and keyword search paths.
**Scope tools** — Two LLM tools let the model discover and switch scope mid-conversation:
- `search_projects` — SequenceMatcher scoring over title + description + `auto_summary`; returns top 5 matching projects.
- `set_rag_scope` — persists the new `rag_project_id` to DB immediately; blocked in workspace view; causes the SSE `done` event to include `new_rag_scope` + `new_rag_scope_label` so the frontend chip updates reactively.
**Project summaries**`generate_project_summary()` calls Ollama (fire-and-forget) and stores the result in `projects.auto_summary`. Triggered on project update and note saves (debounced 1h). `backfill_project_summaries()` runs at startup.
Embedding model: `nomic-embed-text` via Ollama. Backfill runs 30s after startup (background task).
+155
View File
@@ -0,0 +1,155 @@
# Configuration
Configuration is via environment variables. The `docker-compose.yml` file sets defaults for local development; override them in a `.env` file (gitignored).
## Environment Variables
### Core
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/fabledassistant` | PostgreSQL async connection string |
| `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** |
| `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) |
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
| `SECURE_COOKIES` | `false` | Set `true` when running behind TLS |
| `BASE_URL` | — | Public URL (e.g. `https://notes.example.com`) — required for OIDC redirect URIs and email links |
### LLM / Ollama
| Variable | Default | Description |
|----------|---------|-------------|
| `OLLAMA_URL` | `http://ollama:11434` | Ollama API base URL |
| `OLLAMA_MODEL` | `llama3.2` | Default LLM model (used as fallback; per-user setting overrides this) |
| `EMBEDDING_MODEL` | `nomic-embed-text` | Model used for semantic search / RAG embeddings |
| `OLLAMA_NUM_CTX` | `16384` | Context window size passed to Ollama for all generation calls |
### Authentication / OIDC
| Variable | Default | Description |
|----------|---------|-------------|
| `LOCAL_AUTH_ENABLED` | `true` | Set `false` to disable local username/password login (SSO-only mode) |
| `OIDC_ISSUER` | — | OIDC issuer URL (e.g. `https://auth.example.com/application/o/fabled/`) |
| `OIDC_CLIENT_ID` | — | OIDC client ID |
| `OIDC_CLIENT_SECRET` | — | OIDC client secret |
| `OIDC_CLIENT_SECRET_FILE` | — | Docker secret file alternative to `OIDC_CLIENT_SECRET` |
| `OIDC_SCOPES` | `openid profile email` | Space-separated OIDC scopes to request |
See [sso-oauth.md](sso-oauth.md) for provider-specific setup.
### Security / Proxy
| Variable | Default | Description |
|----------|---------|-------------|
| `TRUST_PROXY_HEADERS` | `false` | Set `true` when behind a trusted reverse proxy to read real IP from `X-Forwarded-For` / `X-Real-IP` |
### Web Search / Images
| Variable | Default | Description |
|----------|---------|-------------|
| `SEARXNG_URL` | — | SearXNG base URL for web search and image search tools |
| `IMAGE_CACHE_DIR` | `/data/images` | Directory for cached search images |
| `IMAGE_MAX_BYTES` | `5242880` (5 MB) | Maximum size for cached images |
### Data / Logging
| Variable | Default | Description |
|----------|---------|-------------|
| `LOG_RETENTION_DAYS` | `90` | Days to keep app logs before automatic pruning |
| `DATA_DIR` | `/data` | Root directory for persistent data (VAPID keys, backups) |
### Fable MCP Distribution
| Variable | Default | Description |
|----------|---------|-------------|
| `FABLE_MCP_DIST_DIR` | `/app/dist` | Directory where the bundled `fable-mcp` wheel is placed at build time |
## Docker Compose Setup
### Development (`docker-compose.yml`)
```bash
# Copy the example env file
cp .env.example .env
# Edit .env to set a real SECRET_KEY
# Start the stack
docker compose up --build
# App available at http://localhost:5000
```
The first user to register becomes admin. Registration auto-closes after that (re-enable from Settings → Users).
### Production (`docker-compose.prod.yml`)
The production compose file adds:
- Docker Secrets for `SECRET_KEY_FILE` and `DATABASE_URL_FILE`
- Network isolation (internal bridge for DB + Ollama; only the app is externally accessible)
- Health checks and resource limits
```bash
# Create Docker secrets
echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key -
echo "postgresql+asyncpg://fabled:strongpassword@db/fabledassistant" | docker secret create fabled_db_url -
# Deploy
docker stack deploy -c docker-compose.prod.yml fabled
```
## Production Deployment
### Reverse Proxy (Required)
Fabled Assistant does **not** handle SSL/TLS. Run it behind a reverse proxy:
- **Nginx**, **Traefik**, or **Caddy** in front of the app container
- Terminate TLS at the proxy; forward to port 5000
- **Do not expose port 5000 directly to the internet**
- Rate-limit auth endpoints: ≤ 5 req/min per IP on `/api/auth/login` and `/api/auth/register`
Example Nginx location block:
```nginx
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for SSE streaming
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
```
If using `TRUST_PROXY_HEADERS=true`, ensure your proxy strips any client-supplied `X-Forwarded-For` headers before adding its own.
### Security Checklist
- **Strong `SECRET_KEY`** — generate with:
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
Or use Docker Secrets via `SECRET_KEY_FILE`.
- **`SECURE_COOKIES=true`** — must be set when running behind TLS.
- **`BASE_URL`** — set to your public URL; required for OIDC redirects and email links.
- **Registration** — auto-closes after the first user (admin). Re-enable from Settings → Users or send invite links.
- **Session invalidation** — changing or resetting a password bumps `session_version`, evicting all other active sessions. Button also available in Settings → Account.
- **Keep Ollama on an internal network** — both compose files keep Ollama off the host network. Never expose the Ollama port publicly.
- **Default SECRET_KEY warning** — the app logs a `WARNING` on startup if the default dev key is in use.
## Model Management
Models are managed through Settings → General → Model Management in the web UI. You can:
- Pull new models by name (streams progress via SSE)
- View installed models with size and loaded/unloaded state
- Delete unused models
The app auto-warms user-preferred models on startup (only models already installed — never auto-pulls). The embedding model (`nomic-embed-text`) is auto-pulled on startup if missing.
Recommended models for 2× 8 GB GPU:
- **`qwen3:8b`** — strong reasoning + tools, fits in 8 GB, supports thinking mode
- **`qwen2.5:7b`** — fast, tool-capable, smaller context
- **`llama3.1:8b`** — reliable baseline, widely tested with tools
+173
View File
@@ -0,0 +1,173 @@
# Development
## Workflow
All development is Docker-based. Do not install Python or Node dependencies locally.
```bash
# Start the full stack (app + PostgreSQL + Ollama)
docker compose up --build
# Rebuild after backend changes (frontend changes require rebuild too)
docker compose up --build app
# Reset everything (wipes database)
docker compose down -v && docker compose up --build
# Run checks (lint, format, typecheck, tests)
make check
# Individual checks
make lint # ruff check src/
make fmt # ruff format src/
make typecheck # vue-tsc --noEmit
make test # pytest tests/
```
## Frontend Hot Reload
The Docker setup does not include Vite's hot-reload dev server. After frontend changes, rebuild the image. For faster iteration during active frontend work, you can run Vite locally:
```bash
cd frontend
npm install
npm run dev # Vite dev server at http://localhost:5173
```
Point the Vite dev server at the backend by setting `VITE_API_BASE_URL=http://localhost:5000` (or configure `vite.config.ts` proxy).
## Database Migrations
Alembic migrations run automatically on container startup (`alembic upgrade head` in the `CMD`).
To create a new migration:
```bash
# Inside the running app container
docker compose exec app alembic revision -m "description_of_change"
# Edit the generated file in alembic/versions/
```
Migration conventions:
- Use raw SQL with `IF NOT EXISTS` guards for idempotency
- Use `DO $$ BEGIN CREATE TYPE … EXCEPTION WHEN duplicate_object THEN NULL; END $$` for enum types
- Number migrations sequentially (e.g. `0027_add_something.py`)
- Always provide both `upgrade()` and `downgrade()`
## CI/CD
### Pipeline
CI runs on Forgejo Actions with a custom runner base image (`py3.12-node22`):
| Trigger | Jobs | Docker tags pushed |
|---------|------|--------------------|
| Push to `dev` | typecheck + lint + test → build | `:dev`, `:<sha>` |
| Tag `v*` on `main` | typecheck + lint + test → build | `:latest`, `:<version>`, `:<sha>` |
| Push to `main` | typecheck + lint + test | (no build) |
### Release Process
1. Work on `dev` branch — CI validates on every push
2. When ready, open a PR from `dev``main` in Forgejo
3. Merge the PR
4. Create a release via the Forgejo UI on `main` with a `v*` tag (e.g. `v26.03.23.1` — CalVer: `YY.MM.DD.N`)
5. The tag push triggers CI → build job pushes `:latest` + `:<version>` Docker images
6. After merging to main, sync dev back:
```bash
git checkout dev && git merge main && git push origin dev
```
### Custom Runner
Runner base image: `infra/Dockerfile.runner-base` (Ubuntu 24.04 + Python 3.12 + Node 22 LTS).
Runner config: `infra/act-runner-config.yml` (label: `py3.12-node22`).
Runner compose: `infra/runner-compose.yml`.
To activate a new runner registration, copy `infra/act-runner-config.yml` to the runner's config directory, delete the `.runner` registration file in the runner container, and restart the stack.
### Docker Registry
Images pushed to: `git.fabledsword.com/bvandeusen/fabledassistant`
Cache tag: `:cache` (reduces build time ~80%)
Required secrets (repo → Settings → Secrets → Actions):
- `REGISTRY_USER` — Forgejo username
- `REGISTRY_TOKEN` — Forgejo PAT with `write:packages` scope
## Migration Chain
Current migration sequence (all idempotent raw SQL):
```
0001 create_notes_table
0002 create_tasks_table
0003 task_note_companion (data migration)
0004 merge_tasks_into_notes
0005 add_chat_tables
0006 add_settings_table
0007 add_title_and_updated_at_indexes
0008 add_users_and_user_id
0009 add_message_status
0010 add_app_logs_table
0011 add_password_reset_tokens
0012 add_invitation_tokens
0013 add_tool_calls_to_messages
0014 add_note_embeddings
0015 add_oauth_fields
0016 add_image_cache
0017 add_projects
0018 add_push_subscriptions
0019 add_events (dead code — internal CalDAV/Radicale table; Radicale was removed)
0020 add_milestones
0021 add_task_logs
0022 add_note_versions_and_drafts
0023 add_tags_to_note_versions
0024 add_session_version
0025 add_sharing_and_notifications
0026 add_briefing_tables
0027 add_api_keys
```
**Important:** 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 use raw SQL with `IF NOT EXISTS` / `DO $$ BEGIN ... EXCEPTION WHEN duplicate_object` guards.
## Project Conventions
### Backend
- Services: `async with async_session() as session:` — import from `fabledassistant.models`
- No `fabledassistant.database` module
- Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc.
- All business logic in `services/`; routes are thin wrappers
- Permission checks via `services/access.py` — never inline ownership checks in routes
### Frontend
- API calls via `frontend/src/api/client.ts` typed helpers (`apiGet`, `apiPost`, `apiPatch`, `apiDelete`)
- Pinia stores for shared state; local `ref()` for component-only state
- Composables in `composables/` for reusable behaviour (autosave, keyboard nav, tag suggestions, …)
- Views are page-level components in `views/`; reusable UI in `components/`
### Commit Style
```
type(scope): short description
Longer body if needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
```
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`
Scopes: feature area (e.g. `chat`, `briefing`, `fable-mcp`, `notes`)
## Testing
```bash
# Run all tests
make test
# Run specific test file
docker compose exec app /opt/venv/bin/pytest tests/test_auth.py -v
```
Tests are in `tests/`. They run against a real PostgreSQL instance in CI (not mocked). Keep tests integration-style where possible — mock failures have historically masked real migration bugs.
+154
View File
@@ -0,0 +1,154 @@
# Features
## Notes
Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold, italic, lists, code blocks, and task checklists render inline. A slash-command menu (`/`) inserts common blocks.
**Wikilinks** — Link notes with `[[Title]]` or `[[Title|Display Text]]` syntax. Clicking a wikilink navigates to (or auto-creates) the referenced note. The editor suggests existing note titles as candidate links while typing `[[`. Backlinks appear in the note viewer sidebar.
**Tags** — First-class `ARRAY[text]` column. Tag autocomplete in the editor sidebar suggests existing tags. Hierarchical tags (`project/webapp`) supported — filtering by `project` matches all `project/*` children. Tags are browsable via the knowledge graph.
**Version history** — Every body edit snapshots a version (up to 20 per note). Browse and restore from the editor's History panel. Diff view shows changes against the current body.
**AI writing assist** — Select a passage or work on the full document. Give an instruction ("make this more concise", "add examples"). The assistant streams a proposal; a diff view shows changes to accept or reject. Drafts persist across page loads.
**Link suggestions** — The editor detects note titles appearing as plain text in the body and suggests converting them to wikilinks.
## Tasks
Tasks carry status (`todo``in_progress``done`), priority (`none`/`low`/`medium`/`high`), due date, milestone assignment, and a parent task (sub-tasks).
**Task work logs** — Append progress log entries to a task with optional duration. Time tracking is visible in the task editor sidebar.
**Sub-tasks** — Any task can have child tasks via `parent_id`. The task viewer shows sub-tasks inline.
**Convert freely** — Convert a note to a task (sets `status=todo`) or a task back to a note from the viewer toolbar.
## Projects and Milestones
**Projects** — Group related notes and tasks. Each project has a title, description, goal, status (`active`/`completed`/`archived`), and a colour.
**Milestones** — Ordered stages within a project. Tasks are assigned to milestones. Milestone completion percentage shown on the project page.
**Kanban view**`/projects/:id` groups tasks by milestone in a kanban-style column layout with status-advance buttons directly on cards (→ advance, ✓ complete).
**Project Workspace**`/workspace/:projectId` opens a three-panel environment (tasks / chat / notes) locked to a project. The AI assistant creates and updates content directly in the workspace; new notes auto-load in the editor and the task list refreshes automatically after tool calls.
## Knowledge Graph
`/graph` renders all notes, tasks, and tags as a D3 force-directed graph. Tag nodes cluster notes that share tags; invisible project hub nodes attract project members. Physics controls: repulsion, link distance, link strength, hub pull, gravity. Click any node to open a slide-in peek panel. Click a tag node to filter the notes list.
## AI Chat
Full conversation history with SSE streaming. Features:
- **RAG** — Semantically relevant notes (≥ 0.60 cosine similarity) auto-injected as context. Notes 0.450.60 shown in sidebar as "Suggested."
- **Attach notes** — Paperclip icon to include specific notes in context.
- **RAG scope chip** — Pill above the input bar shows the current note scope. Click to switch: "Orphan notes only" (default — project notes stay out of general chat), any active project, or "All notes." Scope is persisted per conversation. The AI can also call `search_projects` and `set_rag_scope` mid-conversation to switch scope automatically; the chip pulses when this happens.
- **Tool calls** — The assistant can create/update notes, tasks, projects, milestones, search the web, check weather, read RSS, query calendar events, and more. Tool calls display inline with confirm/deny for creates.
- **Thinking mode** — Toggle extended reasoning for complex questions.
- **Abort** — Stop button cancels in-flight generation.
- **Message queue** — Messages sent while generation is in progress are queued and drained sequentially.
- **Save to note** — Save any assistant reply directly as a note.
- **Bulk delete** — Select and delete multiple conversations.
- **Retention** — Conversations auto-pruned after configurable days (default 90).
## Daily Briefing
`/briefing` is a scheduled, dialogue-based morning briefing. The assistant compiles tasks, calendar events, projects, weather forecast, and RSS digest at configurable times, then checks in throughout the day. You can reply interactively.
**Schedule** — Configurable slots: morning (default 4am compile), midday (8am check-in), evening (12pm check-in), night (4pm). Scheduler catches up missed slots on startup.
**Configuration** — Settings → Briefing: enable toggle, location geocoding, office days, time slot toggles, RSS feed management, push notification toggle.
**RSS feeds** — Add feed URLs with optional name and category. Feeds are fetched and cached; the briefing digest includes recent items. Category badges shown in the UI. Feeds can be manually refreshed.
**Weather** — Location-based forecast via Open-Meteo. Multiple locations supported (home, work, or any city name). Geocoding via Nominatim.
**Profile note** — The assistant maintains a profile note for each user that it updates based on briefing conversations, improving personalisation over time.
## Web Research
The assistant can search the web (SearXNG) and fetch pages, synthesising findings into notes. A lightweight `search_web` tool answers quick questions inline without saving. Requires `SEARXNG_URL` to be configured.
## Calendar
`/calendar` shows a full FullCalendar view (month, week, day). Click an empty slot to create an event; click an existing event to edit or delete it via a slide-over panel.
**Internal events store** — Events are stored in the app database (`events` table), making them available without any external calendar. Fields: title, description, start/end datetime, all-day toggle, location, colour.
**AI tools**`create_event`, `list_events`, `search_events`, `update_event`, `delete_event` all operate on the internal store. Tool-call result cards in chat are clickable and open the same EventSlideOver for editing.
**HomeView widget** — The dashboard shows today's and the next 7 days' events as clickable cards above the hero project.
**CalDAV sync (optional)** — Connect an external CalDAV server (Nextcloud, Radicale, etc.) in Settings → Integrations. Events sync bidirectionally via a `caldav_uid` field.
## Sharing and Collaboration
**Share** — Share any project or note/task with users or groups at `viewer`/`editor`/`admin` permission levels. Share button in the viewer/project toolbar opens a dialog.
**Groups** — Admins create platform-wide groups and assign users `member`/`owner` roles. Share a resource with a group in one action.
**Shared with me**`/shared` lists all incoming shared projects and notes with permission badges.
**Notifications** — Bell icon in nav shows unread count (60s polling). Notifications generated for: project shared, note shared, added to group. Click navigates to the resource.
**Push notifications** — Web Push (VAPID) notifies when AI generation completes, even in another tab. Works over HTTPS only. Configurable per-user.
## Quick Capture
Quick capture from the Android app routes to the intent classifier. It creates notes, tasks, or projects based on content — using the user's configured model, not the hardcoded default.
## Data Export and Backup
- **Personal export** — Settings → Data: download all notes/tasks as a Markdown ZIP (with YAML frontmatter) or JSON array.
- **Admin backup** — Full application backup (version 2): includes projects, milestones, task logs, AI drafts, note versions, push subscriptions. ID remapping on restore for cross-instance migration.
## PWA
Installable as a desktop or mobile app. Service worker caches the shell; push notifications are suppressed when the relevant tab is already focused. Works over HTTPS only in Firefox.
## Settings
Settings are tabbed:
| Tab | Contents |
|-----|----------|
| General | Assistant name, default model, model management (pull/delete) |
| Account | Email change, password change, session invalidation |
| Notifications | Push notification subscription, briefing push toggle |
| Integrations | CalDAV configuration, SearXNG status |
| Data | Personal export, backup/restore (admin) |
| Briefing | Enable, location, office days, slots, RSS feeds, weather |
| API Keys | Create/revoke API keys, Fable MCP download and install |
| Config (admin) | Base URL, SMTP, OIDC settings |
| Users (admin) | User list, invite links, registration toggle |
| Logs (admin) | Error, audit, and usage logs with search |
| Groups (admin) | Create/manage groups and membership |
## Roadmap
- Email integration (read/send via IMAP/SMTP tools in chat)
- Session invalidation on user deletion
- Flutter push notifications (requires FCM/APNs — separate from web VAPID)
- Flutter milestone support in project view
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `g` + `h` | Go to Home |
| `g` + `n` | Go to Notes |
| `g` + `t` | Go to Tasks |
| `g` + `p` | Go to Projects |
| `g` + `c` | Go to Chat |
| `g` (bare) | Go to Graph |
| `n` | New note |
| `t` | New task |
| `c` | Focus chat input |
| `e` | Edit current item |
| `/` | Search |
| `?` | Show shortcuts panel |
| `j` / `k` | Navigate list items |
| `Enter` | Open selected item |
| `Escape` | Close panel / blur / go home (progressive) |
| `Ctrl+S` | Save in editor |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,285 @@
# Briefing Service Improvements — Design Spec
**Date:** 2026-03-25
**Status:** Approved
**Scope:** Web-first (no Android changes in this cycle)
---
## Problem Statement
The daily briefing has several usability issues:
1. **Task repetition** — tasks are restated identically every day regardless of whether anything changed, making the briefing feel stale and hard to scan.
2. **RSS repetition** — the same news stories resurface across days with no mechanism to learn what the user cares about.
3. **No path to sources** — news items are summarised in prose with no link to the original article.
4. **Stale weather** — if the weather cache is outdated, the briefing silently uses old data rather than failing gracefully. The current prose weather format is also hard to scan.
5. **No feedback loop** — there is no way to teach the briefing what topics are interesting or uninteresting.
---
## Approach
**Pre-processing pipeline with explicit state tracking.** Rather than relying on the synthesis LLM to handle deduplication and filtering, we add deterministic pre-processing steps before synthesis runs. Each concern is isolated: task change detection, RSS topic classification and filtering, weather staleness gating. The synthesis LLM receives pre-filtered, structured input and focuses on tone and flow.
---
## Data Model
### Migration: `0028_add_briefing_improvements`
**`rss_items` table — two new columns**
```sql
ALTER TABLE rss_items ADD COLUMN IF NOT EXISTS topics TEXT[] DEFAULT '{}';
ALTER TABLE rss_items ADD COLUMN IF NOT EXISTS classified_at TIMESTAMPTZ;
```
`topics` stores LLM-assigned topic tags (e.g. `["technology", "ai"]`). `classified_at` is NULL until classification runs, allowing backfill queries. The `RssFeed` / `RssItem` SQLAlchemy model (`models/rss_feed.py`) must also be updated to add these two mapped columns and expose them in `to_dict()`.
**`messages` table — new metadata column**
```sql
ALTER TABLE messages ADD COLUMN IF NOT EXISTS metadata JSONB;
```
The briefing pipeline populates this when it creates the compiled message. The frontend reads it when loading the conversation to render the `WeatherCard` and attach reaction buttons. No SSE events are needed — structured data travels with the message record.
Schema stored in `metadata`:
```json
{
"weather": {
"location": "Berlin",
"fetched_at": "2026-03-25T06:00:00Z",
"current_temp": 12,
"condition": "Partly Cloudy",
"today_high": 16,
"today_low": 8,
"yesterday_high": 14,
"yesterday_low": 9,
"forecast": [
{"day": "Wed", "condition": "Sunny", "high": 18, "low": 10},
{"day": "Thu", "condition": "Cloudy", "high": 14, "low": 9}
]
},
"rss_item_ids": [42, 17, 89, 103, 55]
}
```
If weather is unavailable, `metadata.weather` is `null` and the card renders a failure placeholder. The `Message` SQLAlchemy model (`models/conversation.py`) must be updated to add the `metadata` mapped column.
**New table: `rss_item_reactions`**
```sql
CREATE TABLE IF NOT EXISTS rss_item_reactions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rss_item_id INTEGER NOT NULL REFERENCES rss_items(id) ON DELETE CASCADE,
reaction TEXT NOT NULL CHECK (reaction IN ('up', 'down')),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, rss_item_id)
);
CREATE INDEX IF NOT EXISTS ix_rss_item_reactions_user_id ON rss_item_reactions(user_id);
```
One reaction per user per item. A second click on the same button removes the reaction; clicking the opposite button flips it.
**New table: `briefing_task_snapshot`**
```sql
CREATE TABLE IF NOT EXISTS briefing_task_snapshot (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
task_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
snapshot_hash TEXT NOT NULL,
last_briefed TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, task_id)
);
CREATE INDEX IF NOT EXISTS ix_briefing_task_snapshot_user_id ON briefing_task_snapshot(user_id);
```
`snapshot_hash` is `SHA-256(status + priority + due_date + title)`. The pipeline diffs current task state against these rows to detect what has changed since the last briefing.
**Settings keys (existing key-value store — no new table)**
- `briefing_include_topics` — JSON array of topic strings to prioritise
- `briefing_exclude_topics` — JSON array of topics to hard-exclude from briefings
---
## Pipeline Changes
### Pre-processing Stage (new, runs before parallel gather)
Three sequential steps added to `services/briefing_pipeline.py`. Note: `_gather_internal` currently serialises tasks as dicts without `id`. It must be updated to include `task_id` in each serialised task dict (the `Note` ORM object has `.id` available) so the post-briefing snapshot upsert has the required FK value.
**1. Task change detection**
For each of the user's current tasks, compute `SHA-256(status + priority + due_date + title)` and compare against `briefing_task_snapshot`. Split into:
- `changed_tasks` — new hash or no snapshot row (included fully in briefing)
- `unchanged_count` — integer count passed to the synthesis prompt as context
The synthesis prompt receives `changed_tasks` and the instruction: "N tasks are unchanged since the last briefing — acknowledge this briefly rather than listing them."
**2. RSS item filtering**
Load the user's `briefing_include_topics` and `briefing_exclude_topics` settings, plus reaction history (last 30 days, aggregated per topic as a net score). Score each recent classified item:
- Hard-remove items tagged with any excluded topic
- Boost items tagged with any included or positively-reacted topic
- Penalise items from negatively-reacted topics
- Sort by score, take top 10
Items with `classified_at IS NULL` pass through unfiltered (new feeds not yet classified) and are queued for background classification.
**3. Weather staleness gate**
Check `weather_cache.fetched_at`. If older than 24 hours: skip weather entirely, set `weather_unavailable = True`. The frontend renders a `WeatherCard` placeholder in the failure state. If fresh: pass the forecast JSON (including `past_days=1` data) to the pipeline for card rendering.
### RSS Classification (background, triggered at fetch time)
When `services/rss.py` stores new items, it queues a fire-and-forget async task to classify them. Classification is a fast, non-streaming LLM call processing batches of up to 10 items:
```
Classify each news item into 1-3 topics from this vocabulary:
technology, science, politics, business, health, environment,
local, entertainment, sports, other, [user_defined_topics]
Return JSON: {"item_id": ["topic1", "topic2"]}
```
The vocabulary is extended with the user's declared preference topics so custom interests can be matched. Results are written to `rss_items.topics` and `classified_at`.
Model: the user's `default_model` setting (same as chat). If the LLM is unavailable or classification fails, the item is stored with `topics = []` and `classified_at` left NULL — it will be retried the next time new items are fetched. No retry loop; classification is best-effort.
### Post-briefing Stage (new, runs after `post_message()` returns)
1. **Upsert task snapshots** — upsert `briefing_task_snapshot` rows for all tasks included in this briefing so the next run can diff against current state.
2. **Populate message metadata** — the `metadata` dict (`weather` + `rss_item_ids`) is assembled during pre-processing and passed through to `post_message()`, which writes it to the `Message.metadata` column. No separate post-step is needed — the metadata is stored atomically with the message.
---
## Weather Card
The weather section is no longer generated as prose by the synthesis LLM. Instead:
- `services/weather.py` is updated to request `past_days=1` from Open-Meteo, including yesterday's high/low in the same API response.
- The pipeline parses the forecast into the `metadata.weather` schema (defined in the Data Model section) and stores it on the `Message` record when `post_message()` is called.
- `BriefingView.vue` reads `message.metadata.weather` when loading the conversation and renders `WeatherCard.vue` above the message text if the field is present.
- The synthesis LLM's weather section is suppressed entirely — the prompt instructs it to skip weather since it is handled by the card.
**`WeatherCard.vue` displays:**
- Location name and "as of" timestamp
- Current temperature and condition
- Today's high / low
- Yesterday's high / low with delta ("3° warmer than yesterday")
- Compact 35 day forecast strip (day name, condition, high/low)
**Failure state:** If `metadata.weather` is `null`, the same card position renders a muted placeholder: "Weather data unavailable — will retry at next slot."
---
## News Cards
### Format
The synthesis LLM is instructed to format each included news item as:
```markdown
**[Headline text](source_url)**
*Outlet Name · Day Month*
One or two sentence summary of the story.
```
No prose wrapper between cards. The synthesis prompt must explicitly instruct the LLM to **present news items in the exact order provided**`metadata.rss_item_ids` records this order and the frontend maps reaction buttons positionally. Reordering by the LLM would break the mapping.
The briefing message structure becomes:
1. Greeting / task summary
2. `WeatherCard` (rendered from `message.metadata.weather`, not prose)
3. News cards (markdown blocks with links)
4. Calendar / other sections
### Reaction Buttons
`BriefingView.vue` reads `message.metadata.rss_item_ids` when loading the conversation. The ordered list of IDs maps directly to the news cards in the rendered message (cards appear in synthesis output in the same order). A 👍 / 👎 pair is rendered below each card. Reaction buttons are only shown in the briefing view — not in message history exports.
Clicking a reaction:
1. Optimistic UI update (button enters selected state immediately)
2. `POST /api/briefing/rss-reactions``{rss_item_id, reaction}`
3. Backend validates ownership: joins through `rss_items → rss_feeds` to confirm `rss_feeds.user_id = g.user.id` before upserting
4. Upserts into `rss_item_reactions` — same reaction removes it, opposite flips it
**New endpoints in `routes/briefing.py`:**
- `POST /api/briefing/rss-reactions` — upsert or remove reaction (ownership-checked)
- `DELETE /api/briefing/rss-reactions/:item_id` — explicit removal (useful for MCP/external API consumers that cannot use the toggle behaviour of POST)
---
## Topic Preferences UI
**Settings → Briefing tab — new "News Preferences" subsection** (added below existing RSS feed management):
Two chip-input fields using the existing `TagInput.vue` component:
- **Interested in** → `briefing_include_topics` setting
- **Not interested in** → `briefing_exclude_topics` setting
A collapsed hint lists the standard topic vocabulary so users know valid terms. Custom terms are accepted — the RSS classifier will attempt to match them.
Saved via the existing `PUT /api/settings/:key` endpoint.
---
## MCP Tool Additions
New file: `fable-mcp/fable_mcp/tools/briefing.py`
Three tools registered in `server.py`:
| Tool | Description |
|------|-------------|
| `add_rss_feed(url, title=None, category=None)` | Adds a feed to the user's RSS list. `title` is optional — the feed title is auto-populated from feed metadata after the first fetch, but an override can be passed. Returns the created feed object. |
| `list_rss_feeds()` | Returns current feed list with id, title, url, category, last_fetched_at. |
| `remove_rss_feed(feed_id)` | Removes a feed by ID. |
These call existing RSS endpoints in `routes/briefing.py` via `FableClient`. No new backend routes required.
---
## New Backend Files
| File | Purpose |
|------|---------|
| `services/briefing_preferences.py` | Load/compute topic preference weights; apply to RSS item scoring |
| `services/rss_classifier.py` | Batch LLM classification of RSS items; background task management |
## Modified Backend Files
| File | Changes |
|------|---------|
| `services/briefing_pipeline.py` | Add pre-processing and post-briefing stages; carry `task_id` through serialised task dicts; pass `metadata` dict to `post_message()` |
| `services/rss.py` | Trigger background classification after storing new items |
| `services/weather.py` | Add `past_days=1` to Open-Meteo request; expose parsed yesterday data |
| `routes/briefing.py` | Add `POST/DELETE /api/briefing/rss-reactions` endpoints |
| `models/rss_feed.py` | Add `topics` and `classified_at` mapped columns to `RssItem`; expose in `to_dict()` |
| `models/conversation.py` | Add `metadata` JSONB mapped column to `Message`; update `to_dict()` to include `metadata` in the returned dict |
| `services/briefing_conversations.py` | Extend `post_message(conversation_id, role, content)` signature to accept an optional `metadata: dict \| None = None` parameter; pass it to the `Message(...)` constructor |
## New Frontend Files
| File | Purpose |
|------|---------|
| `components/WeatherCard.vue` | Weather display card (current, today, yesterday delta, 3-5 day strip, failure state) |
## Modified Frontend Files
| File | Changes |
|------|---------|
| `views/BriefingView.vue` | Read `message.metadata.weather` on conversation load → render `WeatherCard.vue` above message text; read `message.metadata.rss_item_ids` → attach reaction buttons to news cards in order |
| `views/SettingsView.vue` | Add "News Preferences" subsection with two `TagInput` fields |
| `api/client.ts` | Add `postRssReaction()`, `deleteRssReaction()` helpers |
---
## Out of Scope
- Android companion app changes (web-first; parity deferred)
- Full numeric scoring system (Approach C) — can evolve to this once reaction data accumulates
- Push notification integration for briefing reactions
@@ -0,0 +1,214 @@
# Internal Calendar with CalDAV Sync — Design Spec
## Goal
Add an internal event store to Fable's database, a full calendar UI (month/week grid), and a best-effort push sync to the user's existing external CalDAV server. Fable becomes the source of truth for events; CalDAV is an optional downstream target.
## Background
The existing `services/caldav.py` lets the AI assistant create, update, and delete events directly on an external CalDAV server (Nextcloud, etc.) via tool calls. There is no calendar UI and no internal event storage — all reads go to CalDAV at query time. The `events` DB table and `models/event.py` exist as dead code from an abandoned Radicale integration (created in migration `0019_add_events`); the table exists in the database but all columns are empty.
This design revives the internal event store, adds a REST API and calendar view, and re-wires the AI tools to write through the internal DB so all events are accessible regardless of CalDAV availability.
---
## Architecture
### Source of Truth
Fable's `events` DB table is the single source of truth. CalDAV sync is a best-effort push: after every create, update, or delete, a background task attempts to mirror the change to the user's configured CalDAV server. If CalDAV is unconfigured or unreachable, the operation still succeeds and the event remains in Fable.
### Timezone Handling
- **Storage:** `start_dt` / `end_dt` stored as UTC (`TIMESTAMPTZ`).
- **Display:** FullCalendar configured with `timeZone: 'local'` — renders in the browser's IANA timezone automatically.
- **Input:** Frontend datetime inputs are in local time; converted to a timezone-aware ISO string (with UTC offset) before the API call. Backend parses to UTC.
- **CalDAV push:** Uses the user's `caldav_timezone` setting for iCal `DTSTART`/`DTEND`, consistent with existing behaviour.
- **AI tools:** The LLM receives the user's timezone in the system prompt and sends timezone-aware strings, as today.
---
## Data Model
### `events` table (existing since migration 0019, extend via migration 0029)
| Column | Type | Notes |
|--------|------|-------|
| `id` | `SERIAL PK` | |
| `user_id` | `INT FK users CASCADE` | |
| `project_id` | `INT FK projects SET NULL` nullable | Optional link to a Fable project |
| `uid` | `TEXT` | iCal UID — UUID generated on `create_event`, embedded in CalDAV push |
| `caldav_uid` | `TEXT DEFAULT ''` | **New.** Same value as `uid` after a successful CalDAV push (confirms sync); empty if never synced |
| `title` | `TEXT` | |
| `start_dt` | `TIMESTAMPTZ` | UTC |
| `end_dt` | `TIMESTAMPTZ` nullable | UTC; null for open-ended events |
| `all_day` | `BOOLEAN DEFAULT false` | When true, time component is ignored in display |
| `description` | `TEXT DEFAULT ''` | |
| `location` | `TEXT DEFAULT ''` | |
| `color` | `TEXT DEFAULT ''` | **New.** Hex colour for calendar display (e.g. `#6366f1`) |
| `recurrence` | `TEXT` nullable | iCal RRULE string — stored and forwarded to CalDAV; not exposed in the UI slide-over |
| `created_at` | `TIMESTAMPTZ` | |
| `updated_at` | `TIMESTAMPTZ` | |
**Migration `0029_add_calendar_columns`:** `ALTER TABLE events ADD COLUMN IF NOT EXISTS caldav_uid TEXT DEFAULT ''` and `ADD COLUMN IF NOT EXISTS color TEXT DEFAULT ''`. Raw SQL with `IF NOT EXISTS` guards (table already exists from migration 0019).
**`models/event.py`:** Add two `mapped_column` declarations to the `Event` class:
```python
caldav_uid: Mapped[str] = mapped_column(Text, default="")
color: Mapped[str] = mapped_column(Text, default="")
```
Update `to_dict()` to include `"caldav_uid": self.caldav_uid` and `"color": self.color`.
---
## Service Layer
**`src/fabledassistant/services/events.py`** — owns all event CRUD and CalDAV push coordination.
### Public functions
```
create_event(user_id, title, start_dt, end_dt, all_day, description, location,
color, recurrence, project_id,
duration, reminder_minutes, attendees, calendar_name) → Event
get_event(user_id, event_id) → Event
list_events(user_id, date_from, date_to) → list[Event]
search_events(user_id, query, days_ahead=90) → list[Event]
update_event(user_id, event_id, **fields) → Event
delete_event(user_id, event_id) → None
find_events_by_query(user_id, query) → list[Event]
```
`duration`, `reminder_minutes`, `attendees`, and `calendar_name` are accepted by `create_event` for forwarding to CalDAV (they are not stored in the DB). `find_events_by_query` does a case-insensitive `ILIKE` search on `title` and is used by the AI `update_event` / `delete_event` tools.
### CalDAV push — UID strategy
`create_event` generates a UUID and stores it as the event's `uid`. This same UUID must be embedded as the iCal `UID` field when pushing to CalDAV. To enable this, `services/caldav.py`'s `create_event` function is **minimally modified** to accept an optional `uid: str | None = None` parameter; when provided, `event.add("uid", uid)` is called before `cal.add_component(event)`. All other CalDAV functions are unchanged.
On a successful push, `caldav_uid` is set to the same UUID value in the DB (confirming sync). This means `caldav_uid` is Fable's own UID confirmed-as-pushed, not a server-assigned value — which is fine since we generated it.
After every write to the DB:
- `create_event``asyncio.create_task(_push_create(event, user_id, extra_fields))`
- `update_event``asyncio.create_task(_push_update(event, user_id))`
- `delete_event``asyncio.create_task(_push_delete(caldav_uid, user_id))` if `caldav_uid` is set
Each push function calls the relevant function in `services/caldav.py`, marks `caldav_uid` on success, and logs a warning on failure. CalDAV being unconfigured is treated as a no-op (not an error).
### Match policy for AI update/delete tools
`find_events_by_query` returns all events where `title ILIKE '%query%'`. The calling tool applies the same match-count policy as the existing CalDAV tools: zero matches → raise `ValueError("No event found matching '…'.")`; more than 3 matches → raise `ValueError("Too many matches (N) for '…'. Be more specific. Found: …")`. One or two matches use the first result.
---
## REST API
**`src/fabledassistant/routes/events.py`** — new blueprint, registered in `app.py`.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/events?from=&to=` | List events in ISO datetime range |
| `POST` | `/api/events` | Create event |
| `GET` | `/api/events/:id` | Get single event |
| `PATCH` | `/api/events/:id` | Partial update |
| `DELETE` | `/api/events/:id` | Delete event |
All routes require `@login_required`. Ownership enforced in the service layer (404 if event not owned by requesting user).
---
## AI Tool Rewiring
Calendar tools in `services/tools.py` are updated to call `services/events.py` instead of `services/caldav.py` directly. The `is_caldav_configured` gate that currently hides these tools is **removed** — calendar tools are always available since the internal DB is always present. The tool definitions currently grouped under `_CALDAV_TOOLS` (appended to the tools list only when `is_caldav_configured` is true) must be moved into the unconditional `_CORE_TOOLS` list (or equivalent always-included list). The tool parameter names and LLM-facing descriptions do not change.
| Tool | Change |
|------|--------|
| `create_event` | Calls `events.create_event(...)` — all existing params forwarded |
| `list_events` | Calls `events.list_events(...)` |
| `search_events` | Calls `events.search_events(...)` |
| `update_event` | Calls `events.find_events_by_query(...)`, then `events.update_event(...)` |
| `delete_event` | Calls `events.find_events_by_query(...)`, then `events.delete_event(...)` |
---
## Frontend
### Dependencies
```
@fullcalendar/vue3
@fullcalendar/daygrid # month view
@fullcalendar/timegrid # week / day view
@fullcalendar/interaction # drag-to-move, resize, click-to-create
```
All MIT licensed.
### Components
**`frontend/src/views/CalendarView.vue`** — main view at `/calendar`:
- FullCalendar instance with `timeZone: 'local'`, `initialView: 'dayGridMonth'`, header toolbar toggling between month and week
- Loads events for the visible date range via `GET /api/events?from=&to=`
- Re-fetches when the visible range changes
- Click on empty date cell → opens `EventSlideOver` in create mode, pre-filled with the clicked date
- Click on existing event → opens `EventSlideOver` in edit mode
- Drag event to new date/time → `PATCH /api/events/:id` with updated times (optimistic update, revert on error)
- Resize event → same
**`frontend/src/components/EventSlideOver.vue`** — reusable slide-over panel (same pattern as the workspace task slide-over):
- Fields: title (required), start date/time, end date/time, all-day toggle, location, description, colour picker, optional project selector
- `recurrence` is **not** a form field — it is stored and pushed to CalDAV via AI tools only
- Create mode: `POST /api/events`
- Edit mode: `PATCH /api/events/:id` + delete button (`DELETE /api/events/:id`)
- Closes on save, cancel, or Escape
**`frontend/src/api/client.ts`** — add typed helpers and `EventEntry` / `CreateEventBody` interfaces:
```typescript
interface EventEntry {
id: number; uid: string; title: string;
start_dt: string; end_dt: string | null;
all_day: boolean; description: string; location: string;
color: string; recurrence: string | null;
project_id: number | null; caldav_uid: string;
created_at: string; updated_at: string;
}
listEvents(from: string, to: string): Promise<EventEntry[]>
createEvent(body: CreateEventBody): Promise<EventEntry>
getEvent(id: number): Promise<EventEntry>
updateEvent(id: number, body: Partial<CreateEventBody>): Promise<EventEntry>
deleteEvent(id: number): Promise<void>
```
### Navigation
- `/calendar` added to Vue Router
- Sidebar nav item added
- Keyboard shortcut: `g` + `l` (ca**l**endar) — `c` is already taken by chat
---
## Error Handling
- CalDAV push failures are logged at `WARNING` level and do not surface to the user
- If CalDAV is unconfigured, push is skipped silently
- Drag-to-move/resize failures (PATCH error) revert the event to its previous position in FullCalendar's UI via the `revert` callback
- 404 returned for event access by non-owner
---
## Testing
- `tests/test_events_service.py` — unit tests for `create_event`, `list_events`, `search_events`, `update_event`, `delete_event`, `find_events_by_query` with mocked DB session; separate test verifying CalDAV push background task is fired
- `tests/test_events_routes.py` — route tests for all five endpoints verifying ownership enforcement and correct status codes
- TypeScript check (`make typecheck`) after all frontend changes
---
## Out of Scope
- Pull sync (importing events from CalDAV into Fable) — possible future upgrade
- Recurring event expansion in the UI (RRULE stored and forwarded to CalDAV via AI tools only)
- Shared calendars / multi-user event invites
- Event notifications / reminders in the Fable push system
+236
View File
@@ -0,0 +1,236 @@
# RAG Scoping and Context Isolation Design
> **For agentic workers:** Use superpowers:executing-plans or superpowers:subagent-driven-development to implement this plan task-by-task.
**Goal:** Prevent project-associated notes from polluting global chat RAG, and give the LLM tools to discover and switch project scope during a conversation.
**Problem being solved:** Users with multiple projects (e.g. scifi worldbuilding, personal productivity) find that semantic search pulls notes from whichever project has the most content, contaminating general-purpose chat context. Project notes should be siloed by default.
---
## Architecture
### Default RAG behavior change
Currently `rag_project_id=None` in `build_context()` searches all notes. After this change the semantics become a three-value system:
- `rag_project_id = None` → search only orphan notes (`project_id IS NULL`)
- `rag_project_id = <positive int>` → search only that project's notes
- `rag_project_id = -1` → search all notes (explicit opt-in to current global behavior)
**`semantic_search_notes()` change** (`services/embeddings.py`): add `orphan_only: bool = False` parameter. When `True`, add `.where(Note.project_id.is_(None))` to the SQLAlchemy query.
**`search_notes_for_context()` change** (`services/notes.py`): add `orphan_only: bool = False` parameter with the same filter.
**`build_context()` change** (`services/llm.py`): compute the flags before calling search:
```python
# rag_project_id=None → orphan only; -1 → all notes; positive int → that project
orphan_only = rag_project_id is None
effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None
for score, note in await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
project_id=effective_project_id,
orphan_only=orphan_only,
):
...
```
The same `orphan_only` / `effective_project_id` pattern applies to the keyword fallback call to `search_notes_for_context()`.
### Conversation scope persistence
**New DB column:** `conversations.rag_project_id INTEGER` (nullable, default `NULL`).
Semantics match the three-value system above. `NULL` is the default for all new and existing conversations after migration — existing conversations transparently gain orphan-only scoping.
**Model change** (`models/conversation.py`):
```python
rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
```
`to_dict()` must include `"rag_project_id": self.rag_project_id`.
**Route change** (`routes/chat.py`): `PATCH /api/chat/conversations/:id` must accept `rag_project_id` in the request body and call `update_conversation(conv_id, user_id, rag_project_id=value)`. `GET /api/chat/conversations/:id` returns `rag_project_id` via `to_dict()`.
**Service change** (`services/chat.py`): `update_conversation()` must accept and persist `rag_project_id`.
### Project summarization background job
**New DB columns** on `projects`:
```python
auto_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
summary_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
```
**`generate_project_summary(user_id: int, project_id: int) -> None`** (`services/projects.py`):
- Fetches project metadata (title, description, goal)
- Fetches up to 10 notes for that project: `SELECT title, body[:200] FROM notes WHERE project_id=? ORDER BY updated_at DESC LIMIT 10`
- Builds an Ollama prompt: "Summarize this project in 3-4 sentences covering its purpose, themes, and content. Title: {title}. Description: {description}. Goal: {goal}. Recent notes: {sample}"
- Calls Ollama (same `generate_completion()` helper used elsewhere, `stream=False`)
- Stores result in `project.auto_summary` and `project.summary_updated_at = datetime.now(UTC)`
- On Ollama failure: log debug message, return without updating (graceful degradation)
**`backfill_project_summaries() -> None`** (`services/projects.py`): fire-and-forget, called at startup alongside `backfill_note_embeddings()`. Generates summaries for all projects where `auto_summary IS NULL`.
**Trigger from `update_project()`**: after committing, call `asyncio.create_task(generate_project_summary(user_id, project_id))` unconditionally.
**Trigger from note saves**: in `create_note()` and `update_note()` within `services/notes.py`, when `project_id` is not None, check `project.summary_updated_at`. If `None` or more than 1 hour old, fire `asyncio.create_task(generate_project_summary(user_id, project_id))`. This avoids flooding Ollama during heavy note-editing sessions.
`generate_project_summary` and `backfill_project_summaries` are **internal background tasks only**, not exposed as LLM tools.
### New LLM tools
Both tools are added to `_CORE_TOOLS` in `services/tools.py`.
**`search_projects(query: str)`**:
- Loads all projects for the user from the DB (title, description, goal, auto_summary)
- Scores each project: `SequenceMatcher(None, query.lower(), combined_text.lower()).ratio()` where `combined_text = f"{title} {description or ''} {auto_summary or ''}"`, plus keyword overlap bonus
- Returns top 5 as `[{id, title, summary_snippet, score}]` where `summary_snippet` is `auto_summary[:200]` or `description[:200]`
- Tool result type: `"projects_list"`
**`set_rag_scope(project_id: int | None)`**:
- `project_id = <positive int>` → scope to that project
- `project_id = null` → orphan-only
- `project_id = -1` → all notes
- **Workspace guard:** if `workspace_project_id` is set on the current generation context, return `{success: false, error: "Cannot change RAG scope in workspace view"}` and make no changes
- Validates positive project_id exists and belongs to user (returns error if not)
- Calls `await update_conversation(conv_id, user_id, rag_project_id=project_id)` to persist immediately
- Returns `{success: true, type: "rag_scope_set", scope_label: "<human-readable>"}` e.g. `"Orphan notes only"`, `"SciFi Project"`, `"All notes"`
**`conv_id` threading** (`services/tools.py`, `services/generation_task.py`):
Current `execute_tool()` signature:
```python
async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict
```
Updated signature:
```python
async def execute_tool(
user_id: int,
tool_name: str,
arguments: dict,
conv_id: int | None = None,
workspace_project_id: int | None = None,
) -> dict
```
In `generation_task.py`, the call site in the tool execution loop must pass `conv_id=conv_id, workspace_project_id=workspace_project_id` — both are already available as local variables in `run_generation()`.
**SSE `new_rag_scope` wiring:**
In `generation_task.py`, after each tool call returns, check `if result.get("type") == "rag_scope_set" and result.get("success")`. Store `new_rag_scope = arguments["project_id"]` and `new_rag_scope_label = result["scope_label"]`.
When emitting the final SSE `done` event (currently `{"done": True, "message_id": msg_id, "timing": timing}`), add the scope fields when present:
```python
done_payload = {"done": True, "message_id": msg_id, "timing": timing}
if new_rag_scope_label is not None:
done_payload["new_rag_scope"] = new_rag_scope # raw int or None
done_payload["new_rag_scope_label"] = new_rag_scope_label
yield f"data: {json.dumps(done_payload)}\n\n"
```
In `stores/chat.ts`, the SSE `done` handler must check `event.data.new_rag_scope !== undefined` and set `currentConversation.value.rag_project_id = event.data.new_rag_scope`.
### Frontend scope indicator
**Chat store** (`stores/chat.ts`):
- `currentConversation` already holds the full conversation object; add `rag_project_id: number | null` to the conversation interface
- Add a computed `ragProjectId` that reads `currentConversation.value?.rag_project_id ?? null`
- Add `async updateRagScope(convId: number, ragProjectId: number | null)` that calls `PATCH /api/chat/conversations/:id { rag_project_id }` and updates `currentConversation.value.rag_project_id` locally on success
- In the SSE `done` handler, if `event.data.new_rag_scope !== undefined`, set `currentConversation.value.rag_project_id = event.data.new_rag_scope`
**ChatView** (`views/ChatView.vue`):
- Remove the existing `ProjectSelector` from the sidebar RAG scope section
- Add a scope chip just above the message input. Shows: `⊙ Orphan notes` (null) / `⊙ All notes` (-1) / `⊙ {project title}` (positive int)
- Clicking opens a compact dropdown: "Orphan notes only", all active projects by title, "All notes"
- On selection, call `chatStore.updateRagScope(convId, selectedValue)`
- When `chatStore.ragProjectId` changes (from SSE or UI), briefly apply a CSS pulse animation to the chip
- Load the projects list once on mount via `apiGet("/api/projects?status=active")`
**WorkspaceView** — no change. It hardcodes `ragProjectId` to the workspace project's id (positive int). Scope chip is not shown in workspace view.
---
## Files Changed
### Backend
| File | Change |
|------|--------|
| `alembic/versions/0030_rag_scoping.py` | Add `conversations.rag_project_id`, `projects.auto_summary`, `projects.summary_updated_at` |
| `models/conversation.py` | Add `rag_project_id: Mapped[int \| None]`; include in `to_dict()` |
| `models/project.py` | Add `auto_summary: Mapped[str \| None]`, `summary_updated_at: Mapped[datetime \| None]` |
| `services/projects.py` | Add `generate_project_summary()`, `backfill_project_summaries()`; trigger from `update_project()` |
| `services/notes.py` | Add `orphan_only` param to `search_notes_for_context()`; trigger summary regen from note saves |
| `services/embeddings.py` | Add `orphan_only: bool = False` param to `semantic_search_notes()` |
| `services/llm.py` | Compute `orphan_only` + `effective_project_id` from `rag_project_id`; pass to both search calls |
| `services/tools.py` | Add `search_projects` and `set_rag_scope` definitions and handlers; update `execute_tool()` signature |
| `services/generation_task.py` | Pass `conv_id` + `workspace_project_id` to `execute_tool()`; capture scope change; emit in SSE done |
| `services/chat.py` | Add `rag_project_id` parameter to `update_conversation()` |
| `routes/chat.py` | Accept `rag_project_id` in PATCH body; return in GET responses via `to_dict()` |
| `app.py` | Call `backfill_project_summaries()` at startup |
### Frontend
| File | Change |
|------|--------|
| `api/client.ts` | Conversation type includes `rag_project_id`; helpers accept it |
| `stores/chat.ts` | `ragProjectId` computed; `updateRagScope()` method; SSE done handler updates scope |
| `views/ChatView.vue` | Replace sidebar `ProjectSelector` with scope chip above input bar |
---
## Data Flow
```
User changes scope via chip
→ chatStore.updateRagScope(convId, value)
→ PATCH /api/chat/conversations/:id { rag_project_id: value }
→ DB updated; currentConversation.rag_project_id updated locally
→ chip re-renders immediately
User sends message
→ sendMessage() reads chatStore.ragProjectId → passes as rag_project_id in POST body
→ build_context() derives orphan_only + effective_project_id
→ RAG results scoped accordingly
Model calls set_rag_scope(3) during generation
→ execute_tool(conv_id=X, workspace_project_id=None) writes rag_project_id=3 to DB
→ returns { success: true, type: "rag_scope_set", scope_label: "SciFi Project" }
→ generation_task stores new_rag_scope=3, new_rag_scope_label="SciFi Project"
→ SSE done event: { done: true, new_rag_scope: 3, new_rag_scope_label: "SciFi Project", ... }
→ chat store done handler: currentConversation.rag_project_id = 3
→ chip pulses → "⊙ SciFi Project"
User opens existing conversation
→ GET /api/chat/conversations/:id returns rag_project_id
→ chat store sets currentConversation; ragProjectId computed picks it up
→ chip renders correct scope immediately
```
---
## Error Handling
- **Ollama unavailable during summary gen:** log debug, return without updating; old summary (or null) retained; `search_projects` falls back to title + description matching
- **`set_rag_scope` called in workspace:** return `{success: false, error: "Cannot change RAG scope in workspace view"}`; no DB write
- **`set_rag_scope` with unknown positive project_id:** return `{success: false, error: "Project not found"}`; scope unchanged
- **`search_projects` with no summaries yet:** score on title + description only; still returns results
- **Existing conversations after migration:** `rag_project_id` defaults to `NULL` → orphan-only; behavior silently improves without user action
---
## Testing
- Unit: `semantic_search_notes(orphan_only=True)` returns only notes where `project_id IS NULL`
- Unit: `semantic_search_notes(project_id=3, orphan_only=False)` returns only notes for project 3
- Unit: `build_context()` passes correct `orphan_only` / `effective_project_id` for all three `rag_project_id` values (None, -1, positive)
- Unit: `search_projects()` scoring ranks correct project first for representative queries; degrades gracefully with no summaries
- Unit: `generate_project_summary()` builds correct Ollama prompt; handles Ollama failure gracefully
- Integration: `set_rag_scope()` handler writes to DB, returns correct scope_label, rejects in workspace context
- Integration: SSE `done` event includes `new_rag_scope` when scope changes mid-conversation
- Frontend: scope chip reflects loaded conversation scope; updates reactively on model tool call
+20 -34
View File
@@ -1,10 +1,8 @@
# OAuth / OIDC SSO Setup (Authentik)
# OAuth / OIDC SSO Setup
Fabled Assistant supports single sign-on via any OpenID Connect provider.
This guide covers Authentik, but the same pattern works with Keycloak, Authelia, Zitadel, etc.
---
## 1. Create the provider in Authentik
1. Log in to the Authentik admin UI.
@@ -22,8 +20,6 @@ This guide covers Authentik, but the same pattern works with Keycloak, Authelia,
https://auth.example.com/application/o/fabled-assistant/
```
---
## 2. Configure Fabled Assistant
Add the following environment variables to the `app` service in `docker-compose.yml`:
@@ -44,12 +40,11 @@ services:
# Disable local username/password login once SSO is working
# LOCAL_AUTH_ENABLED: "false"
# Make sure BASE_URL matches the redirect URI you registered in Authentik
# Make sure BASE_URL matches the redirect URI you registered
BASE_URL: "https://your-fabled-domain"
```
> **Docker Secrets alternative:** Instead of `OIDC_CLIENT_SECRET`, you can use
> `OIDC_CLIENT_SECRET_FILE` pointing to a Docker secret file.
> **Docker Secrets alternative:** Use `OIDC_CLIENT_SECRET_FILE` pointing to a Docker secret file instead of `OIDC_CLIENT_SECRET`.
Rebuild and restart:
@@ -57,53 +52,44 @@ Rebuild and restart:
docker compose up --build -d
```
---
## 3. Verify
1. Open `/api/auth/status` — it should return:
```json
{ "oauth_enabled": true, "local_auth_enabled": true, ... }
```
2. Go to the login page — you should see a **"Login with Authentik"** button.
3. Click it → you are redirected to Authentik → authenticate → redirected back to Fabled → logged in.
2. Go to the login page — you should see a **"Login with [Provider]"** button.
3. Click it → redirected to provider → authenticate → redirected back → logged in.
4. Check `/api/auth/me` to confirm your user record.
---
## 4. Account linking
## 4. Account Linking
When a user logs in via OAuth for the first time, Fabled checks in this order:
1. **Existing OAuth sub** — returns that user immediately.
2. **Matching email** — if a local account already exists with the same email address, the OAuth identity is linked to it automatically. The user retains all their notes and tasks.
3. **New user** — a fresh account is created. The username defaults to the `preferred_username` claim from the provider; if taken, `_2`, `_3`, etc. is appended.
2. **Matching email** — if a local account already exists with the same email, the OAuth identity is linked to it automatically. The user retains all their notes and tasks.
3. **New user** — a fresh account is created. Username defaults to `preferred_username` from the provider; if taken, `_2`, `_3`, etc. is appended.
---
## 5. Disable Local Login (Optional)
## 5. Disable local login (optional)
Once everyone is using SSO you can hide the username/password form:
Once everyone is using SSO:
```yaml
LOCAL_AUTH_ENABLED: "false"
```
The backend will reject any `POST /api/auth/login` or `POST /api/auth/register` request with a `403`. The login page will only show the SSO button.
The backend will reject any `POST /api/auth/login` or `POST /api/auth/register` request with a 403. The login page will only show the SSO button.
> **Warning:** Make sure at least one account has been linked via OAuth before disabling local login, or you will be locked out.
---
## 6. Other Providers
## 6. Other providers
| Provider | Issuer URL format |
|----------|------------------|
| Authentik | `https://auth.example.com/application/o/<app-slug>/` |
| Keycloak | `https://keycloak.example.com/realms/<realm>` |
| Authelia | `https://auth.example.com` |
| Zitadel | `https://your-instance.zitadel.cloud` |
| Google | `https://accounts.google.com` |
| Provider | Issuer URL format |
|------------|----------------------------------------------------------|
| Authentik | `https://auth.example.com/application/o/<app-slug>/` |
| Keycloak | `https://keycloak.example.com/realms/<realm>` |
| Authelia | `https://auth.example.com` |
| Zitadel | `https://your-instance.zitadel.cloud` |
| Google | `https://accounts.google.com` |
The OIDC discovery endpoint (`<issuer>/.well-known/openid-configuration`) must be
publicly reachable from the Fabled container (server-to-server call).
The OIDC discovery endpoint (`<issuer>/.well-known/openid-configuration`) must be publicly reachable from the Fabled container (server-to-server call at login time).
+2
View File
@@ -0,0 +1,2 @@
FABLE_URL=http://localhost:5000
FABLE_API_KEY=fmcp_your_key_here
View File
+124
View File
@@ -0,0 +1,124 @@
"""Async HTTP client for the Fable Assistant API."""
from __future__ import annotations
import os
from typing import Any, AsyncIterator
import httpx
class FableAPIError(Exception):
"""Raised when the Fable API returns a non-2xx response."""
def __init__(self, status_code: int, message: str) -> None:
self.status_code = status_code
super().__init__(f"Fable API error {status_code}: {message}")
class FableClient:
"""Async wrapper around httpx for the Fable REST API."""
def __init__(self) -> None:
url = os.environ.get("FABLE_URL", "").rstrip("/")
key = os.environ.get("FABLE_API_KEY", "")
if not url:
raise ValueError("FABLE_URL environment variable is required")
if not key:
raise ValueError("FABLE_API_KEY environment variable is required")
self.base_url = url
self._headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> "FableClient":
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers=self._headers,
timeout=30.0,
)
return self
async def __aexit__(self, *args: Any) -> None:
if self._client:
await self._client.aclose()
self._client = None
def _http(self) -> httpx.AsyncClient:
if self._client is None:
raise RuntimeError("FableClient must be used as an async context manager")
return self._client
@staticmethod
def _raise_for_status(response: httpx.Response) -> None:
if response.is_error:
try:
message = response.json().get("error", response.text)
except Exception:
message = response.text
raise FableAPIError(response.status_code, message)
async def get(self, path: str, **kwargs: Any) -> Any:
response = await self._http().get(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def post(self, path: str, **kwargs: Any) -> Any:
response = await self._http().post(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def patch(self, path: str, **kwargs: Any) -> Any:
response = await self._http().patch(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def put(self, path: str, **kwargs: Any) -> Any:
response = await self._http().put(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def delete(self, path: str, **kwargs: Any) -> Any:
response = await self._http().delete(path, **kwargs)
if response.status_code == 204:
return None
self._raise_for_status(response)
if response.content:
return response.json()
return None
async def stream_get(self, path: str, **kwargs: Any) -> AsyncIterator[str]:
"""Yield non-empty lines from a streaming GET response (SSE)."""
async with self._http().stream("GET", path, **kwargs) as response:
self._raise_for_status(response)
async for line in response.aiter_lines():
if line:
yield line
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_client: FableClient | None = None
def init_client() -> FableClient:
"""Initialise the module-level FableClient singleton (reads env vars)."""
global _client
_client = FableClient()
return _client
def get_client() -> FableClient:
"""Return the singleton client; raises RuntimeError if not initialised."""
if _client is None:
raise RuntimeError("Call init_client() before get_client()")
return _client
def _reset_client() -> None:
"""Reset the singleton — used by tests only."""
global _client
_client = None
+643
View File
@@ -0,0 +1,643 @@
"""Fable MCP server — exposes Fable Assistant as MCP tools via stdio transport."""
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from dotenv import load_dotenv
from fable_mcp.client import FableClient
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin, briefing
load_dotenv()
_INSTRUCTIONS = """
Fable Assistant is a self-hosted second-brain and project management system with LLM integration.
## Data model
The hierarchy is: Project → Milestone → Task/Note.
- **Notes** and **Tasks** share the same underlying model. Tasks are notes with `is_task=True`.
The note tools (fable_*_note) operate on notes; the task tools (fable_*_task) operate on tasks.
Do not use note tools to manipulate tasks or vice versa.
- **Projects** group related work. A project has a title, description, goal, status, and an
auto-generated summary used for semantic search. Status values: `active`, `archived`.
- **Milestones** belong to a project and group tasks within it. Status values: `active`, `done`.
- **Tasks** belong to a project and optionally a milestone. They support sub-tasks via `parent_id`.
- Status values: `todo`, `in_progress`, `done`, `cancelled`
- Priority values: `low`, `normal`, `high`
- **Notes** are free-form markdown documents. They can belong to a project or be standalone
(orphan notes). Orphan notes are included in the default RAG scope for chat conversations.
## Tags
Tags are plain strings — do NOT include a `#` prefix. Example: `["python", "architecture"]`.
Tags are stored as an array on the note/task. Passing `tags=[]` clears all tags; omitting `tags`
leaves existing tags unchanged on updates.
## Integer-or-none fields
Due to MCP type constraints, optional integer fields (project_id, milestone_id, parent_id)
use `0` to mean "not set / no association". Pass `0` to leave the field unset.
## Search
`fable_search` performs semantic (embedding-based) search over notes and tasks. Use it to find
relevant content by meaning rather than exact keywords. Returns results ranked by cosine
similarity with id, title, a body snippet, and tags.
## Chat / LLM delegation
`fable_send_message` sends a natural-language message to Fable's built-in LLM (Ollama). Fable
handles its own tool use, RAG context injection, and conversation history internally.
Use `fable_send_message` when:
- The request is conversational or requires Fable's internal reasoning across many records
- You want Fable's RAG to surface relevant notes automatically
Use the direct CRUD tools when:
- You know exactly what to create/read/update/delete
- You need structured data back (IDs, field values) for further processing
- You are populating Fable programmatically from another system
## Task logs
Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting
its main body. Suitable for recording work sessions, decisions, or status updates over time.
## RSS / Briefing
Fable runs a daily briefing that summarises tasks, calendar events, and RSS feed items.
Use `fable_add_rss_feed` / `fable_remove_rss_feed` to manage the feeds included in that briefing.
## Admin logs
`fable_get_app_logs` requires an admin-scoped API key. Regular user keys will be rejected.
"""
mcp = FastMCP("fable", instructions=_INSTRUCTIONS)
# ---------------------------------------------------------------------------
# Notes
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_notes(
limit: int = 20,
offset: int = 0,
tag: str = "",
search_text: str = "",
) -> dict:
"""List notes (non-task documents) stored in Fable.
Optionally filter by a single tag (plain string, no # prefix) or a keyword search
against title and body. Results are ordered by last-updated descending.
Use fable_search for semantic/meaning-based lookup instead of exact keyword search.
"""
async with FableClient() as client:
return await notes.list_notes(
client,
limit=limit,
offset=offset,
tag=tag or None,
search=search_text or None,
)
@mcp.tool()
async def fable_get_note(note_id: int) -> dict:
"""Fetch the full content of a single Fable note by its ID.
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
"""
async with FableClient() as client:
return await notes.get_note(client, note_id=note_id)
@mcp.tool()
async def fable_create_note(
title: str,
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Create a new note in Fable.
Args:
title: Note title (required).
body: Markdown content. Supports [[wikilinks]] to other notes by title.
tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"].
project_id: Associate with a project (use 0 for no project / orphan note).
Returns the created note object including its assigned id.
"""
async with FableClient() as client:
return await notes.create_note(
client,
title=title,
body=body,
tags=tags,
project_id=project_id or None,
)
@mcp.tool()
async def fable_update_note(
note_id: int,
title: str = "",
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Update an existing Fable note. Only explicitly provided fields are changed.
Args:
note_id: ID of the note to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged.
project_id: New project association (0 = remove from project). Omit to leave unchanged.
"""
async with FableClient() as client:
return await notes.update_note(
client,
note_id=note_id,
title=title or None,
body=body or None,
tags=tags,
project_id=project_id or None,
)
@mcp.tool()
async def fable_delete_note(note_id: int) -> str:
"""Permanently delete a Fable note by ID. This cannot be undone."""
async with FableClient() as client:
await notes.delete_note(client, note_id=note_id)
return f"Note {note_id} deleted."
# ---------------------------------------------------------------------------
# Tasks
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_tasks(
limit: int = 20,
offset: int = 0,
status: str = "",
project_id: int = 0,
) -> dict:
"""List tasks in Fable.
Args:
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
project_id: Filter to a specific project. Use 0 for no filter.
Results are ordered by last-updated descending.
"""
async with FableClient() as client:
return await tasks.list_tasks(
client,
limit=limit,
offset=offset,
status=status or None,
project_id=project_id or None,
)
@mcp.tool()
async def fable_get_task(task_id: int) -> dict:
"""Fetch a single Fable task by ID.
Returns id, title, body, status, priority, tags, project_id, milestone_id,
parent_id, parent_title, due_date, created_at, updated_at.
"""
async with FableClient() as client:
return await tasks.get_task(client, task_id=task_id)
@mcp.tool()
async def fable_create_task(
title: str,
body: str = "",
status: str = "todo",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
parent_id: int = 0,
tags: list[str] | None = None,
) -> dict:
"""Create a new task in Fable.
Args:
title: Task title (required).
body: Markdown description / notes for the task.
status: Initial status — one of: todo (default), in_progress, done, cancelled.
priority: One of: low, normal, high. Omit for no priority.
project_id: Associate with a project (0 = no project).
milestone_id: Place within a project milestone (0 = no milestone).
parent_id: Make this a sub-task of another task (0 = top-level).
tags: List of plain-string tags without # prefix.
Returns the created task object including its assigned id.
"""
async with FableClient() as client:
return await tasks.create_task(
client,
title=title,
body=body,
status=status,
priority=priority or None,
project_id=project_id or None,
milestone_id=milestone_id or None,
parent_id=parent_id or None,
tags=tags,
)
@mcp.tool()
async def fable_update_task(
task_id: int,
title: str = "",
body: str = "",
status: str = "",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
) -> dict:
"""Update an existing Fable task. Only explicitly provided fields are changed.
Args:
task_id: ID of the task to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
status: New status — one of: todo, in_progress, done, cancelled.
priority: New priority — one of: low, normal, high.
project_id: New project (0 = remove from project). Omit to leave unchanged.
milestone_id: New milestone (0 = remove from milestone). Omit to leave unchanged.
"""
async with FableClient() as client:
return await tasks.update_task(
client,
task_id=task_id,
title=title or None,
body=body or None,
status=status or None,
priority=priority or None,
project_id=project_id or None,
milestone_id=milestone_id or None,
)
@mcp.tool()
async def fable_add_task_log(task_id: int, body: str) -> dict:
"""Append a timestamped progress log entry to a Fable task.
Use this to record work sessions, decisions, or status updates over time without
overwriting the task's main body. Each entry is stored separately and shown
chronologically in the task view.
"""
async with FableClient() as client:
return await tasks.add_task_log(client, task_id=task_id, body=body)
# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_projects() -> dict:
"""List all Fable projects for the current user.
Returns id, title, description, goal, status (active/archived), color,
and a short auto-generated summary for each project.
"""
async with FableClient() as client:
return await projects.list_projects(client)
@mcp.tool()
async def fable_get_project(project_id: int) -> dict:
"""Fetch a Fable project by ID, including its milestone summary.
Returns full project fields plus a milestone_summary list with each milestone's
id, title, status, and task counts.
"""
async with FableClient() as client:
return await projects.get_project(client, project_id=project_id)
@mcp.tool()
async def fable_create_project(
title: str,
description: str = "",
goal: str = "",
status: str = "active",
color: str = "",
) -> dict:
"""Create a new project in Fable.
Args:
title: Project name (required).
description: Short summary of what the project is.
goal: The desired outcome or definition of done for the project.
status: active (default) or archived.
color: Optional hex colour for the project card (e.g. "#6366f1").
Returns the created project object including its assigned id.
"""
async with FableClient() as client:
return await projects.create_project(
client,
title=title,
description=description,
goal=goal or None,
status=status,
color=color or None,
)
@mcp.tool()
async def fable_update_project(
project_id: int,
title: str = "",
description: str = "",
goal: str = "",
status: str = "",
color: str = "",
) -> dict:
"""Update an existing Fable project. Only explicitly provided fields are changed.
Args:
project_id: ID of the project to update.
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
goal: New goal/definition-of-done, or omit to leave unchanged.
status: New status — active or archived.
color: New hex colour, or omit to leave unchanged.
"""
async with FableClient() as client:
return await projects.update_project(
client,
project_id=project_id,
title=title or None,
description=description or None,
goal=goal or None,
status=status or None,
color=color or None,
)
# ---------------------------------------------------------------------------
# Milestones
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_milestones(project_id: int) -> dict:
"""List milestones for a Fable project, ordered by order_index.
Returns id, title, description, status (active/done), order_index, and task counts.
"""
async with FableClient() as client:
return await milestones.list_milestones(client, project_id=project_id)
@mcp.tool()
async def fable_create_milestone(
project_id: int,
title: str,
description: str = "",
status: str = "active",
) -> dict:
"""Create a milestone within a Fable project.
Args:
project_id: The project this milestone belongs to (required).
title: Milestone name (required).
description: Optional description of what this milestone covers.
status: active (default) or done.
Returns the created milestone including its assigned id.
"""
async with FableClient() as client:
return await milestones.create_milestone(
client,
project_id=project_id,
title=title,
description=description,
status=status,
)
@mcp.tool()
async def fable_update_milestone(
project_id: int,
milestone_id: int,
title: str = "",
description: str = "",
status: str = "",
order_index: int = -1,
) -> dict:
"""Update a Fable milestone. Only explicitly provided fields are changed.
Args:
project_id: Project the milestone belongs to.
milestone_id: ID of the milestone to update.
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
status: New status — active or done.
order_index: New display position (0-based). Use -1 to leave unchanged.
"""
async with FableClient() as client:
return await milestones.update_milestone(
client,
project_id=project_id,
milestone_id=milestone_id,
title=title or None,
description=description or None,
status=status or None,
order_index=order_index if order_index >= 0 else None,
)
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_search(
q: str,
content_type: str = "all",
limit: int = 10,
) -> dict:
"""Semantic search over Fable notes and tasks using embedding similarity.
Finds content by meaning rather than exact keywords. Use this to discover
relevant records when you don't know the exact title or tags.
Args:
q: Natural-language query string.
content_type: "note", "task", or "all" (default).
limit: Maximum number of results (default 10).
Returns results ordered by cosine similarity, each with id, title, body snippet, and tags.
"""
async with FableClient() as client:
return await search.search(client, q=q, content_type=content_type, limit=limit)
# ---------------------------------------------------------------------------
# Chat
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict:
"""List chat conversations stored in Fable, ordered by last activity.
Returns id, title, message_count, created_at, updated_at for each conversation.
Use the id with fable_send_message to continue a specific conversation.
"""
async with FableClient() as client:
return await chat.list_conversations(client, limit=limit, offset=offset)
@mcp.tool()
async def fable_send_message(
message: str,
conversation_id: str = "",
think: bool = False,
) -> dict:
"""Send a natural-language message to Fable's built-in LLM and receive the full response.
Fable handles tool use, RAG context injection, and conversation history internally.
The LLM can create/update notes and tasks, search, manage projects, and more — all
driven by natural language without you needing to call individual tools.
Use this when:
- The request is conversational or exploratory
- You want Fable's RAG to automatically surface relevant notes as context
- The task is complex enough to benefit from Fable's internal reasoning
Use the direct CRUD tools (fable_create_note, etc.) instead when you need
structured data back or are performing bulk/programmatic operations.
Args:
message: The user message to send.
conversation_id: Continue an existing conversation by passing its id.
Omit to start a new conversation.
think: Enable extended reasoning mode for complex multi-step requests.
Returns conversation_id (for follow-up messages), the assistant response text,
and a list of any tool_call events that fired during generation.
"""
async with FableClient() as client:
return await chat.send_message(
client,
message=message,
conversation_id=conversation_id or None,
think=think,
)
# ---------------------------------------------------------------------------
# Admin / observability
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_get_app_logs(
category: str = "error",
limit: int = 20,
search: str = "",
) -> dict:
"""Fetch Fable application logs. Requires an admin-scoped API key.
Args:
category: Log category — "error" (default), "audit", or "usage".
limit: Maximum number of log entries to return.
search: Optional keyword filter matched against action, endpoint, username, details.
Returns a list of log entries ordered by most recent first.
Regular user API keys will receive a 403 — only admin keys are accepted.
"""
async with FableClient() as client:
return await admin.get_app_logs(
client,
category=category,
limit=limit,
search=search or None,
)
# ---------------------------------------------------------------------------
# Briefing / RSS
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_rss_feeds() -> dict:
"""List all RSS/Atom feeds configured in Fable for the current user.
Returns id, title, url, category, and last_fetched_at for each feed.
These feeds are summarised in the user's daily briefing.
"""
async with FableClient() as client:
return await briefing.list_rss_feeds(client)
@mcp.tool()
async def fable_add_rss_feed(
url: str,
title: str = "",
category: str = "",
) -> dict:
"""Add an RSS or Atom feed to Fable's daily briefing.
Args:
url: The RSS/Atom feed URL (required).
title: Optional display name. If omitted, auto-populated from feed metadata.
category: Optional category label to group feeds (e.g. "news", "tech", "finance").
Returns the created feed object including its assigned id.
"""
async with FableClient() as client:
return await briefing.add_rss_feed(
client,
url=url,
title=title or None,
category=category or None,
)
@mcp.tool()
async def fable_remove_rss_feed(feed_id: int) -> dict:
"""Remove an RSS feed from Fable by its ID."""
async with FableClient() as client:
return await briefing.remove_rss_feed(client, feed_id=feed_id)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
# Validate env vars at startup — raises ValueError with a clear message if missing.
FableClient()
mcp.run(transport="stdio")
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
"""Admin tools: application log access (requires admin API key)."""
from __future__ import annotations
from fable_mcp.client import FableClient
async def get_app_logs(
client: FableClient,
category: str = "error",
limit: int = 20,
search: str | None = None,
) -> dict:
"""Fetch application logs from Fable. Requires an admin-scoped API key.
category: "error" | "audit" | "usage" (default: "error")
"""
params: dict = {"category": category, "limit": limit}
if search:
params["search"] = search
return await client.get("/api/admin/logs", params=params)
+32
View File
@@ -0,0 +1,32 @@
"""MCP tools for Fable RSS feed management."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_rss_feeds(client: FableClient) -> dict[str, Any]:
"""List the user's RSS feeds."""
return await client.get("/api/briefing/feeds")
async def add_rss_feed(
client: FableClient,
*,
url: str,
title: str | None = None,
category: str | None = None,
) -> dict[str, Any]:
"""Add a new RSS feed. Title is optional — auto-populated from feed metadata."""
payload: dict[str, Any] = {"url": url}
if title:
payload["title"] = title
if category:
payload["category"] = category
return await client.post("/api/briefing/feeds", json=payload)
async def remove_rss_feed(client: FableClient, *, feed_id: int) -> dict[str, Any]:
"""Remove an RSS feed by ID."""
return await client.delete(f"/api/briefing/feeds/{feed_id}")
+75
View File
@@ -0,0 +1,75 @@
"""MCP tools for Fable chat — create conversations and stream responses."""
from __future__ import annotations
import json
from typing import Any
from fable_mcp.client import FableClient
async def list_conversations(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
"""List MCP chat conversations."""
params: dict[str, Any] = {"limit": limit, "offset": offset, "type": "mcp"}
return await client.get("/api/chat/conversations", params=params)
async def send_message(
client: FableClient,
*,
message: str,
conversation_id: str | None = None,
think: bool = False,
) -> dict[str, Any]:
"""Send a message to Fable and return the full assistant response.
Creates a new MCP conversation if conversation_id is None.
Streams the SSE response and accumulates token chunks into a single string.
Returns:
Dict with:
- "conversation_id": str
- "response": str (full assistant message)
- "tool_calls": list of any tool_call events observed
"""
if conversation_id is None:
conv = await client.post(
"/api/chat/conversations",
json={"conversation_type": "mcp"},
)
conversation_id = conv["id"]
# Build SSE stream URL with message as query param
params: dict[str, Any] = {"message": message}
if think:
params["think"] = "true"
tokens: list[str] = []
tool_calls: list[Any] = []
stream_path = f"/api/chat/conversations/{conversation_id}/stream"
async for raw_line in client.stream_get(stream_path, params=params):
if not raw_line.startswith("data: "):
continue
payload_str = raw_line[len("data: "):]
try:
event = json.loads(payload_str)
except json.JSONDecodeError:
continue
event_type = event.get("type")
if event_type == "token":
tokens.append(event.get("content", ""))
elif event_type == "tool_call":
tool_calls.append(event)
elif event_type == "done":
break
return {
"conversation_id": conversation_id,
"response": "".join(tokens),
"tool_calls": tool_calls,
}
+53
View File
@@ -0,0 +1,53 @@
"""MCP tools for Fable milestones."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_milestones(client: FableClient, *, project_id: int) -> dict[str, Any]:
"""List milestones for a project."""
return await client.get(f"/api/projects/{project_id}/milestones")
async def create_milestone(
client: FableClient,
*,
project_id: int,
title: str,
description: str = "",
status: str = "active",
) -> dict[str, Any]:
"""Create a milestone within a project."""
payload: dict[str, Any] = {
"title": title,
"description": description,
"status": status,
}
return await client.post(f"/api/projects/{project_id}/milestones", json=payload)
async def update_milestone(
client: FableClient,
*,
project_id: int,
milestone_id: int,
title: str | None = None,
description: str | None = None,
status: str | None = None,
order_index: int | None = None,
) -> dict[str, Any]:
"""Update an existing milestone."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if status is not None:
payload["status"] = status
if order_index is not None:
payload["order_index"] = order_index
return await client.patch(
f"/api/projects/{project_id}/milestones/{milestone_id}", json=payload
)
+72
View File
@@ -0,0 +1,72 @@
"""MCP tools for Fable notes."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_notes(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
tag: str | None = None,
search: str | None = None,
) -> dict[str, Any]:
"""List notes with optional filtering."""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if tag:
params["tag"] = tag
if search:
params["search"] = search
return await client.get("/api/notes", params=params)
async def get_note(client: FableClient, *, note_id: int) -> dict[str, Any]:
"""Fetch a single note by ID."""
return await client.get(f"/api/notes/{note_id}")
async def create_note(
client: FableClient,
*,
title: str,
body: str = "",
tags: list[str] | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""Create a new note."""
payload: dict[str, Any] = {"title": title, "body": body}
if tags is not None:
payload["tags"] = tags
if project_id is not None:
payload["project_id"] = project_id
return await client.post("/api/notes", json=payload)
async def update_note(
client: FableClient,
*,
note_id: int,
title: str | None = None,
body: str | None = None,
tags: list[str] | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""Update an existing note."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if tags is not None:
payload["tags"] = tags
if project_id is not None:
payload["project_id"] = project_id
return await client.patch(f"/api/notes/{note_id}", json=payload)
async def delete_note(client: FableClient, *, note_id: int) -> None:
"""Delete a note by ID."""
await client.delete(f"/api/notes/{note_id}")
+59
View File
@@ -0,0 +1,59 @@
"""MCP tools for Fable projects."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_projects(client: FableClient) -> dict[str, Any]:
"""List all projects for the authenticated user."""
return await client.get("/api/projects")
async def get_project(client: FableClient, *, project_id: int) -> dict[str, Any]:
"""Fetch a project by ID (includes milestone summary)."""
return await client.get(f"/api/projects/{project_id}")
async def create_project(
client: FableClient,
*,
title: str,
description: str = "",
goal: str | None = None,
status: str = "active",
color: str | None = None,
) -> dict[str, Any]:
"""Create a new project."""
payload: dict[str, Any] = {"title": title, "description": description, "status": status}
if goal is not None:
payload["goal"] = goal
if color is not None:
payload["color"] = color
return await client.post("/api/projects", json=payload)
async def update_project(
client: FableClient,
*,
project_id: int,
title: str | None = None,
description: str | None = None,
goal: str | None = None,
status: str | None = None,
color: str | None = None,
) -> dict[str, Any]:
"""Update an existing project."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if goal is not None:
payload["goal"] = goal
if status is not None:
payload["status"] = status
if color is not None:
payload["color"] = color
return await client.patch(f"/api/projects/{project_id}", json=payload)
+30
View File
@@ -0,0 +1,30 @@
"""MCP tool for semantic search across Fable notes and tasks."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def search(
client: FableClient,
*,
q: str,
content_type: str = "all",
limit: int = 10,
) -> dict[str, Any]:
"""Semantic search over notes and/or tasks.
Args:
q: Search query string.
content_type: One of "note", "task", or "all" (default).
limit: Maximum number of results to return.
Returns:
Dict with "results" list and "total" count.
Each result has: id, title, body, is_task, tags, similarity.
"""
params: dict[str, Any] = {"q": q, "limit": limit}
if content_type != "all":
params["content_type"] = content_type
return await client.get("/api/search", params=params)
+93
View File
@@ -0,0 +1,93 @@
"""MCP tools for Fable tasks."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_tasks(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
status: str | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""List tasks with optional filtering."""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if status:
params["status"] = status
if project_id is not None:
params["project_id"] = project_id
return await client.get("/api/tasks", params=params)
async def get_task(client: FableClient, *, task_id: int) -> dict[str, Any]:
"""Fetch a single task by ID (includes parent_title)."""
return await client.get(f"/api/tasks/{task_id}")
async def create_task(
client: FableClient,
*,
title: str,
body: str = "",
status: str = "todo",
priority: str | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
parent_id: int | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""Create a new task."""
payload: dict[str, Any] = {"title": title, "body": body, "status": status}
if priority is not None:
payload["priority"] = priority
if project_id is not None:
payload["project_id"] = project_id
if milestone_id is not None:
payload["milestone_id"] = milestone_id
if parent_id is not None:
payload["parent_id"] = parent_id
if tags is not None:
payload["tags"] = tags
return await client.post("/api/tasks", json=payload)
async def update_task(
client: FableClient,
*,
task_id: int,
title: str | None = None,
body: str | None = None,
status: str | None = None,
priority: str | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
) -> dict[str, Any]:
"""Update an existing task."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if status is not None:
payload["status"] = status
if priority is not None:
payload["priority"] = priority
if project_id is not None:
payload["project_id"] = project_id
if milestone_id is not None:
payload["milestone_id"] = milestone_id
return await client.patch(f"/api/tasks/{task_id}", json=payload)
async def add_task_log(
client: FableClient,
*,
task_id: int,
body: str,
) -> dict[str, Any]:
"""Append a log entry to a task."""
return await client.post(f"/api/tasks/{task_id}/logs", json={"body": body})
+24
View File
@@ -0,0 +1,24 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "fable-mcp"
version = "0.1.0"
description = "MCP server for Fabled Assistant"
requires-python = ">=3.12"
dependencies = [
"mcp[cli]>=1.0",
"httpx>=0.27",
"python-dotenv>=1.0",
]
[project.scripts]
fable-mcp = "fable_mcp.server:main"
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
View File
+12
View File
@@ -0,0 +1,12 @@
"""Shared pytest fixtures for fable-mcp tests."""
from unittest.mock import AsyncMock, MagicMock
import pytest
@pytest.fixture
def mock_client():
"""A mock FableClient that returns empty responses by default."""
client = AsyncMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
return client
+142
View File
@@ -0,0 +1,142 @@
"""Tests for FableClient."""
import os
import pytest
import respx
import httpx
from unittest.mock import patch
from fable_mcp.client import FableClient, FableAPIError, get_client, init_client, _reset_client
@pytest.fixture(autouse=True)
def reset_singleton():
"""Reset the module-level client singleton between tests."""
_reset_client()
yield
_reset_client()
@pytest.fixture
def base_env(monkeypatch):
monkeypatch.setenv("FABLE_URL", "http://fable.test")
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
class TestFableClientInit:
def test_missing_fable_url_raises(self, monkeypatch):
monkeypatch.delenv("FABLE_URL", raising=False)
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
with pytest.raises(ValueError, match="FABLE_URL"):
FableClient()
def test_missing_api_key_raises(self, monkeypatch):
monkeypatch.setenv("FABLE_URL", "http://fable.test")
monkeypatch.delenv("FABLE_API_KEY", raising=False)
with pytest.raises(ValueError, match="FABLE_API_KEY"):
FableClient()
def test_trailing_slash_stripped(self, base_env):
with patch.dict(os.environ, {"FABLE_URL": "http://fable.test/"}):
client = FableClient()
assert client.base_url == "http://fable.test"
def test_auth_header_set(self, base_env):
client = FableClient()
assert client._headers["Authorization"] == "Bearer fmcp_testkey"
class TestFableClientHTTP:
@respx.mock
@pytest.mark.asyncio
async def test_get_returns_json(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(200, json={"notes": []})
)
async with FableClient() as client:
data = await client.get("/api/notes")
assert data == {"notes": []}
@respx.mock
@pytest.mark.asyncio
async def test_non_2xx_raises_fable_api_error(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(401, json={"error": "Unauthorized"})
)
async with FableClient() as client:
with pytest.raises(FableAPIError) as exc_info:
await client.get("/api/notes")
assert exc_info.value.status_code == 401
@respx.mock
@pytest.mark.asyncio
async def test_post_sends_json(self, base_env):
respx.post("http://fable.test/api/notes").mock(
return_value=httpx.Response(201, json={"id": 1, "title": "Test"})
)
async with FableClient() as client:
data = await client.post("/api/notes", json={"title": "Test"})
assert data["id"] == 1
@respx.mock
@pytest.mark.asyncio
async def test_patch_sends_json(self, base_env):
respx.patch("http://fable.test/api/notes/1").mock(
return_value=httpx.Response(200, json={"id": 1, "title": "Updated"})
)
async with FableClient() as client:
data = await client.patch("/api/notes/1", json={"title": "Updated"})
assert data["title"] == "Updated"
@respx.mock
@pytest.mark.asyncio
async def test_delete_returns_none_on_204(self, base_env):
respx.delete("http://fable.test/api/notes/1").mock(
return_value=httpx.Response(204)
)
async with FableClient() as client:
result = await client.delete("/api/notes/1")
assert result is None
@respx.mock
@pytest.mark.asyncio
async def test_fable_api_error_includes_message(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(404, json={"error": "Not found"})
)
async with FableClient() as client:
with pytest.raises(FableAPIError) as exc_info:
await client.get("/api/notes")
assert "Not found" in str(exc_info.value)
assert exc_info.value.status_code == 404
class TestSingleton:
def test_get_client_before_init_raises(self):
with pytest.raises(RuntimeError, match="init_client"):
get_client()
def test_init_client_returns_instance(self, base_env):
client = init_client()
assert isinstance(client, FableClient)
def test_get_client_after_init_returns_same(self, base_env):
init_client()
c1 = get_client()
c2 = get_client()
assert c1 is c2
class TestStreamGet:
@respx.mock
@pytest.mark.asyncio
async def test_stream_get_yields_lines(self, base_env):
sse_body = b"data: line1\n\ndata: line2\n\n"
respx.get("http://fable.test/api/stream").mock(
return_value=httpx.Response(200, content=sse_body)
)
lines = []
async with FableClient() as client:
async for line in client.stream_get("/api/stream"):
lines.append(line)
assert "data: line1" in lines
assert "data: line2" in lines
+58
View File
@@ -0,0 +1,58 @@
"""Tests for chat MCP tools."""
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_send_message_creates_conversation_and_streams(client):
from fable_mcp.tools.chat import send_message
client.post = AsyncMock(return_value={"id": "conv-1"})
async def fake_stream(path, **kwargs):
for line in [
'data: {"type": "token", "content": "Hello"}',
'data: {"type": "token", "content": " world"}',
'data: {"type": "done"}',
]:
yield line
client.stream_get = fake_stream
result = await send_message(client, message="Hi", conversation_id=None)
assert result["conversation_id"] == "conv-1"
assert "Hello world" in result["response"]
@pytest.mark.asyncio
async def test_send_message_reuses_existing_conversation(client):
from fable_mcp.tools.chat import send_message
async def fake_stream(path, **kwargs):
for line in [
'data: {"type": "token", "content": "Reply"}',
'data: {"type": "done"}',
]:
yield line
client.stream_get = fake_stream
result = await send_message(client, message="Hi", conversation_id="existing-conv")
# Should not call post to create a conversation
client.post.assert_not_called()
assert result["conversation_id"] == "existing-conv"
@pytest.mark.asyncio
async def test_list_conversations_calls_get(client):
from fable_mcp.tools.chat import list_conversations
client.get = AsyncMock(return_value={"conversations": []})
await list_conversations(client)
client.get.assert_called_once()
assert "/api/chat/conversations" in client.get.call_args[0][0]
+35
View File
@@ -0,0 +1,35 @@
"""Tests for milestones MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_milestones_calls_correct_endpoint(client):
from fable_mcp.tools.milestones import list_milestones
client.get = AsyncMock(return_value={"milestones": []})
await list_milestones(client, project_id=3)
client.get.assert_called_once_with("/api/projects/3/milestones")
@pytest.mark.asyncio
async def test_create_milestone_posts_to_project_endpoint(client):
from fable_mcp.tools.milestones import create_milestone
client.post = AsyncMock(return_value={"id": 10, "title": "M1"})
await create_milestone(client, project_id=3, title="M1")
path = client.post.call_args[0][0]
assert path == "/api/projects/3/milestones"
assert client.post.call_args[1]["json"]["title"] == "M1"
@pytest.mark.asyncio
async def test_update_milestone_patches(client):
from fable_mcp.tools.milestones import update_milestone
client.patch = AsyncMock(return_value={"id": 10, "status": "completed"})
await update_milestone(client, project_id=3, milestone_id=10, status="completed")
path = client.patch.call_args[0][0]
assert "/api/projects/3/milestones/10" in path
+56
View File
@@ -0,0 +1,56 @@
"""Tests for notes MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_notes_calls_get(client):
from fable_mcp.tools.notes import list_notes
client.get = AsyncMock(return_value={"notes": [], "total": 0})
result = await list_notes(client, limit=10)
client.get.assert_called_once()
call_args = client.get.call_args
assert "/api/notes" in call_args[0][0]
@pytest.mark.asyncio
async def test_get_note_calls_correct_endpoint(client):
from fable_mcp.tools.notes import get_note
client.get = AsyncMock(return_value={"id": 42, "title": "Hello"})
result = await get_note(client, note_id=42)
client.get.assert_called_once_with("/api/notes/42")
assert result["id"] == 42
@pytest.mark.asyncio
async def test_create_note_posts_payload(client):
from fable_mcp.tools.notes import create_note
client.post = AsyncMock(return_value={"id": 1, "title": "My Note"})
result = await create_note(client, title="My Note", body="Content")
client.post.assert_called_once()
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "My Note"
assert call_kwargs["json"]["body"] == "Content"
@pytest.mark.asyncio
async def test_update_note_patches_payload(client):
from fable_mcp.tools.notes import update_note
client.patch = AsyncMock(return_value={"id": 1, "title": "Updated"})
result = await update_note(client, note_id=1, title="Updated")
client.patch.assert_called_once()
path = client.patch.call_args[0][0]
assert "/api/notes/1" in path
@pytest.mark.asyncio
async def test_delete_note_calls_delete(client):
from fable_mcp.tools.notes import delete_note
client.delete = AsyncMock(return_value=None)
await delete_note(client, note_id=5)
client.delete.assert_called_once_with("/api/notes/5")
+42
View File
@@ -0,0 +1,42 @@
"""Tests for projects MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_projects_calls_get(client):
from fable_mcp.tools.projects import list_projects
client.get = AsyncMock(return_value={"projects": []})
await list_projects(client)
client.get.assert_called_once_with("/api/projects")
@pytest.mark.asyncio
async def test_get_project_calls_correct_endpoint(client):
from fable_mcp.tools.projects import get_project
client.get = AsyncMock(return_value={"id": 2, "title": "Proj"})
result = await get_project(client, project_id=2)
client.get.assert_called_once_with("/api/projects/2")
@pytest.mark.asyncio
async def test_create_project_posts_payload(client):
from fable_mcp.tools.projects import create_project
client.post = AsyncMock(return_value={"id": 4, "title": "New"})
await create_project(client, title="New", description="Desc")
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "New"
assert call_kwargs["json"]["description"] == "Desc"
@pytest.mark.asyncio
async def test_update_project_patches(client):
from fable_mcp.tools.projects import update_project
client.patch = AsyncMock(return_value={"id": 4, "status": "active"})
await update_project(client, project_id=4, status="active")
assert "/api/projects/4" in client.patch.call_args[0][0]
+54
View File
@@ -0,0 +1,54 @@
"""Tests for tasks MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_tasks_calls_get(client):
from fable_mcp.tools.tasks import list_tasks
client.get = AsyncMock(return_value={"tasks": [], "total": 0})
await list_tasks(client)
client.get.assert_called_once()
assert "/api/tasks" in client.get.call_args[0][0]
@pytest.mark.asyncio
async def test_get_task_calls_correct_endpoint(client):
from fable_mcp.tools.tasks import get_task
client.get = AsyncMock(return_value={"id": 7, "title": "Fix bug"})
result = await get_task(client, task_id=7)
client.get.assert_called_once_with("/api/tasks/7")
@pytest.mark.asyncio
async def test_create_task_posts_payload(client):
from fable_mcp.tools.tasks import create_task
client.post = AsyncMock(return_value={"id": 3, "title": "New task"})
await create_task(client, title="New task")
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "New task"
@pytest.mark.asyncio
async def test_update_task_patches(client):
from fable_mcp.tools.tasks import update_task
client.patch = AsyncMock(return_value={"id": 3, "status": "done"})
await update_task(client, task_id=3, status="done")
client.patch.assert_called_once()
assert "/api/tasks/3" in client.patch.call_args[0][0]
@pytest.mark.asyncio
async def test_add_task_log_posts_to_logs_endpoint(client):
from fable_mcp.tools.tasks import add_task_log
client.post = AsyncMock(return_value={"id": 1, "body": "progress"})
await add_task_log(client, task_id=9, body="progress")
client.post.assert_called_once()
path = client.post.call_args[0][0]
assert path == "/api/tasks/9/logs"
assert client.post.call_args[1]["json"]["body"] == "progress"
+64
View File
@@ -8,6 +8,11 @@
"name": "fabledassistant-frontend",
"version": "0.1.0",
"dependencies": {
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@fullcalendar/vue3": "^6.1.20",
"@tiptap/core": "^3.0.0",
"@tiptap/extension-link": "^3.0.0",
"@tiptap/extension-list": "^3.0.0",
@@ -564,6 +569,55 @@
"license": "MIT",
"optional": true
},
"node_modules/@fullcalendar/core": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
"license": "MIT",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
"integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/timegrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
"integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==",
"license": "MIT",
"dependencies": {
"@fullcalendar/daygrid": "~6.1.20"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/vue3": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz",
"integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20",
"vue": "^3.0.11"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2994,6 +3048,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
+5
View File
@@ -9,6 +9,11 @@
"preview": "vite preview"
},
"dependencies": {
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@fullcalendar/vue3": "^6.1.20",
"@tiptap/core": "^3.0.0",
"@tiptap/extension-link": "^3.0.0",
"@tiptap/extension-list": "^3.0.0",
+10 -1
View File
@@ -8,7 +8,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { apiGet } from "@/api/client";
import { apiGet, apiPut } from "@/api/client";
useTheme();
@@ -22,6 +22,8 @@ const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
function startAppServices() {
chatStore.startStatusPolling();
settingsStore.fetchSettings();
// Sync browser timezone to the server on every login/page load.
apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {});
}
function stopAppServices() {
@@ -82,6 +84,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
case "p": router.push("/projects"); break;
case "c": router.push("/chat"); break;
case "g": router.push("/graph"); break;
case "l": router.push("/calendar"); break;
}
return;
}
@@ -202,6 +205,12 @@ onUnmounted(() => {
<kbd class="shortcut-key">c</kbd>
<span class="shortcut-desc">Chat</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">g</kbd>
<span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">l</kbd>
<span class="shortcut-desc">Calendar</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">Esc</kbd>
<span class="shortcut-desc">Unfocus field go home</span>
+90 -4
View File
@@ -329,7 +329,6 @@ export interface BriefingConfig {
slots: BriefingSlots;
notifications: boolean;
temp_unit: 'C' | 'F';
timezone: string;
}
export interface BriefingFeed {
@@ -353,6 +352,7 @@ export interface BriefingMessage {
role: 'user' | 'assistant' | 'system';
content: string;
created_at: string;
metadata?: Record<string, unknown> | null;
}
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
@@ -363,7 +363,6 @@ const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
temp_unit: 'C',
timezone: '',
};
export async function getBriefingConfig(): Promise<BriefingConfig> {
@@ -384,11 +383,17 @@ export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
return data;
}
export async function createBriefingFeed(url: string): Promise<BriefingFeed> {
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', { url });
export async function createBriefingFeed(url: string, category?: string): Promise<BriefingFeed> {
const body: Record<string, string> = { url };
if (category?.trim()) body.category = category.trim();
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', body);
return { ...data, last_fetched_at: null };
}
export async function refreshBriefingFeeds(): Promise<{ feeds_refreshed: number; new_items: number }> {
return apiPost('/api/briefing/feeds/refresh', {});
}
export async function deleteBriefingFeed(id: number): Promise<void> {
await apiDelete(`/api/briefing/feeds/${id}`);
}
@@ -411,6 +416,17 @@ export async function triggerBriefingSlot(slot: string): Promise<void> {
await apiPost('/api/briefing/trigger', { slot });
}
export async function postRssReaction(
rssItemId: number,
reaction: 'up' | 'down'
): Promise<{ ok: boolean; action: string }> {
return apiPost('/api/briefing/rss-reactions', { rss_item_id: rssItemId, reaction });
}
export async function deleteRssReaction(rssItemId: number): Promise<void> {
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
}
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
try {
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/briefing/weather/geocode', { query: address });
@@ -420,6 +436,10 @@ export async function geocodeAddress(address: string): Promise<{ lat: number; lo
}
}
export async function getFableMcpInfo(): Promise<{ available: boolean; filename: string | null }> {
return apiGet('/api/fable-mcp/info');
}
export async function apiStreamPost(
path: string,
body: unknown,
@@ -494,3 +514,69 @@ export async function apiStreamPost(
}
}
}
// ---------------------------------------------------------------------------
// Calendar events
// ---------------------------------------------------------------------------
export interface EventEntry {
id: number;
uid: string;
title: string;
start_dt: string;
end_dt: string | null;
all_day: boolean;
description: string;
location: string;
color: string;
recurrence: string | null;
caldav_uid: string;
project_id: number | null;
user_id: number;
created_at: string | null;
updated_at: string | null;
}
export interface EventCreatePayload {
title: string;
start_dt: string;
end_dt?: string;
all_day?: boolean;
description?: string;
location?: string;
color?: string;
recurrence?: string;
project_id?: number;
}
export interface EventUpdatePayload {
title?: string;
start_dt?: string;
end_dt?: string;
all_day?: boolean;
description?: string;
location?: string;
color?: string;
recurrence?: string;
project_id?: number;
}
export async function listEvents(from: string, to: string): Promise<EventEntry[]> {
return apiGet<EventEntry[]>(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
}
export async function createEvent(payload: EventCreatePayload): Promise<EventEntry> {
return apiPost<EventEntry>('/api/events', payload);
}
export async function getEvent(id: number): Promise<EventEntry> {
return apiGet<EventEntry>(`/api/events/${id}`);
}
export async function updateEvent(id: number, payload: EventUpdatePayload): Promise<EventEntry> {
return apiPatch<EventEntry>(`/api/events/${id}`, payload);
}
export async function deleteEvent(id: number): Promise<void> {
return apiDelete(`/api/events/${id}`);
}
+2
View File
@@ -77,6 +77,7 @@ router.afterEach(() => {
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/graph" class="nav-link">Graph</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
</div>
@@ -126,6 +127,7 @@ router.afterEach(() => {
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/graph" class="nav-link">Graph</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
@@ -20,7 +20,6 @@ const config = reactive<BriefingConfig>({
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
temp_unit: 'C',
timezone: '',
})
// Step 2 — locations
+457
View File
@@ -0,0 +1,457 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
import ProjectSelector from "@/components/ProjectSelector.vue";
import { useToastStore } from "@/stores/toast";
const props = defineProps<{
// null = create mode; EventEntry = edit mode
event: EventEntry | null;
// pre-filled date string for create mode (YYYY-MM-DD or ISO)
initialDate?: string;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "created", event: EventEntry): void;
(e: "updated", event: EventEntry): void;
(e: "deleted", id: number): void;
}>();
const toast = useToastStore();
const isEditMode = computed(() => !!props.event);
const saving = ref(false);
const deleting = ref(false);
const deleteConfirm = ref(false);
// Form fields
const title = ref("");
const startDate = ref("");
const startTime = ref("");
const endDate = ref("");
const endTime = ref("");
const allDay = ref(false);
const description = ref("");
const location = ref("");
const color = ref("");
const projectId = ref<number | null>(null);
function dateFromIso(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso.slice(0, 10);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function timeFromIso(iso: string): string {
if (!iso.includes("T")) return "09:00";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso.slice(11, 16);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
function toIso(date: string, time: string): string {
if (!time) return `${date}T00:00:00`;
// Include local timezone offset so the server stores the correct UTC time
const local = new Date(`${date}T${time}:00`);
const off = -local.getTimezoneOffset();
const sign = off >= 0 ? "+" : "-";
const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0");
const min = String(Math.abs(off) % 60).padStart(2, "0");
return `${date}T${time}:00${sign}${h}:${min}`;
}
function resetForm() {
if (props.event) {
title.value = props.event.title;
allDay.value = props.event.all_day;
startDate.value = dateFromIso(props.event.start_dt);
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
endDate.value = props.event.end_dt ? dateFromIso(props.event.end_dt) : "";
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : "";
description.value = props.event.description || "";
location.value = props.event.location || "";
color.value = props.event.color || "";
projectId.value = props.event.project_id;
} else {
title.value = "";
allDay.value = false;
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
startDate.value = base;
startTime.value = "09:00";
endDate.value = base;
endTime.value = "10:00";
description.value = "";
location.value = "";
color.value = "";
projectId.value = null;
}
deleteConfirm.value = false;
}
watch(() => props.event, resetForm, { immediate: true });
watch(() => props.initialDate, resetForm);
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") emit("close");
}
onMounted(() => document.addEventListener("keydown", handleKeydown));
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
async function save() {
if (!title.value.trim()) {
toast.show("Title is required", "error");
return;
}
if (!startDate.value) {
toast.show("Start date is required", "error");
return;
}
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
const end_dt = endDate.value
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
: undefined;
saving.value = true;
try {
if (isEditMode.value && props.event) {
const payload: EventUpdatePayload = {
title: title.value.trim(),
start_dt,
end_dt,
all_day: allDay.value,
description: description.value,
location: location.value,
color: color.value,
project_id: projectId.value ?? undefined,
};
const updated = await updateEvent(props.event.id, payload);
toast.show("Event updated", "success");
emit("updated", updated);
} else {
const payload: EventCreatePayload = {
title: title.value.trim(),
start_dt,
end_dt,
all_day: allDay.value,
description: description.value,
location: location.value,
color: color.value,
project_id: projectId.value ?? undefined,
};
const created = await createEvent(payload);
toast.show("Event created", "success");
emit("created", created);
}
} catch {
toast.show("Failed to save event", "error");
} finally {
saving.value = false;
}
}
async function doDelete() {
if (!props.event) return;
deleting.value = true;
try {
await deleteEvent(props.event.id);
toast.show("Event deleted", "success");
emit("deleted", props.event.id);
} catch {
toast.show("Failed to delete event", "error");
deleting.value = false;
}
}
</script>
<template>
<Teleport to="body">
<div class="slide-over-backdrop" @click.self="emit('close')">
<div class="slide-over-panel" role="dialog" aria-modal="true">
<div class="so-header">
<h2 class="so-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
<button class="so-close" @click="emit('close')" aria-label="Close"></button>
</div>
<form class="so-form" @submit.prevent="save">
<!-- Title -->
<div class="so-field">
<label class="so-label">Title <span class="required">*</span></label>
<input v-model="title" class="so-input" placeholder="Event title" autofocus />
</div>
<!-- All-day toggle -->
<div class="so-field so-field-row">
<label class="so-label so-label-inline">All day</label>
<button
type="button"
:class="['toggle-btn', { active: allDay }]"
@click="allDay = !allDay"
>{{ allDay ? "Yes" : "No" }}</button>
</div>
<!-- Start -->
<div class="so-field">
<label class="so-label">Start</label>
<div class="dt-row">
<input v-model="startDate" type="date" class="so-input dt-date" />
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" />
</div>
</div>
<!-- End -->
<div class="so-field">
<label class="so-label">End <span class="so-hint">(optional)</span></label>
<div class="dt-row">
<input v-model="endDate" type="date" class="so-input dt-date" />
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
</div>
</div>
<!-- Location -->
<div class="so-field">
<label class="so-label">Location <span class="so-hint">(optional)</span></label>
<input v-model="location" class="so-input" placeholder="Location" />
</div>
<!-- Description -->
<div class="so-field">
<label class="so-label">Description <span class="so-hint">(optional)</span></label>
<textarea v-model="description" class="so-input so-textarea" placeholder="Description" rows="3" />
</div>
<!-- Color -->
<div class="so-field so-field-row">
<label class="so-label so-label-inline">Color</label>
<div class="color-row">
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
<input v-model="color" class="so-input color-hex" placeholder="#6366f1" />
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"></button>
</div>
</div>
<!-- Project -->
<div class="so-field">
<label class="so-label">Project <span class="so-hint">(optional)</span></label>
<ProjectSelector v-model="projectId" />
</div>
<!-- Actions -->
<div class="so-actions">
<button type="submit" class="btn-primary" :disabled="saving">
{{ saving ? "Saving…" : (isEditMode ? "Save" : "Create") }}
</button>
<button type="button" class="btn-secondary" @click="emit('close')">Cancel</button>
<template v-if="isEditMode">
<button
v-if="!deleteConfirm"
type="button"
class="btn-danger-ghost"
@click="deleteConfirm = true"
>Delete</button>
<template v-else>
<span class="delete-confirm-label">Delete this event?</span>
<button type="button" class="btn-danger" :disabled="deleting" @click="doDelete">
{{ deleting ? "Deleting" : "Yes, delete" }}
</button>
<button type="button" class="btn-secondary" @click="deleteConfirm = false">No</button>
</template>
</template>
</div>
</form>
</div>
</div>
</Teleport>
</template>
<style scoped>
.slide-over-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 200;
display: flex;
justify-content: flex-end;
}
.slide-over-panel {
background: var(--color-surface, #1a1b1e);
border-left: 1px solid var(--color-border, #2a2b30);
width: min(440px, 100vw);
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.4);
}
.so-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--color-border, #2a2b30);
position: sticky;
top: 0;
background: var(--color-surface, #1a1b1e);
z-index: 1;
}
.so-title {
font-size: 1.05rem;
font-weight: 600;
margin: 0;
color: var(--color-text, #e8e9f0);
}
.so-close {
background: none;
border: none;
color: var(--color-text-muted, #888);
cursor: pointer;
font-size: 1.1rem;
padding: 0.25rem 0.4rem;
border-radius: 4px;
line-height: 1;
}
.so-close:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
.so-form {
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
flex: 1;
}
.so-field { display: flex; flex-direction: column; gap: 0.35rem; }
.so-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
.so-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.so-label-inline { flex-shrink: 0; margin: 0; }
.so-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
.required { color: #f87171; }
.so-input {
background: var(--color-input-bg, #111113);
border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text, #e8e9f0);
border-radius: 6px;
padding: 0.5rem 0.65rem;
font-size: 0.9rem;
width: 100%;
box-sizing: border-box;
transition: border-color 0.15s;
}
.so-input:focus { outline: none; border-color: var(--color-primary, #6366f1); }
.so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
.dt-row { display: flex; gap: 0.5rem; }
.dt-date { flex: 1; }
.dt-time { width: 7.5rem; flex-shrink: 0; }
.toggle-btn {
background: var(--color-input-bg, #111113);
border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text-muted, #888);
border-radius: 6px;
padding: 0.3rem 0.9rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
}
.toggle-btn.active {
background: var(--color-primary, #6366f1);
border-color: var(--color-primary, #6366f1);
color: #fff;
}
.color-row { display: flex; align-items: center; gap: 0.5rem; flex: 1; }
.color-picker { width: 2.4rem; height: 2.2rem; border: none; padding: 0; border-radius: 4px; cursor: pointer; flex-shrink: 0; }
.color-hex { flex: 1; }
.btn-clear-color {
background: none;
border: none;
color: var(--color-text-muted, #888);
cursor: pointer;
padding: 0.2rem 0.3rem;
font-size: 0.85rem;
}
.so-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #2a2b30);
margin-top: auto;
}
.btn-primary {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1.2rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary:hover:not(:disabled) { opacity: 0.88; }
.btn-secondary {
background: var(--color-input-bg, #111113);
border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text-muted, #888);
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
cursor: pointer;
}
.btn-secondary:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
.btn-danger-ghost {
margin-left: auto;
background: none;
border: 1px solid #ef4444;
color: #ef4444;
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
cursor: pointer;
}
.btn-danger-ghost:hover { background: rgba(239, 68, 68, 0.1); }
.btn-danger {
background: #ef4444;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
.delete-confirm-label {
font-size: 0.85rem;
color: var(--color-text-muted, #888);
align-self: center;
}
</style>
+149 -14
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { apiPost } from "@/api/client";
import { apiPost, getEvent } from "@/api/client";
import type { EventEntry } from "@/api/client";
import type { ToolCallRecord } from "@/types/chat";
import EventSlideOver from "@/components/EventSlideOver.vue";
const props = defineProps<{
toolCall: ToolCallRecord;
@@ -67,19 +69,19 @@ const suggestedTags = computed(() => props.toolCall.result.suggested_tags ?? [])
const eventData = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "event") return null;
return data as { title: string; start: string; end: string };
return data as { id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string };
});
const updatedEvent = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "event_updated") return null;
return data as { title: string; start: string; end: string };
return data as { id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string };
});
const deletedEvent = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "event_deleted") return null;
return data as { title: string };
return data as { id?: number; title: string };
});
const calendarList = computed(() => {
@@ -111,7 +113,7 @@ const todoCount = computed(() => {
const eventList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "events") return null;
return (data.events as Array<{ title: string; start: string; end: string; location?: string }> | undefined) ?? [];
return (data.events as Array<{ id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string }> | undefined) ?? [];
});
const eventCount = computed(() => {
@@ -251,6 +253,26 @@ async function applyTag(tag: string) {
applyingTag.value = null;
}
}
// ── Event slide-over ─────────────────────────────────────────────────────────
const eventSlideOverOpen = ref(false);
const eventSlideOverEntry = ref<EventEntry | null>(null);
async function openEventSlideOver(id: number | undefined) {
if (!id) return;
try {
const entry = await getEvent(id);
eventSlideOverEntry.value = entry;
eventSlideOverOpen.value = true;
} catch {
// silently fail — event may have been deleted
}
}
function closeEventSlideOver() {
eventSlideOverOpen.value = false;
}
</script>
<template>
@@ -316,12 +338,26 @@ async function applyTag(tag: string) {
</span>
</template>
<template v-else-if="eventData">
<span class="tool-event-title">{{ eventData.title }}</span>
<span class="tool-event-time">{{ formatEventTime(eventData.start) }}</span>
<button v-if="eventData.id" class="tool-event-btn" @click.stop="openEventSlideOver(eventData.id)">
<span class="tool-event-dot" v-if="eventData.color" :style="{ background: eventData.color }"></span>
<span class="tool-event-title">{{ eventData.title }}</span>
<span v-if="eventData.start_dt" class="tool-event-time">{{ formatEventTime(eventData.start_dt) }}</span>
</button>
<template v-else>
<span class="tool-event-title">{{ eventData.title }}</span>
<span v-if="eventData.start_dt" class="tool-event-time">{{ formatEventTime(eventData.start_dt) }}</span>
</template>
</template>
<template v-else-if="updatedEvent">
<span class="tool-event-title">{{ updatedEvent.title }}</span>
<span class="tool-event-time">{{ formatEventTime(updatedEvent.start) }}</span>
<button v-if="updatedEvent.id" class="tool-event-btn" @click.stop="openEventSlideOver(updatedEvent.id)">
<span class="tool-event-dot" v-if="updatedEvent.color" :style="{ background: updatedEvent.color }"></span>
<span class="tool-event-title">{{ updatedEvent.title }}</span>
<span v-if="updatedEvent.start_dt" class="tool-event-time">{{ formatEventTime(updatedEvent.start_dt) }}</span>
</button>
<template v-else>
<span class="tool-event-title">{{ updatedEvent.title }}</span>
<span v-if="updatedEvent.start_dt" class="tool-event-time">{{ formatEventTime(updatedEvent.start_dt) }}</span>
</template>
</template>
<template v-else-if="deletedEvent">
<span class="tool-deleted">{{ deletedEvent.title }}</span>
@@ -410,11 +446,21 @@ async function applyTag(tag: string) {
</template>
<template v-else-if="eventList !== null && eventList.length > 0">
<div class="tool-event-list">
<div v-for="(ev, i) in eventList.slice(0, 5)" :key="i" class="tool-event-item">
<span class="tool-event-item-title">{{ ev.title }}</span>
<span class="tool-event-item-time">{{ formatEventTime(ev.start) }}</span>
</div>
<div class="tool-event-cards">
<button
v-for="(ev, i) in eventList.slice(0, 5)"
:key="i"
class="tool-event-card"
:class="{ clickable: !!ev.id }"
@click="openEventSlideOver(ev.id)"
>
<span class="tool-event-card-dot" :style="ev.color ? { background: ev.color } : {}"></span>
<span class="tool-event-card-body">
<span class="tool-event-card-title">{{ ev.title }}</span>
<span v-if="ev.start_dt" class="tool-event-card-time">{{ formatEventTime(ev.start_dt) }}</span>
<span v-if="ev.location" class="tool-event-card-loc">{{ ev.location }}</span>
</span>
</button>
<div v-if="eventList.length > 5" class="tool-event-more">+{{ eventList.length - 5 }} more</div>
</div>
</template>
@@ -469,6 +515,17 @@ async function applyTag(tag: string) {
</button>
</div>
</div>
<!-- Event slide-over (portal-like, fixed positioned) -->
<EventSlideOver
v-if="eventSlideOverOpen"
:event="eventSlideOverEntry"
initial-date=""
@close="closeEventSlideOver"
@created="closeEventSlideOver"
@updated="closeEventSlideOver"
@deleted="closeEventSlideOver"
/>
</template>
<style scoped>
@@ -671,4 +728,82 @@ async function applyTag(tag: string) {
color: var(--color-danger, #e74c3c);
border-color: var(--color-danger, #e74c3c);
}
/* ── Event header click button ── */
.tool-event-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-family: inherit;
}
.tool-event-btn:hover .tool-event-title {
text-decoration: underline;
color: var(--color-primary);
}
.tool-event-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary, #6366f1);
flex-shrink: 0;
}
/* ── Event cards (list) ── */
.tool-event-cards { display: flex; flex-direction: column; gap: 0.25rem; }
.tool-event-card {
display: flex;
align-items: flex-start;
gap: 0.45rem;
padding: 0.3rem 0.45rem;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-bg-card, #16161a);
text-align: left;
font-family: inherit;
cursor: default;
transition: border-color 0.15s, background 0.15s;
width: 100%;
}
.tool-event-card.clickable { cursor: pointer; }
.tool-event-card.clickable:hover {
border-color: var(--color-primary, #6366f1);
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card, #16161a));
}
.tool-event-card-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary, #6366f1);
flex-shrink: 0;
margin-top: 3px;
}
.tool-event-card-body {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.tool-event-card-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tool-event-card-time {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.tool-event-card-loc {
font-size: 0.7rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { computed } from 'vue'
interface ForecastDay {
day: string
condition: string
high: number
low: number
}
interface WeatherData {
location: string
fetched_at: string
current_temp: number
condition: string
today_high: number | null
today_low: number | null
yesterday_high: number | null
yesterday_low: number | null
forecast: ForecastDay[]
}
const props = defineProps<{
weather: WeatherData | null
tempUnit?: string
}>()
const unit = computed(() => props.tempUnit ?? 'C')
const tempDelta = computed(() => {
const w = props.weather
if (!w || w.today_high == null || w.yesterday_high == null) return null
const diff = w.today_high - w.yesterday_high
if (Math.abs(diff) < 1) return 'Same as yesterday'
const dir = diff > 0 ? 'warmer' : 'cooler'
return `${Math.abs(diff)}° ${dir} than yesterday`
})
const fetchedAtLabel = computed(() => {
if (!props.weather?.fetched_at) return ''
try {
return new Date(props.weather.fetched_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
} catch {
return ''
}
})
</script>
<template>
<div v-if="weather" class="weather-card">
<div class="weather-header">
<span class="weather-location">{{ weather.location }}</span>
<span class="weather-fetched-at">as of {{ fetchedAtLabel }}</span>
</div>
<div class="weather-current">
<span class="weather-temp">{{ weather.current_temp }}°{{ unit }}</span>
<span class="weather-condition">{{ weather.condition }}</span>
</div>
<div class="weather-today" v-if="weather.today_high != null">
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
</div>
<div class="weather-forecast" v-if="weather.forecast.length">
<div v-for="day in weather.forecast" :key="day.day" class="weather-forecast-day">
<span class="forecast-day-name">{{ day.day }}</span>
<span class="forecast-condition">{{ day.condition }}</span>
<span class="forecast-temps">{{ day.high }}° / {{ day.low }}°</span>
</div>
</div>
</div>
<div v-else class="weather-card weather-unavailable">
Weather data unavailable will retry at next slot.
</div>
</template>
<style scoped>
.weather-card {
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.weather-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.weather-location {
font-weight: 600;
font-size: 0.95rem;
}
.weather-fetched-at {
color: var(--color-text-muted);
font-size: 0.78rem;
}
.weather-current {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.weather-temp {
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.weather-condition {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.weather-today {
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
font-size: 0.85rem;
}
.weather-delta {
color: var(--color-text-muted);
font-size: 0.82rem;
}
.weather-forecast {
display: flex;
gap: 0.75rem;
overflow-x: auto;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border);
}
.weather-forecast-day {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
min-width: 4.5rem;
font-size: 0.8rem;
}
.forecast-day-name {
font-weight: 600;
}
.forecast-condition {
color: var(--color-text-muted);
font-size: 0.75rem;
text-align: center;
}
.forecast-temps {
white-space: nowrap;
}
.weather-unavailable {
color: var(--color-text-muted);
font-style: italic;
}
</style>
+5
View File
@@ -110,6 +110,11 @@ const router = createRouter({
name: "shared-with-me",
component: () => import("@/views/SharedWithMeView.vue"),
},
{
path: "/calendar",
name: "calendar",
component: () => import("@/views/CalendarView.vue"),
},
{
path: "/briefing",
name: "briefing",
+18
View File
@@ -75,6 +75,17 @@ export const useChatStore = defineStore("chat", () => {
const streamingPendingTool = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.pendingTool ?? null);
const lastContextMeta = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.contextMeta ?? null);
const ragProjectId = computed<number | null>(
() => currentConversation.value?.rag_project_id ?? null
);
async function updateRagScope(convId: number, ragProjectId: number | null): Promise<void> {
await apiPatch(`/api/chat/conversations/${convId}`, { rag_project_id: ragProjectId });
if (currentConversation.value?.id === convId) {
currentConversation.value.rag_project_id = ragProjectId;
}
}
function isStreamingConv(id: number): boolean {
return convStreams.value[id]?.streaming ?? false;
}
@@ -313,6 +324,7 @@ export const useChatStore = defineStore("chat", () => {
think,
rag_project_id: ragProjectId ?? undefined,
workspace_project_id: workspaceProjectId ?? undefined,
user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
);
assistantMessageId = resp.assistant_message_id;
@@ -387,6 +399,10 @@ export const useChatStore = defineStore("chat", () => {
};
if (currentConversation.value?.id === convId) {
currentConversation.value.messages.push(assistantMsg);
// Update RAG scope if the model changed it mid-conversation
if (event.data.new_rag_scope !== undefined) {
currentConversation.value.rag_project_id = event.data.new_rag_scope as number | null;
}
}
// Update updated_at only — message_count was already incremented at send time
const idx = conversations.value.findIndex((c) => c.id === convId);
@@ -594,6 +610,8 @@ export const useChatStore = defineStore("chat", () => {
streamingStatus,
streamingPendingTool,
lastContextMeta,
ragProjectId,
updateRagScope,
ollamaStatus,
modelStatus,
defaultModel,
+1
View File
@@ -43,6 +43,7 @@ export interface Conversation {
title: string;
model: string;
message_count: number;
rag_project_id: number | null;
created_at: string;
updated_at: string;
}
+13 -2
View File
@@ -5,6 +5,17 @@ function escapeHtmlAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Decode HTML entities that marked introduces before we re-escape for our own output.
// Order matters: &amp; must be last to avoid double-decoding.
function decodeHtmlEntities(s: string): string {
return s
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
}
export function extractTags(body: string): string[] {
const cleaned = body.replace(CODE_FENCE_RE, "");
const tags = new Set<string>();
@@ -39,8 +50,8 @@ export function linkifyWikilinks(html: string): string {
.map((part, i) => {
if (i % 2 === 1) return part;
return part.replace(WIKILINK_RE, (_full, title: string, display?: string) => {
const trimmed = title.trim();
const label = display || trimmed;
const trimmed = decodeHtmlEntities(title.trim());
const label = display ? decodeHtmlEntities(display) : trimmed;
const encoded = encodeURIComponent(trimmed);
return `<a class="wikilink" data-title="${escapeHtmlAttr(trimmed)}" href="/notes/by-title?title=${encoded}">${escapeHtmlAttr(label)}</a>`;
});
+140 -6
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import ChatMessage from '@/components/ChatMessage.vue'
import WeatherCard from '@/components/WeatherCard.vue'
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
import {
getBriefingConfig,
@@ -9,11 +10,28 @@ import {
getBriefingToday,
getBriefingConvMessages,
triggerBriefingSlot,
postRssReaction,
deleteRssReaction,
type BriefingConversation,
type BriefingMessage,
} from '@/api/client'
import type { Message } from '@/types/chat'
interface MessageMetadata {
weather?: {
location: string
fetched_at: string
current_temp: number
condition: string
today_high: number | null
today_low: number | null
yesterday_high: number | null
yesterday_low: number | null
forecast: { day: string; condition: string; high: number; low: number }[]
} | null
rss_item_ids?: number[]
}
const chatStore = useChatStore()
// Setup wizard
@@ -114,6 +132,27 @@ function onKeydown(e: KeyboardEvent) {
}
}
// RSS reactions: map of rss_item_id -> 'up' | 'down' | null
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
const current = reactions.value[itemId]
reactions.value[itemId] = current === reaction ? null : reaction
try {
if (current === reaction) {
await deleteRssReaction(itemId)
} else {
await postRssReaction(itemId, reaction)
}
} catch {
reactions.value[itemId] = current ?? null
}
}
function msgMetadata(msg: BriefingMessage): MessageMetadata | null {
return (msg.metadata as MessageMetadata | null | undefined) ?? null
}
// Manual trigger
const triggering = ref(false)
async function triggerNow() {
@@ -150,9 +189,31 @@ function toMsg(m: BriefingMessage): Message {
}
}
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
let _refreshTimer: ReturnType<typeof setInterval> | null = null
async function _backgroundRefreshMessages() {
if (document.hidden || chatStore.streaming || !isToday.value || !todayConvId.value) return
try {
const today = await getBriefingToday()
if (!today) return
const fresh = today.messages
const last = fresh[fresh.length - 1]
const cur = messages.value[messages.value.length - 1]
if (fresh.length !== messages.value.length || last?.content !== cur?.content) {
messages.value = fresh
}
} catch { /* silent — don't disturb the UI on network hiccup */ }
}
onMounted(async () => {
await checkSetup()
if (!showWizard.value) await loadAll()
_refreshTimer = setInterval(_backgroundRefreshMessages, 60_000)
})
onUnmounted(() => {
if (_refreshTimer !== null) clearInterval(_refreshTimer)
})
</script>
@@ -192,12 +253,42 @@ onMounted(async () => {
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
</div>
<div v-else class="briefing-messages">
<ChatMessage
v-for="msg in messages"
:key="msg.id"
:message="toMsg(msg)"
:is-streaming="false"
/>
<template v-for="msg in messages" :key="msg.id">
<!-- Weather card above the first assistant message with weather metadata -->
<WeatherCard
v-if="msg.role === 'assistant' && msgMetadata(msg)?.weather !== undefined"
:weather="msgMetadata(msg)?.weather ?? null"
/>
<ChatMessage
:message="toMsg(msg)"
:is-streaming="false"
/>
<!-- Reaction buttons below assistant messages with rss_item_ids -->
<div
v-if="msg.role === 'assistant' && (msgMetadata(msg)?.rss_item_ids?.length ?? 0) > 0"
class="rss-reactions"
>
<div
v-for="(itemId, index) in msgMetadata(msg)!.rss_item_ids"
:key="itemId"
class="rss-reaction-row"
>
<span class="reaction-label">Story {{ index + 1 }}</span>
<button
class="reaction-btn"
:class="{ active: reactions[itemId] === 'up' }"
@click="handleReaction(itemId, 'up')"
title="Interested"
>👍</button>
<button
class="reaction-btn"
:class="{ active: reactions[itemId] === 'down' }"
@click="handleReaction(itemId, 'down')"
title="Not interested"
>👎</button>
</div>
</div>
</template>
<!-- Live streaming bubble for today -->
<ChatMessage
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
@@ -392,4 +483,47 @@ onMounted(async () => {
}
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-send:hover:not(:disabled) { opacity: 0.9; }
.rss-reactions {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
margin-bottom: 0.25rem;
}
.rss-reaction-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.reaction-label {
font-size: 0.78rem;
color: var(--color-text-muted);
min-width: 4rem;
}
.reaction-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.15rem 0.4rem;
cursor: pointer;
font-size: 0.85rem;
line-height: 1.4;
opacity: 0.55;
transition: opacity 0.15s, border-color 0.15s;
}
.reaction-btn:hover {
opacity: 1;
border-color: var(--color-primary);
}
.reaction-btn.active {
opacity: 1;
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
</style>
+270
View File
@@ -0,0 +1,270 @@
<script setup lang="ts">
import { ref } from "vue";
import FullCalendar from "@fullcalendar/vue3";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import type { CalendarOptions, EventClickArg, EventDropArg } from "@fullcalendar/core";
import type { DateClickArg, EventResizeDoneArg } from "@fullcalendar/interaction";
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
import EventSlideOver from "@/components/EventSlideOver.vue";
import { useToastStore } from "@/stores/toast";
const toast = useToastStore();
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
// Slide-over state
const slideOverEvent = ref<EventEntry | null>(null); // null = create mode
const slideOverOpen = ref(false);
const slideOverDate = ref<string>("");
function openCreate(date: string) {
slideOverEvent.value = null;
slideOverDate.value = date;
slideOverOpen.value = true;
}
function openEdit(event: EventEntry) {
slideOverEvent.value = event;
slideOverDate.value = "";
slideOverOpen.value = true;
}
function closeSlideOver() {
slideOverOpen.value = false;
}
// Event entry cache keyed by id for quick lookups when clicking FC events
const eventCache = new Map<number, EventEntry>();
function toFcEvent(e: EventEntry) {
return {
id: String(e.id),
title: e.title,
start: e.start_dt,
end: e.end_dt ?? undefined,
allDay: e.all_day,
backgroundColor: e.color || undefined,
borderColor: e.color || undefined,
extendedProps: { entryId: e.id },
};
}
async function loadEvents(
fetchInfo: { startStr: string; endStr: string },
successCallback: (events: object[]) => void,
failureCallback: (error: Error) => void,
) {
try {
const entries = await listEvents(fetchInfo.startStr, fetchInfo.endStr);
eventCache.clear();
for (const e of entries) eventCache.set(e.id, e);
successCallback(entries.map(toFcEvent));
} catch (err) {
failureCallback(err instanceof Error ? err : new Error(String(err)));
}
}
function handleDateClick(arg: DateClickArg) {
openCreate(arg.dateStr);
}
function handleEventClick(arg: EventClickArg) {
const id = arg.event.extendedProps.entryId as number;
const entry = eventCache.get(id);
if (entry) openEdit(entry);
}
async function handleEventDrop(arg: EventDropArg) {
const id = arg.event.extendedProps.entryId as number;
const start_dt = arg.event.startStr;
const end_dt = arg.event.endStr || undefined;
try {
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
eventCache.set(id, updated);
} catch {
arg.revert();
toast.show("Failed to move event", "error");
}
}
async function handleEventResize(arg: EventResizeDoneArg) {
const id = arg.event.extendedProps.entryId as number;
const start_dt = arg.event.startStr;
const end_dt = arg.event.endStr || undefined;
try {
const updated = await updateEvent(id, { start_dt, end_dt });
eventCache.set(id, updated);
} catch {
arg.revert();
toast.show("Failed to resize event", "error");
}
}
function onCreated(entry: EventEntry) {
eventCache.set(entry.id, entry);
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
closeSlideOver();
}
function onUpdated(entry: EventEntry) {
eventCache.set(entry.id, entry);
// Replace the event in FullCalendar
const api = calendarRef.value?.getApi();
if (api) {
const existing = api.getEventById(String(entry.id));
if (existing) {
existing.remove();
api.addEvent(toFcEvent(entry));
}
}
closeSlideOver();
}
function onDeleted(id: number) {
eventCache.delete(id);
const api = calendarRef.value?.getApi();
if (api) {
api.getEventById(String(id))?.remove();
}
closeSlideOver();
}
const calendarOptions: CalendarOptions = {
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
timeZone: "local",
editable: true,
selectable: false,
headerToolbar: {
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
},
events: loadEvents,
dateClick: handleDateClick,
eventClick: handleEventClick,
eventDrop: handleEventDrop,
eventResize: handleEventResize,
height: "auto",
};
</script>
<template>
<div class="calendar-view">
<div class="cal-header">
<h1 class="cal-title">Calendar</h1>
<button class="btn-new-event" @click="openCreate(new Date().toISOString().slice(0, 10))">
+ New Event
</button>
</div>
<div class="fc-wrapper">
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div>
<EventSlideOver
v-if="slideOverOpen"
:event="slideOverEvent"
:initial-date="slideOverDate"
@close="closeSlideOver"
@created="onCreated"
@updated="onUpdated"
@deleted="onDeleted"
/>
</div>
</template>
<style scoped>
.calendar-view {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem 1.5rem 3rem;
}
.cal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.cal-title {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
color: var(--color-text, #e8e9f0);
}
.btn-new-event {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-new-event:hover { opacity: 0.88; }
.fc-wrapper {
background: var(--color-surface, #1a1b1e);
border: 1px solid var(--color-border, #2a2b30);
border-radius: var(--radius-lg, 18px);
padding: 1rem;
overflow: hidden;
}
/* FullCalendar dark theme overrides */
:deep(.fc) {
color: var(--color-text, #e8e9f0);
font-family: inherit;
}
:deep(.fc-toolbar-title) {
font-size: 1.1rem;
font-weight: 600;
}
:deep(.fc-button) {
background: var(--color-input-bg, #111113);
border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text-muted, #888);
font-size: 0.82rem;
padding: 0.3rem 0.7rem;
box-shadow: none;
}
:deep(.fc-button:hover),
:deep(.fc-button-active) {
background: var(--color-primary, #6366f1) !important;
border-color: var(--color-primary, #6366f1) !important;
color: #fff !important;
}
:deep(.fc-button:focus) { box-shadow: none !important; }
:deep(.fc-daygrid-day-number),
:deep(.fc-col-header-cell-cushion) {
color: var(--color-text-muted, #888);
text-decoration: none;
font-size: 0.82rem;
}
:deep(.fc-daygrid-day.fc-day-today) {
background: rgba(99, 102, 241, 0.08);
}
:deep(.fc-event) {
border-radius: 4px;
border: none;
padding: 1px 4px;
font-size: 0.78rem;
cursor: pointer;
}
:deep(.fc-event-main) { color: #fff; }
:deep(.fc-event:not([style*="background"])) {
background: var(--color-primary, #6366f1);
}
:deep(.fc-daygrid-day-frame) { min-height: 5rem; }
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
:deep(.fc-scrollgrid td),
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
:deep(.fc-daygrid-day) { cursor: pointer; }
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
</style>
+132 -25
View File
@@ -9,7 +9,6 @@ import ChatMessage from "@/components/ChatMessage.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
import type { Note } from "@/types/note";
import ProjectSelector from "@/components/ProjectSelector.vue";
const route = useRoute();
const router = useRouter();
@@ -47,8 +46,34 @@ const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null
// Note IDs excluded from auto-injection on next message
const excludedNoteIds = ref<number[]>([]);
// Project scope for RAG — when set, semantic & keyword search is restricted to this project
const ragProjectId = ref<number | null>(null);
// Scope chip state
const scopeDropdownOpen = ref(false);
const projects = ref<{ id: number; title: string }[]>([]);
const scopePulse = ref(false);
const scopeLabel = computed(() => {
const id = store.ragProjectId;
if (id === -1) return "All notes";
if (id === null) return "Orphan notes";
return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`;
});
async function loadProjects() {
try {
const data = await apiGet<{ projects: { id: number; title: string }[] }>("/api/projects?status=active");
projects.value = data.projects ?? [];
} catch {
projects.value = [];
}
}
async function onScopeSelect(value: number | null) {
scopeDropdownOpen.value = false;
if (!convId.value) return;
await store.updateRagScope(convId.value, value);
scopePulse.value = true;
setTimeout(() => { scopePulse.value = false; }, 600);
}
let prevConvId: number | null = null;
@@ -120,7 +145,7 @@ const inputPlaceholder = computed(() => {
onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown);
await store.fetchConversations();
await Promise.all([store.fetchConversations(), loadProjects()]);
if (convId.value) {
if (store.currentConversation?.id !== convId.value) {
await store.fetchConversation(convId.value);
@@ -305,7 +330,7 @@ async function sendMessage() {
true, // enable thinking in the full chat view
undefined,
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
ragProjectId.value,
store.ragProjectId,
);
sending.value = false;
@@ -520,13 +545,6 @@ onUnmounted(() => {
</button>
</div>
<!-- RAG project scope -->
<div class="rag-scope-section">
<label class="rag-scope-label">Scope notes to project</label>
<ProjectSelector v-model="ragProjectId" />
<p v-if="ragProjectId" class="rag-scope-hint">RAG search restricted to this project</p>
</div>
<div class="conv-list">
<template v-for="group in groupedConversations" :key="group.label">
<div class="conv-group-label">{{ group.label }}</div>
@@ -726,6 +744,39 @@ onUnmounted(() => {
</div>
<div class="input-wrapper">
<!-- Scope chip above input -->
<div class="scope-chip-row">
<div class="scope-chip-wrapper">
<button
class="scope-chip"
:class="{ pulse: scopePulse }"
@click="scopeDropdownOpen = !scopeDropdownOpen"
title="Change RAG scope"
>
<span class="scope-dot"></span> {{ scopeLabel }}
</button>
<div v-if="scopeDropdownOpen" class="scope-dropdown">
<button
class="scope-option"
:class="{ active: store.ragProjectId === null }"
@click="onScopeSelect(null)"
>Orphan notes only</button>
<button
v-for="p in projects"
:key="p.id"
class="scope-option"
:class="{ active: store.ragProjectId === p.id }"
@click="onScopeSelect(p.id)"
>{{ p.title }}</button>
<button
class="scope-option"
:class="{ active: store.ragProjectId === -1 }"
@click="onScopeSelect(-1)"
>All notes</button>
</div>
</div>
</div>
<div class="input-area">
<!-- Research button -->
<div class="research-wrapper">
@@ -950,26 +1001,82 @@ onUnmounted(() => {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
}
.rag-scope-section {
padding: 0.5rem 0.75rem 0.25rem;
border-bottom: 1px solid var(--color-border);
.scope-chip-row {
padding: 0.35rem 0.75rem 0;
display: flex;
}
.rag-scope-label {
display: block;
.scope-chip-wrapper {
position: relative;
}
.scope-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.3rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.2rem 0.65rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.rag-scope-hint {
margin: 0.3rem 0 0;
font-size: 0.7rem;
.scope-chip:hover {
color: var(--color-text);
border-color: var(--color-primary);
}
.scope-dot {
color: var(--color-primary);
font-style: italic;
font-size: 0.8rem;
}
@keyframes scope-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 40%, transparent); }
100% { box-shadow: 0 0 0 6px transparent; }
}
.scope-chip.pulse {
animation: scope-pulse 0.5s ease-out;
}
.scope-dropdown {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
z-index: 50;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 8px);
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
min-width: 180px;
overflow: hidden;
}
.scope-option {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem 0.85rem;
font-size: 0.82rem;
color: var(--color-text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.scope-option:hover {
background: var(--color-bg-secondary);
color: var(--color-text);
}
.scope-option.active {
color: var(--color-primary);
font-weight: 600;
}
.conv-list {
+212 -3
View File
@@ -1,15 +1,17 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { apiGet } from "@/api/client";
import { apiGet, listEvents } from "@/api/client";
import type { Note } from "@/types/note";
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
import type { ToolCallRecord, Message } from "@/types/chat";
import type { EventEntry } from "@/api/client";
import NoteCard from "@/components/NoteCard.vue";
import TaskCard from "@/components/TaskCard.vue";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import DashboardChatInput from "@/components/DashboardChatInput.vue";
import EventSlideOver from "@/components/EventSlideOver.vue";
import { useTasksStore } from "@/stores/tasks";
import { useChatStore } from "@/stores/chat";
@@ -62,6 +64,11 @@ const orphanTasks = ref<Task[]>([]);
const orphanNotes = ref<Note[]>([]);
const inboxOpen = ref(true);
// Upcoming events (today + next 7 days)
const upcomingEvents = ref<EventEntry[]>([]);
const eventSlideOverOpen = ref(false);
const editingEvent = ref<EventEntry | null>(null);
// ─── Milestone color palette ──────────────────────────────────────────────────
function milestoneColor(index: number): string {
@@ -75,11 +82,54 @@ function milestoneColor(index: number): string {
return palette[index % palette.length];
}
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
// Runs on a timer while the page is visible. Never touches `loading` so
// existing content stays on screen while the fetch is in flight.
let _refreshTimer: ReturnType<typeof setInterval> | null = null
function _dateRange() {
const today = new Date()
const nextWeek = new Date(today)
nextWeek.setDate(today.getDate() + 7)
return {
todayStr: today.toISOString().slice(0, 10) + 'T00:00:00',
nextWeekStr: nextWeek.toISOString().slice(0, 10) + 'T23:59:59',
}
}
function _backgroundRefresh() {
if (document.hidden || loading.value) return
const { todayStr, nextWeekStr } = _dateRange()
Promise.allSettled([
listEvents(todayStr, nextWeekStr),
apiGet<TaskListResponse>('/api/tasks?no_project=true&sort=updated_at&order=desc&limit=8'),
apiGet<{ notes: Note[] }>('/api/notes?type=note&no_project=true&sort=updated_at&order=desc&limit=6'),
]).then(([eventsRes, tasksRes, notesRes]) => {
if (eventsRes.status === 'fulfilled') upcomingEvents.value = eventsRes.value
if (tasksRes.status === 'fulfilled') orphanTasks.value = tasksRes.value.tasks
if (notesRes.status === 'fulfilled') orphanNotes.value = notesRes.value.notes
})
if (heroProject.value) {
apiGet<TaskListResponse>(
`/api/tasks?project_id=${heroProject.value.id}&status=todo&sort=updated_at&order=desc&limit=1`
)
.then((r) => { heroNextUp.value = r.tasks[0] ?? null })
.catch(() => {})
}
}
// ─── Data loading ─────────────────────────────────────────────────────────────
onMounted(async () => {
// Phase 1: projects list + cross-project recent items + orphaned items — all parallel
const [projectsRes, recentRes, orphanTasksRes, orphanNotesRes] =
// Phase 1: projects list + cross-project recent items + orphaned items + events — all parallel
const today = new Date();
const todayStr = today.toISOString().slice(0, 10) + "T00:00:00";
const nextWeek = new Date(today);
nextWeek.setDate(today.getDate() + 7);
const nextWeekStr = nextWeek.toISOString().slice(0, 10) + "T23:59:59";
const [projectsRes, recentRes, orphanTasksRes, orphanNotesRes, eventsRes] =
await Promise.allSettled([
apiGet<{ projects: DashProject[] }>("/api/projects?status=active"),
apiGet<{ notes: RecentItem[] }>(
@@ -91,6 +141,7 @@ onMounted(async () => {
apiGet<{ notes: Note[] }>(
"/api/notes?type=note&no_project=true&sort=updated_at&order=desc&limit=6"
),
listEvents(todayStr, nextWeekStr),
]);
// Determine hero project: the project whose item was most recently touched
@@ -113,12 +164,16 @@ onMounted(async () => {
orphanTasks.value = orphanTasksRes.value.tasks;
if (orphanNotesRes.status === "fulfilled")
orphanNotes.value = orphanNotesRes.value.notes;
if (eventsRes.status === "fulfilled")
upcomingEvents.value = eventsRes.value;
loading.value = false;
// Focus chat input after data loads
chatInputRef.value?.focus();
loadProjects();
_refreshTimer = setInterval(_backgroundRefresh, 90_000)
});
async function loadProjects() {
@@ -184,6 +239,7 @@ onMounted(() => {
});
onUnmounted(() => {
document.removeEventListener("shortcut:focus-chat", onFocusChatShortcut);
if (_refreshTimer !== null) clearInterval(_refreshTimer)
});
const chatStore = useChatStore();
@@ -242,6 +298,49 @@ function clearDashboardResponse() {
dashboardFinalContent.value = "";
dashboardFinalToolCalls.value = [];
}
// ─── Upcoming events slide-over ───────────────────────────────────────────────
function openEvent(event: EventEntry) {
editingEvent.value = event;
eventSlideOverOpen.value = true;
}
function onEventUpdated(event: EventEntry) {
const idx = upcomingEvents.value.findIndex((e) => e.id === event.id);
if (idx !== -1) upcomingEvents.value[idx] = event;
eventSlideOverOpen.value = false;
}
function onEventDeleted(id: number) {
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
eventSlideOverOpen.value = false;
}
function formatUpcomingTime(event: EventEntry): string {
if (event.all_day) return "All day";
if (!event.start_dt) return "";
try {
const d = new Date(event.start_dt);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
const isToday =
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate();
const isTomorrow =
d.getFullYear() === tomorrow.getFullYear() &&
d.getMonth() === tomorrow.getMonth() &&
d.getDate() === tomorrow.getDate();
const timeStr = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
if (isToday) return `Today ${timeStr}`;
if (isTomorrow) return `Tomorrow ${timeStr}`;
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + " " + timeStr;
} catch {
return event.start_dt;
}
}
</script>
<template>
@@ -290,6 +389,33 @@ function clearDashboardResponse() {
</div>
</div>
<!-- Upcoming events -->
<div v-if="!loading && upcomingEvents.length" class="upcoming-events-section">
<div class="section-header">
<h2>Upcoming</h2>
<router-link to="/calendar" class="see-all">Calendar </router-link>
</div>
<div class="upcoming-events-list">
<button
v-for="ev in upcomingEvents.slice(0, 6)"
:key="ev.id"
class="upcoming-event-card"
@click="openEvent(ev)"
>
<span class="upcoming-event-dot" :style="ev.color ? { background: ev.color } : {}"></span>
<span class="upcoming-event-body">
<span class="upcoming-event-title">{{ ev.title }}</span>
<span class="upcoming-event-time">{{ formatUpcomingTime(ev) }}</span>
<span v-if="ev.location" class="upcoming-event-loc">{{ ev.location }}</span>
</span>
</button>
<div v-if="upcomingEvents.length > 6" class="upcoming-events-more">
+{{ upcomingEvents.length - 6 }} more
<router-link to="/calendar" class="see-all-inline">view all</router-link>
</div>
</div>
</div>
<!-- Skeleton while loading -->
<template v-if="loading">
<div class="skeleton-hero"></div>
@@ -466,6 +592,17 @@ function clearDashboardResponse() {
</template>
</main>
<!-- Event slide-over -->
<EventSlideOver
v-if="eventSlideOverOpen"
:event="editingEvent"
initial-date=""
@close="eventSlideOverOpen = false"
@created="eventSlideOverOpen = false"
@updated="onEventUpdated"
@deleted="onEventDeleted"
/>
</template>
<style scoped>
@@ -917,4 +1054,76 @@ function clearDashboardResponse() {
.projects-grid { grid-template-columns: 1fr; }
.inbox-sub { display: none; }
}
/* ─── Upcoming events ────────────────────────────────────────── */
.upcoming-events-section {
margin-bottom: 1.75rem;
}
.upcoming-events-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.5rem;
}
.upcoming-event-card {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
text-align: left;
font-family: inherit;
transition: border-color 0.15s, background 0.15s;
width: 100%;
}
.upcoming-event-card:hover {
border-color: var(--color-primary, #6366f1);
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card));
}
.upcoming-event-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary, #6366f1);
flex-shrink: 0;
margin-top: 4px;
}
.upcoming-event-body {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.upcoming-event-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upcoming-event-time {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.upcoming-event-loc {
font-size: 0.72rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upcoming-events-more {
font-size: 0.8rem;
color: var(--color-text-muted);
padding: 0.25rem 0;
grid-column: 1 / -1;
}
.see-all-inline {
color: var(--color-primary);
text-decoration: none;
}
.see-all-inline:hover { text-decoration: underline; }
</style>
+65 -6
View File
@@ -209,6 +209,30 @@ async function loadTasks() {
}
}
const advancingTaskId = ref<number | null>(null);
const taskStatusNext: Record<string, string> = {
todo: "in_progress",
in_progress: "done",
};
async function advanceTaskStatus(task: NoteItem, e: Event) {
e.preventDefault();
e.stopPropagation();
const next = taskStatusNext[task.status ?? ""];
if (!next || advancingTaskId.value === task.id) return;
advancingTaskId.value = task.id;
try {
await apiPatch(`/api/notes/${task.id}`, { status: next });
const idx = tasks.value.findIndex((t) => t.id === task.id);
if (idx !== -1) tasks.value[idx] = { ...tasks.value[idx], status: next };
} catch {
toast.show("Failed to update task", "error");
} finally {
advancingTaskId.value = null;
}
}
async function loadNotes() {
notesLoading.value = true;
try {
@@ -475,9 +499,17 @@ async function confirmDelete() {
:class="['task-card', `pri-${task.priority || 'none'}`]"
>
<span class="task-title">{{ task.title || "Untitled" }}</span>
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
<div class="task-card-footer">
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
</div>
<button
class="task-advance-btn"
title="Move to In Progress"
:disabled="advancingTaskId === task.id"
@click="advanceTaskStatus(task, $event)"
></button>
</div>
</router-link>
<p v-if="!group.tasks.filter(t => t.status === 'todo').length" class="col-empty">No tasks</p>
@@ -498,9 +530,17 @@ async function confirmDelete() {
:class="['task-card', `pri-${task.priority || 'none'}`]"
>
<span class="task-title">{{ task.title || "Untitled" }}</span>
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
<div class="task-card-footer">
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
</div>
<button
class="task-advance-btn task-advance-btn--done"
title="Mark as Done"
:disabled="advancingTaskId === task.id"
@click="advanceTaskStatus(task, $event)"
></button>
</div>
</router-link>
<p v-if="!group.tasks.filter(t => t.status === 'in_progress').length" class="col-empty">No tasks</p>
@@ -1088,7 +1128,26 @@ async function confirmDelete() {
.task-card-done .task-title { text-decoration: line-through; }
.task-title { display: block; font-weight: 500; margin-bottom: 0.2rem; line-height: 1.3; word-break: break-word; }
.task-card-footer { display: flex; align-items: center; justify-content: space-between; gap: 0.35rem; min-height: 1.2rem; }
.task-meta { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; }
.task-advance-btn {
flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
width: 1.4rem; height: 1.4rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text-muted);
font-size: 0.75rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
line-height: 1;
}
.task-card:hover .task-advance-btn { opacity: 1; }
.task-advance-btn:hover { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: #fff; }
.task-advance-btn:disabled { opacity: 0.4; cursor: default; }
.priority-dot {
width: 7px;
+715 -56
View File
@@ -3,10 +3,11 @@ import { ref, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, geocodeAddress, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client";
import { usePushStore } from "@/stores/push";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
const store = useSettingsStore();
const authStore = useAuthStore();
@@ -16,6 +17,13 @@ const assistantName = ref("");
const defaultModel = ref("");
const installedModels = ref<string[]>([]);
const defaultChatModel = ref("");
interface OllamaModel { name: string; size: number; loaded: boolean; modified_at: string; }
const ollamaModels = ref<OllamaModel[]>([]);
const pullModelName = ref("");
const pullProgress = ref<{ status: string; pct: number | null } | null>(null);
const pulling = ref(false);
const deletingModel = ref<string | null>(null);
const newEmail = ref("");
const emailPassword = ref("");
const changingEmail = ref(false);
@@ -32,7 +40,7 @@ const appVersion = ref('dev');
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "config", "users", "logs", "groups"]);
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "apikeys", "config", "users", "logs", "groups"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
watch(activeTab, (v) => {
@@ -41,8 +49,131 @@ watch(activeTab, (v) => {
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
if (v === "briefing") loadBriefingTab();
if (v === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
});
// API Keys
const apiKeys = ref<Array<{id: number, name: string, scope: string, key_prefix: string, last_used_at: string | null}>>([]);
const newKeyName = ref('');
const newKeyScope = ref<'read' | 'write'>('write');
const newKeyValue = ref('');
const apiKeyCopied = ref(false);
const creatingApiKey = ref(false);
const revokeConfirmId = ref<number | null>(null);
const mcpInfo = ref<{ available: boolean; filename: string | null } | null>(null);
const mcpInfoLoading = ref(false);
const origin = window.location.origin;
const mcpConfigSnippet = JSON.stringify({
mcpServers: {
fable: {
command: "fable-mcp",
env: {
FABLE_URL: window.location.origin,
FABLE_API_KEY: "<your-api-key>",
},
},
},
}, null, 2);
async function loadMcpInfo() {
if (mcpInfo.value !== null) return;
mcpInfoLoading.value = true;
try {
mcpInfo.value = await getFableMcpInfo();
} catch {
mcpInfo.value = { available: false, filename: null };
} finally {
mcpInfoLoading.value = false;
}
}
async function fetchApiKeys() {
const res = await fetch('/api/api-keys', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
apiKeys.value = data.api_keys;
}
}
async function createApiKey() {
if (!newKeyName.value) return;
creatingApiKey.value = true;
try {
const res = await fetch('/api/api-keys', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName.value, scope: newKeyScope.value }),
});
if (res.ok) {
const data = await res.json();
newKeyValue.value = data.key;
newKeyName.value = '';
await fetchApiKeys();
}
} finally {
creatingApiKey.value = false;
}
}
async function revokeApiKey(id: number) {
await fetch(`/api/api-keys/${id}`, { method: 'DELETE', credentials: 'include' });
revokeConfirmId.value = null;
await fetchApiKeys();
}
async function copyApiKey() {
try {
await navigator.clipboard.writeText(newKeyValue.value);
} catch {
// Fallback for http (non-secure) contexts where clipboard API is unavailable
const ta = document.createElement('textarea');
ta.value = newKeyValue.value;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
apiKeyCopied.value = true;
setTimeout(() => { apiKeyCopied.value = false; }, 2000);
}
function _downloadFile(filename: string, content: string, mimeType = 'text/plain') {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function downloadEnvFile() {
const baseUrl = window.location.origin;
const content = `FABLE_URL=${baseUrl}\nFABLE_API_KEY=${newKeyValue.value}\n`;
_downloadFile('fable-mcp.env', content);
}
function downloadMcpConfig() {
const baseUrl = window.location.origin;
const config = {
mcpServers: {
fable: {
command: 'fable-mcp',
env: {
FABLE_URL: baseUrl,
FABLE_API_KEY: newKeyValue.value,
},
},
},
};
_downloadFile('fable-mcp-config.json', JSON.stringify(config, null, 2), 'application/json');
}
// Groups management
const groups = ref<GroupEntry[]>([]);
const groupsLoading = ref(false);
@@ -127,7 +258,6 @@ const briefingConfig = ref<BriefingConfig>({
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
temp_unit: 'C',
timezone: '',
});
const briefingFeeds = ref<BriefingFeed[]>([]);
const briefingSaving = ref(false);
@@ -135,15 +265,43 @@ const briefingSaved = ref(false);
const briefingGeocoding = ref<Record<string, boolean>>({});
const briefingGeoError = ref<Record<string, string>>({});
const newFeedUrl = ref('');
const newFeedCategory = ref('');
const addingFeed = ref(false);
const refreshingFeeds = ref(false);
const briefingIncludeTopics = ref<string[]>([]);
const briefingExcludeTopics = ref<string[]>([]);
function _parseTopics(raw: unknown): string[] {
try {
const val = typeof raw === 'string' ? JSON.parse(raw) : raw;
return Array.isArray(val) ? val.map(String) : [];
} catch {
return [];
}
}
async function loadBriefingTab() {
briefingConfig.value = await getBriefingConfig();
briefingFeeds.value = await getBriefingFeeds();
// Auto-populate timezone from browser if not already stored.
if (!briefingConfig.value.timezone) {
briefingConfig.value.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
const allSettings = await apiGet<Record<string, string>>('/api/settings').catch(() => ({} as Record<string, string>));
briefingIncludeTopics.value = _parseTopics(allSettings['briefing_include_topics'] ?? '[]');
briefingExcludeTopics.value = _parseTopics(allSettings['briefing_exclude_topics'] ?? '[]');
}
async function saveIncludeTopics(topics: string[]) {
briefingIncludeTopics.value = topics;
await apiPut('/api/settings', { briefing_include_topics: JSON.stringify(topics) });
}
async function saveExcludeTopics(topics: string[]) {
briefingExcludeTopics.value = topics;
await apiPut('/api/settings', { briefing_exclude_topics: JSON.stringify(topics) });
}
const _STANDARD_TOPICS = ['technology', 'science', 'politics', 'business', 'health', 'environment', 'local', 'entertainment', 'sports', 'other'];
async function fetchTopicSuggestions(q: string): Promise<string[]> {
if (!q) return _STANDARD_TOPICS;
return _STANDARD_TOPICS.filter((t) => t.startsWith(q.toLowerCase()));
}
async function geocodeLocation(key: 'home' | 'work') {
@@ -198,11 +356,12 @@ async function addFeed() {
if (!newFeedUrl.value.trim() || addingFeed.value) return;
addingFeed.value = true;
try {
const feed = await createBriefingFeed(newFeedUrl.value.trim());
const feed = await createBriefingFeed(newFeedUrl.value.trim(), newFeedCategory.value.trim() || undefined);
briefingFeeds.value.push(feed);
newFeedUrl.value = '';
} catch {
toastStore.show('Failed to add feed', 'error');
newFeedCategory.value = '';
} catch (err: any) {
toastStore.show(err?.message === 'Feed already added' ? 'That feed is already in your list' : 'Failed to add feed', 'error');
} finally {
addingFeed.value = false;
}
@@ -213,6 +372,32 @@ async function removeFeed(id: number) {
briefingFeeds.value = briefingFeeds.value.filter((f) => f.id !== id);
}
async function refreshFeeds() {
if (refreshingFeeds.value) return;
refreshingFeeds.value = true;
try {
const result = await refreshBriefingFeeds();
// Reload feed list so last_fetched_at updates
briefingFeeds.value = await getBriefingFeeds();
toastStore.show(`Refreshed ${result.feeds_refreshed} feed${result.feeds_refreshed !== 1 ? 's' : ''}${result.new_items} new item${result.new_items !== 1 ? 's' : ''}`);
} catch {
toastStore.show('Failed to refresh feeds', 'error');
} finally {
refreshingFeeds.value = false;
}
}
function feedAge(isoStr: string | null): string {
if (!isoStr) return 'never fetched';
const diff = Date.now() - new Date(isoStr).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
// Chat retention
const chatRetentionDays = ref(90);
const savingRetention = ref(false);
@@ -290,6 +475,7 @@ onMounted(async () => {
} catch {
// Ollama unreachable — dropdowns will be empty
}
await loadOllamaModels();
// Load notification preferences from user settings
const allSettings = await apiGet<Record<string, string>>("/api/settings");
@@ -336,6 +522,7 @@ onMounted(async () => {
// base URL not configured yet
}
if (activeTab.value === "groups") loadGroupsPanel();
if (activeTab.value === "briefing") loadBriefingTab();
}
});
@@ -401,6 +588,89 @@ async function changePassword() {
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "—";
const gb = bytes / 1e9;
if (gb >= 1) return `${gb.toFixed(1)} GB`;
return `${(bytes / 1e6).toFixed(0)} MB`;
}
async function loadOllamaModels() {
try {
const data = await apiGet<{ models: OllamaModel[] }>("/api/chat/models");
ollamaModels.value = data.models ?? [];
installedModels.value = ollamaModels.value.map((m) => m.name);
} catch {
// Ollama unreachable
}
}
async function pullModel() {
const name = pullModelName.value.trim();
if (!name || pulling.value) return;
pulling.value = true;
pullProgress.value = { status: "Connecting…", pct: null };
try {
const resp = await fetch("/api/chat/models/pull", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: name }),
credentials: "include",
});
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.replace(/^data: /, "").trim();
if (!trimmed) continue;
try {
const msg = JSON.parse(trimmed);
if (msg.error) throw new Error(msg.error);
if (msg.status === "success") {
pullProgress.value = { status: "Complete", pct: 100 };
pullModelName.value = "";
} else if (msg.total && msg.completed) {
const pct = Math.round((msg.completed / msg.total) * 100);
pullProgress.value = { status: msg.status || "Downloading…", pct };
} else {
pullProgress.value = { status: msg.status || "Working…", pct: null };
}
} catch (parseErr: any) {
if (parseErr.message !== "JSON parse error") throw parseErr;
}
}
}
await loadOllamaModels();
toastStore.show(`Model ${name} ready`);
} catch (err: any) {
toastStore.show(`Pull failed: ${err.message}`, "error");
} finally {
pulling.value = false;
setTimeout(() => { pullProgress.value = null; }, 3000);
}
}
async function deleteModel(name: string) {
deletingModel.value = name;
try {
await apiPost("/api/chat/models/delete", { model: name });
await loadOllamaModels();
if (defaultModel.value === name) defaultModel.value = "";
toastStore.show(`Deleted ${name}`);
} catch {
toastStore.show(`Failed to delete ${name}`, "error");
} finally {
deletingModel.value = null;
}
}
async function saveAssistant() {
saving.value = true;
saved.value = false;
@@ -870,12 +1140,12 @@ function formatUserDate(iso: string): string {
<div class="sidebar-group">
<div class="sidebar-group-label">User</div>
<button
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing']"
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing', 'apikeys']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
>
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
{{ tab === 'apikeys' ? 'API Keys' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
</button>
</div>
<div v-if="authStore.isAdmin" class="sidebar-group">
@@ -924,6 +1194,69 @@ function formatUserDate(iso: string): string {
<span v-if="saved" class="saved-msg">Saved!</span>
</div>
</section>
<!-- Model Management -->
<section class="settings-section full-width">
<div class="model-mgmt-header">
<div>
<h2>Model Management</h2>
<p class="section-desc">Install and remove Ollama models without leaving the app. Search <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a> for available models.</p>
</div>
<button class="btn-secondary" @click="loadOllamaModels" title="Refresh list"> Refresh</button>
</div>
<!-- Installed models -->
<div v-if="ollamaModels.length" class="model-list">
<div v-for="m in ollamaModels" :key="m.name" class="model-row">
<div class="model-row-info">
<span class="model-name">{{ m.name }}</span>
<span v-if="m.loaded" class="model-badge model-badge--loaded">in VRAM</span>
<span v-if="m.name === (defaultModel || defaultChatModel)" class="model-badge model-badge--default">default</span>
</div>
<div class="model-row-right">
<span class="model-size">{{ formatBytes(m.size) }}</span>
<button
class="model-delete-btn"
:disabled="deletingModel === m.name"
@click="deleteModel(m.name)"
:title="`Remove ${m.name}`"
>{{ deletingModel === m.name ? '…' : '✕' }}</button>
</div>
</div>
</div>
<p v-else class="field-hint">No models installed, or Ollama is unreachable.</p>
<!-- Pull a model -->
<div class="model-pull-form">
<input
v-model="pullModelName"
class="input"
placeholder="e.g. qwen3:7b"
:disabled="pulling"
@keydown.enter="pullModel"
/>
<button class="btn-secondary" @click="pullModel" :disabled="pulling || !pullModelName.trim()">
{{ pulling ? 'Pulling…' : 'Pull' }}
</button>
</div>
<div class="model-suggestions">
<span class="suggestions-label">Suggestions:</span>
<button v-for="s in ['qwen3:7b','qwen3:14b','qwen3:4b','llama3.1:8b','nomic-embed-text']"
:key="s" class="suggestion-chip"
:disabled="pulling || ollamaModels.some(m => m.name === s)"
@click="pullModelName = s"
>{{ s }}</button>
</div>
<!-- Pull progress -->
<div v-if="pullProgress" class="model-pull-progress">
<div class="pull-status">{{ pullProgress.status }}</div>
<div class="pull-bar-track" v-if="pullProgress.pct !== null">
<div class="pull-bar-fill" :style="{ width: pullProgress.pct + '%' }"></div>
</div>
<div v-else class="pull-bar-indeterminate"></div>
</div>
</section>
</div>
<!-- Account -->
@@ -1356,35 +1689,6 @@ function formatUserDate(iso: string): string {
</div>
</section>
<!-- Timezone -->
<section class="settings-section full-width">
<h2>Timezone</h2>
<p class="section-desc">
Briefing slots fire at the times below in this timezone.
Auto-detected from your browser — override if the server should use a different zone.
</p>
<div class="briefing-timezone-row">
<input
type="text"
class="input"
v-model="briefingConfig.timezone"
placeholder="e.g. America/New_York"
style="flex: 1"
/>
<button
type="button"
class="btn-secondary"
@click="briefingConfig.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone"
>Detect</button>
</div>
<p class="field-hint">
Use an
<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" target="_blank" rel="noopener">IANA timezone name</a>
(e.g. <code>Europe/London</code>, <code>America/Chicago</code>).
Your browser reports: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong>
</p>
</section>
<!-- Slots -->
<section class="settings-section full-width">
<h2>Scheduled Slots</h2>
@@ -1409,33 +1713,88 @@ function formatUserDate(iso: string): string {
</label>
</div>
</div>
<p v-if="briefingConfig.timezone" class="field-hint" style="margin-top: 0.5rem">
Firing in timezone: <strong>{{ briefingConfig.timezone }}</strong>
<p class="field-hint" style="margin-top: 0.5rem">
Firing in timezone: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong>
</p>
</section>
<!-- RSS Feeds -->
<section class="settings-section full-width">
<h2>RSS Feeds</h2>
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
<div class="briefing-feeds-header">
<div>
<h2>RSS Feeds</h2>
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
</div>
<button
v-if="briefingFeeds.length"
class="btn-secondary briefing-refresh-btn"
@click="refreshFeeds"
:disabled="refreshingFeeds"
title="Fetch latest items from all feeds"
>{{ refreshingFeeds ? 'Refreshing' : 'Refresh all' }}</button>
</div>
<div class="briefing-feeds-list" v-if="briefingFeeds.length">
<div class="briefing-feed-row" v-for="feed in briefingFeeds" :key="feed.id">
<div class="briefing-feed-info">
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
<span class="briefing-feed-url">{{ feed.url }}</span>
<div class="briefing-feed-title-row">
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
<span v-if="feed.category" class="briefing-feed-cat">{{ feed.category }}</span>
</div>
<span v-if="feed.title" class="briefing-feed-url">{{ feed.url }}</span>
<span class="briefing-feed-age">{{ feedAge(feed.last_fetched_at) }}</span>
</div>
<button class="briefing-btn-remove" @click="removeFeed(feed.id)" aria-label="Remove feed">✕</button>
</div>
</div>
<p v-else class="field-hint">No feeds yet.</p>
<div class="briefing-add-feed">
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" style="flex: 1" />
<p v-else class="field-hint">No feeds yet. Try adding Reuters, BBC World, or Hacker News.</p>
<div class="briefing-add-feed-form">
<div class="briefing-add-feed-inputs">
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" @keydown.enter="addFeed" />
<input v-model="newFeedCategory" class="input briefing-cat-input" placeholder="Category (optional)" @keydown.enter="addFeed" />
</div>
<button class="btn-secondary" @click="addFeed" :disabled="addingFeed || !newFeedUrl.trim()">
{{ addingFeed ? 'Adding' : 'Add Feed' }}
</button>
</div>
</section>
<!-- News Preferences -->
<section class="settings-section full-width">
<h2>News Preferences</h2>
<p class="section-desc">
Tell the briefing what topics you care about. Topics are matched against
classified RSS items before each briefing runs.
</p>
<div class="settings-field">
<label class="field-label">Interested in</label>
<TagInput
:model-value="briefingIncludeTopics"
placeholder="e.g. technology, science, local"
:fetch-tags="fetchTopicSuggestions"
@update:model-value="saveIncludeTopics"
/>
</div>
<div class="settings-field" style="margin-top: 0.75rem">
<label class="field-label">Not interested in</label>
<TagInput
:model-value="briefingExcludeTopics"
placeholder="e.g. sports, celebrity"
:fetch-tags="fetchTopicSuggestions"
@update:model-value="saveExcludeTopics"
/>
</div>
<details class="topic-vocab-hint" style="margin-top: 0.75rem">
<summary style="cursor: pointer; color: var(--color-text-muted); font-size: 0.82rem">Standard topic vocabulary</summary>
<p class="field-hint" style="margin-top: 0.35rem">
technology · science · politics · business · health · environment ·
local · entertainment · sports · other
</p>
<p class="field-hint">Custom terms are also accepted.</p>
</details>
</section>
<!-- Notifications -->
<section class="settings-section full-width">
<h2>Notifications</h2>
@@ -1457,6 +1816,110 @@ function formatUserDate(iso: string): string {
</div>
<!-- ── API Keys ── -->
<div v-show="activeTab === 'apikeys'" class="settings-grid">
<section class="settings-section full-width">
<h2>API Keys</h2>
<p class="settings-description">
API keys let external tools (like the Fable MCP server) access your data without a browser session.
Write-scoped keys can create and update content; read-only keys can only query.
</p>
<!-- Create form -->
<div class="api-key-create-form">
<input v-model="newKeyName" placeholder="Key name (e.g. Claude MCP)" class="settings-input" />
<div class="api-key-scope-select">
<label><input type="radio" v-model="newKeyScope" value="read" /> Read-only</label>
<label><input type="radio" v-model="newKeyScope" value="write" /> Read + Write</label>
</div>
<button @click="createApiKey" :disabled="!newKeyName || creatingApiKey" class="btn btn-primary">
{{ creatingApiKey ? 'Generating' : 'Generate Key' }}
</button>
</div>
<!-- One-time key reveal -->
<div v-if="newKeyValue" class="api-key-reveal">
<p><strong>Copy this key now — it will not be shown again.</strong></p>
<div class="api-key-value-row">
<code class="api-key-value">{{ newKeyValue }}</code>
<button @click="copyApiKey" class="btn btn-secondary btn-sm">{{ apiKeyCopied ? 'Copied!' : 'Copy' }}</button>
</div>
<div style="display:flex; gap:0.5rem; margin-top: 0.5rem;">
<button @click="downloadEnvFile" class="btn btn-secondary btn-sm">Download .env</button>
<button @click="downloadMcpConfig" class="btn btn-secondary btn-sm">Download Claude config</button>
<button @click="newKeyValue = ''" class="btn btn-secondary">Done</button>
</div>
</div>
<!-- Keys table -->
<table v-if="apiKeys.length > 0" class="api-keys-table">
<thead>
<tr><th>Name</th><th>Scope</th><th>Prefix</th><th>Last Used</th><th></th></tr>
</thead>
<tbody>
<tr v-for="key in apiKeys" :key="key.id">
<td>{{ key.name }}</td>
<td><span :class="['scope-badge', key.scope]">{{ key.scope }}</span></td>
<td><code>{{ key.key_prefix }}…</code></td>
<td>{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }}</td>
<td>
<span v-if="revokeConfirmId !== key.id">
<button @click="revokeConfirmId = key.id" class="btn btn-secondary btn-sm">Revoke</button>
</span>
<span v-else>
Sure?
<button @click="revokeApiKey(key.id)" class="btn btn-danger btn-sm">Yes</button>
<button @click="revokeConfirmId = null" class="btn btn-secondary btn-sm">No</button>
</span>
</td>
</tr>
</tbody>
</table>
<p v-else-if="apiKeys.length === 0 && activeTab === 'apikeys'" class="settings-empty">No API keys yet.</p>
</section>
<!-- Fable MCP install section -->
<section class="settings-section full-width">
<h2>Fable MCP</h2>
<p class="settings-description">
The Fable MCP server lets Claude (and other MCP clients) read and write your notes, tasks, and projects.
Install it locally, then point it at this Fable instance with an API key above.
</p>
<div v-if="mcpInfoLoading" class="mcp-status">Checking package availability…</div>
<template v-else-if="mcpInfo">
<div v-if="mcpInfo.available" class="mcp-available">
<div class="mcp-pkg-row">
<span class="mcp-pkg-name">{{ mcpInfo.filename }}</span>
<a href="/api/fable-mcp/download" class="btn btn-primary btn-sm" download>Download</a>
</div>
<div class="mcp-install-steps">
<h3>Installation</h3>
<ol>
<li>
Download the wheel above and install it:
<pre class="mcp-code">pip install {{ mcpInfo.filename }}</pre>
</li>
<li>
Create a <code>.env</code> file (or set environment variables):
<pre class="mcp-code">FABLE_URL={{ origin }}
FABLE_API_KEY=&lt;your-api-key&gt;</pre>
</li>
<li>
Add to your Claude MCP config (<code>~/.claude.json</code> or the Claude Desktop config):
<pre class="mcp-code">{{ mcpConfigSnippet }}</pre>
</li>
</ol>
</div>
</div>
<div v-else class="mcp-unavailable">
<p>The Fable MCP package is not bundled in this image build. It will be available after the next image rebuild.</p>
</div>
</template>
</section>
</div>
<!-- ── Admin ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
@@ -1927,6 +2390,70 @@ function formatUserDate(iso: string): string {
line-height: 1.5;
}
/* ── Model Management ───────────────────────────────────────── */
.model-mgmt-header {
display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.75rem;
}
.model-mgmt-header h2 { margin: 0; }
.model-mgmt-header .section-desc { margin: 0.25rem 0 0; }
.model-list { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; }
.model-row {
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
padding: 0.45rem 0.6rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 6px;
}
.model-row-info { display: flex; align-items: center; gap: 0.4rem; min-width: 0; flex: 1; }
.model-name { font-size: 0.88rem; font-weight: 500; font-family: monospace; }
.model-badge {
font-size: 0.68rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-weight: 600; white-space: nowrap;
}
.model-badge--loaded { background: color-mix(in srgb, #22c55e 18%, transparent); color: #22c55e; }
.model-badge--default { background: color-mix(in srgb, var(--color-primary) 18%, transparent); color: var(--color-primary); }
.model-row-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.model-size { font-size: 0.78rem; color: var(--color-text-muted); }
.model-delete-btn {
background: none; border: none; cursor: pointer; color: var(--color-text-muted);
font-size: 0.8rem; padding: 0.2rem 0.35rem; border-radius: 3px; line-height: 1;
transition: color 0.15s, background 0.15s;
}
.model-delete-btn:hover:not(:disabled) { color: var(--color-danger, #ef4444); background: color-mix(in srgb, var(--color-danger, #ef4444) 10%, transparent); }
.model-delete-btn:disabled { opacity: 0.4; cursor: default; }
.model-pull-form { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
.model-pull-form .input { flex: 1; }
.model-suggestions { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.4rem; }
.suggestions-label { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }
.suggestion-chip {
font-size: 0.72rem; padding: 0.15rem 0.5rem; border-radius: 4px;
border: 1px solid var(--color-border); background: var(--color-bg-card);
cursor: pointer; font-family: monospace; color: var(--color-text);
transition: border-color 0.12s, background 0.12s;
}
.suggestion-chip:hover:not(:disabled) { border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card)); }
.suggestion-chip:disabled { opacity: 0.4; cursor: default; }
.model-pull-progress { margin-top: 0.6rem; }
.pull-status { font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.25rem; }
.pull-bar-track {
height: 4px; background: var(--color-border); border-radius: 2px; overflow: hidden;
}
.pull-bar-fill {
height: 100%; background: var(--color-primary); border-radius: 2px;
transition: width 0.3s ease;
}
.pull-bar-indeterminate {
height: 4px; background: var(--color-border); border-radius: 2px;
position: relative; overflow: hidden;
}
.pull-bar-indeterminate::after {
content: ""; position: absolute; top: 0; left: -40%;
width: 40%; height: 100%; background: var(--color-primary); border-radius: 2px;
animation: indeterminate 1.2s ease-in-out infinite;
}
@keyframes indeterminate {
0% { left: -40%; } 100% { left: 100%; }
}
/* Assistant — 2-col internal grid */
.assistant-grid {
display: grid;
@@ -2752,12 +3279,6 @@ function formatUserDate(iso: string): string {
flex-wrap: wrap;
margin-top: 0.5rem;
}
.briefing-timezone-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
.briefing-unit-toggle {
display: flex;
gap: 0;
@@ -2870,10 +3391,148 @@ function formatUserDate(iso: string): string {
transition: color 0.15s;
}
.briefing-btn-remove:hover { color: var(--color-danger, #ef4444); }
.briefing-feeds-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.briefing-feeds-header h2 { margin: 0; }
.briefing-feeds-header .section-desc { margin: 0.25rem 0 0; }
.briefing-refresh-btn { white-space: nowrap; flex-shrink: 0; }
.briefing-feed-title-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.briefing-feed-cat {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
font-weight: 500;
}
.briefing-feed-age {
font-size: 0.72rem;
color: var(--color-text-muted);
display: block;
margin-top: 0.1rem;
}
.briefing-add-feed-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: flex-end;
margin-top: 0.75rem;
}
.briefing-add-feed-inputs {
display: flex;
gap: 0.5rem;
flex: 1;
flex-wrap: wrap;
}
.briefing-add-feed-inputs .input { flex: 1; min-width: 160px; }
.briefing-cat-input { max-width: 180px; }
.briefing-add-feed {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
/* API Keys tab */
.api-key-create-form {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.api-key-scope-select {
display: flex;
gap: 1rem;
}
.api-key-scope-select label {
display: flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.api-key-reveal {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: var(--radius-md);
padding: 1rem;
margin-bottom: 1.5rem;
}
.api-key-value-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.5rem;
}
.api-key-value {
flex: 1;
background: var(--color-surface-2, var(--color-surface));
padding: 0.4rem 0.6rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
word-break: break-all;
}
.api-keys-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.api-keys-table th, .api-keys-table td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.api-keys-table th { font-weight: 600; opacity: 0.7; }
.scope-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 9999px;
font-size: 0.78rem;
font-weight: 600;
}
.scope-badge.read { background: color-mix(in srgb, #3b82f6 15%, transparent); color: #3b82f6; }
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
.settings-empty { opacity: 0.5; margin-top: 1rem; }
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
/* Fable MCP section */
.mcp-status { opacity: 0.6; font-size: 0.9rem; }
.mcp-unavailable p { opacity: 0.7; }
.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; }
.mcp-pkg-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.65rem 0.9rem;
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
border-radius: 8px;
}
.mcp-pkg-name { font-family: monospace; font-size: 0.9rem; flex: 1; }
.mcp-install-steps h3 { font-size: 0.95rem; font-weight: 600; margin-bottom: 0.75rem; }
.mcp-install-steps ol { padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem; }
.mcp-install-steps li { line-height: 1.6; font-size: 0.9rem; }
.mcp-code {
margin-top: 0.4rem;
padding: 0.55rem 0.75rem;
background: color-mix(in srgb, var(--color-text) 6%, transparent);
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 0.82rem;
font-family: monospace;
white-space: pre-wrap;
word-break: break-all;
overflow-x: auto;
}
</style>
+26 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, computed, ref, watch } from "vue";
import { onMounted, onUnmounted, computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
@@ -94,7 +94,27 @@ async function loadTask(id: number) {
if (bl.status === "fulfilled") backlinks.value = bl.value;
}
onMounted(() => loadTask(taskId.value));
function handleKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
e.stopPropagation(); // prevent App.vue's global handler from also firing
const active = document.activeElement as HTMLElement | null;
if (active && active !== document.body) {
(active as HTMLElement).blur();
return;
}
if (store.currentTask?.project_id) {
router.push(`/projects/${store.currentTask.project_id}`);
} else {
router.push("/tasks");
}
}
onMounted(() => {
loadTask(taskId.value);
// Capture phase so this fires before App.vue's document-level handler
window.addEventListener("keydown", handleKeydown, true);
});
onUnmounted(() => window.removeEventListener("keydown", handleKeydown, true));
watch(() => route.params.id, (newId) => {
if (newId) loadTask(Number(newId));
@@ -221,7 +241,10 @@ const subTaskProgress = computed(() => {
</div>
<template v-else-if="store.currentTask">
<div class="toolbar">
<router-link to="/tasks" class="btn-back"> Tasks</router-link>
<router-link
:to="store.currentTask.project_id ? `/projects/${store.currentTask.project_id}` : '/tasks'"
class="btn-back"
>{{ store.currentTask.project_id ? "← Project" : "← Tasks" }}</router-link>
<router-link
:to="`/tasks/${store.currentTask.id}/edit`"
class="btn-edit"
+14 -1
View File
@@ -20,6 +20,7 @@ from fabledassistant.routes.milestones import milestones_bp
from fabledassistant.routes.task_logs import task_logs_bp
from fabledassistant.routes.projects import projects_bp
from fabledassistant.routes.push import push_bp
from fabledassistant.routes.fable_mcp_dist import fable_mcp_dist_bp
from fabledassistant.routes.quick_capture import quick_capture_bp
from fabledassistant.routes.settings import settings_bp
from fabledassistant.routes.tasks import tasks_bp
@@ -27,6 +28,9 @@ from fabledassistant.routes.groups import groups_bp
from fabledassistant.routes.shares import shares_bp
from fabledassistant.routes.in_app_notifications import notifications_bp
from fabledassistant.routes.users import users_bp
from fabledassistant.routes.api_keys import api_keys_bp
from fabledassistant.routes.events import events_bp
from fabledassistant.routes.search import search_bp
STATIC_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__)
@@ -67,6 +71,7 @@ def create_app() -> Quart:
app.register_blueprint(images_bp)
app.register_blueprint(milestones_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(fable_mcp_dist_bp)
app.register_blueprint(projects_bp)
app.register_blueprint(push_bp)
app.register_blueprint(quick_capture_bp)
@@ -77,6 +82,9 @@ def create_app() -> Quart:
app.register_blueprint(shares_bp)
app.register_blueprint(notifications_bp)
app.register_blueprint(users_bp)
app.register_blueprint(api_keys_bp)
app.register_blueprint(events_bp)
app.register_blueprint(search_bp)
@app.before_request
async def before_request():
@@ -237,12 +245,17 @@ def create_app() -> Quart:
await backfill_note_embeddings()
except Exception:
logger.warning("Embedding backfill failed", exc_info=True)
try:
from fabledassistant.services.projects import backfill_project_summaries
await backfill_project_summaries()
except Exception:
logger.warning("Project summary backfill failed", exc_info=True)
asyncio.create_task(_delayed_backfill())
# Start briefing scheduler
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
start_briefing_scheduler(asyncio.get_event_loop())
await start_briefing_scheduler(asyncio.get_running_loop())
@app.after_serving
async def shutdown():
+23 -1
View File
@@ -1,13 +1,35 @@
import functools
from quart import g, jsonify, session
from quart import g, jsonify, request, session
from fabledassistant.services.auth import get_user_by_id
from fabledassistant.services.api_keys import lookup_key
def _check_auth(f, required_role: str | None = None):
@functools.wraps(f)
async def decorated(*args, **kwargs):
# --- Bearer token path ---
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
raw_key = auth_header[len("Bearer "):]
api_key = await lookup_key(raw_key)
if api_key is None:
return jsonify({"error": "Invalid or revoked API key"}), 401
user = await get_user_by_id(api_key.user_id)
if not user:
return jsonify({"error": "User not found"}), 401
# Scope enforcement: read-only keys cannot mutate
if api_key.scope == "read" and request.method not in ("GET", "HEAD", "OPTIONS"):
return jsonify({"error": "Read-only key cannot perform write operations"}), 403
# Role check (admin_required routes)
if required_role and user.role != required_role:
return jsonify({"error": "Admin access required"}), 403
g.user = user
g.api_key = api_key
return await f(*args, **kwargs)
# --- Session path (unchanged) ---
user_id = session.get("user_id")
if not user_id:
return jsonify({"error": "Authentication required"}), 401
+1
View File
@@ -40,3 +40,4 @@ from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402,
from fabledassistant.models.notification import Notification # noqa: E402, F401
from fabledassistant.models.rss_feed import RssFeed, RssItem # noqa: E402, F401
from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F401
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin
class ApiKey(Base, CreatedAtMixin):
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(Text, nullable=False)
key_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
key_prefix: Mapped[str] = mapped_column(Text, nullable=False)
scope: Mapped[str] = mapped_column(Text, nullable=False) # "read" or "write"
last_used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
Index("ix_api_keys_user_id", "user_id"),
Index("ix_api_keys_key_hash", "key_hash"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"key_prefix": self.key_prefix,
"scope": self.scope,
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
"created_at": self.created_at.isoformat(),
"revoked_at": self.revoked_at.isoformat() if self.revoked_at else None,
}
@@ -23,6 +23,8 @@ class Conversation(Base, TimestampMixin):
conversation_type: Mapped[str] = mapped_column(Text, default="chat", server_default="chat")
# For briefing conversations only: the calendar date this briefing covers.
briefing_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True)
# NULL = orphan notes only; -1 = all notes; positive int = specific project
rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
messages: Mapped[list["Message"]] = relationship(
back_populates="conversation",
@@ -47,6 +49,7 @@ class Conversation(Base, TimestampMixin):
"model": self.model,
"conversation_type": self.conversation_type,
"briefing_date": self.briefing_date.isoformat() if self.briefing_date else None,
"rag_project_id": self.rag_project_id,
"message_count": msg_count,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
@@ -67,6 +70,9 @@ class Message(Base, CreatedAtMixin):
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
tool_calls: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# 'metadata' is reserved by SQLAlchemy Declarative — use msg_metadata as the
# Python attribute name, mapped to the 'metadata' DB column.
msg_metadata: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True)
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
@@ -83,5 +89,6 @@ class Message(Base, CreatedAtMixin):
"status": self.status,
"context_note_id": self.context_note_id,
"tool_calls": self.tool_calls,
"metadata": self.msg_metadata,
"created_at": self.created_at.isoformat(),
}
+7 -2
View File
@@ -24,6 +24,8 @@ class Event(Base):
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[str] = mapped_column(Text, default="")
location: Mapped[str] = mapped_column(Text, default="")
caldav_uid: Mapped[str] = mapped_column(Text, default="")
color: Mapped[str] = mapped_column(Text, default="")
recurrence: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
@@ -37,7 +39,9 @@ class Event(Base):
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"uid": self.uid,
"caldav_uid": self.caldav_uid,
"project_id": self.project_id,
"title": self.title,
"start_dt": self.start_dt.isoformat() if self.start_dt else None,
@@ -45,7 +49,8 @@ class Event(Base):
"all_day": self.all_day,
"description": self.description,
"location": self.location,
"color": self.color,
"recurrence": self.recurrence,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
+6 -1
View File
@@ -1,5 +1,6 @@
import enum
from sqlalchemy import ForeignKey, Integer, Text
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
@@ -20,6 +21,10 @@ class Project(Base, TimestampMixin):
goal: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="active")
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
auto_summary: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
summary_updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
def to_dict(self) -> dict:
return {
+9 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy import ARRAY, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base
@@ -49,6 +49,12 @@ class RssItem(Base):
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
topics: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, default=list, server_default="{}"
)
classified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
feed: Mapped["RssFeed"] = relationship(back_populates="items")
@@ -67,4 +73,6 @@ class RssItem(Base):
"url": self.url,
"published_at": self.published_at.isoformat() if self.published_at else None,
"content": self.content,
"topics": self.topics or [],
"classified_at": self.classified_at.isoformat() if self.classified_at else None,
}
+42
View File
@@ -0,0 +1,42 @@
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
@api_keys_bp.route("", methods=["GET"])
@login_required
async def list_keys_route():
uid = get_current_user_id()
keys = await list_api_keys(uid)
return jsonify({"api_keys": keys})
@api_keys_bp.route("", methods=["POST"])
@login_required
async def create_key_route():
uid = get_current_user_id()
data = await request.get_json(force=True, silent=True) or {}
name = (data.get("name") or "").strip()
scope = (data.get("scope") or "").strip()
if not name:
return jsonify({"error": "name is required"}), 400
if scope not in ("read", "write"):
return jsonify({"error": "scope must be 'read' or 'write'"}), 400
full_key, key_dict = await create_api_key(uid, name=name, scope=scope)
# Return the full key ONCE — it is never retrievable again
return jsonify({"key": full_key, "api_key": key_dict}), 201
@api_keys_bp.route("/<int:key_id>", methods=["DELETE"])
@login_required
async def revoke_key_route(key_id: int):
uid = get_current_user_id()
deleted = await revoke_api_key(uid, key_id)
if not deleted:
return jsonify({"error": "API key not found"}), 404
return "", 204
+91 -4
View File
@@ -44,9 +44,10 @@ async def get_config():
async def put_config():
data = await request.get_json()
await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)})
# Live-patch the scheduler so the new timezone takes effect immediately.
# Live-patch the scheduler using the stored user_timezone.
from fabledassistant.services.briefing_scheduler import update_user_schedule
update_user_schedule(g.user.id, data)
tz_override = await get_setting(g.user.id, "user_timezone") or None
update_user_schedule(g.user.id, data, tz_override=tz_override)
return jsonify({"ok": True})
@@ -232,6 +233,92 @@ async def manual_trigger():
model = await get_setting(g.user.id, "default_model", "")
conv = await get_or_create_today_conversation(g.user.id, model)
text = await run_compilation(g.user.id, slot, model)
msg = await post_message(conv.id, "assistant", text)
text, metadata = await run_compilation(g.user.id, slot, model)
msg = await post_message(conv.id, "assistant", text, metadata=metadata)
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
# ── RSS Reactions ──────────────────────────────────────────────────────────────
@briefing_bp.route("/rss-reactions", methods=["POST"])
@_REQUIRE
async def upsert_rss_reaction():
"""Upsert a 👍/👎 reaction on an RSS item. Same reaction toggles off; opposite flips."""
data = await request.get_json()
rss_item_id = data.get("rss_item_id")
reaction = data.get("reaction")
if not rss_item_id or reaction not in ("up", "down"):
return jsonify({"error": "rss_item_id and reaction ('up'|'down') required"}), 400
from sqlalchemy import text as _text
async with async_session() as session:
# Ownership check: verify item belongs to a feed owned by this user
result = await session.execute(
_text("""
SELECT i.id FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
WHERE i.id = :item_id AND f.user_id = :uid
""").bindparams(item_id=rss_item_id, uid=g.user.id)
)
if result.first() is None:
return jsonify({"error": "Not found"}), 404
# Check existing reaction
existing = await session.execute(
_text("""
SELECT id, reaction FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=rss_item_id)
)
row = existing.first()
if row is None:
await session.execute(
_text("""
INSERT INTO rss_item_reactions (user_id, rss_item_id, reaction)
VALUES (:uid, :item_id, :reaction)
""").bindparams(uid=g.user.id, item_id=rss_item_id, reaction=reaction)
)
action = "created"
elif row.reaction == reaction:
# Toggle off (same reaction clicked again)
await session.execute(
_text("""
DELETE FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=rss_item_id)
)
action = "removed"
else:
# Flip to opposite reaction
await session.execute(
_text("""
UPDATE rss_item_reactions SET reaction = :reaction
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(reaction=reaction, uid=g.user.id, item_id=rss_item_id)
)
action = "updated"
await session.commit()
return jsonify({"ok": True, "action": action})
@briefing_bp.route("/rss-reactions/<int:item_id>", methods=["DELETE"])
@_REQUIRE
async def delete_rss_reaction(item_id: int):
"""Explicitly remove a reaction (useful for MCP/external API callers)."""
from sqlalchemy import text as _text
async with async_session() as session:
await session.execute(
_text("""
DELETE FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=item_id)
)
await session.commit()
return jsonify({"ok": True})
+37 -12
View File
@@ -75,7 +75,11 @@ async def create_conversation_route():
data = await request.get_json(force=True, silent=True) or {}
title = data.get("title", "")
model = data.get("model", Config.OLLAMA_MODEL)
conv = await create_conversation(uid, title=title, model=model)
conversation_type = data.get("conversation_type", "chat")
# Only allow known types to prevent accidental misuse
if conversation_type not in ("chat", "mcp"):
conversation_type = "chat"
conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type)
return jsonify(conv.to_dict()), 201
@@ -111,13 +115,15 @@ async def delete_conversation_route(conv_id: int):
@chat_bp.route("/conversations/<int:conv_id>", methods=["PATCH"])
@login_required
async def update_conversation_route(conv_id: int):
from fabledassistant.services.chat import _UNSET
uid = get_current_user_id()
data = await request.get_json()
title = data.get("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)
rag_project_id = data.get("rag_project_id", _UNSET)
if title is None and model is None and rag_project_id is _UNSET:
return jsonify({"error": "title, model, or rag_project_id is required"}), 400
conv = await update_conversation(uid, conv_id, title=title, model=model, rag_project_id=rag_project_id)
if conv is None:
return not_found("Conversation")
return jsonify(conv.to_dict())
@@ -142,6 +148,9 @@ async def send_message_route(conv_id: int):
think = bool(data.get("think", False))
rag_project_id = data.get("rag_project_id") or None
workspace_project_id = data.get("workspace_project_id") or None
user_timezone = data.get("user_timezone") or None
if not user_timezone:
user_timezone = await get_setting(uid, "user_timezone") or None
effective_rag_project_id = workspace_project_id or rag_project_id
# Reject if generation already running for this conversation
@@ -178,6 +187,7 @@ async def send_message_route(conv_id: int):
think=think,
rag_project_id=effective_rag_project_id,
workspace_project_id=workspace_project_id,
user_timezone=user_timezone,
))
return jsonify({
@@ -401,14 +411,29 @@ async def chat_status_route():
async def list_models_route():
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
resp.raise_for_status()
data = resp.json()
models = [
{"name": m["name"], "size": m.get("size", 0)}
for m in data.get("models", [])
]
return jsonify({"models": models})
tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags"))
ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps"))
tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True)
loaded_names: set[str] = set()
if not isinstance(ps_resp, Exception):
try:
ps_resp.raise_for_status()
loaded_names = {m["name"] for m in ps_resp.json().get("models", [])}
except Exception:
pass
models = []
if not isinstance(tags_resp, Exception):
tags_resp.raise_for_status()
for m in tags_resp.json().get("models", []):
models.append({
"name": m["name"],
"size": m.get("size", 0),
"modified_at": m.get("modified_at", ""),
"loaded": m["name"] in loaded_names,
})
return jsonify({"models": models})
except Exception as e:
logger.warning("Failed to list Ollama models: %s", e)
return jsonify({"models": [], "error": str(e)}), 200
+119
View File
@@ -0,0 +1,119 @@
"""Calendar events REST API."""
from __future__ import annotations
from datetime import datetime
from quart import Blueprint, g, jsonify, request
from fabledassistant.auth import login_required
import fabledassistant.services.events as events_svc
events_bp = Blueprint("events", __name__, url_prefix="/api/events")
def _get_current_user_id() -> int:
return g.user.id
@events_bp.get("")
@login_required
async def list_events():
date_from_str = request.args.get("from")
date_to_str = request.args.get("to")
if not date_from_str or not date_to_str:
return jsonify({"error": "from and to query params are required"}), 400
try:
date_from = datetime.fromisoformat(date_from_str)
date_to = datetime.fromisoformat(date_to_str)
except ValueError:
return jsonify({"error": "Invalid datetime format"}), 400
events = await events_svc.list_events(
user_id=_get_current_user_id(),
date_from=date_from,
date_to=date_to,
)
return jsonify([e.to_dict() for e in events])
@events_bp.post("")
@login_required
async def create_event():
data = await request.get_json() or {}
if not data.get("title") or not data.get("start_dt"):
return jsonify({"error": "title and start_dt are required"}), 400
try:
start_dt = datetime.fromisoformat(data["start_dt"])
end_dt = datetime.fromisoformat(data["end_dt"]) if data.get("end_dt") else None
except ValueError:
return jsonify({"error": "Invalid datetime format"}), 400
event = await events_svc.create_event(
user_id=_get_current_user_id(),
title=data["title"],
start_dt=start_dt,
end_dt=end_dt,
all_day=data.get("all_day", False),
description=data.get("description", ""),
location=data.get("location", ""),
color=data.get("color", ""),
recurrence=data.get("recurrence"),
project_id=data.get("project_id"),
)
return jsonify(event.to_dict()), 201
@events_bp.get("/<int:event_id>")
@login_required
async def get_event(event_id: int):
event = await events_svc.get_event(
user_id=_get_current_user_id(),
event_id=event_id,
)
if event is None:
return jsonify({"error": "Event not found"}), 404
return jsonify(event.to_dict())
@events_bp.patch("/<int:event_id>")
@login_required
async def update_event(event_id: int):
data = await request.get_json() or {}
fields: dict = {}
for str_field in ("title", "description", "location", "color", "recurrence"):
if str_field in data:
fields[str_field] = data[str_field]
for bool_field in ("all_day",):
if bool_field in data:
fields[bool_field] = data[bool_field]
for int_field in ("project_id",):
if int_field in data:
fields[int_field] = data[int_field]
for dt_field in ("start_dt", "end_dt"):
if dt_field in data and data[dt_field]:
try:
fields[dt_field] = datetime.fromisoformat(data[dt_field])
except ValueError:
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
event = await events_svc.update_event(
user_id=_get_current_user_id(),
event_id=event_id,
**fields,
)
if event is None:
return jsonify({"error": "Event not found"}), 404
return jsonify(event.to_dict())
@events_bp.delete("/<int:event_id>")
@login_required
async def delete_event(event_id: int):
event = await events_svc.get_event(
user_id=_get_current_user_id(),
event_id=event_id,
)
if event is None:
return jsonify({"error": "Event not found"}), 404
await events_svc.delete_event(
user_id=_get_current_user_id(),
event_id=event_id,
)
return "", 204
@@ -0,0 +1,42 @@
"""Serve the fable-mcp distribution wheel built into the Docker image."""
import logging
import os
from pathlib import Path
from quart import Blueprint, jsonify, send_file
from fabledassistant.auth import login_required
logger = logging.getLogger(__name__)
fable_mcp_dist_bp = Blueprint("fable_mcp_dist", __name__, url_prefix="/api/fable-mcp")
# Wheel is built into the image at this path (see Dockerfile)
_DIST_DIR = Path(os.environ.get("FABLE_MCP_DIST_DIR", "/app/dist"))
def _find_wheel() -> Path | None:
"""Return the newest fable_mcp wheel in the dist dir, or None."""
wheels = sorted(_DIST_DIR.glob("fable_mcp-*.whl"), reverse=True)
return wheels[0] if wheels else None
@fable_mcp_dist_bp.route("/info", methods=["GET"])
@login_required
async def fable_mcp_info():
"""Return availability and filename of the bundled fable-mcp wheel."""
wheel = _find_wheel()
return jsonify({
"available": wheel is not None,
"filename": wheel.name if wheel else None,
})
@fable_mcp_dist_bp.route("/download", methods=["GET"])
@login_required
async def download_fable_mcp():
"""Serve the fable-mcp wheel file as a download."""
wheel = _find_wheel()
if wheel is None:
return jsonify({"error": "Package not built into this image"}), 404
return await send_file(wheel, as_attachment=True)
+2 -1
View File
@@ -78,7 +78,8 @@ async def quick_capture_route():
if not text:
return jsonify({"error": "text is required"}), 400
model = Config.OLLAMA_MODEL
from fabledassistant.services.settings import get_setting
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
# Build tool list for this user, then restrict to capture-only operations.
all_tools = await get_tools_for_user(uid)
+46
View File
@@ -0,0 +1,46 @@
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.embeddings import semantic_search_notes
search_bp = Blueprint("search", __name__, url_prefix="/api/search")
def _content_type_to_is_task(content_type: str) -> bool | None:
"""Map content_type query param to semantic_search_notes is_task arg."""
if content_type == "note":
return False
if content_type == "task":
return True
return None # "all" or unknown → no filter
@search_bp.route("", methods=["GET"])
@login_required
async def search_route():
uid = get_current_user_id()
q = (request.args.get("q") or "").strip()
if not q:
return jsonify({"error": "q is required"}), 400
content_type = request.args.get("content_type", "all")
limit = min(request.args.get("limit", 10, type=int), 50)
is_task = _content_type_to_is_task(content_type)
results = await semantic_search_notes(
uid, q, limit=limit, is_task=is_task, threshold=0.3
)
return jsonify({
"results": [
{
"id": note.id,
"title": note.title,
"body": note.body or "",
"is_task": note.is_task,
"tags": note.tags or [],
"similarity": score,
}
for score, note in results # semantic_search_notes returns list[tuple[float, Note]]
],
"total": len(results),
})
+9
View File
@@ -1,8 +1,11 @@
import asyncio
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.services.embeddings import upsert_note_embedding
from fabledassistant.services.notes import (
create_note,
delete_note,
@@ -93,6 +96,9 @@ async def create_task_route():
milestone_id=data.get("milestone_id"),
parent_id=data.get("parent_id"),
)
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
return jsonify(task.to_dict()), 201
@@ -147,6 +153,9 @@ async def update_task_route(task_id: int):
task = await update_note(uid, task_id, **fields)
if task is None:
return not_found("Task")
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
return jsonify(task.to_dict())
+87
View File
@@ -0,0 +1,87 @@
import hashlib
import secrets
from datetime import datetime, timezone
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.api_key import ApiKey
def generate_key() -> str:
"""Generate a new full API key. Never stored — caller must hash it."""
return "fmcp_" + secrets.token_urlsafe(32)
def _hash_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
def _key_prefix(key: str) -> str:
"""Return first 12 chars of the key for display (e.g. 'fmcp_abcdefg')."""
return key[:12]
async def create_api_key(
user_id: int, name: str, scope: str
) -> tuple[str, dict]:
"""Create a new API key. Returns (full_key, key_dict). full_key is never stored."""
if scope not in ("read", "write"):
raise ValueError("scope must be 'read' or 'write'")
full_key = generate_key()
key = ApiKey(
user_id=user_id,
name=name,
key_hash=_hash_key(full_key),
key_prefix=_key_prefix(full_key),
scope=scope,
)
async with async_session() as session:
session.add(key)
await session.commit()
await session.refresh(key)
return full_key, key.to_dict()
async def list_api_keys(user_id: int) -> list[dict]:
"""List all non-revoked API keys for the user."""
async with async_session() as session:
result = await session.execute(
select(ApiKey)
.where(ApiKey.user_id == user_id, ApiKey.revoked_at.is_(None))
.order_by(ApiKey.created_at.desc())
)
return [k.to_dict() for k in result.scalars().all()]
async def revoke_api_key(user_id: int, key_id: int) -> bool:
"""Soft-delete a key by setting revoked_at. Returns True if found."""
async with async_session() as session:
result = await session.execute(
select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user_id)
)
key = result.scalars().first()
if key is None:
return False
key.revoked_at = datetime.now(timezone.utc)
await session.commit()
return True
async def lookup_key(raw_key: str) -> ApiKey | None:
"""Look up a non-revoked ApiKey by raw token value. Updates last_used_at."""
key_hash = _hash_key(raw_key)
async with async_session() as session:
result = await session.execute(
select(ApiKey).where(
ApiKey.key_hash == key_hash,
ApiKey.revoked_at.is_(None),
)
)
key = result.scalars().first()
if key is None:
return None
key.last_used_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(key)
return key
@@ -41,7 +41,12 @@ async def get_or_create_today_conversation(user_id: int, model: str) -> Conversa
return conv
async def post_message(conversation_id: int, role: str, content: str) -> Message:
async def post_message(
conversation_id: int,
role: str,
content: str,
metadata: dict | None = None,
) -> Message:
"""Append a message to a briefing conversation."""
async with async_session() as session:
msg = Message(
@@ -49,6 +54,7 @@ async def post_message(conversation_id: int, role: str, content: str) -> Message
role=role,
content=content,
status="complete",
msg_metadata=metadata,
)
session.add(msg)
# Bump conversation updated_at
+209 -29
View File
@@ -5,11 +5,15 @@ Slot names: 'compilation' (4am), 'morning' (8am), 'midday' (12pm), 'afternoon' (
"""
import asyncio
import hashlib
import logging
from datetime import date
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import httpx
from fabledassistant.models import async_session
from fabledassistant.config import Config
from fabledassistant.services.settings import get_setting
@@ -38,6 +42,86 @@ def format_task(task: dict) -> str:
return "".join(parts)
def compute_task_hash(task: dict) -> str:
"""Stable SHA-256 of the task's key change-detectable fields."""
key = "|".join([
str(task.get("status") or ""),
str(task.get("priority") or ""),
str(task.get("due_date") or ""),
str(task.get("title") or ""),
])
return hashlib.sha256(key.encode()).hexdigest()
async def split_changed_tasks(
user_id: int,
tasks: list[dict],
) -> tuple[list[dict], int]:
"""
Compare tasks against the briefing_task_snapshot table.
Returns (changed_tasks, unchanged_count).
changed_tasks includes new tasks (no snapshot row) and tasks whose hash differs.
"""
from sqlalchemy import text
if not tasks:
return [], 0
task_ids = [t["task_id"] for t in tasks if t.get("task_id")]
async with async_session() as session:
result = await session.execute(
text("""
SELECT task_id, snapshot_hash
FROM briefing_task_snapshot
WHERE user_id = :uid AND task_id = ANY(:ids)
""").bindparams(uid=user_id, ids=task_ids)
)
snapshots = {row.task_id: row.snapshot_hash for row in result}
changed = []
unchanged_count = 0
for task in tasks:
current_hash = compute_task_hash(task)
stored_hash = snapshots.get(task.get("task_id"))
if stored_hash is None or stored_hash != current_hash:
changed.append(task)
else:
unchanged_count += 1
return changed, unchanged_count
async def upsert_task_snapshots(user_id: int, tasks: list[dict]) -> None:
"""Upsert snapshot hashes for all tasks included in this briefing."""
from sqlalchemy import text
if not tasks:
return
now = datetime.now(timezone.utc)
async with async_session() as session:
for task in tasks:
task_id = task.get("task_id")
if not task_id:
continue
await session.execute(
text("""
INSERT INTO briefing_task_snapshot (user_id, task_id, snapshot_hash, last_briefed)
VALUES (:uid, :tid, :hash, :now)
ON CONFLICT (user_id, task_id)
DO UPDATE SET snapshot_hash = EXCLUDED.snapshot_hash,
last_briefed = EXCLUDED.last_briefed
""").bindparams(
uid=user_id,
tid=task_id,
hash=compute_task_hash(task),
now=now,
)
)
await session.commit()
# ── Internal data gather ──────────────────────────────────────────────────────
async def _gather_internal(user_id: int) -> dict:
@@ -46,13 +130,21 @@ async def _gather_internal(user_id: int) -> dict:
from fabledassistant.services.projects import list_projects
from fabledassistant.services.caldav import is_caldav_configured, list_events
today = date.today().isoformat()
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
try:
user_tz = ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
user_tz = ZoneInfo("UTC")
today = datetime.now(user_tz).date().isoformat()
# Tasks: overdue, due today, high priority in-progress
all_tasks: list[dict] = []
try:
all_task_objs, _total = await list_notes(user_id, is_task=True, limit=100)
all_tasks = [
{
"task_id": t.id,
"title": t.title,
"status": t.status,
"due_date": t.due_date.isoformat() if t.due_date else None,
@@ -77,17 +169,37 @@ async def _gather_internal(user_id: int) -> dict:
logger.warning("Failed to gather tasks for briefing", exc_info=True)
overdue, due_today, high_priority = [], [], []
# Calendar events today
calendar_events = []
# Calendar events today — internal store
calendar_events: list[str] = []
try:
if is_caldav_configured(user_id):
events = await list_events(user_id, start=today, end=today)
calendar_events = [
f"{e.get('summary', 'Event')} at {e.get('dtstart', 'unknown time')}"
for e in (events or [])
]
from fabledassistant.services.events import list_events as list_internal_events
today_date = datetime.now(user_tz).date()
day_start = datetime(today_date.year, today_date.month, today_date.day, 0, 0, 0, tzinfo=user_tz)
day_end = datetime(today_date.year, today_date.month, today_date.day, 23, 59, 59, tzinfo=user_tz)
internal_events = await list_internal_events(
user_id=user_id, date_from=day_start, date_to=day_end
)
for e in internal_events:
if e.all_day:
time_str = "all day"
elif e.start_dt:
local_dt = e.start_dt.astimezone(user_tz) if e.start_dt.tzinfo else e.start_dt.replace(tzinfo=timezone.utc).astimezone(user_tz)
time_str = local_dt.strftime("%-I:%M %p")
else:
time_str = "unknown time"
calendar_events.append(f"{e.title} at {time_str}")
except Exception:
logger.warning("Failed to gather calendar events for briefing", exc_info=True)
logger.warning("Failed to gather internal calendar events for briefing", exc_info=True)
# Also pull CalDAV events (deduped)
try:
if await is_caldav_configured(user_id):
caldav_evs = await list_events(user_id, start=today, end=today)
for e in (caldav_evs or []):
summary = f"{e.get('summary', 'Event')} at {e.get('dtstart', 'unknown time')}"
if summary not in calendar_events:
calendar_events.append(summary)
except Exception:
logger.warning("Failed to gather CalDAV calendar events for briefing", exc_info=True)
# Projects: active projects
projects_summary = []
@@ -105,6 +217,7 @@ async def _gather_internal(user_id: int) -> dict:
"high_priority": high_priority,
"calendar_events": calendar_events,
"active_projects": projects_summary,
"all_tasks_raw": all_tasks,
}
@@ -162,24 +275,36 @@ def _internal_system_prompt(profile_body: str) -> str:
def _external_system_prompt() -> str:
return (
"You are a briefing assistant for external information. Your job is to summarise "
"the user's RSS feed digest and weather forecast into a concise, engaging update. "
"Group related news items. Note any significant weather changes. "
"Be informative but brief. Do not discuss tasks, calendar, or work items."
"You are a briefing assistant for external information. Your job is to present "
"selected news items and summarise any remaining RSS content. "
"IMPORTANT: Weather is handled separately — do NOT include any weather section.\n\n"
"Format each news item EXACTLY as:\n"
"**[Headline text](source_url)**\n"
"*Outlet Name · Day Month*\n"
"One or two sentence summary.\n\n"
"Present news items in the EXACT ORDER they are provided. Do not reorder them. "
"After the news cards, add a brief paragraph for any remaining context."
)
def _internal_user_prompt(data: dict, slot: str) -> str:
lines = [f"Briefing slot: {slot}", f"Date: {data['date']}", ""]
if data["overdue_tasks"]:
lines.append(f"OVERDUE ({len(data['overdue_tasks'])}):")
lines.extend(f" - {t}" for t in data["overdue_tasks"])
if data.get("unchanged_task_count", 0) > 0:
lines.append(
f"({data['unchanged_task_count']} tasks are unchanged since the last briefing "
"— acknowledge briefly, do not list them.)"
)
lines.append("")
if data["due_today"]:
changed = data.get("changed_tasks") or data.get("overdue_tasks", [])
if changed:
lines.append(f"CHANGED/NEW TASKS ({len(changed)}):")
lines.extend(f" - {t}" for t in changed)
lines.append("")
if data.get("due_today"):
lines.append(f"DUE TODAY ({len(data['due_today'])}):")
lines.extend(f" - {t}" for t in data["due_today"])
lines.append("")
if data["high_priority"]:
if data.get("high_priority"):
lines.append("HIGH PRIORITY (in progress):")
lines.extend(f" - {t}" for t in data["high_priority"])
lines.append("")
@@ -240,49 +365,104 @@ async def _get_temp_unit(user_id: int) -> str:
return "C"
async def run_compilation(user_id: int, slot: str, model: str | None = None) -> str:
async def run_compilation(
user_id: int,
slot: str,
model: str | None = None,
) -> tuple[str, dict]:
"""
Run the full two-lane briefing pipeline for a user and slot.
Returns the combined briefing text to be posted as the opening assistant message.
Returns (briefing_text, metadata_dict) where metadata contains
weather card data and rss_item_ids for frontend rendering.
"""
if model is None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
from fabledassistant.services.briefing_profile import get_profile_body
from fabledassistant.services.briefing_preferences import (
load_topic_preferences,
load_topic_reaction_scores,
score_and_filter_items,
)
from fabledassistant.services.weather import parse_weather_card_data, get_cached_weather_rows
profile_body, temp_unit = await asyncio.gather(
get_profile_body(user_id),
_get_temp_unit(user_id),
)
# Parallel gather
internal_data, external_data = await asyncio.gather(
# ── Pre-processing ──────────────────────────────────────────────────────────
include_topics, exclude_topics = await load_topic_preferences(user_id)
topic_scores = await load_topic_reaction_scores(user_id)
# Parallel raw gather — weather rows fetched in same gather to avoid extra DB round-trip
internal_data, external_data, weather_rows = await asyncio.gather(
_gather_internal(user_id),
_gather_external(user_id),
get_cached_weather_rows(user_id),
)
# Two-lane LLM synthesis (both calls run concurrently)
# Task change detection
all_tasks = internal_data.get("all_tasks_raw", [])
changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks)
# RSS filtering
raw_rss = external_data.get("rss_items") or []
filtered_rss = score_and_filter_items(
raw_rss,
include_topics=include_topics,
exclude_topics=exclude_topics,
topic_scores=topic_scores,
max_items=10,
)
rss_item_ids = [item["id"] for item in filtered_rss if item.get("id")]
# Weather staleness gate — returns None if data is >24h old
weather_card = parse_weather_card_data(weather_rows[0], temp_unit) if weather_rows else None
# ── LLM Synthesis ──────────────────────────────────────────────────────────
# Build filtered internal data with only changed tasks
today = internal_data["date"]
internal_data_filtered = dict(internal_data)
internal_data_filtered["unchanged_task_count"] = unchanged_count
internal_data_filtered["changed_tasks"] = [format_task(t) for t in changed_tasks]
# Build filtered external data (suppress weather prose — card handles it)
external_data_filtered = {
"rss_items": filtered_rss,
"weather": [],
}
internal_text, external_text = await asyncio.gather(
_llm_synthesise(
_internal_system_prompt(profile_body),
_internal_user_prompt(internal_data, slot),
_internal_user_prompt(internal_data_filtered, slot),
model,
),
_llm_synthesise(
_external_system_prompt(),
_external_user_prompt(external_data, slot, temp_unit),
_external_user_prompt(external_data_filtered, slot, temp_unit),
model,
),
)
# ── Post-processing ─────────────────────────────────────────────────────────
await upsert_task_snapshots(user_id, all_tasks)
metadata: dict = {"rss_item_ids": rss_item_ids, "weather": weather_card}
if not internal_text and not external_text:
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
return "", metadata
greeting = slot_greeting(slot)
today = internal_data["date"]
parts = [f"**{greeting}{today}**", ""]
if internal_text:
parts += ["## Your Day", "", internal_text, ""]
if external_text:
parts += ["## The World", "", external_text]
return "\n".join(parts).strip()
return "\n".join(parts).strip(), metadata
async def run_slot_injection(user_id: int, slot: str, model: str | None = None) -> str:
@@ -0,0 +1,110 @@
"""
Briefing preferences: load topic settings, aggregate reaction scores,
filter and rank RSS items for briefing inclusion.
"""
import json
import logging
from datetime import datetime, timezone
from fabledassistant.models import async_session
logger = logging.getLogger(__name__)
async def load_topic_preferences(user_id: int) -> tuple[list[str], list[str]]:
"""
Return (include_topics, exclude_topics) from user settings.
"""
from fabledassistant.services.settings import get_setting
raw_include = await get_setting(user_id, "briefing_include_topics", "[]")
raw_exclude = await get_setting(user_id, "briefing_exclude_topics", "[]")
def _parse(raw) -> list[str]:
try:
val = json.loads(raw) if isinstance(raw, str) else raw
return [str(t) for t in val] if isinstance(val, list) else []
except Exception:
return []
return _parse(raw_include), _parse(raw_exclude)
async def load_topic_reaction_scores(user_id: int) -> dict[str, float]:
"""
Aggregate per-topic reaction scores from the last 30 days.
Returns a dict of topic -> net_score (positive = liked, negative = disliked).
Uses rss_item_reactions joined to rss_items.topics.
"""
try:
from sqlalchemy import text as _text
async with async_session() as session:
result = await session.execute(
_text("""
SELECT unnest(i.topics) AS topic,
SUM(CASE r.reaction WHEN 'up' THEN 1 ELSE -1 END) AS score
FROM rss_item_reactions r
JOIN rss_items i ON i.id = r.rss_item_id
WHERE r.user_id = :uid
AND r.created_at > NOW() - INTERVAL '30 days'
GROUP BY topic
""").bindparams(uid=user_id)
)
return {row.topic: float(row.score) for row in result}
except Exception:
logger.warning("Failed to load topic reaction scores", exc_info=True)
return {}
def score_and_filter_items(
items: list[dict],
include_topics: list[str],
exclude_topics: list[str],
topic_scores: dict[str, float],
max_items: int = 10,
) -> list[dict]:
"""
Score, filter, and rank RSS items for briefing inclusion.
Scoring:
- Hard-exclude: any item tagged with an excluded topic is removed.
- Base score: 0.0
- +2.0 per topic that appears in include_topics
- +1.0 / -1.0 per topic based on reaction score (clamped per topic)
- Tiebreak: newer published_at wins
Returns up to max_items items, highest score first.
Items with classified_at=None (unclassified) pass through with score=0.
"""
include_set = set(include_topics)
exclude_set = set(exclude_topics)
scored = []
for item in items:
item_topics = item.get("topics") or []
# Hard exclude
if exclude_set and any(t in exclude_set for t in item_topics):
continue
score = 0.0
for topic in item_topics:
if topic in include_set:
score += 2.0
if topic in topic_scores:
score += max(-1.0, min(1.0, topic_scores[topic]))
# Parse published_at for tiebreak
pub_str = item.get("published_at") or ""
try:
pub_ts = datetime.fromisoformat(pub_str).timestamp() if pub_str else 0.0
except ValueError:
pub_ts = 0.0
scored.append((score, pub_ts, item))
# Sort: highest score first, then newest first
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
return [item for _, _, item in scored[:max_items]]
@@ -56,17 +56,22 @@ async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
import json
async with async_session() as session:
result = await session.execute(
select(Setting).where(Setting.key == "briefing_config")
select(Setting).where(Setting.key.in_(["briefing_config", "user_timezone"]))
)
rows = list(result.scalars().all())
enabled = []
by_user: dict[int, dict[str, str]] = {}
for row in rows:
by_user.setdefault(row.user_id, {})[row.key] = row.value or ""
enabled = []
for user_id, settings in by_user.items():
try:
config = json.loads(row.value) if row.value else {}
config = json.loads(settings.get("briefing_config", "{}") or "{}")
if config.get("enabled"):
tz = _resolve_timezone(config.get("timezone", "UTC"))
enabled.append((row.user_id, tz))
tz_str = settings.get("user_timezone") or config.get("timezone", "UTC")
tz = _resolve_timezone(tz_str)
enabled.append((user_id, tz))
except Exception:
pass
return enabled
@@ -105,13 +110,15 @@ def _remove_user_jobs(user_id: int) -> None:
# ── Public API ────────────────────────────────────────────────────────────────
def update_user_schedule(user_id: int, config: dict) -> None:
def update_user_schedule(user_id: int, config: dict, tz_override: str | None = None) -> None:
"""
Called when a user saves their briefing config via the settings UI.
Live-patches the scheduler — no restart required.
tz_override takes priority over any timezone in config.
"""
if config.get("enabled"):
tz = _resolve_timezone(config.get("timezone", "UTC"))
tz_str = tz_override or config.get("timezone", "UTC")
tz = _resolve_timezone(tz_str)
_add_user_jobs(user_id, tz)
else:
_remove_user_jobs(user_id)
@@ -153,9 +160,9 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
await _run_profile_closeout(user_id, model)
conv = await get_or_create_today_conversation(user_id, model)
text = await run_compilation(user_id, slot, model)
text, metadata = await run_compilation(user_id, slot, model)
if text:
await post_message(conv.id, "assistant", text)
await post_message(conv.id, "assistant", text, metadata=metadata)
else:
conv = await get_or_create_today_conversation(user_id, model)
@@ -296,10 +303,10 @@ async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None:
)
def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
async def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
"""
Start the APScheduler background scheduler with per-user timezone-aware jobs.
Must be called from the app's before_serving hook with the running event loop.
Must be awaited from the app's before_serving hook (async context).
"""
global _scheduler, _loop
if _scheduler is not None:
@@ -308,10 +315,10 @@ def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
_loop = loop
_scheduler = BackgroundScheduler(timezone="UTC")
# Schedule jobs synchronously: run the async query in the provided loop
future = asyncio.run_coroutine_threadsafe(_get_briefing_enabled_users(), loop)
# Await directly — we're already on the event loop, so run_coroutine_threadsafe
# would deadlock (it blocks the calling thread, which IS the event loop thread).
try:
users = future.result(timeout=10)
users = await _get_briefing_enabled_users()
except Exception:
logger.exception("Failed to load briefing users at startup")
users = []
@@ -325,7 +332,7 @@ def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
len(users), len(users) * len(SLOTS),
)
asyncio.run_coroutine_threadsafe(_catchup_missed_slots(loop), loop)
asyncio.create_task(_catchup_missed_slots(loop))
def stop_briefing_scheduler() -> None:
+6
View File
@@ -176,6 +176,7 @@ async def create_event(
reminder_minutes: int | None = None,
attendees: list[str] | None = None,
calendar_name: str | None = None,
uid: str | None = None,
) -> dict:
"""Create a calendar event.
@@ -193,6 +194,11 @@ async def create_event(
cal.add("version", "2.0")
event = icalendar.Event()
if uid:
# Remove auto-generated UID if the library added one, then inject ours
if "UID" in event:
del event["UID"]
event.add("uid", uid)
event.add("summary", title)
if all_day:
+13 -3
View File
@@ -15,10 +15,10 @@ logger = logging.getLogger(__name__)
async def create_conversation(
user_id: int, title: str = "", model: str = ""
user_id: int, title: str = "", model: str = "", conversation_type: str = "chat"
) -> Conversation:
async with async_session() as session:
conv = Conversation(user_id=user_id, title=title, model=model)
conv = Conversation(user_id=user_id, title=title, model=model, conversation_type=conversation_type)
session.add(conv)
await session.commit()
# Re-fetch with messages eagerly loaded to avoid lazy-load in async context
@@ -128,18 +128,26 @@ async def cleanup_old_conversations(user_id: int, days: int) -> int:
async with async_session() as session:
result = await session.execute(
sa_delete(Conversation)
.where(Conversation.user_id == user_id, Conversation.updated_at < cutoff)
.where(
Conversation.user_id == user_id,
Conversation.updated_at < cutoff,
Conversation.conversation_type != "mcp", # preserve MCP audit trail
)
.returning(Conversation.id)
)
await session.commit()
return len(result.fetchall())
_UNSET = object()
async def update_conversation(
user_id: int,
conversation_id: int,
title: str | None = None,
model: str | None = None,
rag_project_id: object = _UNSET,
) -> Conversation | None:
async with async_session() as session:
result = await session.execute(
@@ -155,6 +163,8 @@ async def update_conversation(
conv.title = title
if model is not None:
conv.model = model
if rag_project_id is not _UNSET:
conv.rag_project_id = rag_project_id # type: ignore[assignment]
conv.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(conv)
+4 -1
View File
@@ -81,6 +81,7 @@ async def semantic_search_notes(
threshold: float = _SIMILARITY_THRESHOLD,
project_id: int | None = None,
is_task: bool | None = None,
orphan_only: bool = False,
) -> list[tuple[float, Note]]:
"""Return up to *limit* (score, note) pairs most relevant to *query*.
@@ -101,7 +102,9 @@ async def semantic_search_notes(
.join(Note, NoteEmbedding.note_id == Note.id)
.where(NoteEmbedding.user_id == user_id)
)
if project_id is not None:
if orphan_only:
stmt = stmt.where(Note.project_id.is_(None))
elif project_id is not None:
stmt = stmt.where(Note.project_id == project_id)
if is_task is True:
stmt = stmt.where(Note.status.isnot(None))
+245
View File
@@ -0,0 +1,245 @@
"""Internal event store service with CalDAV push sync."""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, select
from fabledassistant.models import async_session
from fabledassistant.models.event import Event
logger = logging.getLogger(__name__)
async def create_event(
user_id: int,
title: str,
start_dt: datetime,
end_dt: datetime | None = None,
all_day: bool = False,
description: str = "",
location: str = "",
color: str = "",
recurrence: str | None = None,
project_id: int | None = None,
# CalDAV-only fields (not stored in DB, forwarded to push)
duration: int | None = None,
reminder_minutes: int | None = None,
attendees: list[str] | None = None,
calendar_name: str | None = None,
) -> Event:
"""Create an event in the DB, then fire a CalDAV push task."""
uid = str(uuid.uuid4())
async with async_session() as session:
event = Event(
user_id=user_id,
uid=uid,
title=title,
start_dt=start_dt,
end_dt=end_dt,
all_day=all_day,
description=description,
location=location,
color=color,
recurrence=recurrence,
project_id=project_id,
)
session.add(event)
await session.commit()
await session.refresh(event)
extra_fields = {
"duration": duration,
"reminder_minutes": reminder_minutes,
"attendees": attendees,
"calendar_name": calendar_name,
}
asyncio.create_task(_push_create(event, user_id, extra_fields))
return event
async def get_event(user_id: int, event_id: int) -> Event | None:
"""Return event owned by user_id, or None."""
async with async_session() as session:
result = await session.execute(
select(Event).where(Event.id == event_id, Event.user_id == user_id)
)
return result.scalar_one_or_none()
async def list_events(
user_id: int,
date_from: datetime,
date_to: datetime,
) -> list[Event]:
"""List events for user_id within [date_from, date_to]."""
async with async_session() as session:
result = await session.execute(
select(Event).where(
Event.user_id == user_id,
Event.start_dt >= date_from,
Event.start_dt <= date_to,
).order_by(Event.start_dt)
)
return result.scalars().all()
async def search_events(
user_id: int,
query: str,
days_ahead: int = 90,
) -> list[Event]:
"""Search events by keyword in title, description, or location."""
now = datetime.now(timezone.utc)
date_to = now + timedelta(days=days_ahead)
q = f"%{query}%"
async with async_session() as session:
result = await session.execute(
select(Event).where(
Event.user_id == user_id,
Event.start_dt >= now,
Event.start_dt <= date_to,
or_(
Event.title.ilike(q),
Event.description.ilike(q),
Event.location.ilike(q),
),
).order_by(Event.start_dt)
)
return result.scalars().all()
async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
"""Partial update. Returns updated event or None if not found."""
async with async_session() as session:
result = await session.execute(
select(Event).where(Event.id == event_id, Event.user_id == user_id)
)
event = result.scalar_one_or_none()
if event is None:
return None
old_title = event.title # capture before mutation for CalDAV lookup
allowed = {"title", "start_dt", "end_dt", "all_day", "description",
"location", "color", "recurrence", "project_id"}
for key, value in fields.items():
if key in allowed and value is not None:
setattr(event, key, value)
await session.commit()
await session.refresh(event)
asyncio.create_task(_push_update(event, user_id, old_title=old_title))
return event
async def delete_event(user_id: int, event_id: int) -> None:
"""Delete event. Fires CalDAV delete push if caldav_uid is set."""
async with async_session() as session:
result = await session.execute(
select(Event).where(Event.id == event_id, Event.user_id == user_id)
)
event = result.scalar_one_or_none()
if event is None:
return
caldav_uid = event.caldav_uid
event_title = event.title # needed to find the event on CalDAV by title
await session.delete(event)
await session.commit()
if caldav_uid:
asyncio.create_task(_push_delete(caldav_uid, event_title, user_id))
async def find_events_by_query(user_id: int, query: str) -> list[Event]:
"""ILIKE search on title — used by AI update/delete tools."""
q = f"%{query}%"
async with async_session() as session:
result = await session.execute(
select(Event).where(
Event.user_id == user_id,
Event.title.ilike(q),
).order_by(Event.start_dt)
)
return result.scalars().all()
# ---------------------------------------------------------------------------
# CalDAV push helpers (fire-and-forget)
# ---------------------------------------------------------------------------
async def _push_create(event: Event, user_id: int, extra: dict) -> None:
try:
from fabledassistant.services.caldav import (
create_event as caldav_create,
is_caldav_configured,
)
if not await is_caldav_configured(user_id):
return
await caldav_create(
user_id=user_id,
title=event.title,
start=event.start_dt.isoformat(),
end=event.end_dt.isoformat() if event.end_dt else None,
description=event.description or None,
location=event.location or None,
all_day=event.all_day,
recurrence=event.recurrence,
uid=event.uid,
duration=extra.get("duration"),
reminder_minutes=extra.get("reminder_minutes"),
attendees=extra.get("attendees"),
calendar_name=extra.get("calendar_name"),
)
# Mark as synced
async with async_session() as session:
result = await session.execute(
select(Event).where(Event.id == event.id)
)
ev = result.scalar_one_or_none()
if ev:
ev.caldav_uid = event.uid
await session.commit()
except Exception:
logger.warning("CalDAV push (create) failed for event %d", event.id, exc_info=True)
async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
"""Push an update to CalDAV. Uses old_title to locate the event by its pre-rename SUMMARY."""
if not event.caldav_uid:
return
try:
from fabledassistant.services.caldav import (
update_event as caldav_update,
is_caldav_configured,
)
if not await is_caldav_configured(user_id):
return
# Use old_title so CalDAV can find the event even if the title was changed
query_title = old_title or event.title
await caldav_update(
user_id=user_id,
query=query_title,
title=event.title,
start=event.start_dt.isoformat(),
end=event.end_dt.isoformat() if event.end_dt else None,
description=event.description or None,
location=event.location or None,
)
except Exception:
logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True)
async def _push_delete(caldav_uid: str, event_title: str, user_id: int) -> None:
"""Push a delete to CalDAV. Uses event_title to locate the event by SUMMARY."""
try:
from fabledassistant.services.caldav import (
delete_event as caldav_delete,
is_caldav_configured,
)
if not await is_caldav_configured(user_id):
return
await caldav_delete(user_id=user_id, query=event_title)
except Exception:
logger.warning("CalDAV push (delete) failed for uid %s", caldav_uid, exc_info=True)
@@ -151,6 +151,7 @@ async def run_generation(
think: bool = False,
rag_project_id: int | None = None,
workspace_project_id: int | None = None,
user_timezone: str | None = None,
) -> None:
"""Stream LLM response into buffer with periodic DB flushes."""
MAX_TOOL_ROUNDS = 5
@@ -183,6 +184,7 @@ async def run_generation(
excluded_note_ids=excluded_note_ids,
rag_project_id=rag_project_id,
workspace_project_id=workspace_project_id,
user_timezone=user_timezone,
))
messages, context_meta = await context_task
@@ -208,6 +210,8 @@ async def run_generation(
last_flush = time.monotonic()
all_tool_calls: list[dict] = []
new_rag_scope: object = False # sentinel; set to int|None when scope changes
new_rag_scope_label: str | None = None
try:
cancelled = False
@@ -280,7 +284,16 @@ async def run_generation(
buf.content_so_far += err_text
research_completed = True
else:
result = await execute_tool(user_id, tool_name, arguments)
result = await execute_tool(
user_id, tool_name, arguments,
conv_id=conv_id,
workspace_project_id=workspace_project_id,
)
# Capture RAG scope change for SSE done event
if result.get("type") == "rag_scope_set" and result.get("success"):
new_rag_scope = arguments.get("project_id")
new_rag_scope_label = result.get("scope_label")
timing["tools"].append({"name": tool_name, "ms": int((time.monotonic() - t_tool) * 1000)})
logger.info("Tool %s result: success=%s", tool_name, result.get("success"))
@@ -352,7 +365,11 @@ async def run_generation(
buf.state = GenerationState.COMPLETED
buf.finished_at = time.monotonic()
buf.append_event("done", {"done": True, "message_id": msg_id, "timing": timing})
done_payload: dict = {"done": True, "message_id": msg_id, "timing": timing}
if new_rag_scope is not False:
done_payload["new_rag_scope"] = new_rag_scope
done_payload["new_rag_scope_label"] = new_rag_scope_label
buf.append_event("done", done_payload)
# Fire push notification when complete (non-critical, fire-and-forget)
try:
-1
View File
@@ -132,7 +132,6 @@ Rules:
- create_event: appointments, meetings, scheduled occurrences with a date/time ("dentist Friday 2pm", "team meeting next Tuesday")
- update_note: updating, editing, appending to an existing note or task ("add to my shopping list: eggs", "mark buy milk done", "append to my meeting notes", "update my project note")
- research_topic: user wants a comprehensive research note from web sources ("research X", "look up X and make a note", "find everything about X", "compile a note on X")
- create_project: user wants to create a project, initiative, or campaign ("new project X", "start a project called Y", "create a project for Z")
- create_note: everything else — ideas, observations, links, excerpts, longer text
- For create_task / create_event: extract a concise title; put any extra detail in "body"
- For create_note: use a short descriptive title (≤60 chars); put the FULL original text as "body"
+21 -5
View File
@@ -452,6 +452,7 @@ async def build_context(
excluded_note_ids: list[int] | None = None,
rag_project_id: int | None = None,
workspace_project_id: int | None = None,
user_timezone: str | None = None,
) -> tuple[list[dict], dict]:
"""Build messages array for Ollama with system prompt and context.
@@ -475,9 +476,16 @@ async def build_context(
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes, "
"create_event, list_events, search_events, update_event, delete_event, list_calendars."
)
tool_lines.append("For calendar events, use ISO 8601 datetime format (e.g. 2026-09-30T00:00:00).")
tool_lines.append(
"For calendar events, use ISO 8601 datetime format with the user's timezone offset"
+ (f" ({user_timezone})" if user_timezone else "")
+ ". Always include the UTC offset in datetime strings (e.g. 2026-09-30T14:00:00+01:00)."
)
tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.")
tool_lines.append("For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format.")
tool_lines.append(
"For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format. "
+ (f"Always include the UTC offset when creating events (user's timezone: {user_timezone})." if user_timezone else "For event datetimes, include the UTC offset (e.g. 2026-09-30T14:00:00+01:00).")
)
tool_lines.append("When creating notes, use the `tags` parameter — do not embed #tag text in the note body.")
tool_lines.append(
"When search_images returns results, embed each image directly in your response by writing "
@@ -498,11 +506,12 @@ async def build_context(
)
tool_guidance = "\n".join(tool_lines)
tz_line = f" The user's timezone is {user_timezone}." if user_timezone else ""
system_parts = [
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. "
"Help users with their notes, tasks, and general questions. "
"When note context is provided, use it to give relevant answers. "
f"Today's date is {today}.\n\n"
f"Today's date is {today}.{tz_line}\n\n"
f"{tool_guidance}"
]
@@ -536,12 +545,18 @@ async def build_context(
# (score, note) pairs — score is float for semantic results, None for keyword fallback.
found_scored: list[tuple[float | None, object]] = []
# Derive scope flags from rag_project_id three-value semantics:
# None → orphan notes only; -1 → all notes; positive int → that project
orphan_only = rag_project_id is None
effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None
# Try semantic search first; fall back to keyword search on failure / no results.
try:
from fabledassistant.services.embeddings import semantic_search_notes
for score, note in await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
project_id=rag_project_id,
project_id=effective_project_id,
orphan_only=orphan_only,
):
found_scored.append((score, note))
except Exception:
@@ -553,7 +568,8 @@ async def build_context(
try:
for note in await search_notes_for_context(
user_id, keywords, exclude_ids=search_exclude or None, limit=8,
project_id=rag_project_id,
project_id=effective_project_id,
orphan_only=orphan_only,
):
found_scored.append((None, note))
except Exception:
+35 -3
View File
@@ -22,6 +22,29 @@ def _normalize_tags(tags: list[str]) -> list[str]:
return out
async def _maybe_trigger_project_summary(user_id: int, project_id: int) -> None:
"""Fire generate_project_summary() if the project summary is missing or >1h old."""
import asyncio
from datetime import timedelta
from fabledassistant.models.project import Project
from fabledassistant.services.projects import generate_project_summary
try:
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id)
)).scalars().first()
if project is None:
return
stale = (
project.summary_updated_at is None
or (datetime.now(timezone.utc) - project.summary_updated_at) > timedelta(hours=1)
)
if stale:
asyncio.create_task(generate_project_summary(user_id, project_id))
except Exception:
logger.debug("_maybe_trigger_project_summary failed for project %d", project_id, exc_info=True)
async def create_note(
user_id: int,
title: str = "",
@@ -61,7 +84,11 @@ async def create_note(
session.add(note)
await session.commit()
await session.refresh(note)
return note
if project_id is not None:
await _maybe_trigger_project_summary(user_id, project_id)
return note
async def get_note(user_id: int, note_id: int) -> Note | None:
@@ -157,7 +184,6 @@ async def list_notes(
if no_project:
query = query.where(Note.project_id.is_(None))
count_query = count_query.where(Note.project_id.is_(None))
count_query = count_query.where(Note.parent_id == parent_id)
sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc":
@@ -223,6 +249,9 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
from fabledassistant.services.note_versions import create_version
await create_version(user_id, note_id, old_body, old_title, old_tags)
if note.project_id is not None:
await _maybe_trigger_project_summary(user_id, note.project_id)
return note
@@ -304,6 +333,7 @@ async def search_notes_for_context(
exclude_ids: set[int] | None = None,
limit: int = 3,
project_id: int | None = None,
orphan_only: bool = False,
) -> list[Note]:
"""Search notes by keywords with OR logic. Optimized for context building — no count query."""
async with async_session() as session:
@@ -314,7 +344,9 @@ async def search_notes_for_context(
keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern)))
query = select(Note).where(Note.user_id == user_id, or_(*keyword_filters))
if project_id is not None:
if orphan_only:
query = query.where(Note.project_id.is_(None))
elif project_id is not None:
query = query.where(Note.project_id == project_id)
if exclude_ids:
query = query.where(Note.id.notin_(exclude_ids))
+67 -1
View File
@@ -70,6 +70,7 @@ async def list_projects(user_id: int, status: str | None = None) -> list[Project
async def update_project(user_id: int, project_id: int, **fields: object) -> Project | None:
import asyncio
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
@@ -83,7 +84,72 @@ async def update_project(user_id: int, project_id: int, **fields: object) -> Pro
project.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(project)
return project
asyncio.create_task(generate_project_summary(user_id, project_id))
return project
async def generate_project_summary(user_id: int, project_id: int) -> None:
"""Generate an LLM summary for a project and persist it. Fire-and-forget safe."""
try:
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)).scalars().first()
if project is None:
return
note_rows = (await session.execute(
select(Note.title, Note.body)
.where(Note.project_id == project_id, Note.user_id == user_id)
.order_by(Note.updated_at.desc())
.limit(10)
)).all()
title = project.title or ""
description = project.description or ""
goal = project.goal or ""
note_snippets = "\n".join(
f"- {r.title}: {(r.body or '')[:200]}" for r in note_rows
)
prompt = (
f"Summarize this project in 3-4 sentences covering its purpose, themes, and content.\n"
f"Title: {title}\nDescription: {description}\nGoal: {goal}\n"
f"Recent notes:\n{note_snippets}"
)
from fabledassistant.services.llm import generate_completion
from fabledassistant.config import Config
messages = [{"role": "user", "content": prompt}]
summary = await generate_completion(messages, model=Config.OLLAMA_MODEL, max_tokens=400)
if not summary:
return
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id)
)).scalars().first()
if project:
project.auto_summary = summary.strip()
project.summary_updated_at = datetime.now(timezone.utc)
await session.commit()
logger.debug("Generated summary for project %d", project_id)
except Exception:
logger.debug("Failed to generate summary for project %d", project_id, exc_info=True)
async def backfill_project_summaries() -> None:
"""Generate summaries for all projects missing auto_summary. Fire-and-forget."""
import asyncio
try:
async with async_session() as session:
rows = (await session.execute(
select(Project.id, Project.user_id).where(Project.auto_summary.is_(None))
)).all()
for row in rows:
asyncio.create_task(generate_project_summary(row.user_id, row.id))
except Exception:
logger.debug("backfill_project_summaries failed", exc_info=True)
async def delete_project(user_id: int, project_id: int) -> bool:
+26
View File
@@ -66,6 +66,8 @@ async def fetch_and_cache_feed(feed_id: int, url: str) -> int:
return 0
new_count = 0
feed_user_id: int | None = None
async with async_session() as session:
for entry in parsed.entries:
item_data = extract_item(entry)
@@ -91,14 +93,38 @@ async def fetch_and_cache_feed(feed_id: int, url: str) -> int:
feed_row = await session.get(RssFeed, feed_id)
if feed_row:
feed_row.last_fetched_at = datetime.now(timezone.utc)
feed_user_id = feed_row.user_id
# Auto-populate title from feed metadata if blank
if not feed_row.title and parsed.feed.get("title"):
feed_row.title = parsed.feed.title[:200]
await session.commit()
# Collect IDs of unclassified items after commit.
# We query classified_at IS NULL (not just the items inserted above) because
# classification is best-effort and may have failed on previous fetches.
# Re-queuing all unclassified items for this feed on each fetch is intentional:
# it provides automatic retry without a separate retry loop. The classifier
# only writes to items it successfully classifies, so already-classified items
# are not re-processed (they have classified_at set).
unclassified_ids: list[int] = []
if new_count > 0:
result = await session.execute(
select(RssItem.id).where(
RssItem.feed_id == feed_id,
RssItem.classified_at.is_(None),
)
)
unclassified_ids = list(result.scalars().all())
# Prune old items to keep DB tidy
await _prune_old_items(feed_id)
# Fire-and-forget classification for unclassified items
if unclassified_ids and feed_user_id is not None:
from fabledassistant.services.rss_classifier import classify_and_store
asyncio.create_task(classify_and_store(unclassified_ids, feed_user_id))
return new_count
@@ -0,0 +1,147 @@
"""
RSS item topic classifier.
Classifies RSS items into topic tags using a fast non-streaming LLM call.
Called from rss.py after new items are stored — fire-and-forget.
"""
import json
import logging
from datetime import datetime, timezone
import httpx
from fabledassistant.config import Config
logger = logging.getLogger(__name__)
STANDARD_TOPICS = [
"technology", "science", "politics", "business",
"health", "environment", "local", "entertainment", "sports", "other",
]
_CLASSIFY_PROMPT = """\
Classify each news item into 1-3 topics. Use only topics from this list: {vocab}.
Return ONLY a JSON object mapping item_id (as string) to a list of topics.
Example: {{"1": ["technology", "ai"], "2": ["politics"]}}
Items:
{items_block}"""
async def _llm_classify(prompt: str, model: str) -> str:
"""Make a fast non-streaming LLM call and return the raw text response."""
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"options": {"num_ctx": 2048, "temperature": 0.0},
}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(f"{Config.OLLAMA_URL}/api/chat", json=payload)
resp.raise_for_status()
return resp.json().get("message", {}).get("content", "")
async def classify_items_batch(
items: list[dict],
user_include_topics: list[str],
model: str | None = None,
) -> dict[int, list[str]]:
"""
Classify a batch of RSS items into topic tags.
Args:
items: list of dicts with 'id', 'title', 'content'
user_include_topics: extra topics from user preferences to add to vocabulary
model: Ollama model name; defaults to Config.OLLAMA_MODEL
Returns:
dict mapping item_id (int) -> list of topic strings.
Items not returned had classification fail; callers should leave classified_at=NULL.
"""
if not items:
return {}
if model is None:
model = Config.OLLAMA_MODEL
vocab = STANDARD_TOPICS + [t for t in user_include_topics if t not in STANDARD_TOPICS]
items_block = "\n".join(
f"[{item['id']}] {item['title']}{item.get('content', '')[:300]}"
for item in items
)
prompt = _CLASSIFY_PROMPT.format(vocab=", ".join(vocab), items_block=items_block)
try:
raw = await _llm_classify(prompt, model)
# Extract JSON from response (LLM may wrap it in markdown)
raw = raw.strip()
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
parsed = json.loads(raw)
return {int(k): v for k, v in parsed.items() if isinstance(v, list)}
except Exception:
logger.warning("RSS classification failed", exc_info=True)
return {}
async def classify_and_store(
item_ids: list[int],
user_id: int,
) -> None:
"""
Classify unclassified RSS items and write results to DB.
Called as a fire-and-forget task from rss.py.
"""
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.rss_feed import RssItem
from fabledassistant.services.settings import get_setting
if not item_ids:
return
# Load the items
async with async_session() as session:
result = await session.execute(
select(RssItem).where(RssItem.id.in_(item_ids))
)
items = list(result.scalars().all())
if not items:
return
# Get user's include topics to extend vocabulary
raw_include = await get_setting(user_id, "briefing_include_topics", "[]")
try:
include_topics = json.loads(raw_include) if isinstance(raw_include, str) else []
except Exception:
include_topics = []
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
# Classify in batches of 10
batch_size = 10
all_results: dict[int, list[str]] = {}
for i in range(0, len(items), batch_size):
batch = items[i: i + batch_size]
batch_dicts = [{"id": it.id, "title": it.title, "content": it.content} for it in batch]
results = await classify_items_batch(batch_dicts, include_topics, model=model)
all_results.update(results)
# Write back to DB
now = datetime.now(timezone.utc)
async with async_session() as session:
for item in items:
item_db = await session.get(RssItem, item.id)
if item_db is None:
continue
topics = all_results.get(item.id)
if topics is not None:
item_db.topics = topics
item_db.classified_at = now
await session.commit()

Some files were not shown because too many files have changed in this diff Show More