Phase 10: CalDAV full lifecycle, update_note, dashboard inline streaming, keyboard shortcuts
Backend:
- caldav.py: Full event lifecycle — update_event, delete_event; VTODO suite —
create_todo, list_todos, complete_todo, delete_todo; list_calendars; timezone
support via ZoneInfo; reminders via VALARM; attendees; multi-calendar search
(_get_all_calendars scans all calendars when no specific one is configured)
- tools.py: New update_note tool (find by title + replace/append modes),
7 new CalDAV tool definitions, corresponding execute_tool cases
- llm.py: Update system prompt — add update_note guidance, full CalDAV action list
- intent.py: Confidence scoring (high/medium/low) + should_execute property;
conversation history support for anaphora resolution; routing rules for
update/delete events, todos, update_note vs create_note disambiguation,
time-period → list_events (not search_events), reminder_minutes conversion
- generation_task.py: Parallel fetch of tools + intent_model setting; dedicated
intent model (OLLAMA_INTENT_MODEL env var or per-user intent_model setting)
- config.py: Add OLLAMA_INTENT_MODEL env var
Frontend:
- HomeView.vue: Inline streaming response (no navigation); quick action chips;
isConversational computed — prominent "Continue this conversation" CTA when
no tool calls; auto-focus chat input on mount via chatInputRef
- DashboardChatInput.vue: defineExpose({ focus }) for external focus control
- ChatView.vue: Escape key handler — close picker → close sidebar → clear
textarea → navigate home; onUnmounted cleanup
- App.vue: Global ? key shortcut toggles keyboard shortcuts overlay; shared
state via useShortcuts composable; Transition animation
- AppHeader.vue: ? button for shortcuts overlay discoverability
- useShortcuts.ts (new): Shared showShortcuts ref + open/close/toggle helpers
- ToolCallCard.vue: note_updated, event_updated, event_deleted, calendars,
todo, todos, todo_completed, todo_deleted label cases + render blocks
- SettingsView.vue: Intent model field + caldav_timezone setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,12 +4,19 @@ import logging
|
||||
from datetime import date, datetime
|
||||
|
||||
from fabledassistant.services.caldav import (
|
||||
complete_todo,
|
||||
create_event,
|
||||
create_todo,
|
||||
delete_event,
|
||||
delete_todo,
|
||||
is_caldav_configured,
|
||||
list_calendars,
|
||||
list_events,
|
||||
list_todos,
|
||||
search_events,
|
||||
update_event,
|
||||
)
|
||||
from fabledassistant.services.notes import create_note, list_notes
|
||||
from fabledassistant.services.notes import create_note, get_note_by_title, list_notes, update_note
|
||||
from fabledassistant.services.tag_suggestions import suggest_tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -67,6 +74,43 @@ _CORE_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_note",
|
||||
"description": (
|
||||
"Update an existing note's content or title. "
|
||||
"Use this when the user asks to add to, edit, expand, flesh out, or modify a note that already exists. "
|
||||
"NEVER use create_note when updating an existing note — always use update_note."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Title or keyword to find the note to update",
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "New note content in markdown",
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Optional new title for the note",
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["replace", "append"],
|
||||
"description": (
|
||||
"How to apply the new body: 'replace' overwrites the existing content (default), "
|
||||
"'append' adds the new content after the existing content"
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["query", "body"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -128,6 +172,23 @@ _CALDAV_TOOLS = [
|
||||
"type": "string",
|
||||
"description": "Optional iCalendar RRULE (e.g. 'FREQ=YEARLY' for annual, 'FREQ=WEEKLY' for weekly, 'FREQ=MONTHLY' for monthly)",
|
||||
},
|
||||
"reminder_minutes": {
|
||||
"type": "integer",
|
||||
"description": "Optional reminder N minutes before the event (e.g. 30 for 30 minutes before)",
|
||||
},
|
||||
"attendees": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of attendee email addresses",
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "Optional IANA timezone (e.g. 'America/New_York'). Falls back to user's caldav_timezone setting.",
|
||||
},
|
||||
"calendar_name": {
|
||||
"type": "string",
|
||||
"description": "Optional calendar name to create the event in. Falls back to default calendar.",
|
||||
},
|
||||
},
|
||||
"required": ["title", "start"],
|
||||
},
|
||||
@@ -164,7 +225,187 @@ _CALDAV_TOOLS = [
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search keyword to match against event titles and locations",
|
||||
"description": "Search keyword to match against event titles, locations, and descriptions",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_event",
|
||||
"description": "Update an existing calendar event. Use this when the user asks to change, move, reschedule, or modify an event.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search term to find the event to update (matches against title)",
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "New title for the event",
|
||||
},
|
||||
"start": {
|
||||
"type": "string",
|
||||
"description": "New start datetime in ISO 8601 format",
|
||||
},
|
||||
"end": {
|
||||
"type": "string",
|
||||
"description": "New end datetime in ISO 8601 format",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "New event description",
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "New event location",
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "Optional IANA timezone (e.g. 'America/New_York')",
|
||||
},
|
||||
"calendar_name": {
|
||||
"type": "string",
|
||||
"description": "Optional calendar name to search in",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "delete_event",
|
||||
"description": "Delete a calendar event. Use this when the user asks to cancel, remove, or delete an event.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search term to find the event to delete (matches against title)",
|
||||
},
|
||||
"calendar_name": {
|
||||
"type": "string",
|
||||
"description": "Optional calendar name to search in",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_calendars",
|
||||
"description": "List all available calendars. Use this when the user asks which calendars they have.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_todo",
|
||||
"description": "Create a CalDAV todo item on the calendar server. Use this when the user explicitly asks to create a calendar todo or CalDAV todo (NOT for regular app tasks).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "The todo summary/title",
|
||||
},
|
||||
"due": {
|
||||
"type": "string",
|
||||
"description": "Optional due date/datetime in ISO 8601 format",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional description",
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer",
|
||||
"description": "Optional priority (1=highest, 9=lowest)",
|
||||
},
|
||||
"reminder_minutes": {
|
||||
"type": "integer",
|
||||
"description": "Optional reminder N minutes before due date",
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "Optional IANA timezone (e.g. 'America/New_York')",
|
||||
},
|
||||
"calendar_name": {
|
||||
"type": "string",
|
||||
"description": "Optional calendar name",
|
||||
},
|
||||
},
|
||||
"required": ["summary"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_todos",
|
||||
"description": "List CalDAV todos from the calendar server. Use this when the user asks to see their calendar todos.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"include_completed": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to include completed todos (default false)",
|
||||
},
|
||||
"calendar_name": {
|
||||
"type": "string",
|
||||
"description": "Optional calendar name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "complete_todo",
|
||||
"description": "Mark a CalDAV todo as completed. Use this when the user says they finished or completed a calendar todo.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search term to find the todo to complete (matches against summary)",
|
||||
},
|
||||
"calendar_name": {
|
||||
"type": "string",
|
||||
"description": "Optional calendar name",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "delete_todo",
|
||||
"description": "Delete a CalDAV todo. Use this when the user asks to remove or delete a calendar todo.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search term to find the todo to delete (matches against summary)",
|
||||
},
|
||||
"calendar_name": {
|
||||
"type": "string",
|
||||
"description": "Optional calendar name",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
@@ -241,6 +482,48 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"suggested_tags": suggested,
|
||||
}
|
||||
|
||||
elif tool_name == "update_note":
|
||||
query = arguments.get("query", "")
|
||||
new_body = arguments.get("body", "")
|
||||
new_title = arguments.get("title")
|
||||
mode = arguments.get("mode", "replace")
|
||||
|
||||
# Find the note: exact title match first, then fuzzy search
|
||||
note = await get_note_by_title(user_id, query)
|
||||
if note is None:
|
||||
notes, _ = await list_notes(user_id=user_id, q=query, limit=5)
|
||||
# Prefer notes (not tasks) when there are mixed results
|
||||
note_only = [n for n in notes if n.status is None]
|
||||
candidates = note_only or notes
|
||||
if not candidates:
|
||||
return {"success": False, "error": f"No note found matching '{query}'."}
|
||||
note = candidates[0]
|
||||
|
||||
# Build the update payload
|
||||
update_fields: dict = {}
|
||||
if new_title:
|
||||
update_fields["title"] = new_title
|
||||
if new_body:
|
||||
if mode == "append" and note.body:
|
||||
update_fields["body"] = note.body + "\n\n" + new_body
|
||||
else:
|
||||
update_fields["body"] = new_body
|
||||
|
||||
updated = await update_note(user_id, note.id, **update_fields)
|
||||
if updated is None:
|
||||
return {"success": False, "error": "Failed to update note."}
|
||||
|
||||
suggested = await suggest_tags(user_id, updated.title, updated.body or "")
|
||||
return {
|
||||
"success": True,
|
||||
"type": "note_updated",
|
||||
"data": {
|
||||
"id": updated.id,
|
||||
"title": updated.title,
|
||||
},
|
||||
"suggested_tags": suggested,
|
||||
}
|
||||
|
||||
elif tool_name == "search_notes":
|
||||
query = arguments.get("query", "")
|
||||
notes, total = await list_notes(user_id=user_id, q=query, limit=5)
|
||||
@@ -274,6 +557,10 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
location=arguments.get("location"),
|
||||
all_day=arguments.get("all_day", False),
|
||||
recurrence=arguments.get("recurrence"),
|
||||
timezone=arguments.get("timezone"),
|
||||
reminder_minutes=arguments.get("reminder_minutes"),
|
||||
attendees=arguments.get("attendees"),
|
||||
calendar_name=arguments.get("calendar_name"),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
@@ -311,6 +598,103 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
},
|
||||
}
|
||||
|
||||
elif tool_name == "update_event":
|
||||
result = await update_event(
|
||||
user_id=user_id,
|
||||
query=arguments["query"],
|
||||
title=arguments.get("title"),
|
||||
start=arguments.get("start"),
|
||||
end=arguments.get("end"),
|
||||
description=arguments.get("description"),
|
||||
location=arguments.get("location"),
|
||||
timezone=arguments.get("timezone"),
|
||||
calendar_name=arguments.get("calendar_name"),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "event_updated",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
elif tool_name == "delete_event":
|
||||
result = await delete_event(
|
||||
user_id=user_id,
|
||||
query=arguments["query"],
|
||||
calendar_name=arguments.get("calendar_name"),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "event_deleted",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
elif tool_name == "list_calendars":
|
||||
calendars = await list_calendars(user_id=user_id)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "calendars",
|
||||
"data": {
|
||||
"count": len(calendars),
|
||||
"calendars": calendars,
|
||||
},
|
||||
}
|
||||
|
||||
elif tool_name == "create_todo":
|
||||
result = await create_todo(
|
||||
user_id=user_id,
|
||||
summary=arguments.get("summary", "Untitled Todo"),
|
||||
due=arguments.get("due"),
|
||||
description=arguments.get("description"),
|
||||
priority=arguments.get("priority"),
|
||||
reminder_minutes=arguments.get("reminder_minutes"),
|
||||
timezone=arguments.get("timezone"),
|
||||
calendar_name=arguments.get("calendar_name"),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "todo",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
elif tool_name == "list_todos":
|
||||
todos = await list_todos(
|
||||
user_id=user_id,
|
||||
include_completed=arguments.get("include_completed", False),
|
||||
calendar_name=arguments.get("calendar_name"),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "todos",
|
||||
"data": {
|
||||
"count": len(todos),
|
||||
"todos": todos,
|
||||
},
|
||||
}
|
||||
|
||||
elif tool_name == "complete_todo":
|
||||
result = await complete_todo(
|
||||
user_id=user_id,
|
||||
query=arguments["query"],
|
||||
calendar_name=arguments.get("calendar_name"),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "todo_completed",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
elif tool_name == "delete_todo":
|
||||
result = await delete_todo(
|
||||
user_id=user_id,
|
||||
query=arguments["query"],
|
||||
calendar_name=arguments.get("calendar_name"),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "todo_deleted",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown tool: {tool_name}"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user