Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7888788d42 | |||
| 7a12cba4d5 | |||
| 78791175a1 | |||
| 2a1644e571 | |||
| bc5f1679d5 | |||
| 22634aa0c9 | |||
| 699e525cb9 | |||
| 2b2e5c666a | |||
| 26a8fb5c51 | |||
| 3431719ff3 | |||
| 916cfa50df | |||
| 190664366d | |||
| 08d738ddfb | |||
| a63e498067 | |||
| 48d1d9e64f | |||
| 538b67e57d | |||
| 383a4430f1 |
+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
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -580,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}`)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -521,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() {
|
||||
|
||||
@@ -13,6 +13,7 @@ class TaskStatus(str, enum.Enum):
|
||||
todo = "todo"
|
||||
in_progress = "in_progress"
|
||||
done = "done"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class TaskPriority(str, enum.Enum):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user