From d7bc3f3222e7cca71d587cad715de6678d80f368 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 15 Feb 2026 22:40:20 -0500 Subject: [PATCH] Add CalDAV calendar integration, LLM-suggested tags, and settings refinements - CalDAV integration: per-user calendar config, create/list/search events via caldav library, LLM tools for calendar operations from chat - LLM-suggested tags: new tag_suggestions service prompts LLM with existing tags and note content to suggest 3-5 relevant tags; exposed via API endpoints (suggest-tags, append-tag); integrated into editor views (suggest button + clickable pills) and chat tool calls (pills in ToolCallCard with one-click apply) - Settings/model UI refinements, generation task improvements Co-Authored-By: Claude Opus 4.6 --- .../src/components/DashboardChatInput.vue | 17 +- frontend/src/components/ModelSelector.vue | 79 -- frontend/src/components/ToolCallCard.vue | 180 ++++- frontend/src/stores/chat.ts | 52 +- frontend/src/stores/settings.ts | 35 +- frontend/src/types/chat.ts | 14 +- frontend/src/types/settings.ts | 8 - frontend/src/views/ChatView.vue | 36 +- frontend/src/views/HomeView.vue | 3 +- frontend/src/views/NoteEditorView.vue | 118 +++ frontend/src/views/SettingsView.vue | 717 ++++-------------- frontend/src/views/TaskEditorView.vue | 118 +++ pyproject.toml | 2 + src/fabledassistant/config.py | 2 +- src/fabledassistant/routes/notes.py | 32 + src/fabledassistant/routes/settings.py | 37 + src/fabledassistant/services/caldav.py | 188 +++++ .../services/generation_task.py | 33 +- src/fabledassistant/services/llm.py | 49 +- .../services/tag_suggestions.py | 85 +++ src/fabledassistant/services/tools.py | 160 +++- summary.md | 30 +- 22 files changed, 1158 insertions(+), 837 deletions(-) delete mode 100644 frontend/src/components/ModelSelector.vue create mode 100644 src/fabledassistant/services/caldav.py create mode 100644 src/fabledassistant/services/tag_suggestions.py diff --git a/frontend/src/components/DashboardChatInput.vue b/frontend/src/components/DashboardChatInput.vue index a7018e3..8c8a463 100644 --- a/frontend/src/components/DashboardChatInput.vue +++ b/frontend/src/components/DashboardChatInput.vue @@ -1,17 +1,15 @@ - - - - diff --git a/frontend/src/components/ToolCallCard.vue b/frontend/src/components/ToolCallCard.vue index 481656b..11dbdd1 100644 --- a/frontend/src/components/ToolCallCard.vue +++ b/frontend/src/components/ToolCallCard.vue @@ -1,11 +1,15 @@ + + + + +
+ Suggested tags: + +
@@ -121,4 +218,85 @@ const searchResults = computed(() => { color: var(--color-text-muted); margin-right: 0.1rem; } +.tool-event-title { + font-weight: 600; + color: var(--color-text); +} +.tool-event-time { + color: var(--color-text-muted); + font-size: 0.75rem; +} +.tool-event-list { + display: flex; + flex-direction: column; + gap: 0.2rem; + width: 100%; +} +.tool-event-item { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.5rem; +} +.tool-event-item-title { + color: var(--color-text); + font-size: 0.8rem; +} +.tool-event-item-time { + color: var(--color-text-muted); + font-size: 0.75rem; + white-space: nowrap; +} +.tool-event-more { + color: var(--color-text-muted); + font-size: 0.75rem; + font-style: italic; +} + +/* Tag suggestions */ +.tag-suggestions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem; + width: 100%; + margin-top: 0.25rem; + padding-top: 0.3rem; + border-top: 1px solid var(--color-border); +} +.tag-suggestions-label { + font-size: 0.7rem; + color: var(--color-text-muted); + font-weight: 500; +} +.tag-pill { + display: inline-flex; + align-items: center; + gap: 0.2rem; + padding: 0.15rem 0.5rem; + border: 1px solid var(--color-primary); + border-radius: 999px; + background: transparent; + color: var(--color-primary); + font-size: 0.75rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.tag-pill:hover:not(:disabled) { + background: var(--color-primary); + color: #fff; +} +.tag-pill.applied { + background: var(--color-success, #2ecc71); + border-color: var(--color-success, #2ecc71); + color: #fff; + cursor: default; +} +.tag-pill:disabled:not(.applied) { + opacity: 0.6; + cursor: wait; +} +.tag-check { + font-size: 0.65rem; +} diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index 3218f7d..6bbe65f 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -13,9 +13,7 @@ import type { ConversationDetail, ContextMeta, Message, - OllamaModel, OllamaStatus, - RunningModel, SendMessageResponse, ToolCallRecord, } from "@/types/chat"; @@ -29,8 +27,6 @@ export const useChatStore = defineStore("chat", () => { const streamingContent = ref(""); const streamingToolCalls = ref([]); const lastContextMeta = ref(null); - const models = ref([]); - const runningModels = ref([]); const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking"); const modelStatus = ref<"checking" | "ready" | "not_found">("checking"); const defaultModel = ref(""); @@ -56,14 +52,10 @@ export const useChatStore = defineStore("chat", () => { } } - async function createConversation( - title = "", - model = "" - ): Promise { + async function createConversation(title = ""): Promise { try { const conv = await apiPost("/api/chat/conversations", { title, - model, }); conversations.value.unshift(conv); return conv; @@ -319,24 +311,6 @@ export const useChatStore = defineStore("chat", () => { } } - async function fetchModels() { - try { - const data = await apiGet<{ models: OllamaModel[] }>("/api/chat/models"); - models.value = data.models; - } catch { - models.value = []; - } - } - - async function fetchRunningModels() { - try { - const data = await apiGet<{ models: RunningModel[] }>("/api/chat/ps"); - runningModels.value = data.models; - } catch { - runningModels.value = []; - } - } - async function warmModel(model: string) { try { await apiPost("/api/chat/warm", { model }); @@ -345,25 +319,6 @@ export const useChatStore = defineStore("chat", () => { } } - async function updateConversationModel(id: number, model: string) { - try { - const updated = await apiPatch( - `/api/chat/conversations/${id}`, - { model } - ); - const idx = conversations.value.findIndex((c) => c.id === id); - if (idx !== -1) { - conversations.value[idx] = { ...conversations.value[idx], ...updated }; - } - if (currentConversation.value?.id === id) { - currentConversation.value.model = updated.model; - } - } catch (e) { - useToastStore().show("Failed to update model", "error"); - throw e; - } - } - return { conversations, currentConversation, @@ -373,8 +328,6 @@ export const useChatStore = defineStore("chat", () => { streamingContent, streamingToolCalls, lastContextMeta, - models, - runningModels, ollamaStatus, modelStatus, defaultModel, @@ -388,10 +341,7 @@ export const useChatStore = defineStore("chat", () => { cancelGeneration, saveMessageAsNote, summarizeAsNote, - fetchModels, - fetchRunningModels, warmModel, - updateConversationModel, fetchStatus, startStatusPolling, stopStatusPolling, diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts index 717ffbc..130341b 100644 --- a/frontend/src/stores/settings.ts +++ b/frontend/src/stores/settings.ts @@ -1,13 +1,12 @@ import { ref, computed } from "vue"; import { defineStore } from "pinia"; -import { apiGet, apiPut, apiPost, apiStreamPost } from "@/api/client"; +import { apiGet, apiPut } from "@/api/client"; import { useToastStore } from "@/stores/toast"; import type { AppSettings } from "@/types/settings"; export const useSettingsStore = defineStore("settings", () => { const settings = ref({}); const loading = ref(false); - const installedModels = ref([]); const assistantName = computed( () => settings.value.assistant_name || "Fable" @@ -40,44 +39,12 @@ export const useSettingsStore = defineStore("settings", () => { } } - async function fetchInstalledModels() { - try { - const data = await apiGet<{ models: { name: string }[] }>("/api/chat/models"); - installedModels.value = data.models.map((m) => m.name); - } catch { - installedModels.value = []; - } - } - - async function pullModel( - model: string, - onProgress?: (data: Record) => void, - ) { - await apiStreamPost("/api/chat/models/pull", { model }, (data) => { - if (onProgress) onProgress(data); - }); - } - - async function deleteModel(model: string) { - try { - await apiPost("/api/chat/models/delete", { model }); - await fetchInstalledModels(); - } catch (e) { - useToastStore().show("Failed to delete model", "error"); - throw e; - } - } - return { settings, loading, - installedModels, assistantName, defaultModel, fetchSettings, updateSettings, - fetchInstalledModels, - pullModel, - deleteModel, }; }); diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts index 174d558..02011df 100644 --- a/frontend/src/types/chat.ts +++ b/frontend/src/types/chat.ts @@ -1,7 +1,7 @@ export interface ToolCallRecord { function: string; arguments: Record; - result: { success: boolean; type?: string; data?: Record; error?: string }; + result: { success: boolean; type?: string; data?: Record; error?: string; suggested_tags?: string[] }; status: "success" | "error"; } @@ -34,18 +34,6 @@ export interface ConversationDetail extends Conversation { messages: Message[]; } -export interface OllamaModel { - name: string; - size: number; -} - -export interface RunningModel { - name: string; - size: number; - size_vram: number; - expires_at: string; -} - export interface ContextMeta { context_note_id: number | null; context_note_title: string | null; diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts index 6ca20bb..ec4862a 100644 --- a/frontend/src/types/settings.ts +++ b/frontend/src/types/settings.ts @@ -3,11 +3,3 @@ export interface AppSettings { default_model?: string; [key: string]: string | undefined; } - -export interface ModelInfo { - name: string; - description: string; - size: string; - bestFor: string; - category: string; -} diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index 974e636..2b5e16b 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -6,7 +6,6 @@ import { useSettingsStore } from "@/stores/settings"; import { apiGet } from "@/api/client"; import { renderMarkdown } from "@/utils/markdown"; import ChatMessage from "@/components/ChatMessage.vue"; -import ModelSelector from "@/components/ModelSelector.vue"; import ToolCallCard from "@/components/ToolCallCard.vue"; import type { Note } from "@/types/note"; @@ -30,9 +29,6 @@ const noteSearchResults = ref<{ id: number; title: string }[]>([]); const noteSearchLoading = ref(false); let noteSearchTimer: ReturnType | null = null; -// Model selection -const selectedModel = ref(""); - // Exclude tracking (session-scoped per conversation) const excludedNoteIds = ref>(new Set()); @@ -76,12 +72,6 @@ onMounted(async () => { await store.fetchConversations(); if (convId.value) { await store.fetchConversation(convId.value); - if (store.currentConversation?.model) { - selectedModel.value = store.currentConversation.model; - } - } - if (!selectedModel.value) { - selectedModel.value = store.defaultModel; } nextTick(() => inputEl.value?.focus()); }); @@ -112,26 +102,6 @@ watch(convId, async (newId) => { nextTick(() => inputEl.value?.focus()); }); -// Sync selectedModel from loaded conversation -watch( - () => store.currentConversation?.model, - (model) => { - if (model) selectedModel.value = model; - } -); - -// Persist model changes to backend -watch(selectedModel, (newModel, oldModel) => { - if ( - newModel && - oldModel && - newModel !== oldModel && - store.currentConversation - ) { - store.updateConversationModel(store.currentConversation.id, newModel); - } -}); - watch( () => store.streamingContent, () => scrollToBottom() @@ -155,7 +125,7 @@ async function selectConversation(id: number) { } async function newConversation() { - const conv = await store.createConversation("", selectedModel.value); + const conv = await store.createConversation(); await store.fetchConversation(conv.id); router.push(`/chat/${conv.id}`); } @@ -344,10 +314,6 @@ function excludeAutoNote(noteId: number) { -

{{ store.currentConversation.title || "New Chat" }}

+ + +