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:
2026-02-17 22:04:41 -05:00
parent 75560dee4e
commit 70cba72a80
15 changed files with 1569 additions and 71 deletions
@@ -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"))