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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 22:04:41 -05:00
parent 75560dee4e
commit 70cba72a80
15 changed files with 1569 additions and 71 deletions
+165
View File
@@ -3,6 +3,7 @@ import { onMounted, onUnmounted, watch } from "vue";
import AppHeader from "@/components/AppHeader.vue";
import 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">&times;</button>
</div>
<div class="shortcuts-body">
<div class="shortcuts-section">
<div class="shortcuts-section-title">Navigation</div>
<div class="shortcut-row">
<span class="shortcut-key">Esc</span>
<span class="shortcut-desc">Go to home / close dialog</span>
</div>
<div class="shortcut-row">
<span class="shortcut-key">?</span>
<span class="shortcut-desc">Toggle this shortcuts panel</span>
</div>
</div>
<div class="shortcuts-section">
<div class="shortcuts-section-title">Chat</div>
<div class="shortcut-row">
<kbd class="shortcut-key">Enter</kbd>
<span class="shortcut-desc">Send message</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">Shift</kbd>
<span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">Enter</kbd>
<span class="shortcut-desc">New line in message</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">Esc</kbd>
<span class="shortcut-desc">Clear input / go home</span>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<template 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>
+20
View File
@@ -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>
+107
View File
@@ -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 {
+12
View File
@@ -0,0 +1,12 @@
import { ref } from "vue";
const showShortcuts = ref(false);
export function useShortcuts() {
return {
showShortcuts,
openShortcuts: () => { showShortcuts.value = true; },
closeShortcuts: () => { showShortcuts.value = false; },
toggleShortcuts: () => { showShortcuts.value = !showShortcuts.value; },
};
}
+29 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
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" }}