+
+
- {{ chat.title || "Untitled" }}
- {{ chat.message_count }} messages
-
+ {{ q }}
+
-
-
No chats yet.
+
+
+
+
+
+
{{ dashboardQuery }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ chatStore.streamingContent }}
+ ...
+
+
+
+ {{ dashboardFinalContent }}
+
+
+
+
+ {{ isConversational ? 'Continue this conversation →' : 'Continue in Chat →' }}
+
+ Clear
+
+
+
+
+
+
Recent
+
+
+ {{ chat.title || "Untitled" }}
+ {{ chat.message_count }} messages
+
+
-
@@ -387,6 +493,123 @@ async function onChatSubmit(payload: {
color: var(--color-text-muted);
white-space: nowrap;
}
+/* Quick actions */
+.quick-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-bottom: 0.75rem;
+}
+.quick-action-chip {
+ padding: 0.3rem 0.75rem;
+ border: 1px solid var(--color-border);
+ border-radius: 999px;
+ background: var(--color-bg-secondary);
+ color: var(--color-text);
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: border-color 0.15s, color 0.15s;
+}
+.quick-action-chip:hover:not(:disabled) {
+ border-color: var(--color-primary);
+ color: var(--color-primary);
+}
+.quick-action-chip:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+/* Inline response */
+.dashboard-response {
+ margin-top: 0.75rem;
+ padding: 0.75rem 1rem;
+ background: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+}
+.dashboard-response-query {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--color-text-muted);
+ margin-bottom: 0.5rem;
+}
+.dashboard-tool-calls {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ margin-bottom: 0.5rem;
+}
+.dashboard-response-text {
+ font-size: 0.9rem;
+ line-height: 1.55;
+ color: var(--color-text);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.dashboard-response-text.streaming {
+ color: var(--color-text-muted);
+}
+.thinking-dots {
+ display: inline-block;
+ animation: blink 1.2s infinite;
+}
+@keyframes blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+}
+.dashboard-response-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-top: 0.75rem;
+ padding-top: 0.5rem;
+ border-top: 1px solid var(--color-border);
+}
+.btn-open-chat {
+ font-size: 0.85rem;
+ color: var(--color-primary);
+ text-decoration: none;
+ font-weight: 500;
+}
+.btn-open-chat:hover {
+ text-decoration: underline;
+}
+.btn-open-chat.prominent {
+ background: var(--color-primary);
+ color: #fff;
+ padding: 0.35rem 0.85rem;
+ border-radius: var(--radius-sm);
+ font-size: 0.9rem;
+}
+.btn-open-chat.prominent:hover {
+ text-decoration: none;
+ opacity: 0.9;
+}
+.btn-clear {
+ font-size: 0.8rem;
+ color: var(--color-text-muted);
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+}
+.btn-clear:hover {
+ color: var(--color-text);
+}
+
+/* Recent chats below */
+.recent-chats {
+ margin-top: 1rem;
+}
+.recent-chats-label {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 0.5rem;
+}
+
.empty-state {
text-align: center;
padding: 1.5rem 0;
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index bd0ae28..14022b6 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -9,6 +9,7 @@ const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
const assistantName = ref("");
+const intentModel = ref("");
const currentPassword = ref("");
const newPassword = ref("");
const confirmNewPassword = ref("");
@@ -63,6 +64,7 @@ onMounted(async () => {
// Load notification preferences from user settings
const allSettings = await apiGet>("/api/settings");
+ intentModel.value = allSettings.intent_model ?? "";
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
@@ -126,7 +128,10 @@ async function saveAssistant() {
saving.value = true;
saved.value = false;
try {
- await store.updateSettings({ assistant_name: assistantName.value.trim() || "Fable" });
+ await store.updateSettings({
+ assistant_name: assistantName.value.trim() || "Fable",
+ intent_model: intentModel.value.trim(),
+ });
saved.value = true;
setTimeout(() => (saved.value = false), 2000);
} finally {
@@ -310,6 +315,22 @@ async function handleRestoreFile(event: Event) {
+
+
Intent Model
+
+
+ Optional smaller/faster model for intent routing (e.g. llama3.2:3b,
+ qwen2.5:3b). Leave empty to use the same model as chat.
+ Can also be set via OLLAMA_INTENT_MODEL environment variable.
+
+
+
{{ saving ? "Saving..." : "Save" }}
diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py
index 3f0825d..10bcb80 100644
--- a/src/fabledassistant/config.py
+++ b/src/fabledassistant/config.py
@@ -24,6 +24,9 @@ class Config:
)
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3")
+ # Optional dedicated model for intent classification (should be small/fast).
+ # Falls back to OLLAMA_MODEL if not set. Can also be overridden per-user via settings.
+ OLLAMA_INTENT_MODEL: str = os.environ.get("OLLAMA_INTENT_MODEL", "")
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
diff --git a/src/fabledassistant/services/caldav.py b/src/fabledassistant/services/caldav.py
index 5b6c235..3afb75b 100644
--- a/src/fabledassistant/services/caldav.py
+++ b/src/fabledassistant/services/caldav.py
@@ -3,6 +3,7 @@
import asyncio
import logging
from datetime import date as date_type, datetime, timedelta
+from zoneinfo import ZoneInfo
import caldav
import icalendar
@@ -11,7 +12,7 @@ from fabledassistant.services.settings import get_all_settings
logger = logging.getLogger(__name__)
-CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name"]
+CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name", "caldav_timezone"]
async def get_caldav_config(user_id: int) -> dict[str, str]:
@@ -41,6 +42,15 @@ def _get_calendar(client: caldav.DAVClient, calendar_name: str) -> caldav.Calend
return calendars[0]
+def _get_all_calendars(client: caldav.DAVClient) -> list[caldav.Calendar]:
+ """Get all calendars for the user (synchronous)."""
+ principal = client.principal()
+ calendars = principal.calendars()
+ if not calendars:
+ raise ValueError("No calendars found on the CalDAV server.")
+ return calendars
+
+
def _make_client(config: dict[str, str]) -> caldav.DAVClient:
"""Create a CalDAV client from config dict."""
return caldav.DAVClient(
@@ -58,15 +68,99 @@ def _parse_vevent(component) -> dict | None:
dtstart = component.get("DTSTART")
dtend = component.get("DTEND")
location = str(component.get("LOCATION", ""))
+ description = str(component.get("DESCRIPTION", ""))
+ uid = str(component.get("UID", ""))
start_str = dtstart.dt.isoformat() if dtstart else ""
end_str = dtend.dt.isoformat() if dtend else ""
- return {
+
+ result = {
+ "uid": uid,
"title": title,
"start": start_str,
"end": end_str,
"location": location,
+ "description": description,
}
+ # Extract recurrence rule
+ rrule = component.get("RRULE")
+ if rrule:
+ result["recurrence"] = rrule.to_ical().decode("utf-8")
+
+ # Extract alarms
+ alarms = []
+ for sub in component.subcomponents:
+ if sub.name == "VALARM":
+ trigger = sub.get("TRIGGER")
+ if trigger and trigger.dt:
+ minutes = abs(int(trigger.dt.total_seconds() // 60))
+ alarms.append({"minutes_before": minutes})
+ if alarms:
+ result["alarms"] = alarms
+
+ # Extract attendees
+ attendees = component.get("ATTENDEE")
+ if attendees:
+ if not isinstance(attendees, list):
+ attendees = [attendees]
+ result["attendees"] = [str(a).replace("mailto:", "") for a in attendees]
+
+ return result
+
+
+def _parse_vtodo(component) -> dict | None:
+ """Extract todo data from a VTODO component."""
+ if component.name != "VTODO":
+ return None
+ uid = str(component.get("UID", ""))
+ summary = str(component.get("SUMMARY", ""))
+ description = str(component.get("DESCRIPTION", ""))
+ status = str(component.get("STATUS", ""))
+ due = component.get("DUE")
+ due_str = due.dt.isoformat() if due else ""
+ priority = component.get("PRIORITY")
+ priority_val = int(priority) if priority else None
+
+ return {
+ "uid": uid,
+ "summary": summary,
+ "description": description,
+ "due": due_str,
+ "status": status,
+ "priority": priority_val,
+ }
+
+
+def _apply_timezone(dt: datetime, timezone: str | None) -> datetime:
+ """Apply a timezone to a naive datetime. Returns dt unchanged if already aware."""
+ if dt.tzinfo is not None:
+ return dt
+ if timezone:
+ return dt.replace(tzinfo=ZoneInfo(timezone))
+ return dt
+
+
+def _build_valarm(minutes_before: int) -> icalendar.Alarm:
+ """Create a DISPLAY alarm component triggered N minutes before the event."""
+ alarm = icalendar.Alarm()
+ alarm.add("action", "DISPLAY")
+ alarm.add("description", "Reminder")
+ alarm.add("trigger", timedelta(minutes=-minutes_before))
+ return alarm
+
+
+def _add_attendees(event: icalendar.Event, attendees: list[str]) -> None:
+ """Add mailto: attendees to an iCalendar event."""
+ for email in attendees:
+ attendee = icalendar.vCalAddress(f"mailto:{email}")
+ event.add("attendee", attendee)
+
+
+def _check_config(config: dict[str, str]) -> None:
+ """Raise if CalDAV is not configured."""
+ if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
+ raise ValueError("CalDAV is not configured. Go to Settings -> Calendar to set it up.")
+
async def create_event(
user_id: int,
@@ -78,6 +172,10 @@ async def create_event(
location: str | None = None,
all_day: bool = False,
recurrence: str | None = None,
+ timezone: str | None = None,
+ reminder_minutes: int | None = None,
+ attendees: list[str] | None = None,
+ calendar_name: str | None = None,
) -> dict:
"""Create a calendar event.
@@ -86,8 +184,9 @@ async def create_event(
recurrence is an iCalendar RRULE string (e.g. "FREQ=YEARLY").
"""
config = await get_caldav_config(user_id)
- if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
- raise ValueError("CalDAV is not configured. Go to Settings → Calendar to set it up.")
+ _check_config(config)
+
+ tz = timezone or config.get("caldav_timezone") or None
cal = icalendar.Calendar()
cal.add("prodid", "-//FabledAssistant//EN")
@@ -108,9 +207,9 @@ async def create_event(
result_start = d_start.isoformat()
result_end = d_end.isoformat()
else:
- dt_start = datetime.fromisoformat(start)
+ dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
if end:
- dt_end = datetime.fromisoformat(end)
+ dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
else:
dt_end = dt_start + timedelta(minutes=duration or 60)
event.add("dtstart", dt_start)
@@ -130,6 +229,10 @@ async def create_event(
key, value = part.split("=", 1)
rrule_parts[key.strip().lower()] = value.strip()
event.add("rrule", rrule_parts)
+ if reminder_minutes is not None:
+ event.add_component(_build_valarm(reminder_minutes))
+ if attendees:
+ _add_attendees(event, attendees)
cal.add_component(event)
@@ -137,7 +240,8 @@ async def create_event(
def _save():
client = _make_client(config)
- calendar = _get_calendar(client, config.get("caldav_calendar_name", ""))
+ cal_name = calendar_name or config.get("caldav_calendar_name", "")
+ calendar = _get_calendar(client, cal_name)
calendar.save_event(ical_str)
await asyncio.to_thread(_save)
@@ -154,18 +258,30 @@ async def create_event(
async def list_events(user_id: int, date_from: str, date_to: str) -> list[dict]:
- """List calendar events in a date range. Dates are ISO datetime strings."""
+ """List calendar events in a date range. Dates are ISO datetime strings.
+
+ Searches all calendars unless caldav_calendar_name is configured.
+ """
config = await get_caldav_config(user_id)
- if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
- raise ValueError("CalDAV is not configured. Go to Settings → Calendar to set it up.")
+ _check_config(config)
dt_from = datetime.fromisoformat(date_from)
dt_to = datetime.fromisoformat(date_to)
def _search():
client = _make_client(config)
- calendar = _get_calendar(client, config.get("caldav_calendar_name", ""))
- return calendar.date_search(dt_from, dt_to)
+ cal_name = config.get("caldav_calendar_name", "")
+ if cal_name:
+ calendars = [_get_calendar(client, cal_name)]
+ else:
+ calendars = _get_all_calendars(client)
+ all_results = []
+ for calendar in calendars:
+ try:
+ all_results.extend(calendar.date_search(dt_from, dt_to))
+ except Exception:
+ logger.warning("Failed to search calendar '%s'", getattr(calendar, 'name', '?'))
+ return all_results
results = await asyncio.to_thread(_search)
@@ -189,10 +305,315 @@ async def search_events(user_id: int, query: str, days_ahead: int = 90) -> list[
q = query.lower()
return [
e for e in all_events
- if q in e["title"].lower() or q in e.get("location", "").lower()
+ if q in e["title"].lower() or q in e.get("location", "").lower() or q in e.get("description", "").lower()
]
+async def update_event(
+ user_id: int,
+ query: str,
+ title: str | None = None,
+ start: str | None = None,
+ end: str | None = None,
+ description: str | None = None,
+ location: str | None = None,
+ timezone: str | None = None,
+ calendar_name: str | None = None,
+) -> dict:
+ """Update a calendar event matching the query."""
+ config = await get_caldav_config(user_id)
+ _check_config(config)
+ tz = timezone or config.get("caldav_timezone") or None
+
+ def _do_update():
+ client = _make_client(config)
+ cal_name = calendar_name or config.get("caldav_calendar_name", "")
+ now = datetime.now()
+ if cal_name:
+ calendars = [_get_calendar(client, cal_name)]
+ else:
+ calendars = _get_all_calendars(client)
+ results = []
+ for cal in calendars:
+ try:
+ results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365)))
+ except Exception:
+ logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?'))
+
+ q = query.lower()
+ matches = []
+ for r in results:
+ cal_obj = icalendar.Calendar.from_ical(r.data)
+ for component in cal_obj.walk():
+ if component.name == "VEVENT":
+ event_title = str(component.get("SUMMARY", ""))
+ if q in event_title.lower():
+ matches.append((r, component))
+
+ if not matches:
+ raise ValueError(f"No event found matching '{query}'.")
+ if len(matches) > 3:
+ titles = [str(m[1].get("SUMMARY", "")) for m in matches]
+ raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
+
+ event_obj, component = matches[0]
+ if title:
+ component["SUMMARY"] = title
+ if start:
+ dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
+ del component["DTSTART"]
+ component.add("dtstart", dt_start)
+ if end:
+ dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
+ if "DTEND" in component:
+ del component["DTEND"]
+ component.add("dtend", dt_end)
+ if description is not None:
+ if "DESCRIPTION" in component:
+ del component["DESCRIPTION"]
+ component.add("description", description)
+ if location is not None:
+ if "LOCATION" in component:
+ del component["LOCATION"]
+ component.add("location", location)
+
+ # Rebuild ical data and save
+ cal_data = icalendar.Calendar()
+ cal_data.add("prodid", "-//FabledAssistant//EN")
+ cal_data.add("version", "2.0")
+ cal_data.add_component(component)
+ event_obj.data = cal_data.to_ical().decode("utf-8")
+ event_obj.save()
+
+ return _parse_vevent(component)
+
+ return await asyncio.to_thread(_do_update)
+
+
+async def delete_event(
+ user_id: int,
+ query: str,
+ calendar_name: str | None = None,
+) -> dict:
+ """Delete a calendar event matching the query."""
+ config = await get_caldav_config(user_id)
+ _check_config(config)
+
+ def _do_delete():
+ client = _make_client(config)
+ cal_name = calendar_name or config.get("caldav_calendar_name", "")
+ now = datetime.now()
+ if cal_name:
+ calendars = [_get_calendar(client, cal_name)]
+ else:
+ calendars = _get_all_calendars(client)
+ results = []
+ for cal in calendars:
+ try:
+ results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365)))
+ except Exception:
+ logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?'))
+
+ q = query.lower()
+ matches = []
+ for r in results:
+ cal_obj = icalendar.Calendar.from_ical(r.data)
+ for component in cal_obj.walk():
+ if component.name == "VEVENT":
+ event_title = str(component.get("SUMMARY", ""))
+ if q in event_title.lower():
+ matches.append((r, component))
+
+ if not matches:
+ raise ValueError(f"No event found matching '{query}'.")
+ if len(matches) > 3:
+ titles = [str(m[1].get("SUMMARY", "")) for m in matches]
+ raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
+
+ event_obj, component = matches[0]
+ parsed = _parse_vevent(component)
+ event_obj.delete()
+ return parsed
+
+ return await asyncio.to_thread(_do_delete)
+
+
+async def list_calendars(user_id: int) -> list[dict]:
+ """List all calendars for the user."""
+ config = await get_caldav_config(user_id)
+ _check_config(config)
+
+ def _list():
+ client = _make_client(config)
+ principal = client.principal()
+ calendars = principal.calendars()
+ return [{"name": c.name, "url": str(c.url)} for c in calendars]
+
+ return await asyncio.to_thread(_list)
+
+
+async def create_todo(
+ user_id: int,
+ summary: str,
+ due: str | None = None,
+ description: str | None = None,
+ priority: int | None = None,
+ reminder_minutes: int | None = None,
+ timezone: str | None = None,
+ calendar_name: str | None = None,
+) -> dict:
+ """Create a CalDAV todo (VTODO)."""
+ config = await get_caldav_config(user_id)
+ _check_config(config)
+ tz = timezone or config.get("caldav_timezone") or None
+
+ def _create():
+ client = _make_client(config)
+ cal_name = calendar_name or config.get("caldav_calendar_name", "")
+ calendar = _get_calendar(client, cal_name)
+
+ kwargs = {"summary": summary}
+ if due:
+ dt_due = datetime.fromisoformat(due)
+ dt_due = _apply_timezone(dt_due, tz)
+ kwargs["due"] = dt_due
+
+ todo = calendar.save_todo(**kwargs)
+
+ # Modify component for extra fields
+ cal_obj = icalendar.Calendar.from_ical(todo.data)
+ modified = False
+ for component in cal_obj.walk():
+ if component.name == "VTODO":
+ if description:
+ component.add("description", description)
+ modified = True
+ if priority is not None:
+ component.add("priority", priority)
+ modified = True
+ if reminder_minutes is not None:
+ component.add_component(_build_valarm(reminder_minutes))
+ modified = True
+ if modified:
+ todo.data = cal_obj.to_ical().decode("utf-8")
+ todo.save()
+ return _parse_vtodo(component)
+
+ return {"summary": summary}
+
+ return await asyncio.to_thread(_create)
+
+
+async def list_todos(
+ user_id: int,
+ include_completed: bool = False,
+ calendar_name: str | None = None,
+) -> list[dict]:
+ """List CalDAV todos."""
+ config = await get_caldav_config(user_id)
+ _check_config(config)
+
+ def _list():
+ client = _make_client(config)
+ cal_name = calendar_name or config.get("caldav_calendar_name", "")
+ calendar = _get_calendar(client, cal_name)
+ todos = calendar.todos(include_completed=include_completed)
+ results = []
+ for t in todos:
+ cal_obj = icalendar.Calendar.from_ical(t.data)
+ for component in cal_obj.walk():
+ parsed = _parse_vtodo(component)
+ if parsed:
+ results.append(parsed)
+ return results
+
+ return await asyncio.to_thread(_list)
+
+
+async def complete_todo(
+ user_id: int,
+ query: str,
+ calendar_name: str | None = None,
+) -> dict:
+ """Complete a CalDAV todo matching the query."""
+ config = await get_caldav_config(user_id)
+ _check_config(config)
+
+ def _complete():
+ client = _make_client(config)
+ cal_name = calendar_name or config.get("caldav_calendar_name", "")
+ calendar = _get_calendar(client, cal_name)
+ todos = calendar.todos(include_completed=False)
+
+ q = query.lower()
+ matches = []
+ for t in todos:
+ cal_obj = icalendar.Calendar.from_ical(t.data)
+ for component in cal_obj.walk():
+ if component.name == "VTODO":
+ s = str(component.get("SUMMARY", ""))
+ if q in s.lower():
+ matches.append((t, component))
+
+ if not matches:
+ raise ValueError(f"No todo found matching '{query}'.")
+ if len(matches) > 3:
+ titles = [str(m[1].get("SUMMARY", "")) for m in matches]
+ raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
+
+ todo_obj, component = matches[0]
+ todo_obj.complete()
+
+ # Re-parse after completing
+ cal_obj = icalendar.Calendar.from_ical(todo_obj.data)
+ for comp in cal_obj.walk():
+ parsed = _parse_vtodo(comp)
+ if parsed:
+ return parsed
+ return {"summary": str(component.get("SUMMARY", "")), "status": "COMPLETED"}
+
+ return await asyncio.to_thread(_complete)
+
+
+async def delete_todo(
+ user_id: int,
+ query: str,
+ calendar_name: str | None = None,
+) -> dict:
+ """Delete a CalDAV todo matching the query."""
+ config = await get_caldav_config(user_id)
+ _check_config(config)
+
+ def _delete():
+ client = _make_client(config)
+ cal_name = calendar_name or config.get("caldav_calendar_name", "")
+ calendar = _get_calendar(client, cal_name)
+ todos = calendar.todos(include_completed=True)
+
+ q = query.lower()
+ matches = []
+ for t in todos:
+ cal_obj = icalendar.Calendar.from_ical(t.data)
+ for component in cal_obj.walk():
+ if component.name == "VTODO":
+ s = str(component.get("SUMMARY", ""))
+ if q in s.lower():
+ matches.append((t, component))
+
+ if not matches:
+ raise ValueError(f"No todo found matching '{query}'.")
+ if len(matches) > 3:
+ titles = [str(m[1].get("SUMMARY", "")) for m in matches]
+ raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
+
+ todo_obj, component = matches[0]
+ parsed = _parse_vtodo(component)
+ todo_obj.delete()
+ return parsed
+
+ return await asyncio.to_thread(_delete)
+
+
async def test_connection(user_id: int) -> dict:
"""Test the CalDAV connection and return status."""
config = await get_caldav_config(user_id)
diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py
index fc77896..5744f10 100644
--- a/src/fabledassistant/services/generation_task.py
+++ b/src/fabledassistant/services/generation_task.py
@@ -4,6 +4,7 @@ Streams from Ollama into a GenerationBuffer, periodically flushing to DB.
Runs independently of any HTTP connection.
"""
+import asyncio
import json
import logging
import re
@@ -11,12 +12,14 @@ import time
from sqlalchemy import update
+from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Message
from fabledassistant.services.generation_buffer import GenerationBuffer, GenerationState
from fabledassistant.services.llm import ChatChunk, generate_completion, stream_chat, stream_chat_with_tools
from fabledassistant.services.chat import update_conversation_title
from fabledassistant.services.intent import classify_intent
+from fabledassistant.services.settings import get_setting
from fabledassistant.services.tools import get_tools_for_user, execute_tool
logger = logging.getLogger(__name__)
@@ -92,9 +95,16 @@ async def run_generation(
last_flush = time.monotonic()
all_tool_calls: list[dict] = []
- # Resolve tools based on user's configured integrations
- tools = await get_tools_for_user(user_id)
- logger.info("Starting generation for conv %d: model=%s, tools=%d", conv_id, model, len(tools))
+ # Resolve tools and intent model in parallel
+ tools, intent_model_setting = await asyncio.gather(
+ get_tools_for_user(user_id),
+ get_setting(user_id, "intent_model", ""),
+ )
+ intent_model = intent_model_setting or Config.OLLAMA_INTENT_MODEL or model
+ logger.info(
+ "Starting generation for conv %d: model=%s, intent_model=%s, tools=%d",
+ conv_id, model, intent_model, len(tools),
+ )
try:
cancelled = False
@@ -105,9 +115,24 @@ async def run_generation(
# Intent routing — first round only
if _round == 0 and tools:
- intent = await classify_intent(user_content, tools, model)
- if intent.tool_name:
- logger.info("Intent router detected tool: %s(%s)", intent.tool_name, json.dumps(intent.arguments)[:200])
+ # Pass last 3 user/assistant pairs (6 messages) for anaphora resolution.
+ # messages = [system, *history, current_user] — exclude system and current user.
+ intent_history = [
+ m for m in messages[1:-1]
+ if m.get("role") in ("user", "assistant") and m.get("content")
+ ][-6:]
+ intent = await classify_intent(user_content, tools, intent_model, history=intent_history)
+ if intent.should_execute:
+ logger.info(
+ "Intent router detected tool (confidence=%s): %s(%s)",
+ intent.confidence, intent.tool_name, json.dumps(intent.arguments)[:200],
+ )
+ elif intent.tool_name:
+ logger.info(
+ "Intent router low confidence (%s) for tool=%s — falling through to streaming",
+ intent.confidence, intent.tool_name,
+ )
+ if intent.should_execute:
result = await execute_tool(user_id, intent.tool_name, intent.arguments)
logger.info("Intent-routed tool %s result: success=%s", intent.tool_name, result.get("success"))
diff --git a/src/fabledassistant/services/intent.py b/src/fabledassistant/services/intent.py
index c612023..8788ed6 100644
--- a/src/fabledassistant/services/intent.py
+++ b/src/fabledassistant/services/intent.py
@@ -21,6 +21,12 @@ logger = logging.getLogger(__name__)
class IntentResult:
tool_name: str | None = None # None = no tool, just chat
arguments: dict = field(default_factory=dict)
+ confidence: str = "high" # "high", "medium", or "low"
+
+ @property
+ def should_execute(self) -> bool:
+ """True if a tool was identified with sufficient confidence."""
+ return self.tool_name is not None and self.confidence != "low"
def _build_tool_summary(tools: list[dict]) -> str:
@@ -45,8 +51,9 @@ def _build_tool_summary(tools: list[dict]) -> str:
_SYSTEM_PROMPT_TEMPLATE = """\
-You are an intent classifier. Given a user message, decide whether it \
-requires calling one of the available tools or is just general chat.
+You are an intent classifier. Given a user message (and recent conversation \
+history for context), decide whether it requires calling one of the available \
+tools or is just general chat.
Today's date is {today}.
@@ -54,15 +61,33 @@ Available tools:
{tool_summary}
Respond with ONLY a JSON object, no other text:
-- If a tool should be called: {{"tool": "tool_name", "arguments": {{...}}}}
-- If it's general chat: {{"tool": null}}
+- If a tool should be called: {{"tool": "tool_name", "arguments": {{...}}, "confidence": "high"|"medium"|"low"}}
+- If it's general chat: {{"tool": null, "confidence": "high"}}
+
+Confidence levels:
+- "high": the intent is clear and all required arguments are unambiguous
+- "medium": probably requires the tool but some argument is uncertain or inferred
+- "low": uncertain whether this needs a tool at all, or the message is too ambiguous to act on
Rules:
+- Use recent conversation history to resolve references like "it", "that event", "the meeting", "move it", etc.
- For dates like "tomorrow", "next Friday", "in 3 days", resolve them to YYYY-MM-DD format.
- For datetime parameters, use ISO 8601 format (e.g. 2026-09-30T14:00:00).
- Only include arguments the user actually specified or that can be clearly inferred.
- Infer reasonable defaults: birthdays and holidays are all-day + yearly recurring; "weekly meeting" is weekly recurring.
- Use descriptive titles: "My Birthday" not just "Birthday", "Team Standup" not just "Meeting".
+- "add to", "update", "edit", "expand", "flesh out", "modify", "append to", "continue writing" a note → use update_note with query= and mode="append" for additions or mode="replace" for full rewrites.
+- If a note was created earlier in the conversation and the user provides more content for it, use update_note (not create_note).
+- Use create_note ONLY when the user explicitly wants a brand new note that doesn't already exist.
+- "update", "change", "move", "reschedule" an event → use update_event.
+- "delete", "cancel", "remove" an event → use delete_event.
+- "which calendars", "list calendars", "my calendars" → use list_calendars.
+- "create a calendar todo", "add a CalDAV todo" → use create_todo. Default to create_task for general tasks unless the user explicitly mentions CalDAV or calendar todo.
+- "show calendar todos", "list calendar todos" → use list_todos.
+- "completed/finished calendar todo" → use complete_todo.
+- "delete/remove calendar todo" → use delete_todo.
+- "remind me X minutes/hours before" an event → convert to reminder_minutes parameter on create_event.
+- When the user asks about events in a time period (e.g. "events in September", "what's on next week", "my schedule for March"), use list_events with date_from/date_to covering that period. Do NOT use search_events for time-based queries — search_events is only for keyword matching against event titles.
- Do NOT wrap the JSON in markdown code fences."""
@@ -70,10 +95,14 @@ async def classify_intent(
user_message: str,
tools: list[dict],
model: str,
+ history: list[dict] | None = None,
) -> IntentResult:
"""Classify user intent via a fast non-streaming LLM call.
- Returns an IntentResult. On any failure, returns IntentResult(tool_name=None)
+ history is a list of recent {role, content} messages (user/assistant only,
+ no system messages) for resolving anaphoric references.
+
+ Returns an IntentResult. On any failure, returns IntentResult(tool_name=None)
so the caller falls through to the normal streaming path.
"""
if not tools:
@@ -82,16 +111,25 @@ async def classify_intent(
tool_summary = _build_tool_summary(tools)
today = date_type.today().isoformat()
- messages = [
+ messages: list[dict] = [
{
"role": "system",
"content": _SYSTEM_PROMPT_TEMPLATE.format(
today=today, tool_summary=tool_summary
),
},
- {"role": "user", "content": user_message},
]
+ # Inject recent history turns so the model can resolve references
+ if history:
+ for turn in history:
+ role = turn.get("role")
+ content = turn.get("content", "")
+ if role in ("user", "assistant") and content:
+ messages.append({"role": role, "content": content})
+
+ messages.append({"role": "user", "content": user_message})
+
try:
raw = await generate_completion(messages, model)
except Exception:
@@ -124,8 +162,12 @@ def _parse_intent(raw: str, tools: list[dict]) -> IntentResult:
return IntentResult()
tool_name = parsed.get("tool")
+ confidence = parsed.get("confidence", "high")
+ if confidence not in ("high", "medium", "low"):
+ confidence = "high"
+
if tool_name is None:
- return IntentResult()
+ return IntentResult(confidence=confidence)
# Validate tool name against available tools
valid_names = {
@@ -139,8 +181,11 @@ def _parse_intent(raw: str, tools: list[dict]) -> IntentResult:
if not isinstance(arguments, dict):
arguments = {}
- logger.info("Intent classified: tool=%s, args=%s", tool_name, json.dumps(arguments)[:200])
- return IntentResult(tool_name=tool_name, arguments=arguments)
+ logger.info(
+ "Intent classified: tool=%s, confidence=%s, args=%s",
+ tool_name, confidence, json.dumps(arguments)[:200],
+ )
+ return IntentResult(tool_name=tool_name, arguments=arguments, confidence=confidence)
def _try_json(text: str) -> dict | list | None:
diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py
index 7acd39c..54049ed 100644
--- a/src/fabledassistant/services/llm.py
+++ b/src/fabledassistant/services/llm.py
@@ -250,12 +250,23 @@ async def build_context(
tool_lines = [
"You have access to tool functions. You MUST use them when the user asks you to create, add, find, schedule, or search for anything.",
"CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.",
- "Available actions: create_task, create_note, search_notes.",
+ "Available actions: create_task, create_note, update_note, search_notes.",
]
if has_caldav:
- tool_lines[-1] = "Available actions: create_task, create_note, search_notes, create_event, list_events, search_events."
+ tool_lines[-1] = (
+ "Available actions: create_task, create_note, update_note, search_notes, "
+ "create_event, list_events, search_events, update_event, delete_event, "
+ "list_calendars, create_todo, list_todos, complete_todo, delete_todo."
+ )
tool_lines.append("For calendar events, use ISO 8601 datetime format (e.g. 2026-09-30T00:00:00).")
+ tool_lines.append("CalDAV todos are separate from app tasks. Use create_task for app tasks, create_todo for CalDAV todos.")
+ 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(
+ "Use update_note to edit/expand an existing note. "
+ "Use create_note ONLY for genuinely new notes with a different title. "
+ "If a note was created earlier in the conversation and the user provides more content for it, use update_note."
+ )
tool_guidance = "\n".join(tool_lines)
system_parts = [
diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py
index ec1752a..eefa12e 100644
--- a/src/fabledassistant/services/tools.py
+++ b/src/fabledassistant/services/tools.py
@@ -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}"}
diff --git a/summary.md b/summary.md
index 59fb974..a289fc3 100644
--- a/summary.md
+++ b/summary.md
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
-2026-02-16 — Phase 9: Switch to qwen3, intent routing for reliable tool calling, all-day/recurring events
+2026-02-17 — Phase 10: CalDAV full lifecycle, update_note tool, dashboard inline streaming, keyboard shortcuts, intent router upgrades
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -265,9 +265,9 @@ fabledassistant/
│ │ ├── generation_buffer.py # In-memory SSE event buffer with cancel_event, reconnect support, auto-cleanup; supports chat (int keys) and assist (string keys)
│ │ ├── generation_task.py # Background asyncio tasks: run_generation (chat, DB flush, titles, intent routing + tool loop) + run_assist_generation (lightweight, no DB)
│ │ ├── intent.py # Intent routing: classify_intent() makes fast non-streaming LLM call to detect tool intent before streaming
-│ │ ├── tools.py # LLM tool definitions (create_task, create_note, search_notes, CalDAV events with all-day/recurrence) + execute_tool dispatcher
+│ │ ├── tools.py # LLM tool definitions (create_task, create_note, update_note, search_notes, full CalDAV suite) + execute_tool dispatcher
│ │ ├── tag_suggestions.py # LLM-powered tag suggestions: suggest_tags() builds prompt with existing tags, calls generate_completion, parses JSON response
-│ │ ├── caldav.py # CalDAV integration: create/list/search calendar events via caldav library, all-day + recurring event support (per-user config from settings)
+│ │ ├── caldav.py # CalDAV integration: full event lifecycle (create/list/search/update/delete), todos (create/list/complete/delete), list_calendars, timezone (ZoneInfo), reminders (VALARM), attendees, multi-calendar search
│ │ ├── settings.py # Settings CRUD with user_id isolation: get_setting, set_setting, set_settings_batch, get_all_settings
│ │ ├── logging.py # App logging: log_audit, log_usage, log_error, get_logs, get_log_stats, delete_old_logs, start_log_retention_loop
│ │ ├── email.py # SMTP email service: get_smtp_config, is_smtp_configured, send_email, send_test_email
@@ -289,6 +289,7 @@ fabledassistant/
│ │ └── client.ts # ApiError class, apiGet/apiPost/apiPut/apiPatch/apiDelete + apiSSEStream (fetch+ReadableStream), apiStreamPost (legacy), auto 401→login redirect
│ ├── composables/
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
+ │ │ ├── useShortcuts.ts # Shared showShortcuts ref + open/close/toggle helpers (used by App.vue + AppHeader.vue)
│ │ ├── useAutocomplete.ts # Legacy textarea autocomplete (replaced by Tiptap suggestion extensions)
│ │ └── useAssist.ts # AI Assist composable: section parsing, target selection, two-step POST+SSE streaming (apiPost → apiSSEStream), accept/reject; watches body ref for auto-sync
│ ├── stores/
@@ -522,6 +523,19 @@ When adding a new migration, follow these conventions:
- `due_before`/`due_after` query params on `/api/tasks` for date-range filtering
- Recent chats and recently edited notes sections
- All task sections hidden when empty; marking done removes from all lists
+- **Inline chat widget:** Submitting a message streams the response inline on the dashboard — no
+ navigation to `/chat`. Streaming tool calls shown live; final response captured from store after SSE.
+- **Quick action chips:** Pre-defined prompts above the chat input for common queries.
+- **Conversational detection (Option A):** When the response has no tool calls (pure text), the
+ "Continue in Chat" link becomes a prominent filled button labeled "Continue this conversation →",
+ signalling that the inline response is just a snippet of a longer exchange.
+- **Auto-focus:** Dashboard chat input gains focus on page mount (via `defineExpose({ focus })` on
+ `DashboardChatInput` and a template ref in `HomeView`).
+- **Keyboard shortcut — Escape in ChatView:** Cascade close: note picker → mobile sidebar → clear
+ textarea if non-empty → navigate to `/` home.
+- **Keyboard shortcuts overlay:** Press `?` (when not in a text input) or click the `?` button in
+ the nav bar to open a panel listing all shortcuts. Escape or click-outside closes it. State shared
+ via `useShortcuts` composable (`frontend/src/composables/useShortcuts.ts`).
### LLM Chat
- Ollama integration via async HTTP (httpx), default model qwen3 (better tool support than mistral), auto-pull on startup
@@ -540,21 +554,34 @@ When adding a new migration, follow these conventions:
- Ollama configured with `OLLAMA_MAX_LOADED_MODELS=2` and `OLLAMA_KEEP_ALIVE=30m`
- Timeout tuning: connect timeout 30s (cold model loading), warm timeout 300s, pull timeout 1800s
- SSE reconnection failure shows error toast instead of silently recovering
-- **LLM tool calling:** Models with tool support can create tasks, create notes, and search notes
- on behalf of the user during chat. Multi-round tool loop (max 5 rounds) allows the LLM to
- execute tools and then produce a natural language response incorporating results. Tool call
- results are persisted in message `tool_calls` JSONB column and rendered as compact ToolCallCard
- components (linked titles for created items, search result lists). SSE emits `tool_call` events
- for real-time rendering during streaming. System prompt includes today's date for relative date
- resolution. Graceful degradation: models without tool support respond normally.
+- **LLM tool calling:** Models with tool support can create tasks, create/update notes, search notes,
+ and manage CalDAV events/todos on behalf of the user during chat. Multi-round tool loop (max 5 rounds)
+ allows the LLM to execute tools and then produce a natural language response incorporating results.
+ Tool call results are persisted in message `tool_calls` JSONB column and rendered as compact
+ ToolCallCard components (linked titles for created items, search result lists, event cards, todo cards).
+ SSE emits `tool_call` events for real-time rendering during streaming. System prompt includes today's
+ date for relative date resolution. Graceful degradation: models without tool support respond normally.
+ `update_note` tool finds existing notes by exact title (falling back to fuzzy search) and supports
+ `replace` (overwrite body) or `append` (add to existing body) modes — prevents duplicate note creation
+ when the user provides follow-up content for an already-created note.
- **Intent routing:** Before streaming, a fast non-streaming LLM call classifies user intent and
extracts tool parameters (`services/intent.py`). If a tool call is detected, it executes directly
— bypassing the model's native (sometimes unreliable) tool calling API. Falls through to normal
streaming when no tool is detected or classification fails. Only runs on first round of tool loop.
-- **CalDAV calendar integration:** Per-user CalDAV settings (URL, username, password, calendar name).
- LLM tools: `create_event` (with `all_day` and `recurrence` support), `list_events`, `search_events`.
+ Supports confidence levels ("high"/"medium"/"low") — low-confidence intents fall through to streaming.
+ Passes last 6 user/assistant turns as history for anaphora resolution ("move it", "cancel that").
+ Dedicated intent model configurable via `OLLAMA_INTENT_MODEL` env var or per-user `intent_model`
+ setting — allows a smaller/faster model for routing while the main model handles responses.
+ Intent router rules cover: update/delete events, CalDAV todos, time-period → list_events (not
+ search_events), update_note vs create_note disambiguation, reminder_minutes conversion.
+- **CalDAV calendar integration:** Per-user CalDAV settings (URL, username, password, calendar name, timezone).
+ LLM tools: `create_event` (all_day, recurrence, timezone, reminder_minutes, attendees, calendar_name),
+ `list_events`, `search_events`, `update_event`, `delete_event`, `list_calendars`,
+ `create_todo`, `list_todos`, `complete_todo`, `delete_todo`.
All-day events use iCalendar DATE values; recurrence uses RRULE (e.g. `FREQ=YEARLY`).
- Runs synchronous caldav library calls in asyncio executor. Settings UI for CalDAV configuration.
+ VALARM components added for reminders. Attendees via mailto: vCalAddress.
+ Multi-calendar search: when no specific calendar configured, all calendars are scanned.
+ Runs synchronous caldav library calls in asyncio executor. Settings UI for CalDAV config including timezone.
- **LLM-suggested tags:** Backend service (`tag_suggestions.py`) prompts LLM with existing user tags
and note content, returns 3-5 relevant tag suggestions. Tags already in body are filtered out.
Exposed via `POST /api/notes/suggest-tags` and `POST /api/notes/:id/append-tag`. Integrated in: