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:
@@ -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">×</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
+238
-15
@@ -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,10 +316,74 @@ 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">
|
|
||||||
|
<!-- Quick action chips -->
|
||||||
|
<div class="quick-actions">
|
||||||
|
<button
|
||||||
|
v-for="q in QUICK_ACTIONS"
|
||||||
|
:key="q"
|
||||||
|
class="quick-action-chip"
|
||||||
|
:disabled="chatStore.streaming || !chatStore.chatReady"
|
||||||
|
@click="onQuickAction(q)"
|
||||||
|
>
|
||||||
|
{{ q }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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
|
<router-link
|
||||||
v-for="chat in recentChats"
|
v-for="chat in recentChats"
|
||||||
:key="chat.id"
|
:key="chat.id"
|
||||||
@@ -285,10 +394,7 @@ async function onChatSubmit(payload: {
|
|||||||
<span class="chat-card-meta">{{ chat.message_count }} messages</span>
|
<span class="chat-card-meta">{{ chat.message_count }} messages</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="empty-state">
|
|
||||||
<p class="empty-text">No chats yet.</p>
|
|
||||||
</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;
|
||||||
|
|||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +95,13 @@ 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.
|
||||||
|
|
||||||
|
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)
|
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.
|
||||||
"""
|
"""
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user