Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 190664366d | |||
| 08d738ddfb | |||
| a63e498067 | |||
| 48d1d9e64f | |||
| 538b67e57d | |||
| 383a4430f1 | |||
| 4aacd093e5 | |||
| aab478359b | |||
| 6214666942 | |||
| 62dbb8d496 | |||
| fd05c65018 |
+1
-1
@@ -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
|
||||
|
||||
|
||||
+255
-34
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,17 @@ function escapeHtmlAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Decode HTML entities that marked introduces before we re-escape for our own output.
|
||||
// Order matters: & must be last to avoid double-decoding.
|
||||
function decodeHtmlEntities(s: string): string {
|
||||
return s
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/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>`;
|
||||
});
|
||||
|
||||
@@ -189,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>
|
||||
|
||||
|
||||
@@ -82,6 +82,43 @@ 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 () => {
|
||||
@@ -135,6 +172,8 @@ onMounted(async () => {
|
||||
// Focus chat input after data loads
|
||||
chatInputRef.value?.focus();
|
||||
loadProjects();
|
||||
|
||||
_refreshTimer = setInterval(_backgroundRefresh, 90_000)
|
||||
});
|
||||
|
||||
async function loadProjects() {
|
||||
@@ -200,6 +239,7 @@ onMounted(() => {
|
||||
});
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("shortcut:focus-chat", onFocusChatShortcut);
|
||||
if (_refreshTimer !== null) clearInterval(_refreshTimer)
|
||||
});
|
||||
|
||||
const chatStore = useChatStore();
|
||||
|
||||
@@ -258,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);
|
||||
@@ -284,10 +283,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'] ?? '[]');
|
||||
@@ -1694,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>
|
||||
@@ -1747,8 +1713,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 +3279,6 @@ FABLE_API_KEY=<your-api-key></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;
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -148,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
|
||||
@@ -184,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({
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user