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 ToastNotification from "@/components/ToastNotification.vue";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
@@ -12,6 +13,7 @@ useTheme();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
||||
|
||||
function startAppServices() {
|
||||
chatStore.startStatusPolling();
|
||||
@@ -22,7 +24,28 @@ function stopAppServices() {
|
||||
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 () => {
|
||||
document.addEventListener("keydown", onGlobalKeydown);
|
||||
await authStore.checkAuth();
|
||||
if (authStore.isAuthenticated) {
|
||||
startAppServices();
|
||||
@@ -41,6 +64,7 @@ watch(
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", onGlobalKeydown);
|
||||
stopAppServices();
|
||||
});
|
||||
</script>
|
||||
@@ -53,6 +77,48 @@ onUnmounted(() => {
|
||||
<router-view />
|
||||
</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 v-else>
|
||||
<router-view />
|
||||
@@ -76,4 +142,103 @@ onUnmounted(() => {
|
||||
min-height: 0;
|
||||
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>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { toggleShortcuts } = useShortcuts();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const router = useRouter();
|
||||
@@ -66,6 +68,9 @@ router.afterEach(() => {
|
||||
<span class="status-indicator" :class="statusClass" :title="statusLabel">
|
||||
<span class="status-dot"></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`">
|
||||
{{ theme === "dark" ? "\u2600" : "\u263E" }}
|
||||
</button>
|
||||
@@ -164,6 +169,21 @@ router.afterEach(() => {
|
||||
0%, 100% { opacity: 1; }
|
||||
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 {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
@@ -101,6 +101,12 @@ function selectNote(note: { id: number; title: string }) {
|
||||
function removeAttachedNote() {
|
||||
attachedNote.value = null;
|
||||
}
|
||||
|
||||
function focus() {
|
||||
inputEl.value?.focus();
|
||||
}
|
||||
|
||||
defineExpose({ focus });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -17,12 +17,28 @@ const label = computed(() => {
|
||||
return "Created task";
|
||||
case "note":
|
||||
return "Created note";
|
||||
case "note_updated":
|
||||
return "Updated note";
|
||||
case "search":
|
||||
return "Searched notes";
|
||||
case "event":
|
||||
return "Created event";
|
||||
case "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:
|
||||
return "Tool call";
|
||||
}
|
||||
@@ -57,6 +73,45 @@ const eventData = computed(() => {
|
||||
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 data = props.toolCall.result.data;
|
||||
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-time">{{ formatEventTime(eventData.start) }}</span>
|
||||
</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">
|
||||
<span class="tool-search-info">{{ eventCount }} found</span>
|
||||
<div v-if="eventList.length > 0" class="tool-event-list">
|
||||
@@ -252,6 +337,28 @@ async function applyTag(tag: string) {
|
||||
font-size: 0.75rem;
|
||||
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 {
|
||||
|
||||
@@ -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">
|
||||
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