Phase 10: CalDAV full lifecycle, update_note, dashboard inline streaming, keyboard shortcuts

Backend:
- caldav.py: Full event lifecycle — update_event, delete_event; VTODO suite —
  create_todo, list_todos, complete_todo, delete_todo; list_calendars; timezone
  support via ZoneInfo; reminders via VALARM; attendees; multi-calendar search
  (_get_all_calendars scans all calendars when no specific one is configured)
- tools.py: New update_note tool (find by title + replace/append modes),
  7 new CalDAV tool definitions, corresponding execute_tool cases
- llm.py: Update system prompt — add update_note guidance, full CalDAV action list
- intent.py: Confidence scoring (high/medium/low) + should_execute property;
  conversation history support for anaphora resolution; routing rules for
  update/delete events, todos, update_note vs create_note disambiguation,
  time-period → list_events (not search_events), reminder_minutes conversion
- generation_task.py: Parallel fetch of tools + intent_model setting; dedicated
  intent model (OLLAMA_INTENT_MODEL env var or per-user intent_model setting)
- config.py: Add OLLAMA_INTENT_MODEL env var

Frontend:
- HomeView.vue: Inline streaming response (no navigation); quick action chips;
  isConversational computed — prominent "Continue this conversation" CTA when
  no tool calls; auto-focus chat input on mount via chatInputRef
- DashboardChatInput.vue: defineExpose({ focus }) for external focus control
- ChatView.vue: Escape key handler — close picker → close sidebar → clear
  textarea → navigate home; onUnmounted cleanup
- App.vue: Global ? key shortcut toggles keyboard shortcuts overlay; shared
  state via useShortcuts composable; Transition animation
- AppHeader.vue: ? button for shortcuts overlay discoverability
- useShortcuts.ts (new): Shared showShortcuts ref + open/close/toggle helpers
- ToolCallCard.vue: note_updated, event_updated, event_deleted, calendars,
  todo, todos, todo_completed, todo_deleted label cases + render blocks
