Compare commits

...

22 Commits

Author SHA1 Message Date
bvandeusen 7888788d42 Merge pull request 'Release v26.03.27.2' (#15) from dev into main
Release v26.03.27.2
2026-03-27 21:23:53 +00:00
bvandeusen 7a12cba4d5 feat: add 'cancelled' task status; fix 500 on invalid status/priority
TaskStatus enum was missing 'cancelled' — the LLM tried to use it and
hit TaskStatus("cancelled") raising ValueError → 500. Added the value,
a migration to extend the task_status Postgres enum, and proper 400
validation guards on both create and update task routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:18:16 -04:00
bvandeusen 78791175a1 Merge pull request 'Release v26.03.27.1' (#14) from dev into main
Release v26.03.27.1
2026-03-27 04:12:27 +00:00
bvandeusen 2a1644e571 feat: news story cards in briefing — backend embeds structured RSS items in metadata
Previously metadata only stored rss_item_ids (integers); the full item data
was discarded after LLM synthesis. Now rss_items (id, title, url, source,
snippet, published_at) is also stored so clients can render per-story cards
without additional API calls.

Web BriefingView: replaces bare reaction-row buttons with news cards showing
source, headline (linked), 2-line snippet, and 👍/👎 per card.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 00:08:21 -04:00
bvandeusen bc5f1679d5 fix: accept PATCH on /api/tasks/:id (MCP update_task compatibility)
The MCP fable_update_task tool calls PATCH /api/tasks/{id} but the route
only declared PUT. Adding PATCH to the same handler fixes the 405.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 23:44:20 -04:00
bvandeusen 22634aa0c9 refactor: DRY pass on backend — pagination helper and sharing utilities
- Add parse_pagination() to routes/utils.py; replace 6 duplicate limit/offset extractions in notes, tasks, chat, projects, milestones routes
- Extract _enrich_shares() in sharing.py; eliminates identical 12-line loop in list_project_shares and list_note_shares
- Extract _deduplicate_by_permission() in sharing.py; eliminates identical deduplication blocks in list_shared_with_me for projects and notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:52:39 -04:00
bvandeusen 699e525cb9 refactor: DRY pass on frontend — shared palette, composable, and tab loader
- Extract milestoneColor to utils/palette.ts; remove duplicate in HomeView + ProjectListView
- Create useBackgroundRefresh composable; wire into HomeView + BriefingView (removes manual setInterval/clearInterval boilerplate)
- Extract _loadTabContent() in SettingsView so watch and onMounted share one tab→loader mapping
- Move raw fetch() api-key calls to typed helpers in api/client.ts (listApiKeys, createApiKey, revokeApiKey)
- Drop unused onUnmounted import from BriefingView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:43:59 -04:00
bvandeusen 2b2e5c666a fix: load all pre-selected settings tabs correctly on mount
The watch(activeTab) handler loads tab data on navigation, but not on
initial mount when localStorage restores a tab. Three more gaps:

- briefing: was inside the isAdmin guard — non-admin users who last
  visited the Briefing tab would see an empty form
- users: no onMounted equivalent — admin user list never loaded
- logs: no onMounted equivalent — admin log viewer never loaded

Moves briefing outside the admin guard and adds users/logs inside it.
2026-03-26 22:32:28 -04:00
bvandeusen 26a8fb5c51 fix: load API keys and MCP info on mount when tab is pre-selected
fetchApiKeys() and loadMcpInfo() were only wired to the activeTab watcher,
which fires on changes but not on initial mount. If localStorage had
'apikeys' as the last tab, both calls were skipped entirely — causing
an empty key list and no whl download button.
2026-03-26 22:24:37 -04:00
bvandeusen 3431719ff3 Merge pull request 'dev → main' (#13) from dev into main 2026-03-26 23:22:23 +00:00
bvandeusen 916cfa50df fix: add missing onUnmounted import in BriefingView 2026-03-26 18:50:25 -04:00
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
29 changed files with 696 additions and 244 deletions
+1 -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
@@ -0,0 +1,18 @@
"""Add 'cancelled' value to task_status enum."""
from alembic import op
revision = "0031"
down_revision = "0030"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TYPE task_status ADD VALUE IF NOT EXISTS 'cancelled'")
def downgrade() -> None:
# PostgreSQL does not support removing enum values without a full type rebuild.
# Downgrade is a no-op; the value simply becomes unused.
pass
+255 -34
View File
@@ -9,7 +9,76 @@ from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, ad
load_dotenv()
mcp = FastMCP("fable")
_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)
# ---------------------------------------------------------------------------
@@ -24,7 +93,13 @@ async def fable_list_notes(
tag: str = "",
search_text: str = "",
) -> dict:
"""List notes stored in Fable. Optionally filter by tag or search text."""
"""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,
@@ -37,7 +112,10 @@ async def fable_list_notes(
@mcp.tool()
async def fable_get_note(note_id: int) -> dict:
"""Fetch the full content of a single Fable note by its ID."""
"""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)
@@ -49,7 +127,16 @@ async def fable_create_note(
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Create a new note in Fable."""
"""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,
@@ -68,7 +155,15 @@ async def fable_update_note(
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Update an existing Fable note. Only provided fields are changed."""
"""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,
@@ -82,7 +177,7 @@ async def fable_update_note(
@mcp.tool()
async def fable_delete_note(note_id: int) -> str:
"""Delete a Fable note by ID."""
"""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."
@@ -100,7 +195,14 @@ async def fable_list_tasks(
status: str = "",
project_id: int = 0,
) -> dict:
"""List tasks in Fable. Filter by status (todo/in_progress/done) or project."""
"""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,
@@ -113,7 +215,11 @@ async def fable_list_tasks(
@mcp.tool()
async def fable_get_task(task_id: int) -> dict:
"""Fetch a single Fable task by ID, including parent task title."""
"""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)
@@ -129,7 +235,20 @@ async def fable_create_task(
parent_id: int = 0,
tags: list[str] | None = None,
) -> dict:
"""Create a new task in Fable."""
"""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,
@@ -154,7 +273,17 @@ async def fable_update_task(
project_id: int = 0,
milestone_id: int = 0,
) -> dict:
"""Update an existing Fable task. Only provided fields are changed."""
"""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,
@@ -170,7 +299,12 @@ async def fable_update_task(
@mcp.tool()
async def fable_add_task_log(task_id: int, body: str) -> dict:
"""Append a progress log entry to a Fable task."""
"""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)
@@ -182,14 +316,22 @@ async def fable_add_task_log(task_id: int, body: str) -> dict:
@mcp.tool()
async def fable_list_projects() -> dict:
"""List all Fable projects for the current user."""
"""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 milestone summary."""
"""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)
@@ -202,7 +344,17 @@ async def fable_create_project(
status: str = "active",
color: str = "",
) -> dict:
"""Create a new project in Fable."""
"""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,
@@ -223,7 +375,16 @@ async def fable_update_project(
status: str = "",
color: str = "",
) -> dict:
"""Update an existing Fable project."""
"""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,
@@ -243,7 +404,10 @@ async def fable_update_project(
@mcp.tool()
async def fable_list_milestones(project_id: int) -> dict:
"""List milestones for a Fable project."""
"""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)
@@ -255,7 +419,16 @@ async def fable_create_milestone(
description: str = "",
status: str = "active",
) -> dict:
"""Create a milestone within a Fable project."""
"""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,
@@ -275,7 +448,16 @@ async def fable_update_milestone(
status: str = "",
order_index: int = -1,
) -> dict:
"""Update a Fable milestone."""
"""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,
@@ -299,10 +481,17 @@ async def fable_search(
content_type: str = "all",
limit: int = 10,
) -> dict:
"""Semantic search over Fable notes and tasks.
"""Semantic search over Fable notes and tasks using embedding similarity.
content_type: "note", "task", or "all" (default).
Returns results ranked by similarity with id, title, body snippet, tags.
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)
@@ -315,7 +504,11 @@ async def fable_search(
@mcp.tool()
async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict:
"""List MCP chat conversations stored in Fable."""
"""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)
@@ -326,11 +519,28 @@ async def fable_send_message(
conversation_id: str = "",
think: bool = False,
) -> dict:
"""Send a message to Fable's LLM and receive the full response.
"""Send a natural-language message to Fable's built-in LLM and receive the full response.
Fable handles tool use, RAG, and history internally.
Pass conversation_id to continue an existing conversation.
Returns conversation_id, response text, and any tool_call events.
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(
@@ -352,10 +562,15 @@ async def fable_get_app_logs(
limit: int = 20,
search: str = "",
) -> dict:
"""Fetch Fable application logs. Requires an admin API key.
"""Fetch Fable application logs. Requires an admin-scoped API key.
category: "error" (default) | "audit" | "usage"
search: optional keyword filter applied to action, endpoint, username, and details
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(
@@ -373,7 +588,11 @@ async def fable_get_app_logs(
@mcp.tool()
async def fable_list_rss_feeds() -> dict:
"""List all RSS feeds configured in Fable for the current user."""
"""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)
@@ -384,12 +603,14 @@ async def fable_add_rss_feed(
title: str = "",
category: str = "",
) -> dict:
"""Add a new RSS feed to Fable. Title is optional — auto-populated from feed metadata.
"""Add an RSS or Atom feed to Fable's daily briefing.
Args:
url: The RSS/Atom feed URL.
title: Optional display name override.
category: Optional category label (e.g. 'news', 'tech').
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(
+3 -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() {
+18 -2
View File
@@ -329,7 +329,6 @@ export interface BriefingConfig {
slots: BriefingSlots;
notifications: boolean;
temp_unit: 'C' | 'F';
timezone: string;
}
export interface BriefingFeed {
@@ -364,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> {
@@ -582,3 +580,21 @@ export async function updateEvent(id: number, payload: EventUpdatePayload): Prom
export async function deleteEvent(id: number): Promise<void> {
return apiDelete(`/api/events/${id}`);
}
// ─── API Keys ─────────────────────────────────────────────────────────────────
export interface ApiKeyEntry {
id: number
name: string
scope: string
key_prefix: string
last_used_at: string | null
}
export const listApiKeys = () =>
apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys)
export const createApiKey = (name: string, scope: 'read' | 'write') =>
apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope })
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
@@ -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
+16 -3
View File
@@ -38,17 +38,30 @@ const color = ref("");
const projectId = ref<number | null>(null);
function dateFromIso(iso: string): string {
return iso.slice(0, 10);
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";
return iso.slice(11, 16);
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`;
return `${date}T${time}: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() {
+9 -7
View File
@@ -107,34 +107,36 @@ const fetchedAtLabel = computed(() => {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.35rem;
margin-bottom: 0.5rem;
}
.weather-temp {
font-size: 1.8rem;
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.85rem;
font-size: 0.82rem;
}
.weather-forecast {
display: flex;
gap: 0.5rem;
gap: 0.75rem;
overflow-x: auto;
padding-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border);
}
@@ -142,8 +144,8 @@ const fetchedAtLabel = computed(() => {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
min-width: 3.5rem;
gap: 0.25rem;
min-width: 4.5rem;
font-size: 0.8rem;
}
@@ -0,0 +1,29 @@
import { onMounted, onUnmounted } from 'vue'
/**
* Runs `refreshFn` on a recurring interval while the page is visible.
* Safe to use in any component — timer is cleared on unmount.
*
* @param refreshFn Called each tick. Should be silent (no loading state changes).
* @param intervalMs Polling interval in milliseconds.
* @param canRun Optional guard — refresh is skipped when this returns false.
*/
export function useBackgroundRefresh(
refreshFn: () => void,
intervalMs: number,
canRun?: () => boolean,
): void {
let timer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
timer = setInterval(() => {
if (document.hidden) return
if (canRun && !canRun()) return
refreshFn()
}, intervalMs)
})
onUnmounted(() => {
if (timer !== null) clearInterval(timer)
})
}
+1
View File
@@ -324,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;
+13
View File
@@ -0,0 +1,13 @@
/** Cyclic color palette for milestone progress bars. */
export const MILESTONE_PALETTE = [
'var(--color-primary)',
'var(--color-success, #22c55e)',
'#c98a00',
'#8b5cf6',
'var(--color-danger, #ef4444)',
'#06b6d4',
]
export function milestoneColor(index: number): string {
return MILESTONE_PALETTE[index % MILESTONE_PALETTE.length]
}
+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>`;
});
+129 -29
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
import { useChatStore } from '@/stores/chat'
import ChatMessage from '@/components/ChatMessage.vue'
import WeatherCard from '@/components/WeatherCard.vue'
@@ -17,6 +18,15 @@ import {
} from '@/api/client'
import type { Message } from '@/types/chat'
interface RssItemMeta {
id: number
title: string
url: string
source: string
snippet: string
published_at: string | null
}
interface MessageMetadata {
weather?: {
location: string
@@ -30,6 +40,7 @@ interface MessageMetadata {
forecast: { day: string; condition: string; high: number; low: number }[]
} | null
rss_item_ids?: number[]
rss_items?: RssItemMeta[]
}
const chatStore = useChatStore()
@@ -153,6 +164,16 @@ function msgMetadata(msg: BriefingMessage): MessageMetadata | null {
return (msg.metadata as MessageMetadata | null | undefined) ?? null
}
function formatRelativeDate(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
const now = new Date()
const diffH = (now.getTime() - d.getTime()) / 3_600_000
if (diffH < 24) return `${Math.round(diffH)}h ago`
if (diffH < 48) return 'Yesterday'
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
// Manual trigger
const triggering = ref(false)
async function triggerNow() {
@@ -189,6 +210,26 @@ function toMsg(m: BriefingMessage): Message {
}
}
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
async function _backgroundRefreshMessages() {
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 */ }
}
useBackgroundRefresh(
_backgroundRefreshMessages,
60_000,
() => !chatStore.streaming && isToday.value && !!todayConvId.value,
)
onMounted(async () => {
await checkSetup()
if (!showWizard.value) await loadAll()
@@ -241,29 +282,43 @@ onMounted(async () => {
:message="toMsg(msg)"
:is-streaming="false"
/>
<!-- Reaction buttons below assistant messages with rss_item_ids -->
<!-- News cards below assistant messages with rss_items -->
<div
v-if="msg.role === 'assistant' && (msgMetadata(msg)?.rss_item_ids?.length ?? 0) > 0"
class="rss-reactions"
v-if="msg.role === 'assistant' && (msgMetadata(msg)?.rss_items?.length ?? 0) > 0"
class="news-cards"
>
<div
v-for="(itemId, index) in msgMetadata(msg)!.rss_item_ids"
:key="itemId"
class="rss-reaction-row"
v-for="item in msgMetadata(msg)!.rss_items"
:key="item.id"
class="news-card"
>
<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 class="news-card-meta">
<span class="news-source">{{ item.source }}</span>
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
</div>
<a
v-if="item.url"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="news-title"
>{{ item.title }}</a>
<p v-else class="news-title news-title--plain">{{ item.title }}</p>
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
<div class="news-reactions">
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'up' }"
@click="handleReaction(item.id, 'up')"
title="Interested"
>👍</button>
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'down' }"
@click="handleReaction(item.id, 'down')"
title="Not interested"
>👎</button>
</div>
</div>
</div>
</template>
@@ -462,33 +517,78 @@ onMounted(async () => {
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-send:hover:not(:disabled) { opacity: 0.9; }
.rss-reactions {
.news-cards {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
gap: 0.5rem;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.rss-reaction-row {
.news-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.65rem 0.85rem;
display: flex;
align-items: center;
gap: 0.4rem;
flex-direction: column;
gap: 0.25rem;
}
.reaction-label {
.news-card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.news-source {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-primary);
}
.news-date {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.news-title {
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.35;
text-decoration: none;
margin: 0;
}
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
.news-snippet {
font-size: 0.78rem;
color: var(--color-text-muted);
min-width: 4rem;
line-height: 1.45;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.news-reactions {
display: flex;
gap: 0.3rem;
margin-top: 0.15rem;
}
.reaction-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.15rem 0.4rem;
padding: 0.1rem 0.35rem;
cursor: pointer;
font-size: 0.85rem;
font-size: 0.82rem;
line-height: 1.4;
opacity: 0.55;
transition: opacity 0.15s, border-color 0.15s;
+36 -10
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { apiGet, listEvents } from "@/api/client";
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh";
import { milestoneColor } from "@/utils/palette";
import type { Note } from "@/types/note";
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
import type { ToolCallRecord, Message } from "@/types/chat";
@@ -69,17 +71,38 @@ const upcomingEvents = ref<EventEntry[]>([]);
const eventSlideOverOpen = ref(false);
const editingEvent = ref<EventEntry | null>(null);
// ─── Milestone color palette ──────────────────────────────────────────────────
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
// Never touches `loading` so existing content stays on screen while fetching.
function milestoneColor(index: number): string {
const palette = [
"var(--color-primary)",
"var(--color-success, #22c55e)",
"#c98a00",
"var(--color-danger, #e74c3c)",
"#8b5cf6",
];
return palette[index % palette.length];
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 ─────────────────────────────────────────────────────────────
@@ -135,8 +158,11 @@ onMounted(async () => {
// Focus chat input after data loads
chatInputRef.value?.focus();
loadProjects();
});
useBackgroundRefresh(_backgroundRefresh, 90_000, () => !loading.value);
async function loadProjects() {
if (!heroProject.value) return;
const hid = heroProject.value.id;
+1 -11
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import { milestoneColor } from "@/utils/palette";
interface MilestoneSummary {
id: number;
@@ -73,17 +74,6 @@ async function loadProjects() {
}
}
function milestoneColor(index: number): string {
const palette = [
"var(--color-primary)",
"var(--color-success)",
"#c98a00",
"#8b5cf6",
"#ef4444",
"#06b6d4",
];
return palette[index % palette.length];
}
onMounted(loadProjects);
+23 -69
View File
@@ -3,7 +3,7 @@ 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, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, 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, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, type ApiKeyEntry, 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";
@@ -43,17 +43,24 @@ const restoreFileInput = ref<HTMLInputElement | null>(null);
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");
function _loadTabContent(tab: string) {
if (authStore.isAdmin) {
if (tab === "users") loadUsersPanel();
else if (tab === "logs") loadLogsPanel();
else if (tab === "groups") loadGroupsPanel();
}
if (tab === "briefing") loadBriefingTab();
if (tab === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
}
watch(activeTab, (v) => {
localStorage.setItem("settings_tab", v === "admin" ? "config" : v);
if (v === "users" && authStore.isAdmin) loadUsersPanel();
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
if (v === "briefing") loadBriefingTab();
if (v === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
_loadTabContent(v);
});
// API Keys
const apiKeys = ref<Array<{id: number, name: string, scope: string, key_prefix: string, last_used_at: string | null}>>([]);
const apiKeys = ref<ApiKeyEntry[]>([]);
const newKeyName = ref('');
const newKeyScope = ref<'read' | 'write'>('write');
const newKeyValue = ref('');
@@ -89,36 +96,24 @@ async function loadMcpInfo() {
}
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;
}
apiKeys.value = await listApiKeys();
}
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();
}
const data = await apiCreateApiKey(newKeyName.value, newKeyScope.value);
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' });
await apiRevokeApiKey(id);
revokeConfirmId.value = null;
await fetchApiKeys();
}
@@ -258,7 +253,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);
@@ -284,10 +278,6 @@ function _parseTopics(raw: unknown): string[] {
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'] ?? '[]');
@@ -526,9 +516,8 @@ onMounted(async () => {
} catch {
// base URL not configured yet
}
if (activeTab.value === "groups") loadGroupsPanel();
if (activeTab.value === "briefing") loadBriefingTab();
}
_loadTabContent(activeTab.value);
});
async function changeEmail() {
@@ -1694,35 +1683,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>
@@ -1747,8 +1707,8 @@ 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>
@@ -3313,12 +3273,6 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
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;
+1
View File
@@ -13,6 +13,7 @@ class TaskStatus(str, enum.Enum):
todo = "todo"
in_progress = "in_progress"
done = "done"
cancelled = "cancelled"
class TaskPriority(str, enum.Enum):
+3 -2
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})
+6 -3
View File
@@ -6,7 +6,7 @@ import httpx
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.routes.utils import not_found, parse_pagination
from fabledassistant.config import Config
from fabledassistant.services.chat import (
add_message,
@@ -38,8 +38,7 @@ chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
@login_required
async def list_conversations_route():
uid = get_current_user_id()
limit = min(request.args.get("limit", 50, type=int), 500)
offset = request.args.get("offset", 0, type=int)
limit, offset = parse_pagination()
conv_type = request.args.get("type", "chat")
# Apply retention policy before returning list
retention_str = await get_setting(uid, "chat_retention_days", "90")
@@ -148,6 +147,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
@@ -184,6 +186,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({
+2 -3
View File
@@ -4,7 +4,7 @@ import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.routes.utils import not_found, parse_pagination
from fabledassistant.services.milestones import (
create_milestone,
delete_milestone,
@@ -107,8 +107,7 @@ async def get_milestone_tasks_route(project_id: int, milestone_id: int):
if milestone is None or milestone.project_id != project_id:
return not_found("Milestone")
status_filter = request.args.get("status")
limit = min(request.args.get("limit", 100, type=int), 500)
offset = request.args.get("offset", 0, type=int)
limit, offset = parse_pagination(default_limit=100)
notes, total = await list_notes(
uid,
is_task=True,
+2 -3
View File
@@ -7,7 +7,7 @@ from fabledassistant.services.embeddings import upsert_note_embedding
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
from fabledassistant.config import Config
from fabledassistant.services.assist import build_assist_messages
from fabledassistant.services.generation_buffer import (
@@ -49,8 +49,7 @@ async def list_notes_route():
tag = request.args.getlist("tag")
sort = request.args.get("sort", "updated_at")
order = request.args.get("order", "desc")
limit = min(request.args.get("limit", 50, type=int), 500)
offset = request.args.get("offset", 0, type=int)
limit, offset = parse_pagination()
# Default to non-task notes only; ?is_task=true for tasks, ?all=true for everything
is_task: bool | None = False
+2 -3
View File
@@ -4,7 +4,7 @@ import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.routes.utils import not_found, parse_pagination
from fabledassistant.services.milestones import list_milestones
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import (
@@ -100,8 +100,7 @@ async def get_project_notes_route(project_id: int):
# type filter: "note", "task", or None (both)
type_filter = request.args.get("type")
status_filter = request.args.get("status")
limit = min(request.args.get("limit", 100, type=int), 500)
offset = request.args.get("offset", 0, type=int)
limit, offset = parse_pagination(default_limit=100)
is_task: bool | None = None
if type_filter == "task":
+22 -9
View File
@@ -4,7 +4,7 @@ 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.routes.utils import not_found, parse_iso_date, parse_pagination
from fabledassistant.services.embeddings import upsert_note_embedding
from fabledassistant.services.notes import (
create_note,
@@ -28,8 +28,7 @@ async def list_tasks_route():
priority = request.args.get("priority")
sort = request.args.get("sort", "updated_at")
order = request.args.get("order", "desc")
limit = min(request.args.get("limit", 50, type=int), 500)
offset = request.args.get("offset", 0, type=int)
limit, offset = parse_pagination()
project_id = request.args.get("project_id", type=int)
no_project = request.args.get("no_project", "").lower() == "true"
@@ -72,10 +71,14 @@ async def create_task_route():
if isinstance(due_date, tuple):
return due_date
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
priority = (
TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
)
try:
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
except ValueError:
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
try:
priority = TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
except ValueError:
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
project_id = data.get("project_id")
if project_id is None and data.get("project"):
@@ -118,15 +121,25 @@ async def get_task_route(task_id: int):
return jsonify(data)
@tasks_bp.route("/<int:task_id>", methods=["PUT"])
@tasks_bp.route("/<int:task_id>", methods=["PUT", "PATCH"])
@login_required
async def update_task_route(task_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
for key in ("title", "status", "priority"):
for key in ("title",):
if key in data:
fields[key] = data[key]
if "status" in data:
try:
fields["status"] = TaskStatus(data["status"]).value
except ValueError:
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
if "priority" in data:
try:
fields["priority"] = TaskPriority(data["priority"]).value
except ValueError:
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
# Accept both "body" and "description" (prefer body)
if "body" in data:
+8 -1
View File
@@ -1,6 +1,6 @@
from datetime import date
from quart import jsonify
from quart import jsonify, request
def not_found(resource: str = "Item"):
@@ -15,3 +15,10 @@ def parse_iso_date(value: str | None, field: str = "date"):
return date.fromisoformat(value)
except ValueError:
return jsonify({"error": f"Invalid {field} format. Use YYYY-MM-DD."}), 400
def parse_pagination(default_limit: int = 50, max_limit: int = 500) -> tuple[int, int]:
"""Extract and clamp ``limit`` / ``offset`` from the current request's query string."""
limit = min(request.args.get("limit", default_limit, type=int), max_limit)
offset = request.args.get("offset", 0, type=int)
return limit, offset
@@ -8,6 +8,7 @@ import asyncio
import hashlib
import logging
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import httpx
@@ -129,7 +130,13 @@ 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] = []
@@ -166,9 +173,9 @@ async def _gather_internal(user_id: int) -> dict:
calendar_events: list[str] = []
try:
from fabledassistant.services.events import list_events as list_internal_events
today_date = date.today()
day_start = datetime(today_date.year, today_date.month, today_date.day, 0, 0, 0)
day_end = datetime(today_date.year, today_date.month, today_date.day, 23, 59, 59)
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
)
@@ -176,7 +183,8 @@ async def _gather_internal(user_id: int) -> dict:
if e.all_day:
time_str = "all day"
elif e.start_dt:
time_str = e.start_dt.strftime("%-I:%M %p")
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}")
@@ -408,6 +416,18 @@ async def run_compilation(
max_items=10,
)
rss_item_ids = [item["id"] for item in filtered_rss if item.get("id")]
rss_items_meta = [
{
"id": item["id"],
"title": item.get("title", ""),
"url": item.get("url", ""),
"source": item.get("feed_title", ""),
"snippet": (item.get("content") or "")[:300],
"published_at": item.get("published_at"),
}
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
@@ -441,7 +461,7 @@ async def run_compilation(
# ── Post-processing ─────────────────────────────────────────────────────────
await upsert_task_snapshots(user_id, all_tasks)
metadata: dict = {"rss_item_ids": rss_item_ids, "weather": weather_card}
metadata: dict = {"rss_item_ids": rss_item_ids, "rss_items": rss_items_meta, "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)
@@ -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)
@@ -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
+12 -3
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}"
]
+31 -34
View File
@@ -16,6 +16,33 @@ logger = logging.getLogger(__name__)
VALID_PERMISSIONS = {"viewer", "editor", "admin"}
async def _enrich_shares(session, shares) -> list[dict]:
"""Attach username / group_name to a list of share rows (ProjectShare or NoteShare)."""
result = []
for s in shares:
d = s.to_dict()
if s.shared_with_user_id:
user = await session.get(User, s.shared_with_user_id)
d["username"] = user.username if user else None
if s.shared_with_group_id:
group = await session.get(Group, s.shared_with_group_id)
d["group_name"] = group.name if group else None
result.append(d)
return result
def _deduplicate_by_permission(shares, id_attr: str) -> dict[int, str]:
"""Return {resource_id: best_permission} keeping the highest-ranked permission per resource."""
from fabledassistant.services.access import PERMISSION_RANK
seen: dict[int, str] = {}
for share in shares:
rid = getattr(share, id_attr)
prev = seen.get(rid)
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen[rid] = share.permission
return seen
# ---------------------------------------------------------------------------
# Project shares
# ---------------------------------------------------------------------------
@@ -83,17 +110,7 @@ async def list_project_shares(project_id: int) -> list[dict]:
shares = (await session.execute(
select(ProjectShare).where(ProjectShare.project_id == project_id)
)).scalars().all()
result = []
for s in shares:
d = s.to_dict()
if s.shared_with_user_id:
user = await session.get(User, s.shared_with_user_id)
d["username"] = user.username if user else None
if s.shared_with_group_id:
group = await session.get(Group, s.shared_with_group_id)
d["group_name"] = group.name if group else None
result.append(d)
return result
return await _enrich_shares(session, shares)
# ---------------------------------------------------------------------------
@@ -166,17 +183,7 @@ async def list_note_shares(note_id: int) -> list[dict]:
shares = (await session.execute(
select(NoteShare).where(NoteShare.note_id == note_id)
)).scalars().all()
result = []
for s in shares:
d = s.to_dict()
if s.shared_with_user_id:
user = await session.get(User, s.shared_with_user_id)
d["username"] = user.username if user else None
if s.shared_with_group_id:
group = await session.get(Group, s.shared_with_group_id)
d["group_name"] = group.name if group else None
result.append(d)
return result
return await _enrich_shares(session, shares)
# ---------------------------------------------------------------------------
@@ -203,12 +210,7 @@ async def list_shared_with_me(user_id: int) -> dict:
)
)).scalars().all()
seen_projects: dict[int, str] = {}
for share in list(proj_direct) + list(proj_group):
prev = seen_projects.get(share.project_id)
from fabledassistant.services.access import PERMISSION_RANK
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen_projects[share.project_id] = share.permission
seen_projects = _deduplicate_by_permission(list(proj_direct) + list(proj_group), "project_id")
projects = []
for pid, perm in seen_projects.items():
@@ -239,12 +241,7 @@ async def list_shared_with_me(user_id: int) -> dict:
)
)).scalars().all()
seen_notes: dict[int, str] = {}
for share in list(note_direct) + list(note_group):
prev = seen_notes.get(share.note_id)
from fabledassistant.services.access import PERMISSION_RANK
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen_notes[share.note_id] = share.permission
seen_notes = _deduplicate_by_permission(list(note_direct) + list(note_group), "note_id")
notes = []
for nid, perm in seen_notes.items():