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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
Reference in New Issue
Block a user