- SettingsView.vue: Intent model field + caldav_timezone setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 22:04:41 -05:00
parent 75560dee4e
commit 70cba72a80
15 changed files with 1569 additions and 71 deletions
+29 -1
View File
@@ -1,5 +1,5 @@
<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 { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
@@ -69,6 +69,7 @@ const inputPlaceholder = computed(() => {
});
onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown);
await store.fetchConversations();
if (convId.value) {
await store.fetchConversation(convId.value);
@@ -265,6 +266,33 @@ function promoteAutoNote(note: { id: number; title: string }) {
function excludeAutoNote(noteId: number) {
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>
<template>
+246 -23
View File
@@ -1,18 +1,17 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ref, computed, onMounted, nextTick } from "vue";
import { apiGet } from "@/api/client";
import type { Note, NoteListResponse } from "@/types/note";
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 TaskCard from "@/components/TaskCard.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import DashboardChatInput from "@/components/DashboardChatInput.vue";
import type { TaskStatus } from "@/types/task";
import { useTasksStore } from "@/stores/tasks";
import { useChatStore } from "@/stores/chat";
const router = useRouter();
const recentNotes = ref<Note[]>([]);
const overdueTasks = ref<Task[]>([]);
const dueTodayTasks = ref<Task[]>([]);
@@ -133,6 +132,7 @@ onMounted(async () => {
}
loading.value = false;
nextTick(() => chatInputRef.value?.focus());
});
const allTaskLists = [
@@ -167,6 +167,8 @@ function onStatusToggle(id: number, status: TaskStatus) {
});
}
const chatInputRef = ref<{ focus: () => void } | null>(null);
const chatStore = useChatStore();
// Warm default model after status fetch
@@ -176,14 +178,57 @@ chatStore.fetchStatus().then(() => {
}
});
async function onChatSubmit(payload: {
content: string;
contextNoteId?: number;
}) {
// Dashboard inline response state
const dashboardConvId = ref<number | null>(null);
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();
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);
// 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>
@@ -271,24 +316,85 @@ async function onChatSubmit(payload: {
<section class="section">
<div class="section-header">
<h2>Recent Chats</h2>
<router-link to="/chat" class="see-all">See all</router-link>
<h2>Ask Fable</h2>
<router-link to="/chat" class="see-all">All chats</router-link>
</div>
<div v-if="recentChats.length" class="cards">
<router-link
v-for="chat in recentChats"
:key="chat.id"
:to="`/chat/${chat.id}`"
class="chat-card"
<!-- 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)"
>
<span class="chat-card-title">{{ chat.title || "Untitled" }}</span>
<span class="chat-card-meta">{{ chat.message_count }} messages</span>
</router-link>
{{ q }}
</button>
</div>
<div v-else class="empty-state">
<p class="empty-text">No chats yet.</p>
<DashboardChatInput ref="chatInputRef" @submit="onChatSubmit" />
<!-- Inline streaming response -->
<div v-if="dashboardConvId" class="dashboard-response">
<div class="dashboard-response-query">{{ dashboardQuery }}</div>
<!-- Tool calls show live during streaming, final after done -->
<div
v-if="chatStore.streaming && chatStore.streamingToolCalls.length"
class="dashboard-tool-calls"
>
<ToolCallCard
v-for="(tc, i) in chatStore.streamingToolCalls"
:key="i"
:tool-call="tc"
/>
</div>
<div v-else-if="dashboardDone && dashboardFinalToolCalls.length" class="dashboard-tool-calls">
<ToolCallCard
v-for="(tc, i) in dashboardFinalToolCalls"
:key="i"
:tool-call="tc"
/>
</div>
<!-- Streaming text -->
<div v-if="chatStore.streaming" class="dashboard-response-text streaming">
<span v-if="chatStore.streamingContent">{{ chatStore.streamingContent }}</span>
<span v-else class="thinking-dots">...</span>
</div>
<!-- Final text -->
<div v-else-if="dashboardDone && dashboardFinalContent" class="dashboard-response-text">
{{ dashboardFinalContent }}
</div>
<div class="dashboard-response-actions" :class="{ conversational: isConversational }">
<router-link
:to="`/chat/${dashboardConvId}`"
class="btn-open-chat"
:class="{ prominent: isConversational }"
>
{{ isConversational ? 'Continue this conversation →' : 'Continue in Chat →' }}
</router-link>
<button class="btn-clear" @click="clearDashboardResponse">Clear</button>
</div>
</div>
<!-- Recent chats -->
<div v-if="recentChats.length" class="recent-chats">
<div class="recent-chats-label">Recent</div>
<div class="cards">
<router-link
v-for="chat in recentChats"
:key="chat.id"
:to="`/chat/${chat.id}`"
class="chat-card"
>
<span class="chat-card-title">{{ chat.title || "Untitled" }}</span>
<span class="chat-card-meta">{{ chat.message_count }} messages</span>
</router-link>
</div>
</div>
<DashboardChatInput @submit="onChatSubmit" />
</section>
<section class="section">
@@ -387,6 +493,123 @@ async function onChatSubmit(payload: {
color: var(--color-text-muted);
white-space: nowrap;
}
/* Quick actions */
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.quick-action-chip {
padding: 0.3rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-bg-secondary);
color: var(--color-text);
font-size: 0.8rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.quick-action-chip:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.quick-action-chip:disabled {
opacity: 0.4;
cursor: default;
}
/* Inline response */
.dashboard-response {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.dashboard-response-query {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.dashboard-tool-calls {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 0.5rem;
}
.dashboard-response-text {
font-size: 0.9rem;
line-height: 1.55;
color: var(--color-text);
white-space: pre-wrap;
word-break: break-word;
}
.dashboard-response-text.streaming {
color: var(--color-text-muted);
}
.thinking-dots {
display: inline-block;
animation: blink 1.2s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.dashboard-response-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border);
}
.btn-open-chat {
font-size: 0.85rem;
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
}
.btn-open-chat:hover {
text-decoration: underline;
}
.btn-open-chat.prominent {
background: var(--color-primary);
color: #fff;
padding: 0.35rem 0.85rem;
border-radius: var(--radius-sm);
font-size: 0.9rem;
}
.btn-open-chat.prominent:hover {
text-decoration: none;
opacity: 0.9;
}
.btn-clear {
font-size: 0.8rem;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.btn-clear:hover {
color: var(--color-text);
}
/* Recent chats below */
.recent-chats {
margin-top: 1rem;
}
.recent-chats-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.5rem;
}
.empty-state {
text-align: center;
padding: 1.5rem 0;
+22 -1
View File
@@ -9,6 +9,7 @@ const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
const assistantName = ref("");
const intentModel = ref("");
const currentPassword = ref("");
const newPassword = ref("");
const confirmNewPassword = ref("");
@@ -63,6 +64,7 @@ onMounted(async () => {
// Load notification preferences from user settings
const allSettings = await apiGet<Record<string, string>>("/api/settings");
intentModel.value = allSettings.intent_model ?? "";
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
@@ -126,7 +128,10 @@ async function saveAssistant() {
saving.value = true;
saved.value = false;
try {
await store.updateSettings({ assistant_name: assistantName.value.trim() || "Fable" });
await store.updateSettings({
assistant_name: assistantName.value.trim() || "Fable",
intent_model: intentModel.value.trim(),
});
saved.value = true;
setTimeout(() => (saved.value = false), 2000);
} finally {
@@ -310,6 +315,22 @@ async function handleRestoreFile(event: Event) {
</p>
</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">
<button class="btn-save" @click="saveAssistant" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}