Add persistent context sidebar, note title fix, and expanded tool suite

Context sidebar + note title:
- ChatView: replace ephemeral context pills with a persistent right-panel sidebar;
  auto-found notes accumulate across turns; attached note shows with pin icon;
  × button excludes a note from future auto-search; hidden on mobile
- routes/chat.py: batch-fetch note titles via get_notes_by_ids() and inject
  context_note_title into each message dict at conversation load time
- notes.py: add get_notes_by_ids() batch fetch helper
- types/chat.ts: add context_note_title field to Message interface
- stores/chat.ts: sendMessage accepts optional 5th arg contextNoteTitle,
  included in optimistic user message
- ChatMessage.vue: context badge shows note title instead of 'Note #N'

Expanded LLM tool suite (all with intent router rules + ToolCallCard display):
- delete_note / delete_task: permanent delete with user confirmation (write tool),
  type-safe (refuse to delete wrong type), clears note context cache on success
- get_note: fetch full note body by query (search_notes returns only 200-char preview)
- list_notes: browse notes by recency/keyword/tags with limit; notes only
- update_note: add tags + tag_mode (replace/add/remove) parameters
- search_notes: add optional type filter ("note" | "task")
- search_todos (CalDAV): keyword-filter todos, companion to list_todos
- caldav.py: add search_todos() built on top of list_todos()
- generation_task.py: register new tools in _WRITE_TOOLS, _TOOL_LABELS, _TOOL_ACTIONS
- llm.py: update available actions list and guidance in system prompt
- intent.py: routing rules for all new tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 14:40:34 -05:00
parent d6f4a6dbb6
commit 32e4ee12f2
13 changed files with 578 additions and 187 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ const timingParts = computed((): string[] => {
class="context-badge"
>
<router-link :to="`/notes/${message.context_note_id}`">
Note #{{ message.context_note_id }}
{{ message.context_note_title || `Note #${message.context_note_id}` }}
</router-link>
</div>
</div>
+68
View File
@@ -20,6 +20,14 @@ const label = computed(() => {
return "Created note";
case "note_updated":
return "Updated note";
case "note_deleted":
return "Deleted note";
case "task_deleted":
return "Deleted task";
case "note_content":
return "Note";
case "notes_list":
return "Notes";
case "tasks":
return "Tasks";
case "todo_updated":
@@ -166,6 +174,32 @@ const searchResults = computed(() => {
return results && results.length > 0 ? results : null;
});
const deletedNote = computed(() => {
const data = props.toolCall.result.data;
const type = props.toolCall.result.type;
if (!data || (type !== "note_deleted" && type !== "task_deleted")) return null;
return data as { id: number; title: string };
});
const noteContent = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "note_content") return null;
return data as { id: number; title: string; body: string; tags: string[] };
});
const noteList = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "notes_list") return null;
const results = data.results as Array<{ id: number; title: string; tags: string[]; preview: string }> | undefined;
return results ?? [];
});
const noteListCount = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "notes_list") return 0;
return (data.total as number) ?? 0;
});
async function applyTag(tag: string) {
if (!noteId.value || appliedTags.value.has(tag) || applyingTag.value) return;
applyingTag.value = tag;
@@ -189,6 +223,30 @@ async function applyTag(tag: string) {
<template v-else-if="toolCall.status === 'error'">
<span class="tool-error">{{ toolCall.result.error }}</span>
</template>
<template v-else-if="deletedNote">
<span class="tool-deleted">{{ deletedNote.title || "Untitled" }}</span>
</template>
<template v-else-if="noteContent">
<router-link :to="`/notes/${noteContent.id}`" class="tool-link">{{ noteContent.title || "Untitled" }}</router-link>
<span v-if="noteContent.tags.length" class="tool-note-tags">
<span v-for="tag in noteContent.tags" :key="tag" class="tool-note-tag">#{{ tag }}</span>
</span>
</template>
<template v-else-if="noteList !== null">
<span class="tool-search-info">{{ noteListCount }} found</span>
<div v-if="noteList.length > 0" class="tool-search-results">
<router-link
v-for="n in noteList.slice(0, 8)"
:key="n.id"
:to="`/notes/${n.id}`"
class="tool-search-item"
>
{{ n.title || "Untitled" }}
</router-link>
<span v-if="noteList.length > 8" class="tool-event-more">+{{ noteList.length - 8 }} more</span>
</div>
<span v-else class="tool-search-info">No notes found</span>
</template>
<template v-else-if="searchResults">
<span class="tool-search-info">{{ (toolCall.result.data?.total as number) ?? 0 }} found</span>
<div class="tool-search-results">
@@ -380,6 +438,16 @@ async function applyTag(tag: string) {
text-decoration: line-through;
opacity: 0.7;
}
.tool-note-tags {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
width: 100%;
}
.tool-note-tag {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.tool-task-priority {
font-size: 0.7rem;
font-weight: 600;
+2
View File
@@ -121,6 +121,7 @@ export const useChatStore = defineStore("chat", () => {
contextNoteId?: number | null,
excludeNoteIds?: number[],
think = false,
contextNoteTitle?: string,
) {
if (!currentConversation.value) return;
@@ -150,6 +151,7 @@ export const useChatStore = defineStore("chat", () => {
role: "user",
content,
context_note_id: contextNoteId ?? null,
context_note_title: contextNoteTitle ?? null,
created_at: new Date().toISOString(),
};
currentConversation.value.messages.push(userMsg);
+1
View File
@@ -26,6 +26,7 @@ export interface Message {
content: string;
status?: "complete" | "generating" | "error";
context_note_id: number | null;
context_note_title?: string | null;
tool_calls?: ToolCallRecord[] | null;
created_at: string;
timing?: GenerationTiming;
+156 -165
View File
@@ -33,6 +33,9 @@ let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
// Exclude tracking (session-scoped per conversation)
const excludedNoteIds = ref<Set<number>>(new Set());
// Persistent context notes — populated as assistant responds, cleared on conv change
const contextNotes = ref<{ id: number; title: string }[]>([]);
let prevConvId: number | null = null;
const convId = computed(() => {
@@ -89,6 +92,7 @@ watch(convId, async (newId) => {
prevConvId = newId ?? null;
excludedNoteIds.value = new Set();
contextNotes.value = [];
attachedNote.value = null;
store.lastContextMeta = null;
if (newId) {
@@ -109,6 +113,21 @@ watch(
() => scrollToBottom()
);
watch(
() => store.lastContextMeta?.auto_notes,
(newNotes) => {
if (!newNotes) return;
const excluded = excludedNoteIds.value;
const existing = new Set(contextNotes.value.map((n) => n.id));
for (const note of newNotes) {
if (!excluded.has(note.id) && !existing.has(note.id)) {
contextNotes.value.push(note);
existing.add(note.id);
}
}
}
);
function scrollToBottom() {
nextTick(() => {
if (messagesEl.value) {
@@ -152,6 +171,7 @@ async function sendMessage() {
sending.value = true;
const contextNoteId = attachedNote.value?.id ?? undefined;
const contextNoteTitle = attachedNote.value?.title ?? undefined;
attachedNote.value = null;
messageInput.value = "";
resetTextareaHeight();
@@ -162,6 +182,7 @@ async function sendMessage() {
contextNoteId,
excludedNoteIds.value.size ? [...excludedNoteIds.value] : undefined,
true, // enable thinking in the full chat view
contextNoteTitle,
);
sending.value = false;
@@ -261,12 +282,9 @@ function removeAttachedNote() {
attachedNote.value = null;
}
function promoteAutoNote(note: { id: number; title: string }) {
attachedNote.value = note;
}
function excludeAutoNote(noteId: number) {
excludedNoteIds.value = new Set([...excludedNoteIds.value, noteId]);
contextNotes.value = contextNotes.value.filter((n) => n.id !== noteId);
}
// Keyboard shortcuts
@@ -355,88 +373,77 @@ onUnmounted(() => {
</button>
</div>
<div ref="messagesEl" class="messages-container">
<div class="messages-inner">
<ChatMessage
v-for="msg in store.currentConversation.messages"
:key="msg.id"
:message="msg"
@save-as-note="handleSaveAsNote"
/>
<div class="chat-body">
<div ref="messagesEl" class="messages-container">
<div class="messages-inner">
<ChatMessage
v-for="msg in store.currentConversation.messages"
:key="msg.id"
:message="msg"
@save-as-note="handleSaveAsNote"
/>
<!-- Context pills (auto-found notes) -->
<div
v-if="store.lastContextMeta?.auto_notes?.length && (store.streaming || store.currentConversation.messages.length)"
class="context-pills"
>
<span class="context-pills-label">Context:</span>
<span
v-for="note in store.lastContextMeta.auto_notes"
:key="note.id"
class="context-pill"
>
<router-link :to="`/notes/${note.id}`" class="context-pill-link">
{{ note.title }}
</router-link>
<button
class="context-pill-btn promote"
@click="promoteAutoNote(note)"
title="Attach this note for next message"
>+</button>
<button
class="context-pill-btn exclude"
@click="excludeAutoNote(note.id)"
title="Exclude from auto-search"
>&times;</button>
</span>
</div>
<!-- Streaming message (assistant typing) -->
<div v-if="store.streaming" class="chat-message role-assistant">
<div class="message-bubble streaming-bubble">
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<div v-if="store.streamingToolCalls.length" class="streaming-tool-calls">
<ToolCallCard
v-for="(tc, i) in store.streamingToolCalls"
:key="i"
:tool-call="tc"
<!-- Streaming message (assistant typing) -->
<div v-if="store.streaming" class="chat-message role-assistant">
<div class="message-bubble streaming-bubble">
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<div v-if="store.streamingToolCalls.length" class="streaming-tool-calls">
<ToolCallCard
v-for="(tc, i) in store.streamingToolCalls"
:key="i"
:tool-call="tc"
/>
</div>
<ToolConfirmCard
v-if="store.streamingPendingTool"
:pending-tool="store.streamingPendingTool"
@accept="store.confirmTool(true)"
@decline="store.confirmTool(false)"
/>
<div v-if="store.streamingStatus" class="streaming-status-line">
<span class="streaming-status-dot"></span>
{{ store.streamingStatus }}
</div>
<div class="message-content prose" v-html="streamingRendered"></div>
<span v-if="!store.streamingStatus" class="typing-indicator"></span>
</div>
<ToolConfirmCard
v-if="store.streamingPendingTool"
:pending-tool="store.streamingPendingTool"
@accept="store.confirmTool(true)"
@decline="store.confirmTool(false)"
/>
<div v-if="store.streamingStatus" class="streaming-status-line">
<span class="streaming-status-dot"></span>
{{ store.streamingStatus }}
</div>
<div class="message-content prose" v-html="streamingRendered"></div>
<span v-if="!store.streamingStatus" class="typing-indicator"></span>
</div>
<p
v-if="!store.currentConversation.messages.length && !store.streaming"
class="empty-msg"
>
Send a message to start the conversation.
</p>
</div>
</div>
<!-- Persistent context sidebar -->
<aside v-if="contextNotes.length || attachedNote" class="context-sidebar">
<div class="context-sidebar-header">In Context</div>
<!-- Manually attached note pinned until sent -->
<div v-if="attachedNote" class="context-note context-note-pinned">
<span class="context-note-icon" title="Attached for this message">📌</span>
<router-link :to="`/notes/${attachedNote.id}`" class="context-note-name">
{{ attachedNote.title }}
</router-link>
<button class="context-note-remove" @click="removeAttachedNote" title="Remove">&times;</button>
</div>
<p
v-if="!store.currentConversation.messages.length && !store.streaming"
class="empty-msg"
>
Send a message to start the conversation.
</p>
</div>
<!-- Auto-found notes persist across turns -->
<div v-for="note in contextNotes" :key="note.id" class="context-note">
<router-link :to="`/notes/${note.id}`" class="context-note-name">
{{ note.title }}
</router-link>
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context">&times;</button>
</div>
</aside>
</div>
<div class="input-wrapper">
<!-- Attached note pill -->
<div v-if="attachedNote" class="attached-note">
<span class="attached-note-pill">
{{ attachedNote.title }}
<button class="attached-note-remove" @click="removeAttachedNote">&times;</button>
</span>
</div>
<div class="input-area">
<!-- Note picker button -->
<div class="note-picker-wrapper">
@@ -656,6 +663,13 @@ onUnmounted(() => {
cursor: default;
}
.chat-body {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.messages-container {
flex: 1;
overflow-y: auto;
@@ -666,6 +680,68 @@ onUnmounted(() => {
margin: 0 auto;
width: 100%;
}
.context-sidebar {
width: 220px;
min-width: 180px;
border-left: 1px solid var(--color-border);
background: var(--color-bg-secondary);
display: flex;
flex-direction: column;
padding: 0.75rem;
gap: 0.35rem;
overflow-y: auto;
}
.context-sidebar-header {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.25rem;
}
.context-note {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.4rem;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
font-size: 0.82rem;
}
.context-note-pinned {
border-color: var(--color-primary);
}
.context-note-icon {
font-size: 0.75rem;
flex-shrink: 0;
}
.context-note-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
text-decoration: none;
}
.context-note-name:hover {
color: var(--color-primary);
}
.context-note-remove {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 1rem;
line-height: 1;
padding: 0 0.1rem;
flex-shrink: 0;
}
.context-note-remove:hover {
color: var(--color-danger, #e74c3c);
}
.messages-inner {
margin-top: auto;
}
@@ -743,67 +819,6 @@ onUnmounted(() => {
50% { opacity: 1; }
}
/* Context pills */
.context-pills {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0;
}
.context-pills-label {
font-size: 0.75rem;
color: var(--color-text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.context-pill {
display: inline-flex;
align-items: center;
gap: 0.15rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 0.15rem 0.35rem 0.15rem 0.5rem;
font-size: 0.8rem;
}
.context-pill-link {
color: var(--color-primary);
text-decoration: none;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-pill-link:hover {
text-decoration: underline;
}
.context-pill-btn {
background: none;
border: none;
cursor: pointer;
font-size: 0.85rem;
line-height: 1;
padding: 0 0.15rem;
color: var(--color-text-muted);
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.context-pill-btn:hover {
background: var(--color-border);
}
.context-pill-btn.promote:hover {
color: var(--color-primary);
}
.context-pill-btn.exclude:hover {
color: var(--color-danger, #e74c3c);
}
/* Input wrapper */
.input-wrapper {
max-width: 960px;
@@ -813,33 +828,6 @@ onUnmounted(() => {
box-sizing: border-box;
}
/* Attached note */
.attached-note {
padding: 0.25rem 0.5rem;
}
.attached-note-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--color-primary);
color: #fff;
border-radius: 12px;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
}
.attached-note-remove {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0 0.15rem;
}
.attached-note-remove:hover {
color: #fff;
}
/* Floating input bar */
.input-area {
display: flex;
@@ -1038,6 +1026,9 @@ onUnmounted(() => {
background: var(--color-overlay);
z-index: 99;
}
.context-sidebar {
display: none;
}
.messages-container {
padding: 0.75rem;
}
+9 -1
View File
@@ -23,6 +23,7 @@ from fabledassistant.services.generation_buffer import (
get_buffer,
)
from fabledassistant.services.generation_task import run_generation
from fabledassistant.services.notes import get_notes_by_ids
from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__)
@@ -62,7 +63,14 @@ async def get_conversation_route(conv_id: int):
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
result = conv.to_dict()
result["messages"] = [m.to_dict() for m in conv.messages]
note_ids = [m.context_note_id for m in conv.messages if m.context_note_id]
note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {}
result["messages"] = []
for m in conv.messages:
msg_dict = m.to_dict()
if m.context_note_id and m.context_note_id in note_map:
msg_dict["context_note_title"] = note_map[m.context_note_id].title
result["messages"].append(msg_dict)
return jsonify(result)
+15
View File
@@ -530,6 +530,21 @@ async def list_todos(
return await asyncio.to_thread(_list)
async def search_todos(
user_id: int,
query: str,
include_completed: bool = False,
calendar_name: str | None = None,
) -> list[dict]:
"""Search CalDAV todos by keyword in summary or description."""
todos = await list_todos(user_id, include_completed=include_completed, calendar_name=calendar_name)
q = query.lower()
return [
t for t in todos
if q in t.get("summary", "").lower() or q in (t.get("description") or "").lower()
]
async def complete_todo(
user_id: int,
query: str,
@@ -41,6 +41,10 @@ _TOOL_LABELS: dict[str, str] = {
"create_task": "Creating task",
"create_note": "Creating note",
"update_note": "Updating note",
"delete_note": "Deleting note",
"delete_task": "Deleting task",
"get_note": "Reading note",
"list_notes": "Listing notes",
"list_tasks": "Searching tasks",
"search_notes": "Searching notes",
"create_event": "Creating calendar event",
@@ -51,6 +55,7 @@ _TOOL_LABELS: dict[str, str] = {
"list_calendars": "Listing calendars",
"create_todo": "Creating todo",
"list_todos": "Listing todos",
"search_todos": "Searching todos",
"update_todo": "Updating todo",
"complete_todo": "Completing todo",
"delete_todo": "Removing todo",
@@ -58,7 +63,7 @@ _TOOL_LABELS: dict[str, str] = {
# Tools that write data and require explicit user confirmation before executing.
_WRITE_TOOLS: frozenset[str] = frozenset({
"create_task", "create_note", "update_note",
"create_task", "create_note", "update_note", "delete_note", "delete_task",
"create_event", "update_event", "delete_event",
"create_todo", "update_todo", "complete_todo", "delete_todo",
})
@@ -68,8 +73,13 @@ _TOOL_ACTIONS: dict[str, str] = {
"create_task": "create a task",
"create_note": "create a new note",
"update_note": "update an existing note",
"delete_note": "permanently delete a note",
"delete_task": "permanently delete a task",
"get_note": "read a note",
"list_notes": "list notes",
"list_tasks": "look up tasks",
"search_notes": "search through notes",
"search_todos": "search calendar todos",
"create_event": "schedule a calendar event",
"list_events": "check the calendar",
"search_events": "search calendar events",
@@ -351,7 +361,7 @@ async def run_generation(
# Invalidate the note context cache after any successful note write
# so the next turn can pick up newly created/modified notes.
if result.get("success") and tool_name in {"create_task", "create_note", "update_note"}:
if result.get("success") and tool_name in {"create_task", "create_note", "update_note", "delete_note", "delete_task"}:
clear_conv_note_cache(conv_id)
tool_record = {
@@ -422,7 +432,7 @@ async def run_generation(
timing["tools"].append({"name": tool_name, "ms": int((time.monotonic() - t_tool) * 1000)})
logger.info("Tool %s result: success=%s", tool_name, result.get("success"))
if result.get("success") and tool_name in {"create_task", "create_note", "update_note"}:
if result.get("success") and tool_name in {"create_task", "create_note", "update_note", "delete_note", "delete_task"}:
clear_conv_note_cache(conv_id)
tool_record = {
+6
View File
@@ -89,10 +89,16 @@ Rules:
- "which calendars", "list calendars", "my calendars" → use list_calendars.
- "create a calendar todo", "add a CalDAV todo" → use create_todo. Default to create_task for general tasks unless the user explicitly mentions CalDAV or calendar todo.
- "show calendar todos", "list calendar todos" → use list_todos.
- "find calendar todo", "search calendar todos", "find todo named X" → use search_todos with query=<keyword>.
- "completed/finished calendar todo" → use complete_todo.
- "delete/remove calendar todo" → use delete_todo.
- "remind me X minutes/hours before" an event → convert to reminder_minutes parameter on create_event.
- When the user asks about events in a time period (e.g. "events in September", "what's on next week", "my schedule for March"), use list_events with date_from/date_to covering that period. Do NOT use search_events for time-based queries — search_events is only for keyword matching against event titles.
- "delete", "remove", "trash", "get rid of" a note (not a task) → use delete_note with query=<note name/keyword>. NEVER use this for tasks.
- "delete", "remove", "trash", "get rid of" a task → use delete_task with query=<task name/keyword>. NEVER use this for notes.
- "read", "open", "show me", "what does X say", "display", "pull up" a specific note → use get_note with query=<note name>.
- "list my notes", "show notes", "recent notes", "browse notes", "notes tagged X" → use list_notes (with optional q or tags).
- "tag X with Y", "add tag Y to X", "untag Y from X", "remove tag Y from X" → use update_note with tags=[Y] and tag_mode="add" or "remove".
- Do NOT wrap the JSON in markdown code fences."""
+8 -4
View File
@@ -324,23 +324,27 @@ async def build_context(
tool_lines = [
"You have access to tool functions. You MUST use them when the user asks you to create, add, find, schedule, or search for anything.",
"CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.",
"Available actions: create_task, create_note, update_note, list_tasks, search_notes.",
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes.",
]
if has_caldav:
tool_lines[-1] = (
"Available actions: create_task, create_note, update_note, list_tasks, search_notes, "
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes, "
"create_event, list_events, search_events, update_event, delete_event, "
"list_calendars, create_todo, list_todos, update_todo, complete_todo, delete_todo."
"list_calendars, create_todo, list_todos, search_todos, update_todo, complete_todo, delete_todo."
)
tool_lines.append("For calendar events, use ISO 8601 datetime format (e.g. 2026-09-30T00:00:00).")
tool_lines.append("CalDAV todos are separate from app tasks. Use create_task for app tasks, create_todo for CalDAV todos.")
tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.")
tool_lines.append("Use search_todos to find a specific CalDAV todo by keyword when list_todos would return too many results.")
tool_lines.append("For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format.")
tool_lines.append(
"Use update_note to edit/expand an existing note OR to update a task's status/priority/due_date. "
"Use create_note ONLY for genuinely new notes with a different title. "
"Use list_tasks to find tasks by status, priority, or due date (e.g. overdue, high priority, in progress). "
"If a note was created earlier in the conversation and the user provides more content for it, use update_note."
"If a note was created earlier in the conversation and the user provides more content for it, use update_note. "
"Use get_note to read the full content of a specific note. "
"Use list_notes to browse notes by recency or tag. "
"Use delete_note / delete_task only when explicitly asked to delete — these require confirmation."
)
tool_guidance = "\n".join(tool_lines)
+11
View File
@@ -250,6 +250,17 @@ async def search_notes_for_context(
return list(result.scalars().all())
async def get_notes_by_ids(user_id: int, note_ids: list[int]) -> dict[int, Note]:
"""Batch fetch notes by ID list. Returns {note_id: Note}."""
if not note_ids:
return {}
async with async_session() as session:
result = await session.execute(
select(Note).where(Note.user_id == user_id, Note.id.in_(note_ids))
)
return {n.id: n for n in result.scalars().all()}
async def get_backlinks(user_id: int, note_id: int) -> list[dict]:
note = await get_note(user_id, note_id)
if note is None:
+264 -2
View File
@@ -15,10 +15,11 @@ from fabledassistant.services.caldav import (
list_events,
list_todos,
search_events,
search_todos,
update_event,
update_todo,
)
from fabledassistant.services.notes import create_note, get_note_by_title, list_notes, update_note
from fabledassistant.services.notes import create_note, delete_note, get_note_by_title, list_notes, update_note
from fabledassistant.services.tag_suggestions import suggest_tags
logger = logging.getLogger(__name__)
@@ -132,6 +133,16 @@ _CORE_TOOLS = [
"type": "string",
"description": "New due date in YYYY-MM-DD format",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Tags to apply (see tag_mode for how they're applied)",
},
"tag_mode": {
"type": "string",
"enum": ["replace", "add", "remove"],
"description": "'replace' sets tags to exactly this list, 'add' appends without duplicates, 'remove' removes listed tags (default: replace)",
},
},
"required": ["query"],
},
@@ -149,6 +160,104 @@ _CORE_TOOLS = [
"type": "string",
"description": "Search query to find notes/tasks",
},
"type": {
"type": "string",
"enum": ["note", "task"],
"description": "Restrict results to only notes or only tasks. Omit to search both.",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "get_note",
"description": (
"Retrieve the full content of a specific note. Use this when the user asks to read, "
"view, or check what a particular note says. Returns the complete note body."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to identify the note",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "list_notes",
"description": (
"Browse the user's notes (not tasks). Use this when the user asks to see, list, or browse "
"their notes — by recency, keyword, or tag. For tasks, use list_tasks instead."
),
"parameters": {
"type": "object",
"properties": {
"q": {
"type": "string",
"description": "Optional keyword filter",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Filter notes that have ALL of these tags",
},
"sort": {
"type": "string",
"enum": ["updated_at", "created_at", "title"],
"description": "Sort field (default: updated_at)",
},
"limit": {
"type": "integer",
"description": "Maximum number of notes to return (default 10)",
},
},
},
},
},
{
"type": "function",
"function": {
"name": "delete_note",
"description": (
"Delete a note permanently. Use ONLY when the user explicitly asks to delete or remove a note. "
"This action requires user confirmation and cannot be undone."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to find the note to delete",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "delete_task",
"description": (
"Delete a task permanently. Use ONLY when the user explicitly asks to delete or remove a task. "
"This action requires user confirmation and cannot be undone."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to find the task to delete",
},
},
"required": ["query"],
},
@@ -518,6 +627,34 @@ _CALDAV_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "search_todos",
"description": (
"Search CalDAV todos by keyword. Use this when the user asks to find a specific calendar todo "
"by name or content, rather than listing all todos."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Keyword to search for in todo summary or description",
},
"include_completed": {
"type": "boolean",
"description": "Include completed todos in search (default false)",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name to restrict search",
},
},
"required": ["query"],
},
},
},
]
@@ -622,6 +759,21 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
update_fields["priority"] = arguments["priority"]
if "due_date" in arguments:
update_fields["due_date"] = _parse_due_date(arguments["due_date"])
if "tags" in arguments:
new_tags = arguments["tags"]
if isinstance(new_tags, list):
tag_mode = arguments.get("tag_mode", "replace")
if tag_mode == "add":
existing = list(note.tags or [])
for t in new_tags:
if t not in existing:
existing.append(t)
update_fields["tags"] = existing
elif tag_mode == "remove":
existing = list(note.tags or [])
update_fields["tags"] = [t for t in existing if t not in new_tags]
else:
update_fields["tags"] = new_tags
updated = await update_note(user_id, note.id, **update_fields)
if updated is None:
@@ -673,7 +825,13 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
elif tool_name == "search_notes":
query = arguments.get("query", "")
notes, total = await list_notes(user_id=user_id, q=query, limit=5)
type_filter = arguments.get("type")
is_task: bool | None = None
if type_filter == "note":
is_task = False
elif type_filter == "task":
is_task = True
notes, total = await list_notes(user_id=user_id, q=query, is_task=is_task, limit=5)
results = []
for n in notes:
results.append({
@@ -859,6 +1017,110 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"data": result,
}
elif tool_name == "search_todos":
results = await search_todos(
user_id=user_id,
query=arguments.get("query", ""),
include_completed=bool(arguments.get("include_completed", False)),
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "todos",
"data": {
"count": len(results),
"todos": results,
},
}
elif tool_name == "get_note":
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, limit=3)
if not notes:
return {"success": False, "error": f"No note found matching '{query}'."}
note = notes[0]
return {
"success": True,
"type": "note_content",
"data": {
"id": note.id,
"title": note.title,
"body": note.body or "",
"tags": note.tags or [],
},
}
elif tool_name == "list_notes":
tags_raw = arguments.get("tags")
tags = tags_raw if isinstance(tags_raw, list) else None
notes, total = await list_notes(
user_id=user_id,
q=arguments.get("q") or arguments.get("query"),
tags=tags,
is_task=False,
sort=arguments.get("sort", "updated_at"),
order="desc",
limit=int(arguments.get("limit", 10)),
)
results = [
{
"id": n.id,
"title": n.title,
"tags": n.tags or [],
"preview": (n.body[:200] + "...") if n.body and len(n.body) > 200 else (n.body or ""),
}
for n in notes
]
return {
"success": True,
"type": "notes_list",
"data": {
"total": total,
"count": len(results),
"results": results,
},
}
elif tool_name == "delete_note":
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, is_task=False, limit=3)
if not notes:
return {"success": False, "error": f"No note found matching '{query}'."}
note = notes[0]
if note.status is not None:
return {"success": False, "error": f"'{note.title}' is a task. Use delete_task instead."}
deleted = await delete_note(user_id, note.id)
if not deleted:
return {"success": False, "error": "Failed to delete note."}
return {
"success": True,
"type": "note_deleted",
"data": {"id": note.id, "title": note.title},
}
elif tool_name == "delete_task":
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, is_task=True, limit=3)
if not notes:
return {"success": False, "error": f"No task found matching '{query}'."}
note = notes[0]
if note.status is None:
return {"success": False, "error": f"'{note.title}' is a note. Use delete_note instead."}
deleted = await delete_note(user_id, note.id)
if not deleted:
return {"success": False, "error": "Failed to delete task."}
return {
"success": True,
"type": "task_deleted",
"data": {"id": note.id, "title": note.title},
}
else:
return {"success": False, "error": f"Unknown tool: {tool_name}"}
+24 -11
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-02-18 — Phase 11: streaming status transparency UX + model load state indicator
2026-02-19 — Phase 12: persistent context sidebar, note title in chat, expanded tool suite
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -207,6 +207,7 @@ for AI-assisted features.
- `context_note_id` tracks which note was attached as context when message was sent
- `tool_calls` stores an array of tool call records `[{function, arguments, result, status}]` for assistant messages that used tools
- `to_dict()` returns: `id`, `conversation_id`, `role`, `content`, `status`, `context_note_id`, `tool_calls`, `created_at`
- `context_note_title` is NOT a DB column — it is synthesised at query time in `get_conversation_route` via a batch `get_notes_by_ids()` lookup and injected into the message dict so the frontend can display the note title without a separate fetch
## Project Structure (Current)
```
@@ -265,9 +266,9 @@ fabledassistant/
│ │ ├── generation_buffer.py # In-memory SSE event buffer with cancel_event, reconnect support, auto-cleanup; supports chat (int keys) and assist (string keys)
│ │ ├── generation_task.py # Background asyncio tasks: run_generation (chat, DB flush, titles, intent routing + tool loop) + run_assist_generation (lightweight, no DB)
│ │ ├── intent.py # Intent routing: classify_intent() makes fast non-streaming LLM call to detect tool intent before streaming
│ │ ├── tools.py # LLM tool definitions (create_task, create_note, update_note, list_tasks, search_notes, full CalDAV suite incl. update_todo) + execute_tool dispatcher
│ │ ├── tools.py # LLM tool definitions (create/delete note+task, update_note w/tag management, get_note, list_notes, search_notes w/type filter, list_tasks, full CalDAV suite incl. search_todos) + execute_tool dispatcher
│ │ ├── tag_suggestions.py # LLM-powered tag suggestions: suggest_tags() builds prompt with existing tags, calls generate_completion, parses JSON response
│ │ ├── caldav.py # CalDAV integration: full event lifecycle (create/list/search/update/delete), todos (create/list/complete/delete), list_calendars, timezone (ZoneInfo), reminders (VALARM), attendees, multi-calendar search
│ │ ├── caldav.py # CalDAV integration: full event lifecycle (create/list/search/update/delete), todos (create/list/search/update/complete/delete), list_calendars, timezone (ZoneInfo), reminders (VALARM), attendees, multi-calendar search
│ │ ├── settings.py # Settings CRUD with user_id isolation: get_setting, set_setting, set_settings_batch, get_all_settings
│ │ ├── logging.py # App logging: log_audit, log_usage, log_error, get_logs, get_log_stats, delete_old_logs, start_log_retention_loop
│ │ ├── email.py # SMTP email service: get_smtp_config, is_smtp_configured, send_email, send_test_email
@@ -320,7 +321,7 @@ fabledassistant/
│ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled
│ │ ├── RegisterInviteView.vue # Invitation-based registration: validates token, creates account with pre-set email
│ │ ├── UserManagementView.vue # Admin user management: registration toggle, invitations (send/revoke), user list with delete
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context pills, model selector in header
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, persistent context sidebar (right panel, hidden mobile), model selector in header
│ │ ├── HomeView.vue # Actionable dashboard: overdue, due today, due this week, high priority, in progress tasks; recent chats with inline chat input; recently edited notes; model warming on mount
│ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin)
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
@@ -335,7 +336,7 @@ fabledassistant/
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop), bubble-style messages, floating dark input, note picker, context pills with promote/exclude
│ │ ├── ModelSelector.vue # Model dropdown (v-model pattern): fetches installed + running models, hot/cold indicators
│ │ ├── DashboardChatInput.vue # Inline chat bar: ModelSelector + note picker + textarea + send button; emits submit event
│ │ ├── ToolCallCard.vue # Compact card for tool call results (created task/note link, search results, errors) + suggested tag pills with apply-on-click
│ │ ├── ToolCallCard.vue # Compact card for tool call results (created/deleted task/note, note content, notes list, search results, CalDAV events/todos, errors) + suggested tag pills with apply-on-click
│ │ ├── ChatMessage.vue # Message bubble: markdown rendering, tool call cards, configurable assistant name label, "Save as Note" action on assistant messages
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit, hover edit button
│ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags, hover edit button
@@ -542,7 +543,12 @@ When adding a new migration, follow these conventions:
- Background generation with `GenerationBuffer` (in-memory SSE fan-out, `Last-Event-ID` reconnect, 60s cleanup)
- Stop generation with partial content preservation
- Note-aware context building: current note + keyword search for related notes + URL fetching
- Context pills with promote/exclude controls; note picker (paperclip) in chat input
- **Persistent context sidebar:** Right panel in ChatView accumulates auto-found notes across turns.
Manually attached note appears with 📌 pin, clears after send. × excludes from future auto-search.
Hidden on mobile (≤768px). Replaces old ephemeral context pills in the message stream.
- Note picker (paperclip) in chat input; attached note title passed to store for optimistic render.
`context_note_title` synthesised server-side at conversation load via batch `get_notes_by_ids()`;
message badge shows note title instead of "Note #N".
- LLM-generated conversation titles (re-generated every 10th message)
- Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations
- Dedicated `/chat` page with responsive sidebar + slide-out chat panel from header
@@ -573,12 +579,17 @@ When adding a new migration, follow these conventions:
Full tool suite:
- `create_task` / `create_note` — create new items
- `update_note` — edit note content (replace/append) AND update task fields: `status`, `priority`,
`due_date`; finds by exact title first, falls back to fuzzy search; prevents duplicate notes
`due_date`, `tags` (with `tag_mode`: replace/add/remove); finds by exact title first, falls back to fuzzy search
- `delete_note` / `delete_task` — permanently delete (require user confirmation via confirm UI;
validates type so delete_note won't delete a task and vice versa; clears note context cache)
- `get_note` — retrieve full note body by title/keyword (search_notes only returns 200-char preview)
- `list_notes` — browse notes by recency/keyword/tags with configurable limit; notes only (use list_tasks for tasks)
- `list_tasks` — filter tasks by `status`, `priority`, `due_before`, `due_after`, `limit`;
backed by `list_notes(is_task=True)`; enables "overdue tasks", "high priority", "in progress" queries
- `search_notes` — keyword search across notes and tasks
- `search_notes` — keyword search across notes and tasks; optional `type: "note"|"task"` filter
- Full CalDAV suite: `create_event`, `list_events`, `search_events`, `update_event`, `delete_event`,
`list_calendars`, `create_todo`, `list_todos`, `update_todo`, `complete_todo`, `delete_todo`
`list_calendars`, `create_todo`, `list_todos`, `search_todos`, `update_todo`, `complete_todo`, `delete_todo`
(`search_todos` keyword-filters the todo list — companion to `list_todos`)
- **Streaming status transparency:** The backend emits `status` SSE events at each pipeline stage
so the user always sees what's happening instead of a blank progress dot. Stages:
(1) `"Analyzing your request..."` before intent classification; (2) human-readable tool label
@@ -597,11 +608,13 @@ When adding a new migration, follow these conventions:
Dedicated intent model configurable via `OLLAMA_INTENT_MODEL` env var or per-user `intent_model`
setting — allows a smaller/faster model for routing while the main model handles responses.
Intent router rules cover: update/delete events, CalDAV todos, time-period → list_events (not
search_events), update_note vs create_note disambiguation, reminder_minutes conversion.
search_events), update_note vs create_note disambiguation, reminder_minutes conversion,
delete_note vs delete_task disambiguation, get_note for "read/show me this note", list_notes for
"browse/list notes", tag management via update_note (tag_mode add/remove), search_todos.
- **CalDAV calendar integration:** Per-user CalDAV settings (URL, username, password, calendar name, timezone).
LLM tools: `create_event` (all_day, recurrence, timezone, reminder_minutes, attendees, calendar_name),
`list_events`, `search_events`, `update_event`, `delete_event`, `list_calendars`,
`create_todo`, `list_todos`, `complete_todo`, `delete_todo`.
`create_todo`, `list_todos`, `search_todos`, `update_todo`, `complete_todo`, `delete_todo`.
All-day events use iCalendar DATE values; recurrence uses RRULE (e.g. `FREQ=YEARLY`).
VALARM components added for reminders. Attendees via mailto: vCalAddress.
Multi-calendar search: when no specific calendar configured, all calendars are scanned.