From 316a85e13b179b40ab28fd3d317ebbc381dd8a39 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 26 Feb 2026 06:36:35 -0500 Subject: [PATCH] =?UTF-8?q?Phase=2020:=20Dedicated=20tag=20field=20?= =?UTF-8?q?=E2=80=94=20chip=20input,=20explicit=20tags=20array?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags are now a first-class field rather than being auto-extracted from the note body. A new TagInput.vue chip component handles tag entry in both editor views with autocomplete, Enter/comma/backspace UX, and space-to-hyphen sanitization. Backend: - routes/notes.py: create reads tags from JSON; update accepts explicit tags (omit = keep existing); append_tag writes to tags array with dedup; suggest-tags accepts current_tags filter; remove extract_tags - routes/tasks.py: same — explicit tags on create/update; remove extract_tags - services/tag_suggestions.py: current_tags param replaces body extraction - services/tools.py: create_note tool schema adds tags param; executor passes it - services/llm.py: system prompt tells LLM to use tags param, not embed #tag in body Frontend: - components/TagInput.vue: new chip-based tag input (autocomplete, keyboard UX) - NoteEditorView.vue / TaskEditorView.vue: tags ref loaded from note.tags; TagInput placed between title and body; save/autosave include tags; suggest now adds chips; fetchTagSuggestions passes current_tags; dirty tracks tags - TiptapEditor.vue: remove fetchTags prop and TagSuggestion extension; keep TagDecoration for legacy inline #tag highlighting No DB migration needed — tags column already correct. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/TagInput.vue | 212 ++++++++++++++++++ frontend/src/components/TiptapEditor.vue | 6 - frontend/src/stores/notes.ts | 3 +- frontend/src/stores/tasks.ts | 3 +- frontend/src/views/NoteEditorView.vue | 30 ++- frontend/src/views/TaskEditorView.vue | 24 +- src/fabledassistant/routes/notes.py | 18 +- src/fabledassistant/routes/tasks.py | 7 +- src/fabledassistant/services/llm.py | 2 +- .../services/tag_suggestions.py | 11 +- src/fabledassistant/services/tools.py | 7 + summary.md | 47 ++-- 12 files changed, 315 insertions(+), 55 deletions(-) create mode 100644 frontend/src/components/TagInput.vue diff --git a/frontend/src/components/TagInput.vue b/frontend/src/components/TagInput.vue new file mode 100644 index 0000000..4fa8f36 --- /dev/null +++ b/frontend/src/components/TagInput.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/frontend/src/components/TiptapEditor.vue b/frontend/src/components/TiptapEditor.vue index a971193..5152bcf 100644 --- a/frontend/src/components/TiptapEditor.vue +++ b/frontend/src/components/TiptapEditor.vue @@ -9,18 +9,15 @@ import DOMPurify from "dompurify"; import { serializeToMarkdown } from "@/utils/markdownSerializer"; import { TagDecoration } from "@/extensions/TagDecoration"; import { WikilinkDecoration } from "@/extensions/WikilinkDecoration"; -import { TagSuggestion } from "@/extensions/TagSuggestion"; import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion"; const props = withDefaults( defineProps<{ modelValue: string; placeholder?: string; - fetchTags?: (query: string) => Promise; }>(), { placeholder: "", - fetchTags: undefined, } ); @@ -72,9 +69,6 @@ try { }), TagDecoration, WikilinkDecoration, - TagSuggestion.configure({ - fetchTags: props.fetchTags ?? (async () => []), - }), WikilinkSuggestion, ], onUpdate({ editor: ed }) { diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index f718f94..6494f3a 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -77,6 +77,7 @@ export const useNotesStore = defineStore("notes", () => { async function createNote(data: { title: string; body: string; + tags?: string[]; }): Promise { try { return await apiPost("/api/notes", data); @@ -88,7 +89,7 @@ export const useNotesStore = defineStore("notes", () => { async function updateNote( id: number, - data: Partial> + data: Partial> ): Promise { try { const note = await apiPut(`/api/notes/${id}`, data); diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index d46c5fb..7641adc 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -65,6 +65,7 @@ export const useTasksStore = defineStore("tasks", () => { async function createTask(data: { title: string; body: string; + tags?: string[]; status?: TaskStatus; priority?: TaskPriority; due_date?: string | null; @@ -80,7 +81,7 @@ export const useTasksStore = defineStore("tasks", () => { async function updateTask( id: number, data: Partial< - Pick + Pick > ): Promise { try { diff --git a/frontend/src/views/NoteEditorView.vue b/frontend/src/views/NoteEditorView.vue index b5e08c9..9312c6d 100644 --- a/frontend/src/views/NoteEditorView.vue +++ b/frontend/src/views/NoteEditorView.vue @@ -9,6 +9,7 @@ import { apiPost } from "@/api/client"; import type { Editor } from "@tiptap/vue-3"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import TiptapEditor from "@/components/TiptapEditor.vue"; +import TagInput from "@/components/TagInput.vue"; const route = useRoute(); const router = useRouter(); @@ -17,6 +18,7 @@ const toast = useToastStore(); const title = ref(""); const body = ref(""); +const tags = ref([]); const dirty = ref(false); const saving = ref(false); const showPreview = ref(false); @@ -106,6 +108,7 @@ async function fetchTagSuggestions() { const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", { title: title.value, body: body.value, + current_tags: tags.value, }); suggestedTags.value = res.suggested_tags; } catch { @@ -117,7 +120,9 @@ async function fetchTagSuggestions() { function applyTagSuggestion(tag: string) { if (appliedTags.value.has(tag)) return; - body.value = body.value.trimEnd() + `\n#${tag}`; + if (!tags.value.includes(tag)) { + tags.value = [...tags.value, tag]; + } appliedTags.value.add(tag); markDirty(); } @@ -130,9 +135,13 @@ function dismissTagSuggestions() { // Track saved state for dirty detection let savedTitle = ""; let savedBody = ""; +let savedTags: string[] = []; function markDirty() { - dirty.value = title.value !== savedTitle || body.value !== savedBody; + dirty.value = + title.value !== savedTitle || + body.value !== savedBody || + JSON.stringify(tags.value) !== JSON.stringify(savedTags); } function onBodyUpdate(newVal: string) { @@ -146,8 +155,10 @@ onMounted(async () => { if (store.currentNote) { title.value = store.currentNote.title; body.value = store.currentNote.body; + tags.value = [...(store.currentNote.tags || [])]; savedTitle = title.value; savedBody = body.value; + savedTags = [...tags.value]; } } }); @@ -160,15 +171,18 @@ async function save() { await store.updateNote(noteId.value!, { title: title.value, body: body.value, + tags: tags.value, }); savedTitle = title.value; savedBody = body.value; + savedTags = [...tags.value]; dirty.value = false; toast.show("Note saved"); } else { const note = await store.createNote({ title: title.value, body: body.value, + tags: tags.value, }); dirty.value = false; toast.show("Note created"); @@ -209,9 +223,10 @@ async function autoSave() { if (!isEditing.value || !dirty.value || saving.value) return; saving.value = true; try { - await store.updateNote(noteId.value!, { title: title.value, body: body.value }); + await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value }); savedTitle = title.value; savedBody = body.value; + savedTags = [...tags.value]; dirty.value = false; toast.show("Auto-saved"); } catch { @@ -275,6 +290,12 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload)); @input="markDirty" /> + +
+ +