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
+165
View File
@@ -3,6 +3,7 @@ import { onMounted, onUnmounted, watch } from "vue";
import AppHeader from "@/components/AppHeader.vue"; import AppHeader from "@/components/AppHeader.vue";
import ToastNotification from "@/components/ToastNotification.vue"; import ToastNotification from "@/components/ToastNotification.vue";
import { useTheme } from "@/composables/useTheme"; import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
@@ -12,6 +13,7 @@ useTheme();
const authStore = useAuthStore(); const authStore = useAuthStore();
const chatStore = useChatStore(); const chatStore = useChatStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
function startAppServices() { function startAppServices() {
chatStore.startStatusPolling(); chatStore.startStatusPolling();
@@ -22,7 +24,28 @@ function stopAppServices() {
chatStore.stopStatusPolling(); chatStore.stopStatusPolling();
} }
function isInputActive(): boolean {
const el = document.activeElement;
if (!el) return false;
const tag = (el as HTMLElement).tagName;
return tag === "INPUT" || tag === "TEXTAREA" || (el as HTMLElement).isContentEditable;
}
function onGlobalKeydown(e: KeyboardEvent) {
if (!authStore.isAuthenticated) return;
// ? — toggle shortcuts overlay (only when not typing)
if (e.key === "?" && !isInputActive() && !e.ctrlKey && !e.metaKey) {
toggleShortcuts();
return;
}
// Escape — close shortcuts overlay
if (e.key === "Escape" && showShortcuts.value) {
closeShortcuts();
}
}
onMounted(async () => { onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown);
await authStore.checkAuth(); await authStore.checkAuth();
if (authStore.isAuthenticated) { if (authStore.isAuthenticated) {
startAppServices(); startAppServices();
@@ -41,6 +64,7 @@ watch(
); );
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("keydown", onGlobalKeydown);
stopAppServices(); stopAppServices();
}); });
</script> </script>
@@ -53,6 +77,48 @@ onUnmounted(() => {
<router-view /> <router-view />
</div> </div>
</div> </div>
<!-- Keyboard shortcuts overlay -->
<Transition name="shortcuts-fade">
<div v-if="showShortcuts" class="shortcuts-overlay" @click.self="closeShortcuts">
<div class="shortcuts-panel">
<div class="shortcuts-header">
<h3>Keyboard Shortcuts</h3>
<button class="shortcuts-close" @click="closeShortcuts">&times;</button>
</div>
<div class="shortcuts-body">
<div class="shortcuts-section">
<div class="shortcuts-section-title">Navigation</div>
<div class="shortcut-row">
<span class="shortcut-key">Esc</span>
<span class="shortcut-desc">Go to home / close dialog</span>
</div>
<div class="shortcut-row">
<span class="shortcut-key">?</span>
<span class="shortcut-desc">Toggle this shortcuts panel</span>
</div>
</div>
<div class="shortcuts-section">
<div class="shortcuts-section-title">Chat</div>
<div class="shortcut-row">
<kbd class="shortcut-key">Enter</kbd>
<span class="shortcut-desc">Send message</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">Shift</kbd>
<span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">Enter</kbd>
<span class="shortcut-desc">New line in message</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">Esc</kbd>
<span class="shortcut-desc">Clear input / go home</span>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template> </template>
<template v-else> <template v-else>
<router-view /> <router-view />
@@ -76,4 +142,103 @@ onUnmounted(() => {
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
/* Shortcuts overlay */
.shortcuts-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
}
.shortcuts-panel {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 8px);
box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2));
width: min(420px, 92vw);
overflow: hidden;
}
.shortcuts-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.shortcuts-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
}
.shortcuts-close {
background: none;
border: none;
font-size: 1.4rem;
line-height: 1;
color: var(--color-text-muted);
cursor: pointer;
padding: 0 0.25rem;
}
.shortcuts-close:hover {
color: var(--color-text);
}
.shortcuts-body {
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.shortcuts-section-title {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
margin-bottom: 0.4rem;
}
.shortcut-row {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0;
}
.shortcut-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.8rem;
padding: 0.15rem 0.4rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-bottom-width: 2px;
border-radius: 4px;
font-size: 0.78rem;
font-family: ui-monospace, monospace;
color: var(--color-text);
white-space: nowrap;
user-select: none;
}
.shortcut-key-sep {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.shortcut-desc {
font-size: 0.875rem;
color: var(--color-text);
margin-left: 0.25rem;
}
/* Transition */
.shortcuts-fade-enter-active,
.shortcuts-fade-leave-active {
transition: opacity 0.15s ease;
}
.shortcuts-fade-enter-from,
.shortcuts-fade-leave-to {
opacity: 0;
}
</style> </style>
+20
View File
@@ -2,11 +2,13 @@
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useTheme } from "@/composables/useTheme"; import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
import AppLogo from "@/components/AppLogo.vue"; import AppLogo from "@/components/AppLogo.vue";
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
const authStore = useAuthStore(); const authStore = useAuthStore();
const chatStore = useChatStore(); const chatStore = useChatStore();
const router = useRouter(); const router = useRouter();
@@ -66,6 +68,9 @@ router.afterEach(() => {
<span class="status-indicator" :class="statusClass" :title="statusLabel"> <span class="status-indicator" :class="statusClass" :title="statusLabel">
<span class="status-dot"></span> <span class="status-dot"></span>
</span> </span>
<button class="btn-shortcuts" @click="toggleShortcuts" title="Keyboard shortcuts (?)">
?
</button>
<button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`"> <button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
{{ theme === "dark" ? "\u2600" : "\u263E" }} {{ theme === "dark" ? "\u2600" : "\u263E" }}
</button> </button>
@@ -164,6 +169,21 @@ router.afterEach(() => {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.4; } 50% { opacity: 0.4; }
} }
.btn-shortcuts {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.25rem 0.55rem;
cursor: pointer;
font-size: 0.9rem;
font-weight: 700;
color: var(--color-text-muted);
line-height: 1;
}
.btn-shortcuts:hover {
background: var(--color-bg-card);
color: var(--color-text);
}
.theme-toggle { .theme-toggle {
background: none; background: none;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -101,6 +101,12 @@ function selectNote(note: { id: number; title: string }) {
function removeAttachedNote() { function removeAttachedNote() {
attachedNote.value = null; attachedNote.value = null;
} }
function focus() {
inputEl.value?.focus();
}
defineExpose({ focus });
</script> </script>
<template> <template>
+107
View File
@@ -17,12 +17,28 @@ const label = computed(() => {
return "Created task"; return "Created task";
case "note": case "note":
return "Created note"; return "Created note";
case "note_updated":
return "Updated note";
case "search": case "search":
return "Searched notes"; return "Searched notes";
case "event": case "event":
return "Created event"; return "Created event";
case "events": case "events":
return "Found events"; return "Found events";
case "event_updated":
return "Updated event";
case "event_deleted":
return "Deleted event";
case "calendars":
return "Calendars";
case "todo":
return "Created todo";
case "todos":
return "Calendar todos";
case "todo_completed":
return "Completed todo";
case "todo_deleted":
return "Deleted todo";
default: default:
return "Tool call"; return "Tool call";
} }
@@ -57,6 +73,45 @@ const eventData = computed(() => {
return data as { title: string; start: string; end: string }; return data as { title: string; start: string; end: string };
}); });
const updatedEvent = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "event_updated") return null;
return data as { title: string; start: string; end: string };
});
const deletedEvent = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "event_deleted") return null;
return data as { title: string };
});
const calendarList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "calendars") return null;
const calendars = data.calendars as Array<{ name: string }> | undefined;
return calendars?.map((c) => c.name) ?? [];
});
const todoData = computed(() => {
const data = props.toolCall.result.data;
const type = props.toolCall.result.type;
if (!data || (type !== "todo" && type !== "todo_completed" && type !== "todo_deleted")) return null;
return data as { summary: string; due?: string; status?: string };
});
const todoList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "todos") return null;
const todos = data.todos as Array<{ summary: string; due?: string; status?: string }> | undefined;
return todos ?? [];
});
const todoCount = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "todos") return 0;
return (data.count as number) ?? 0;
});
const eventList = computed(() => { const eventList = computed(() => {
const data = props.toolCall.result.data; const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "events") return null; if (!data || props.toolCall.result.type !== "events") return null;
@@ -130,6 +185,36 @@ async function applyTag(tag: string) {
<span class="tool-event-title">{{ eventData.title }}</span> <span class="tool-event-title">{{ eventData.title }}</span>
<span class="tool-event-time">{{ formatEventTime(eventData.start) }}</span> <span class="tool-event-time">{{ formatEventTime(eventData.start) }}</span>
</template> </template>
<template v-else-if="updatedEvent">
<span class="tool-event-title">{{ updatedEvent.title }}</span>
<span class="tool-event-time">{{ formatEventTime(updatedEvent.start) }}</span>
</template>
<template v-else-if="deletedEvent">
<span class="tool-deleted">{{ deletedEvent.title }}</span>
</template>
<template v-else-if="calendarList !== null">
<div class="tool-calendar-list">
<span v-for="name in calendarList" :key="name" class="tool-calendar-name">{{ name }}</span>
</div>
</template>
<template v-else-if="todoData">
<span :class="{ 'tool-deleted': toolCall.result.type === 'todo_deleted', 'tool-completed': toolCall.result.type === 'todo_completed' }">
{{ todoData.summary }}
</span>
<span v-if="todoData.due" class="tool-event-time">{{ formatEventTime(todoData.due) }}</span>
</template>
<template v-else-if="todoList !== null">
<span class="tool-search-info">{{ todoCount }} found</span>
<div v-if="todoList.length > 0" class="tool-event-list">
<div v-for="(t, i) in todoList.slice(0, 5)" :key="i" class="tool-event-item">
<span class="tool-event-item-title">{{ t.summary }}</span>
<span v-if="t.due" class="tool-event-item-time">{{ formatEventTime(t.due) }}</span>
</div>
<div v-if="todoList.length > 5" class="tool-event-more">
+{{ todoList.length - 5 }} more
</div>
</div>
</template>
<template v-else-if="eventList !== null"> <template v-else-if="eventList !== null">
<span class="tool-search-info">{{ eventCount }} found</span> <span class="tool-search-info">{{ eventCount }} found</span>
<div v-if="eventList.length > 0" class="tool-event-list"> <div v-if="eventList.length > 0" class="tool-event-list">
@@ -252,6 +337,28 @@ async function applyTag(tag: string) {
font-size: 0.75rem; font-size: 0.75rem;
font-style: italic; font-style: italic;
} }
.tool-deleted {
text-decoration: line-through;
opacity: 0.7;
}
.tool-completed {
text-decoration: line-through;
color: var(--color-success, #2ecc71);
}
.tool-calendar-list {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.tool-calendar-name {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
font-size: 0.75rem;
color: var(--color-text);
}
/* Tag suggestions */ /* Tag suggestions */
.tag-suggestions { .tag-suggestions {
+12
View File
@@ -0,0 +1,12 @@
import { ref } from "vue";
const showShortcuts = ref(false);
export function useShortcuts() {
return {
showShortcuts,
openShortcuts: () => { showShortcuts.value = true; },
closeShortcuts: () => { showShortcuts.value = false; },
toggleShortcuts: () => { showShortcuts.value = !showShortcuts.value; },
};
}
+29 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed, watch, nextTick } from "vue"; import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
@@ -69,6 +69,7 @@ const inputPlaceholder = computed(() => {
}); });
onMounted(async () => { onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown);
await store.fetchConversations(); await store.fetchConversations();
if (convId.value) { if (convId.value) {
await store.fetchConversation(convId.value); await store.fetchConversation(convId.value);
@@ -265,6 +266,33 @@ function promoteAutoNote(note: { id: number; title: string }) {
function excludeAutoNote(noteId: number) { function excludeAutoNote(noteId: number) {
excludedNoteIds.value = new Set([...excludedNoteIds.value, noteId]); excludedNoteIds.value = new Set([...excludedNoteIds.value, noteId]);
} }
// Keyboard shortcuts
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
// Close note picker first
if (showNotePicker.value) {
showNotePicker.value = false;
return;
}
// Close mobile sidebar overlay
if (sidebarOpen.value) {
sidebarOpen.value = false;
return;
}
// If textarea is focused and has content, clear it (don't navigate away)
if (document.activeElement === inputEl.value && messageInput.value.trim()) {
messageInput.value = "";
resetTextareaHeight();
return;
}
// Navigate home
router.push("/");
}
onUnmounted(() => {
document.removeEventListener("keydown", onGlobalKeydown);
});
</script> </script>
<template> <template>
+246 -23
View File
@@ -1,18 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, computed, onMounted, nextTick } from "vue";
import { useRouter } from "vue-router";
import { apiGet } from "@/api/client"; import { apiGet } from "@/api/client";
import type { Note, NoteListResponse } from "@/types/note"; import type { Note, NoteListResponse } from "@/types/note";
import type { Task, TaskListResponse } from "@/types/task"; import type { Task, TaskListResponse } from "@/types/task";
import type { Conversation } from "@/types/chat"; import type { Conversation, ToolCallRecord, Message } from "@/types/chat";
import NoteCard from "@/components/NoteCard.vue"; import NoteCard from "@/components/NoteCard.vue";
import TaskCard from "@/components/TaskCard.vue"; import TaskCard from "@/components/TaskCard.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import DashboardChatInput from "@/components/DashboardChatInput.vue"; import DashboardChatInput from "@/components/DashboardChatInput.vue";
import type { TaskStatus } from "@/types/task"; import type { TaskStatus } from "@/types/task";
import { useTasksStore } from "@/stores/tasks"; import { useTasksStore } from "@/stores/tasks";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
const router = useRouter();
const recentNotes = ref<Note[]>([]); const recentNotes = ref<Note[]>([]);
const overdueTasks = ref<Task[]>([]); const overdueTasks = ref<Task[]>([]);
const dueTodayTasks = ref<Task[]>([]); const dueTodayTasks = ref<Task[]>([]);
@@ -133,6 +132,7 @@ onMounted(async () => {
} }
loading.value = false; loading.value = false;
nextTick(() => chatInputRef.value?.focus());
}); });
const allTaskLists = [ const allTaskLists = [
@@ -167,6 +167,8 @@ function onStatusToggle(id: number, status: TaskStatus) {
}); });
} }
const chatInputRef = ref<{ focus: () => void } | null>(null);
const chatStore = useChatStore(); const chatStore = useChatStore();
// Warm default model after status fetch // Warm default model after status fetch
@@ -176,14 +178,57 @@ chatStore.fetchStatus().then(() => {
} }
}); });
async function onChatSubmit(payload: { // Dashboard inline response state
content: string; const dashboardConvId = ref<number | null>(null);
contextNoteId?: number; const dashboardDone = ref(false);
}) { const dashboardQuery = ref("");
const dashboardFinalContent = ref("");
const dashboardFinalToolCalls = ref<ToolCallRecord[]>([]);
// True when the response is pure text with no tool actions — suggest continuing in chat
const isConversational = computed(
() => dashboardDone.value && dashboardFinalToolCalls.value.length === 0
);
const QUICK_ACTIONS = [
"What's due today?",
"Events this week?",
"Any overdue tasks?",
"My high priority tasks?",
];
async function onChatSubmit(payload: { content: string; contextNoteId?: number }) {
dashboardConvId.value = null;
dashboardDone.value = false;
dashboardFinalContent.value = "";
dashboardFinalToolCalls.value = [];
dashboardQuery.value = payload.content;
const conv = await chatStore.createConversation(); const conv = await chatStore.createConversation();
await chatStore.fetchConversation(conv.id); await chatStore.fetchConversation(conv.id);
router.push(`/chat/${conv.id}`); dashboardConvId.value = conv.id;
// Stream inline — sendMessage awaits until SSE is complete
await chatStore.sendMessage(payload.content, payload.contextNoteId); await chatStore.sendMessage(payload.content, payload.contextNoteId);
// Capture final response from the message pushed by the store's done handler
const msgs = chatStore.currentConversation?.messages ?? [];
const lastAssistant = [...msgs].reverse().find((m: Message) => m.role === "assistant");
dashboardFinalContent.value = lastAssistant?.content ?? "";
dashboardFinalToolCalls.value = (lastAssistant?.tool_calls ?? []) as ToolCallRecord[];
dashboardDone.value = true;
}
async function onQuickAction(query: string) {
await onChatSubmit({ content: query });
}
function clearDashboardResponse() {
dashboardConvId.value = null;
dashboardDone.value = false;
dashboardQuery.value = "";
dashboardFinalContent.value = "";
dashboardFinalToolCalls.value = [];
} }
</script> </script>
@@ -271,24 +316,85 @@ async function onChatSubmit(payload: {
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
<h2>Recent Chats</h2> <h2>Ask Fable</h2>
<router-link to="/chat" class="see-all">See all</router-link> <router-link to="/chat" class="see-all">All chats</router-link>
</div> </div>
<div v-if="recentChats.length" class="cards">
<router-link <!-- Quick action chips -->
v-for="chat in recentChats" <div class="quick-actions">
:key="chat.id" <button
:to="`/chat/${chat.id}`" v-for="q in QUICK_ACTIONS"
class="chat-card" :key="q"
class="quick-action-chip"
:disabled="chatStore.streaming || !chatStore.chatReady"
@click="onQuickAction(q)"
> >
<span class="chat-card-title">{{ chat.title || "Untitled" }}</span> {{ q }}
<span class="chat-card-meta">{{ chat.message_count }} messages</span> </button>
</router-link>
</div> </div>
<div v-else class="empty-state">
<p class="empty-text">No chats yet.</p> <DashboardChatInput ref="chatInputRef" @submit="onChatSubmit" />
<!-- Inline streaming response -->
<div v-if="dashboardConvId" class="dashboard-response">
<div class="dashboard-response-query">{{ dashboardQuery }}</div>
<!-- Tool calls show live during streaming, final after done -->
<div
v-if="chatStore.streaming && chatStore.streamingToolCalls.length"
class="dashboard-tool-calls"
>
<ToolCallCard
v-for="(tc, i) in chatStore.streamingToolCalls"
:key="i"
:tool-call="tc"
/>
</div>
<div v-else-if="dashboardDone && dashboardFinalToolCalls.length" class="dashboard-tool-calls">
<ToolCallCard
v-for="(tc, i) in dashboardFinalToolCalls"
:key="i"
:tool-call="tc"
/>
</div>
<!-- Streaming text -->
<div v-if="chatStore.streaming" class="dashboard-response-text streaming">
<span v-if="chatStore.streamingContent">{{ chatStore.streamingContent }}</span>
<span v-else class="thinking-dots">...</span>
</div>
<!-- Final text -->
<div v-else-if="dashboardDone && dashboardFinalContent" class="dashboard-response-text">
{{ dashboardFinalContent }}
</div>
<div class="dashboard-response-actions" :class="{ conversational: isConversational }">
<router-link
:to="`/chat/${dashboardConvId}`"
class="btn-open-chat"
:class="{ prominent: isConversational }"
>
{{ isConversational ? 'Continue this conversation →' : 'Continue in Chat →' }}
</router-link>
<button class="btn-clear" @click="clearDashboardResponse">Clear</button>
</div>
</div>
<!-- Recent chats -->
<div v-if="recentChats.length" class="recent-chats">
<div class="recent-chats-label">Recent</div>
<div class="cards">
<router-link
v-for="chat in recentChats"
:key="chat.id"
:to="`/chat/${chat.id}`"
class="chat-card"
>
<span class="chat-card-title">{{ chat.title || "Untitled" }}</span>
<span class="chat-card-meta">{{ chat.message_count }} messages</span>
</router-link>
</div>
</div> </div>
<DashboardChatInput @submit="onChatSubmit" />
</section> </section>
<section class="section"> <section class="section">
@@ -387,6 +493,123 @@ async function onChatSubmit(payload: {
color: var(--color-text-muted); color: var(--color-text-muted);
white-space: nowrap; 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 { .empty-state {
text-align: center; text-align: center;
padding: 1.5rem 0; padding: 1.5rem 0;
+22 -1
View File
@@ -9,6 +9,7 @@ const store = useSettingsStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
const toastStore = useToastStore(); const toastStore = useToastStore();
const assistantName = ref(""); const assistantName = ref("");
const intentModel = ref("");
const currentPassword = ref(""); const currentPassword = ref("");
const newPassword = ref(""); const newPassword = ref("");
const confirmNewPassword = ref(""); const confirmNewPassword = ref("");
@@ -63,6 +64,7 @@ onMounted(async () => {
// Load notification preferences from user settings // Load notification preferences from user settings
const allSettings = await apiGet<Record<string, string>>("/api/settings"); const allSettings = await apiGet<Record<string, string>>("/api/settings");
intentModel.value = allSettings.intent_model ?? "";
if (allSettings.notify_task_reminders !== undefined) { if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false"; notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
} }
@@ -126,7 +128,10 @@ async function saveAssistant() {
saving.value = true; saving.value = true;
saved.value = false; saved.value = false;
try { 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; saved.value = true;
setTimeout(() => (saved.value = false), 2000); setTimeout(() => (saved.value = false), 2000);
} finally { } finally {
@@ -310,6 +315,22 @@ async function handleRestoreFile(event: Event) {
</p> </p>
</div> </div>
<div class="field">
<label for="intent-model">Intent Model</label>
<input
id="intent-model"
v-model="intentModel"
type="text"
placeholder="Same as chat model"
class="input"
/>
<p class="field-hint">
Optional smaller/faster model for intent routing (e.g. <code>llama3.2:3b</code>,
<code>qwen2.5:3b</code>). Leave empty to use the same model as chat.
Can also be set via <code>OLLAMA_INTENT_MODEL</code> environment variable.
</p>
</div>
<div class="actions"> <div class="actions">
<button class="btn-save" @click="saveAssistant" :disabled="saving"> <button class="btn-save" @click="saveAssistant" :disabled="saving">
{{ saving ? "Saving..." : "Save" }} {{ saving ? "Saving..." : "Save" }}
+3
View File
@@ -24,6 +24,9 @@ class Config:
) )
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434") OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3") 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") 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") SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO") LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
+434 -13
View File
@@ -3,6 +3,7 @@
import asyncio import asyncio
import logging import logging
from datetime import date as date_type, datetime, timedelta from datetime import date as date_type, datetime, timedelta
from zoneinfo import ZoneInfo
import caldav import caldav
import icalendar import icalendar
@@ -11,7 +12,7 @@ from fabledassistant.services.settings import get_all_settings
logger = logging.getLogger(__name__) 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]: 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] 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: def _make_client(config: dict[str, str]) -> caldav.DAVClient:
"""Create a CalDAV client from config dict.""" """Create a CalDAV client from config dict."""
return caldav.DAVClient( return caldav.DAVClient(
@@ -58,15 +68,99 @@ def _parse_vevent(component) -> dict | None:
dtstart = component.get("DTSTART") dtstart = component.get("DTSTART")
dtend = component.get("DTEND") dtend = component.get("DTEND")
location = str(component.get("LOCATION", "")) location = str(component.get("LOCATION", ""))
description = str(component.get("DESCRIPTION", ""))
uid = str(component.get("UID", ""))
start_str = dtstart.dt.isoformat() if dtstart else "" start_str = dtstart.dt.isoformat() if dtstart else ""
end_str = dtend.dt.isoformat() if dtend else "" end_str = dtend.dt.isoformat() if dtend else ""
return {
result = {
"uid": uid,
"title": title, "title": title,
"start": start_str, "start": start_str,
"end": end_str, "end": end_str,
"location": location, "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( async def create_event(
user_id: int, user_id: int,
@@ -78,6 +172,10 @@ async def create_event(
location: str | None = None, location: str | None = None,
all_day: bool = False, all_day: bool = False,
recurrence: str | None = None, recurrence: str | None = None,
timezone: str | None = None,
reminder_minutes: int | None = None,
attendees: list[str] | None = None,
calendar_name: str | None = None,
) -> dict: ) -> dict:
"""Create a calendar event. """Create a calendar event.
@@ -86,8 +184,9 @@ async def create_event(
recurrence is an iCalendar RRULE string (e.g. "FREQ=YEARLY"). recurrence is an iCalendar RRULE string (e.g. "FREQ=YEARLY").
""" """
config = await get_caldav_config(user_id) config = await get_caldav_config(user_id)
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")): _check_config(config)
raise ValueError("CalDAV is not configured. Go to Settings → Calendar to set it up.")
tz = timezone or config.get("caldav_timezone") or None
cal = icalendar.Calendar() cal = icalendar.Calendar()
cal.add("prodid", "-//FabledAssistant//EN") cal.add("prodid", "-//FabledAssistant//EN")
@@ -108,9 +207,9 @@ async def create_event(
result_start = d_start.isoformat() result_start = d_start.isoformat()
result_end = d_end.isoformat() result_end = d_end.isoformat()
else: else:
dt_start = datetime.fromisoformat(start) dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
if end: if end:
dt_end = datetime.fromisoformat(end) dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
else: else:
dt_end = dt_start + timedelta(minutes=duration or 60) dt_end = dt_start + timedelta(minutes=duration or 60)
event.add("dtstart", dt_start) event.add("dtstart", dt_start)
@@ -130,6 +229,10 @@ async def create_event(
key, value = part.split("=", 1) key, value = part.split("=", 1)
rrule_parts[key.strip().lower()] = value.strip() rrule_parts[key.strip().lower()] = value.strip()
event.add("rrule", rrule_parts) 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) cal.add_component(event)
@@ -137,7 +240,8 @@ async def create_event(
def _save(): def _save():
client = _make_client(config) 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) calendar.save_event(ical_str)
await asyncio.to_thread(_save) 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]: 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) config = await get_caldav_config(user_id)
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")): _check_config(config)
raise ValueError("CalDAV is not configured. Go to Settings → Calendar to set it up.")
dt_from = datetime.fromisoformat(date_from) dt_from = datetime.fromisoformat(date_from)
dt_to = datetime.fromisoformat(date_to) dt_to = datetime.fromisoformat(date_to)
def _search(): def _search():
client = _make_client(config) client = _make_client(config)
calendar = _get_calendar(client, config.get("caldav_calendar_name", "")) cal_name = config.get("caldav_calendar_name", "")
return calendar.date_search(dt_from, dt_to) 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) 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() q = query.lower()
return [ return [
e for e in all_events 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: async def test_connection(user_id: int) -> dict:
"""Test the CalDAV connection and return status.""" """Test the CalDAV connection and return status."""
config = await get_caldav_config(user_id) config = await get_caldav_config(user_id)
@@ -4,6 +4,7 @@ Streams from Ollama into a GenerationBuffer, periodically flushing to DB.
Runs independently of any HTTP connection. Runs independently of any HTTP connection.
""" """
import asyncio
import json import json
import logging import logging
import re import re
@@ -11,12 +12,14 @@ import time
from sqlalchemy import update from sqlalchemy import update
from fabledassistant.config import Config
from fabledassistant.models import async_session from fabledassistant.models import async_session
from fabledassistant.models.conversation import Message from fabledassistant.models.conversation import Message
from fabledassistant.services.generation_buffer import GenerationBuffer, GenerationState 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.llm import ChatChunk, generate_completion, stream_chat, stream_chat_with_tools
from fabledassistant.services.chat import update_conversation_title from fabledassistant.services.chat import update_conversation_title
from fabledassistant.services.intent import classify_intent 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 from fabledassistant.services.tools import get_tools_for_user, execute_tool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -92,9 +95,16 @@ async def run_generation(
last_flush = time.monotonic() last_flush = time.monotonic()
all_tool_calls: list[dict] = [] all_tool_calls: list[dict] = []
# Resolve tools based on user's configured integrations # Resolve tools and intent model in parallel
tools = await get_tools_for_user(user_id) tools, intent_model_setting = await asyncio.gather(
logger.info("Starting generation for conv %d: model=%s, tools=%d", conv_id, model, len(tools)) 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: try:
cancelled = False cancelled = False
@@ -105,9 +115,24 @@ async def run_generation(
# Intent routing — first round only # Intent routing — first round only
if _round == 0 and tools: if _round == 0 and tools:
intent = await classify_intent(user_content, tools, model) # Pass last 3 user/assistant pairs (6 messages) for anaphora resolution.
if intent.tool_name: # messages = [system, *history, current_user] — exclude system and current user.
logger.info("Intent router detected tool: %s(%s)", intent.tool_name, json.dumps(intent.arguments)[:200]) 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) 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")) logger.info("Intent-routed tool %s result: success=%s", intent.tool_name, result.get("success"))
+55 -10
View File
@@ -21,6 +21,12 @@ logger = logging.getLogger(__name__)
class IntentResult: class IntentResult:
tool_name: str | None = None # None = no tool, just chat tool_name: str | None = None # None = no tool, just chat
arguments: dict = field(default_factory=dict) 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: def _build_tool_summary(tools: list[dict]) -> str:
@@ -45,8 +51,9 @@ def _build_tool_summary(tools: list[dict]) -> str:
_SYSTEM_PROMPT_TEMPLATE = """\ _SYSTEM_PROMPT_TEMPLATE = """\
You are an intent classifier. Given a user message, decide whether it \ You are an intent classifier. Given a user message (and recent conversation \
requires calling one of the available tools or is just general chat. history for context), decide whether it requires calling one of the available \
tools or is just general chat.
Today's date is {today}. Today's date is {today}.
@@ -54,15 +61,33 @@ Available tools:
{tool_summary} {tool_summary}
Respond with ONLY a JSON object, no other text: Respond with ONLY a JSON object, no other text:
- If a tool should be called: {{"tool": "tool_name", "arguments": {{...}}}} - If a tool should be called: {{"tool": "tool_name", "arguments": {{...}}, "confidence": "high"|"medium"|"low"}}
- If it's general chat: {{"tool": null}} - 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: 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 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). - 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. - 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. - 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". - 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=<note title from context> 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.""" - Do NOT wrap the JSON in markdown code fences."""
@@ -70,10 +95,14 @@ async def classify_intent(
user_message: str, user_message: str,
tools: list[dict], tools: list[dict],
model: str, model: str,
history: list[dict] | None = None,
) -> IntentResult: ) -> IntentResult:
"""Classify user intent via a fast non-streaming LLM call. """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. so the caller falls through to the normal streaming path.
""" """
if not tools: if not tools:
@@ -82,16 +111,25 @@ async def classify_intent(
tool_summary = _build_tool_summary(tools) tool_summary = _build_tool_summary(tools)
today = date_type.today().isoformat() today = date_type.today().isoformat()
messages = [ messages: list[dict] = [
{ {
"role": "system", "role": "system",
"content": _SYSTEM_PROMPT_TEMPLATE.format( "content": _SYSTEM_PROMPT_TEMPLATE.format(
today=today, tool_summary=tool_summary 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: try:
raw = await generate_completion(messages, model) raw = await generate_completion(messages, model)
except Exception: except Exception:
@@ -124,8 +162,12 @@ def _parse_intent(raw: str, tools: list[dict]) -> IntentResult:
return IntentResult() return IntentResult()
tool_name = parsed.get("tool") tool_name = parsed.get("tool")
confidence = parsed.get("confidence", "high")
if confidence not in ("high", "medium", "low"):
confidence = "high"
if tool_name is None: if tool_name is None:
return IntentResult() return IntentResult(confidence=confidence)
# Validate tool name against available tools # Validate tool name against available tools
valid_names = { valid_names = {
@@ -139,8 +181,11 @@ def _parse_intent(raw: str, tools: list[dict]) -> IntentResult:
if not isinstance(arguments, dict): if not isinstance(arguments, dict):
arguments = {} arguments = {}
logger.info("Intent classified: tool=%s, args=%s", tool_name, json.dumps(arguments)[:200]) logger.info(
return IntentResult(tool_name=tool_name, arguments=arguments) "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: def _try_json(text: str) -> dict | list | None:
+13 -2
View File
@@ -250,12 +250,23 @@ async def build_context(
tool_lines = [ 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.", "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.", "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: 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("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("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) tool_guidance = "\n".join(tool_lines)
system_parts = [ system_parts = [
+386 -2
View File
@@ -4,12 +4,19 @@ import logging
from datetime import date, datetime from datetime import date, datetime
from fabledassistant.services.caldav import ( from fabledassistant.services.caldav import (
complete_todo,
create_event, create_event,
create_todo,
delete_event,
delete_todo,
is_caldav_configured, is_caldav_configured,
list_calendars,
list_events, list_events,
list_todos,
search_events, 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 from fabledassistant.services.tag_suggestions import suggest_tags
logger = logging.getLogger(__name__) 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", "type": "function",
"function": { "function": {
@@ -128,6 +172,23 @@ _CALDAV_TOOLS = [
"type": "string", "type": "string",
"description": "Optional iCalendar RRULE (e.g. 'FREQ=YEARLY' for annual, 'FREQ=WEEKLY' for weekly, 'FREQ=MONTHLY' for monthly)", "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"], "required": ["title", "start"],
}, },
@@ -164,7 +225,187 @@ _CALDAV_TOOLS = [
"properties": { "properties": {
"query": { "query": {
"type": "string", "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"], "required": ["query"],
@@ -241,6 +482,48 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"suggested_tags": suggested, "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": elif tool_name == "search_notes":
query = arguments.get("query", "") query = arguments.get("query", "")
notes, total = await list_notes(user_id=user_id, q=query, limit=5) 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"), location=arguments.get("location"),
all_day=arguments.get("all_day", False), all_day=arguments.get("all_day", False),
recurrence=arguments.get("recurrence"), 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 { return {
"success": True, "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: else:
return {"success": False, "error": f"Unknown tool: {tool_name}"} return {"success": False, "error": f"Unknown tool: {tool_name}"}
+40 -13
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial. > Include file-level details in the commit body when the change is non-trivial.
## Last Updated ## 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 ## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with 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_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) │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 │ │ └── client.ts # ApiError class, apiGet/apiPost/apiPut/apiPatch/apiDelete + apiSSEStream (fetch+ReadableStream), apiStreamPost (legacy), auto 401→login redirect
│ ├── composables/ │ ├── composables/
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme │ │ ├── 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) │ │ ├── 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 │ │ └── 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/ │ ├── 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 - `due_before`/`due_after` query params on `/api/tasks` for date-range filtering
- Recent chats and recently edited notes sections - Recent chats and recently edited notes sections
- All task sections hidden when empty; marking done removes from all lists - 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 ### LLM Chat
- Ollama integration via async HTTP (httpx), default model qwen3 (better tool support than mistral), auto-pull on startup - 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` - 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 - Timeout tuning: connect timeout 30s (cold model loading), warm timeout 300s, pull timeout 1800s
- SSE reconnection failure shows error toast instead of silently recovering - 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 - **LLM tool calling:** Models with tool support can create tasks, create/update notes, search notes,
on behalf of the user during chat. Multi-round tool loop (max 5 rounds) allows the LLM to and manage CalDAV events/todos on behalf of the user during chat. Multi-round tool loop (max 5 rounds)
execute tools and then produce a natural language response incorporating results. Tool call allows the LLM to execute tools and then produce a natural language response incorporating results.
results are persisted in message `tool_calls` JSONB column and rendered as compact ToolCallCard Tool call results are persisted in message `tool_calls` JSONB column and rendered as compact
components (linked titles for created items, search result lists). SSE emits `tool_call` events ToolCallCard components (linked titles for created items, search result lists, event cards, todo cards).
for real-time rendering during streaming. System prompt includes today's date for relative date SSE emits `tool_call` events for real-time rendering during streaming. System prompt includes today's
resolution. Graceful degradation: models without tool support respond normally. 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 - **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 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 — 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. 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). Supports confidence levels ("high"/"medium"/"low") — low-confidence intents fall through to streaming.
LLM tools: `create_event` (with `all_day` and `recurrence` support), `list_events`, `search_events`. 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`). 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 - **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. 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: Exposed via `POST /api/notes/suggest-tags` and `POST /api/notes/:id/append-tag`. Integrated in: