Phase 20: Dedicated tag field — chip input, explicit tags array

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 06:36:35 -05:00
parent 7947134e22
commit 316a85e13b
12 changed files with 315 additions and 55 deletions
+212
View File
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { ref, nextTick } from "vue";
const props = defineProps<{
modelValue: string[];
fetchTags: (q: string) => Promise<string[]>;
}>();
const emit = defineEmits<{
"update:modelValue": [value: string[]];
}>();
const inputValue = ref("");
const suggestions = ref<string[]>([]);
const showSuggestions = ref(false);
const inputRef = ref<HTMLInputElement | null>(null);
let fetchDebounce: ReturnType<typeof setTimeout> | null = null;
function sanitize(raw: string): string {
return raw.trim().replace(/\s+/g, "-").replace(/^#+/, "");
}
function addTag(raw: string) {
const tag = sanitize(raw);
if (!tag) return;
if (!props.modelValue.includes(tag)) {
emit("update:modelValue", [...props.modelValue, tag]);
}
inputValue.value = "";
suggestions.value = [];
showSuggestions.value = false;
}
function removeTag(tag: string) {
emit("update:modelValue", props.modelValue.filter((t) => t !== tag));
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
const val = inputValue.value.replace(/,$/, "");
if (val.trim()) {
addTag(val);
}
} else if (e.key === "Backspace" && !inputValue.value) {
const tags = props.modelValue;
if (tags.length > 0) {
emit("update:modelValue", tags.slice(0, -1));
}
} else if (e.key === "Escape") {
showSuggestions.value = false;
}
}
function onInput() {
const q = inputValue.value.trim().replace(/^#+/, "");
if (fetchDebounce !== null) clearTimeout(fetchDebounce);
if (!q) {
suggestions.value = [];
showSuggestions.value = false;
return;
}
fetchDebounce = setTimeout(async () => {
try {
const results = await props.fetchTags(q);
suggestions.value = results.filter((t) => !props.modelValue.includes(t));
showSuggestions.value = suggestions.value.length > 0;
} catch {
suggestions.value = [];
}
}, 200);
}
function onBlur() {
// Small delay to allow click on suggestion
setTimeout(() => {
showSuggestions.value = false;
// Commit any pending text on blur
if (inputValue.value.trim()) {
addTag(inputValue.value);
}
}, 150);
}
function focusInput() {
nextTick(() => inputRef.value?.focus());
}
</script>
<template>
<div class="tag-input-wrapper" @click="focusInput">
<span
v-for="tag in modelValue"
:key="tag"
class="tag-chip"
>
#{{ tag }}
<button
type="button"
class="tag-chip-remove"
@click.stop="removeTag(tag)"
tabindex="-1"
>&times;</button>
</span>
<div class="tag-input-inner">
<input
ref="inputRef"
v-model="inputValue"
type="text"
class="tag-input-field"
placeholder="Add tag..."
@keydown="onKeydown"
@input="onInput"
@blur="onBlur"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
<ul v-if="showSuggestions" class="tag-autocomplete">
<li
v-for="s in suggestions"
:key="s"
class="tag-autocomplete-item"
@mousedown.prevent="addTag(s)"
>
#{{ s }}
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.tag-input-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
cursor: text;
min-height: 2.25rem;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
border: 1px solid var(--color-primary);
color: var(--color-primary);
font-size: 0.8rem;
white-space: nowrap;
}
.tag-chip-remove {
border: none;
background: none;
color: inherit;
cursor: pointer;
padding: 0;
line-height: 1;
font-size: 0.85rem;
opacity: 0.7;
}
.tag-chip-remove:hover {
opacity: 1;
}
.tag-input-inner {
position: relative;
flex: 1;
min-width: 80px;
}
.tag-input-field {
width: 100%;
border: none;
outline: none;
background: transparent;
color: var(--color-text);
font-size: 0.875rem;
padding: 0;
}
.tag-autocomplete {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 160px;
max-width: 280px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
list-style: none;
margin: 0;
padding: 0.25rem 0;
z-index: 100;
}
.tag-autocomplete-item {
padding: 0.35rem 0.75rem;
font-size: 0.85rem;
cursor: pointer;
color: var(--color-text);
}
.tag-autocomplete-item:hover {
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
color: var(--color-primary);
}
</style>
-6
View File
@@ -9,18 +9,15 @@ import DOMPurify from "dompurify";
import { serializeToMarkdown } from "@/utils/markdownSerializer"; import { serializeToMarkdown } from "@/utils/markdownSerializer";
import { TagDecoration } from "@/extensions/TagDecoration"; import { TagDecoration } from "@/extensions/TagDecoration";
import { WikilinkDecoration } from "@/extensions/WikilinkDecoration"; import { WikilinkDecoration } from "@/extensions/WikilinkDecoration";
import { TagSuggestion } from "@/extensions/TagSuggestion";
import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion"; import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
modelValue: string; modelValue: string;
placeholder?: string; placeholder?: string;
fetchTags?: (query: string) => Promise<string[]>;
}>(), }>(),
{ {
placeholder: "", placeholder: "",
fetchTags: undefined,
} }
); );
@@ -72,9 +69,6 @@ try {
}), }),
TagDecoration, TagDecoration,
WikilinkDecoration, WikilinkDecoration,
TagSuggestion.configure({
fetchTags: props.fetchTags ?? (async () => []),
}),
WikilinkSuggestion, WikilinkSuggestion,
], ],
onUpdate({ editor: ed }) { onUpdate({ editor: ed }) {
+2 -1
View File
@@ -77,6 +77,7 @@ export const useNotesStore = defineStore("notes", () => {
async function createNote(data: { async function createNote(data: {
title: string; title: string;
body: string; body: string;
tags?: string[];
}): Promise<Note> { }): Promise<Note> {
try { try {
return await apiPost<Note>("/api/notes", data); return await apiPost<Note>("/api/notes", data);
@@ -88,7 +89,7 @@ export const useNotesStore = defineStore("notes", () => {
async function updateNote( async function updateNote(
id: number, id: number,
data: Partial<Pick<Note, "title" | "body">> data: Partial<Pick<Note, "title" | "body" | "tags">>
): Promise<Note> { ): Promise<Note> {
try { try {
const note = await apiPut<Note>(`/api/notes/${id}`, data); const note = await apiPut<Note>(`/api/notes/${id}`, data);
+2 -1
View File
@@ -65,6 +65,7 @@ export const useTasksStore = defineStore("tasks", () => {
async function createTask(data: { async function createTask(data: {
title: string; title: string;
body: string; body: string;
tags?: string[];
status?: TaskStatus; status?: TaskStatus;
priority?: TaskPriority; priority?: TaskPriority;
due_date?: string | null; due_date?: string | null;
@@ -80,7 +81,7 @@ export const useTasksStore = defineStore("tasks", () => {
async function updateTask( async function updateTask(
id: number, id: number,
data: Partial< data: Partial<
Pick<Task, "title" | "body" | "status" | "priority" | "due_date"> Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date">
> >
): Promise<Task> { ): Promise<Task> {
try { try {
+25 -5
View File
@@ -9,6 +9,7 @@ import { apiPost } from "@/api/client";
import type { Editor } from "@tiptap/vue-3"; import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue"; import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -17,6 +18,7 @@ const toast = useToastStore();
const title = ref(""); const title = ref("");
const body = ref(""); const body = ref("");
const tags = ref<string[]>([]);
const dirty = ref(false); const dirty = ref(false);
const saving = ref(false); const saving = ref(false);
const showPreview = ref(false); const showPreview = ref(false);
@@ -106,6 +108,7 @@ async function fetchTagSuggestions() {
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", { const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
title: title.value, title: title.value,
body: body.value, body: body.value,
current_tags: tags.value,
}); });
suggestedTags.value = res.suggested_tags; suggestedTags.value = res.suggested_tags;
} catch { } catch {
@@ -117,7 +120,9 @@ async function fetchTagSuggestions() {
function applyTagSuggestion(tag: string) { function applyTagSuggestion(tag: string) {
if (appliedTags.value.has(tag)) return; 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); appliedTags.value.add(tag);
markDirty(); markDirty();
} }
@@ -130,9 +135,13 @@ function dismissTagSuggestions() {
// Track saved state for dirty detection // Track saved state for dirty detection
let savedTitle = ""; let savedTitle = "";
let savedBody = ""; let savedBody = "";
let savedTags: string[] = [];
function markDirty() { 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) { function onBodyUpdate(newVal: string) {
@@ -146,8 +155,10 @@ onMounted(async () => {
if (store.currentNote) { if (store.currentNote) {
title.value = store.currentNote.title; title.value = store.currentNote.title;
body.value = store.currentNote.body; body.value = store.currentNote.body;
tags.value = [...(store.currentNote.tags || [])];
savedTitle = title.value; savedTitle = title.value;
savedBody = body.value; savedBody = body.value;
savedTags = [...tags.value];
} }
} }
}); });
@@ -160,15 +171,18 @@ async function save() {
await store.updateNote(noteId.value!, { await store.updateNote(noteId.value!, {
title: title.value, title: title.value,
body: body.value, body: body.value,
tags: tags.value,
}); });
savedTitle = title.value; savedTitle = title.value;
savedBody = body.value; savedBody = body.value;
savedTags = [...tags.value];
dirty.value = false; dirty.value = false;
toast.show("Note saved"); toast.show("Note saved");
} else { } else {
const note = await store.createNote({ const note = await store.createNote({
title: title.value, title: title.value,
body: body.value, body: body.value,
tags: tags.value,
}); });
dirty.value = false; dirty.value = false;
toast.show("Note created"); toast.show("Note created");
@@ -209,9 +223,10 @@ async function autoSave() {
if (!isEditing.value || !dirty.value || saving.value) return; if (!isEditing.value || !dirty.value || saving.value) return;
saving.value = true; saving.value = true;
try { 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; savedTitle = title.value;
savedBody = body.value; savedBody = body.value;
savedTags = [...tags.value];
dirty.value = false; dirty.value = false;
toast.show("Auto-saved"); toast.show("Auto-saved");
} catch { } catch {
@@ -275,6 +290,12 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
@input="markDirty" @input="markDirty"
/> />
<TagInput
v-model="tags"
:fetchTags="(q: string) => store.fetchAllTags(q)"
@update:modelValue="markDirty"
/>
<div class="tag-suggest-row"> <div class="tag-suggest-row">
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags"> <button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }} {{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
@@ -318,8 +339,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
<TiptapEditor <TiptapEditor
ref="editorRef" ref="editorRef"
:modelValue="body" :modelValue="body"
placeholder="Write your note in Markdown... Use #tags inline" placeholder="Write your note in Markdown..."
:fetchTags="(q: string) => store.fetchAllTags(q)"
@update:modelValue="onBodyUpdate" @update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange" @selectionChange="onSelectionChange"
/> />
+21 -3
View File
@@ -11,6 +11,7 @@ import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Editor } from "@tiptap/vue-3"; import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue"; import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -20,6 +21,7 @@ const toast = useToastStore();
const title = ref(""); const title = ref("");
const body = ref(""); const body = ref("");
const tags = ref<string[]>([]);
const status = ref<TaskStatus>("todo"); const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none"); const priority = ref<TaskPriority>("none");
const dueDate = ref(""); const dueDate = ref("");
@@ -112,6 +114,7 @@ async function fetchTagSuggestions() {
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", { const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
title: title.value, title: title.value,
body: body.value, body: body.value,
current_tags: tags.value,
}); });
suggestedTags.value = res.suggested_tags; suggestedTags.value = res.suggested_tags;
} catch { } catch {
@@ -123,7 +126,9 @@ async function fetchTagSuggestions() {
function applyTagSuggestion(tag: string) { function applyTagSuggestion(tag: string) {
if (appliedTags.value.has(tag)) return; 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); appliedTags.value.add(tag);
markDirty(); markDirty();
} }
@@ -135,6 +140,7 @@ function dismissTagSuggestions() {
let savedTitle = ""; let savedTitle = "";
let savedBody = ""; let savedBody = "";
let savedTags: string[] = [];
let savedStatus: TaskStatus = "todo"; let savedStatus: TaskStatus = "todo";
let savedPriority: TaskPriority = "none"; let savedPriority: TaskPriority = "none";
let savedDueDate = ""; let savedDueDate = "";
@@ -143,6 +149,7 @@ function markDirty() {
dirty.value = dirty.value =
title.value !== savedTitle || title.value !== savedTitle ||
body.value !== savedBody || body.value !== savedBody ||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
status.value !== savedStatus || status.value !== savedStatus ||
priority.value !== savedPriority || priority.value !== savedPriority ||
dueDate.value !== savedDueDate; dueDate.value !== savedDueDate;
@@ -159,11 +166,13 @@ onMounted(async () => {
if (store.currentTask) { if (store.currentTask) {
title.value = store.currentTask.title; title.value = store.currentTask.title;
body.value = store.currentTask.body; body.value = store.currentTask.body;
tags.value = [...(store.currentTask.tags || [])];
status.value = store.currentTask.status as TaskStatus; status.value = store.currentTask.status as TaskStatus;
priority.value = store.currentTask.priority as TaskPriority; priority.value = store.currentTask.priority as TaskPriority;
dueDate.value = store.currentTask.due_date || ""; dueDate.value = store.currentTask.due_date || "";
savedTitle = title.value; savedTitle = title.value;
savedBody = body.value; savedBody = body.value;
savedTags = [...tags.value];
savedStatus = status.value; savedStatus = status.value;
savedPriority = priority.value; savedPriority = priority.value;
savedDueDate = dueDate.value; savedDueDate = dueDate.value;
@@ -178,6 +187,7 @@ async function save() {
const data = { const data = {
title: title.value, title: title.value,
body: body.value, body: body.value,
tags: tags.value,
status: status.value, status: status.value,
priority: priority.value, priority: priority.value,
due_date: dueDate.value || null, due_date: dueDate.value || null,
@@ -186,6 +196,7 @@ async function save() {
await store.updateTask(taskId.value!, data); await store.updateTask(taskId.value!, data);
savedTitle = title.value; savedTitle = title.value;
savedBody = body.value; savedBody = body.value;
savedTags = [...tags.value];
savedStatus = status.value; savedStatus = status.value;
savedPriority = priority.value; savedPriority = priority.value;
savedDueDate = dueDate.value; savedDueDate = dueDate.value;
@@ -235,12 +246,14 @@ async function autoSave() {
await store.updateTask(taskId.value!, { await store.updateTask(taskId.value!, {
title: title.value, title: title.value,
body: body.value, body: body.value,
tags: tags.value,
status: status.value, status: status.value,
priority: priority.value, priority: priority.value,
due_date: dueDate.value || null, due_date: dueDate.value || null,
}); });
savedTitle = title.value; savedTitle = title.value;
savedBody = body.value; savedBody = body.value;
savedTags = [...tags.value];
savedStatus = status.value; savedStatus = status.value;
savedPriority = priority.value; savedPriority = priority.value;
savedDueDate = dueDate.value; savedDueDate = dueDate.value;
@@ -333,6 +346,12 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</div> </div>
</div> </div>
<TagInput
v-model="tags"
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
@update:modelValue="markDirty"
/>
<div class="tag-suggest-row"> <div class="tag-suggest-row">
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags"> <button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }} {{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
@@ -376,8 +395,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
<TiptapEditor <TiptapEditor
ref="editorRef" ref="editorRef"
:modelValue="body" :modelValue="body"
placeholder="Describe this task... Use #tags inline" placeholder="Describe this task..."
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
@update:modelValue="onBodyUpdate" @update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange" @selectionChange="onSelectionChange"
/> />
+9 -9
View File
@@ -30,7 +30,6 @@ from fabledassistant.services.notes import (
) )
from fabledassistant.services.settings import get_setting from fabledassistant.services.settings import get_setting
from fabledassistant.services.tag_suggestions import suggest_tags from fabledassistant.services.tag_suggestions import suggest_tags
from fabledassistant.utils.tags import extract_tags
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -67,7 +66,7 @@ async def create_note_route():
uid = get_current_user_id() uid = get_current_user_id()
data = await request.get_json() data = await request.get_json()
body = data.get("body", "") body = data.get("body", "")
tags = extract_tags(body) tags = data.get("tags", [])
# Optional task fields # Optional task fields
status = data.get("status") status = data.get("status")
@@ -111,7 +110,8 @@ async def suggest_tags_route():
data = await request.get_json() data = await request.get_json()
title = data.get("title", "") title = data.get("title", "")
body = data.get("body", "") body = data.get("body", "")
tags = await suggest_tags(uid, title, body) current_tags = data.get("current_tags", [])
tags = await suggest_tags(uid, title, body, current_tags=current_tags)
return jsonify({"suggested_tags": tags}) return jsonify({"suggested_tags": tags})
@@ -128,10 +128,10 @@ async def append_tag_route(note_id: int):
if note is None: if note is None:
return jsonify({"error": "Note not found"}), 404 return jsonify({"error": "Note not found"}), 404
# Append #tag to body existing = list(note.tags or [])
new_body = note.body.rstrip() + f"\n#{tag}" if note.body else f"#{tag}" if tag not in existing:
tags = extract_tags(new_body) existing.append(tag)
updated = await update_note(uid, note_id, body=new_body, tags=tags) updated = await update_note(uid, note_id, tags=existing)
return jsonify(updated.to_dict()) return jsonify(updated.to_dict())
@@ -189,8 +189,8 @@ async def update_note_route(note_id: int):
else: else:
fields["due_date"] = None fields["due_date"] = None
if "body" in fields: if "tags" in data:
fields["tags"] = extract_tags(fields["body"]) fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields) note = await update_note(uid, note_id, **fields)
if note is None: if note is None:
return jsonify({"error": "Note not found"}), 404 return jsonify({"error": "Note not found"}), 404
+3 -4
View File
@@ -11,7 +11,6 @@ from fabledassistant.services.notes import (
list_notes, list_notes,
update_note, update_note,
) )
from fabledassistant.utils.tags import extract_tags
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks") tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
@@ -60,7 +59,7 @@ async def create_task_route():
uid = get_current_user_id() uid = get_current_user_id()
data = await request.get_json() data = await request.get_json()
body = data.get("body", "") or data.get("description", "") body = data.get("body", "") or data.get("description", "")
tags = extract_tags(body) tags = data.get("tags", [])
due_date = None due_date = None
if data.get("due_date"): if data.get("due_date"):
@@ -121,8 +120,8 @@ async def update_task_route(task_id: int):
else: else:
fields["due_date"] = None fields["due_date"] = None
if "body" in fields: if "tags" in data:
fields["tags"] = extract_tags(fields["body"]) fields["tags"] = data["tags"]
task = await update_note(uid, task_id, **fields) task = await update_note(uid, task_id, **fields)
if task is None: if task is None:
+1 -1
View File
@@ -337,7 +337,7 @@ async def build_context(
tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.") 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("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("For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format.")
tool_lines.append("When writing #tags in note bodies, use hyphens for multi-word tags (e.g. #science-fiction, #space-travel). Never use spaces inside a tag.") tool_lines.append("When creating notes, use the `tags` parameter — do not embed #tag text in the note body.")
tool_lines.append( tool_lines.append(
"Use update_note to edit/expand an existing note OR to update a task's status/priority/due_date. " "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 create_note ONLY for genuinely new notes with a different title. "
@@ -8,16 +8,15 @@ from fabledassistant.config import Config
from fabledassistant.services.llm import generate_completion from fabledassistant.services.llm import generate_completion
from fabledassistant.services.notes import get_all_tags from fabledassistant.services.notes import get_all_tags
from fabledassistant.services.settings import get_setting from fabledassistant.services.settings import get_setting
from fabledassistant.utils.tags import extract_tags
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def suggest_tags(user_id: int, title: str, body: str) -> list[str]: async def suggest_tags(user_id: int, title: str, body: str, current_tags: list[str] | None = None) -> list[str]:
"""Suggest relevant tags for a note/task based on its content. """Suggest relevant tags for a note/task based on its content.
Returns a list of tag strings (without # prefix), excluding tags Returns a list of tag strings (without # prefix), excluding tags
already present in the body. already present in current_tags.
""" """
if not title.strip() and not body.strip(): if not title.strip() and not body.strip():
return [] return []
@@ -54,9 +53,9 @@ async def suggest_tags(user_id: int, title: str, body: str) -> list[str]:
tags = _parse_tag_list(response) tags = _parse_tag_list(response)
# Filter out tags already in the body # Filter out tags already applied
body_tags = set(extract_tags(body)) existing = set(current_tags or [])
tags = [t for t in tags if t not in body_tags] tags = [t for t in tags if t not in existing]
return tags[:5] return tags[:5]
+7
View File
@@ -81,6 +81,11 @@ _CORE_TOOLS = [
"type": "string", "type": "string",
"description": "The note content in markdown", "description": "The note content in markdown",
}, },
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Tags for the note (without # prefix, hyphens for multi-word: [\"science-fiction\", \"story/idea\"]). Do NOT embed #tags in the note body.",
},
}, },
"required": ["title"], "required": ["title"],
}, },
@@ -710,10 +715,12 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
elif tool_name == "create_note": elif tool_name == "create_note":
note_title = arguments.get("title", "Untitled Note") note_title = arguments.get("title", "Untitled Note")
note_body = arguments.get("body", "") note_body = arguments.get("body", "")
note_tags = arguments.get("tags", [])
note = await create_note( note = await create_note(
user_id=user_id, user_id=user_id,
title=note_title, title=note_title,
body=note_body, body=note_body,
tags=note_tags,
) )
suggested = await suggest_tags(user_id, note_title, note_body) suggested = await suggest_tags(user_id, note_title, note_body)
_schedule_embedding(note.id, user_id, note_title, note_body) _schedule_embedding(note.id, user_id, note_title, note_body)
+28 -19
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial. > Include file-level details in the commit body when the change is non-trivial.
## Last Updated ## Last Updated
2026-02-26 — Phase 19: Light mode indigo theme, OLLAMA_NUM_CTX, model dropdowns, optimistic streaming 2026-02-26 — Phase 20: Dedicated tag field (chip input), tags no longer extracted from body
## Project Overview ## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -41,10 +41,11 @@ for AI-assisted features.
intercepting API GETs. intercepting API GETs.
- **LLM integration is a separate service:** The app communicates with Ollama (or - **LLM integration is a separate service:** The app communicates with Ollama (or
compatible) over HTTP. compatible) over HTTP.
- **Inline tag extraction:** Tags are extracted from note/task body text using - **First-class tag field:** Tags live in the `tags ARRAY[text]` column and are
`#tag` syntax (Obsidian-style), not manually entered. Backend is source of truth explicitly set by the client — they are NOT auto-extracted from the body text.
for tag extraction. The `TAG_RE` regex uses `(?<!&)` negative lookbehind to avoid `extract_tags()` in `utils/tags.py` is preserved but no longer called on save.
matching HTML entities like `&#39;` as tags. `TagDecoration.ts` keeps visual `#tag` highlighting in existing notes that have
inline tags for cosmetic backward compatibility.
- **Hierarchical tags:** `#project/webapp` stored as `"project/webapp"`. Filtering - **Hierarchical tags:** `#project/webapp` stored as `"project/webapp"`. Filtering
by `project` matches both `project` and `project/*` children via SQL `unnest` + by `project` matches both `project` and `project/*` children via SQL `unnest` +
`LIKE` prefix. `LIKE` prefix.
@@ -159,7 +160,7 @@ for AI-assisted features.
`priority` (nullable str — `none`/`low`/`medium`/`high`), `due_date` (nullable date), `priority` (nullable str — `none`/`low`/`medium`/`high`), `due_date` (nullable date),
`created_at`, `updated_at` `created_at`, `updated_at`
- **A note is a task when `status IS NOT NULL`** — the `is_task` property checks this - **A note is a task when `status IS NOT NULL`** — the `is_task` property checks this
- Tags are auto-extracted from body text on create/update via `#tag` regex - Tags are explicitly provided by the client — NOT extracted from body text
- Supports hierarchical organization via `parent_id` - Supports hierarchical organization via `parent_id`
- Lookup by exact title via `get_note_by_title()` for wikilink resolution - Lookup by exact title via `get_note_by_title()` for wikilink resolution
- Auto-create via `get_or_create_note_by_title()` for wikilink clicks - Auto-create via `get_or_create_note_by_title()` for wikilink clicks
@@ -345,10 +346,10 @@ fabledassistant/
│ │ ├── HomeView.vue # Chat-first dashboard: quick actions + chat widget (top, full-width), inline response panel, two-column grid (3fr tasks / 2fr notes); task sections: Overdue, Due Today, Due This Week, High Priority, In Progress, Other (capped 10, due-dated first); 8 recent notes; model warming on mount │ │ ├── HomeView.vue # Chat-first dashboard: quick actions + chat widget (top, full-width), inline response panel, two-column grid (3fr tasks / 2fr notes); task sections: Overdue, Due Today, Due This Week, High Priority, In Progress, Other (capped 10, due-dated first); 8 recent notes; model warming on mount
│ │ ├── SettingsView.vue # Settings page: assistant name, chat/intent model dropdowns (populated from installed models), email change (with password confirmation for local-auth users), change password, notifications, CalDAV, SMTP (admin), base URL (admin), data export/restore (admin) │ │ ├── SettingsView.vue # Settings page: assistant name, chat/intent model dropdowns (populated from installed models), email change (with password confirmation for local-auth users), change password, notifications, CalDAV, SMTP (admin), base URL (admin), data export/restore (admin)
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination │ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
│ │ ├── NoteEditorView.vue # Create/edit: Tiptap editor, sticky toolbar, AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions, Ctrl+S, auto-save (5min), unsaved guard; styles from editor-shared.css │ │ ├── NoteEditorView.vue # Create/edit: Tiptap editor, sticky toolbar, TagInput chip field (between title and body), AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions (adds chips), Ctrl+S, auto-save (5min), unsaved guard; styles from editor-shared.css
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks, table of contents sidebar │ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks, table of contents sidebar
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination │ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination
│ │ ├── TaskEditorView.vue # Create/edit task: Tiptap editor, sticky toolbar, AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions, Ctrl+S, auto-save (5min), dirty guard; styles from editor-shared.css + task-specific scoped styles │ │ ├── TaskEditorView.vue # Create/edit task: Tiptap editor, sticky toolbar, TagInput chip field (between metadata fields and body), AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions (adds chips), Ctrl+S, auto-save (5min), dirty guard; styles from editor-shared.css + task-specific scoped styles
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges (isOverdue uses ISO string comparison), convert-to-note, backlinks, table of contents sidebar │ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges (isOverdue uses ISO string comparison), convert-to-note, backlinks, table of contents sidebar
│ ├── components/ │ ├── components/
│ │ ├── LogsView.vue # Admin log viewer: stats summary, category/search/date filters, paginated table with IP column + expandable detail rows (expands on ip_address or details) │ │ ├── LogsView.vue # Admin log viewer: stats summary, category/search/date filters, paginated table with IP column + expandable detail rows (expands on ip_address or details)
@@ -363,7 +364,8 @@ fabledassistant/
│ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling │ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
│ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none") │ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none")
│ │ ├── MarkdownToolbar.vue # Tiptap command-based toolbar: bold/italic/link/list/heading with active state highlighting │ │ ├── MarkdownToolbar.vue # Tiptap command-based toolbar: bold/italic/link/list/heading with active state highlighting
│ │ ├── TiptapEditor.vue # Tiptap wrapper: markdown↔HTML round-trip, paste handling, selection change emit (closest-match offset strategy), expose editor │ │ ├── TagInput.vue # Chip-based tag input: Enter/comma/click to confirm, Backspace removes last, × removes chip, autocomplete from /api/notes/tags, space→hyphen sanitization
│ │ ├── TiptapEditor.vue # Tiptap wrapper: markdown↔HTML round-trip, paste handling, selection change emit (closest-match offset strategy), expose editor; no fetchTags prop (TagSuggestion removed, TagDecoration kept)
│ │ ├── SuggestionDropdown.vue # Shared autocomplete dropdown for tag/wikilink suggestions │ │ ├── SuggestionDropdown.vue # Shared autocomplete dropdown for tag/wikilink suggestions
│ │ ├── SearchBar.vue # Debounced search input │ │ ├── SearchBar.vue # Debounced search input
│ │ ├── TagPill.vue # Clickable/dismissible tag pill │ │ ├── TagPill.vue # Clickable/dismissible tag pill
@@ -398,14 +400,14 @@ fabledassistant/
| GET | `/api/admin/logs` | List log entries (admin only, params: `category`, `user_id`, `search`, `date_from`, `date_to`, `limit`, `offset`) | | GET | `/api/admin/logs` | List log entries (admin only, params: `category`, `user_id`, `search`, `date_from`, `date_to`, `limit`, `offset`) |
| GET | `/api/admin/logs/stats` | Get log category counts (admin only) | | GET | `/api/admin/logs/stats` | Get log category counts (admin only) |
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`; defaults to `is_task=false` — plain notes only; `?is_task=true` for tasks, `?all=true` for everything) | | GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`; defaults to `is_task=false` — plain notes only; `?is_task=true` for tasks, `?all=true` for everything) |
| POST | `/api/notes` | Create note (body: `{title, body, status?, priority?, due_date?}` — tags auto-extracted) | | POST | `/api/notes` | Create note (body: `{title, body, tags?: string[], status?, priority?, due_date?}` — tags explicit array) |
| GET | `/api/notes/tags` | List all tags from notes table (param: `q` for filter) | | GET | `/api/notes/tags` | List all tags from notes table (param: `q` for filter) |
| POST | `/api/notes/suggest-tags` | LLM-suggest tags for content (body: `{title, body}`, returns `{suggested_tags: [...]}`) | | POST | `/api/notes/suggest-tags` | LLM-suggest tags (body: `{title, body, current_tags?: string[]}`, returns `{suggested_tags: [...]}`) |
| POST | `/api/notes/:id/append-tag` | Append `#tag` to note body (body: `{tag}`, returns updated note) | | POST | `/api/notes/:id/append-tag` | Add tag to note's tags array with deduplication (body: `{tag}`, returns updated note) |
| GET | `/api/notes/by-title?title=...` | Resolve note by exact title (case-insensitive) | | GET | `/api/notes/by-title?title=...` | Resolve note by exact title (case-insensitive) |
| POST | `/api/notes/resolve-title` | Get-or-create note by title (for wikilink clicks) | | POST | `/api/notes/resolve-title` | Get-or-create note by title (for wikilink clicks) |
| GET | `/api/notes/:id` | Get single note (response includes `is_task`, `status`, `priority`, `due_date`) | | GET | `/api/notes/:id` | Get single note (response includes `is_task`, `status`, `priority`, `due_date`) |
| PUT | `/api/notes/:id` | Update note (body: `{title?, body?, status?, priority?, due_date?}`tags re-extracted if body changes) | | PUT | `/api/notes/:id` | Update note (body: `{title?, body?, tags?: string[], status?, priority?, due_date?}`omitting tags keeps existing) |
| DELETE | `/api/notes/:id` | Delete note (simple delete, no cascade) | | DELETE | `/api/notes/:id` | Delete note (simple delete, no cascade) |
| POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` on note (returns 200) | | POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` on note (returns 200) |
| POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` from note (returns 200) | | POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` from note (returns 200) |
@@ -420,9 +422,9 @@ fabledassistant/
| GET | `/api/admin/invitations` | List pending invitations (admin only) | | GET | `/api/admin/invitations` | List pending invitations (admin only) |
| DELETE | `/api/admin/invitations/:id` | Revoke invitation (admin only) | | DELETE | `/api/admin/invitations/:id` | Revoke invitation (admin only) |
| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `sort`, `order`, `limit`, `offset`) — queries notes where `status IS NOT NULL` | | GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `sort`, `order`, `limit`, `offset`) — queries notes where `status IS NOT NULL` |
| POST | `/api/tasks` | Create task (body: `{title, body, status?, priority?, due_date?}` — accepts `description` as fallback for `body`) | | POST | `/api/tasks` | Create task (body: `{title, body, tags?: string[], status?, priority?, due_date?}` — accepts `description` as fallback for `body`) |
| GET | `/api/tasks/:id` | Get single task | | GET | `/api/tasks/:id` | Get single task |
| PUT | `/api/tasks/:id` | Update task (accepts `body` or `description`, prefers `body`) | | PUT | `/api/tasks/:id` | Update task (body: `{title?, body?, tags?: string[], status?, priority?, due_date?}` — accepts `description` as fallback for `body`) |
| PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) | | PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) |
| DELETE | `/api/tasks/:id` | Delete task (simple delete) | | DELETE | `/api/tasks/:id` | Delete task (simple delete) |
| GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) | | GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) |
@@ -531,7 +533,7 @@ When adding a new migration, follow these conventions:
## Implemented Features ## Implemented Features
### Notes & Tasks ### Notes & Tasks
- Full CRUD with markdown bodies, Obsidian-style `#tag` extraction (skips code fences), hierarchical tag filtering - Full CRUD with markdown bodies, first-class tag array (chip input in editors), hierarchical tag filtering
- Unified data model: a task is a note with `status IS NOT NULL` — convert freely between note ↔ task - Unified data model: a task is a note with `status IS NOT NULL` — convert freely between note ↔ task
- Task attributes: status (todo/in_progress/done), priority (none/low/medium/high), due_date - Task attributes: status (todo/in_progress/done), priority (none/low/medium/high), due_date
- Obsidian-style `[[wikilinks]]` with auto-create on click, backlinks ("what links here") - Obsidian-style `[[wikilinks]]` with auto-create on click, backlinks ("what links here")
@@ -647,12 +649,19 @@ When adding a new migration, follow these conventions:
VALARM components added for reminders. Attendees via mailto: vCalAddress. VALARM components added for reminders. Attendees via mailto: vCalAddress.
Multi-calendar search: when no specific calendar configured, all calendars are scanned. Multi-calendar search: when no specific calendar configured, all calendars are scanned.
Runs synchronous caldav library calls in asyncio executor. Settings UI for CalDAV config including timezone. Runs synchronous caldav library calls in asyncio executor. Settings UI for CalDAV config including timezone.
- **Dedicated tag chip input (Phase 20):** Tags are now a first-class UI field. `TagInput.vue` is a
chip-based input placed between the title and body in both editor views. Chips confirmed with
Enter/comma/autocomplete click; Backspace removes last chip; × removes individual chips. Autocomplete
fetches from `/api/notes/tags?q=`. Tags saved as explicit `tags: string[]` in create/update payloads.
`TagSuggestion` Tiptap extension removed; `TagDecoration` kept for legacy inline `#tag` display.
- **LLM-suggested tags:** Backend service (`tag_suggestions.py`) prompts LLM with existing user tags - **LLM-suggested tags:** Backend service (`tag_suggestions.py`) prompts LLM with existing user tags
and note content, returns 3-5 relevant tag suggestions. Tags already in body are filtered out. and note content, returns 3-5 relevant tag suggestions filtered against `current_tags` (not body text).
Exposed via `POST /api/notes/suggest-tags` and `POST /api/notes/:id/append-tag`. Integrated in: Exposed via `POST /api/notes/suggest-tags` (accepts `current_tags` list) and `POST /api/notes/:id/append-tag`.
(1) Editor views — "Suggest tags" button shows clickable pills that insert `#tag` into body; Integrated in:
(1) Editor views — "Suggest tags" button shows clickable pills that add chips to TagInput (not body);
(2) Chat tool calls — `create_note`/`create_task` results include `suggested_tags`, rendered as (2) Chat tool calls — `create_note`/`create_task` results include `suggested_tags`, rendered as
pills in ToolCallCard with one-click apply via append-tag API. pills in ToolCallCard with one-click apply via append-tag API.
LLM instructed to use the `tags` parameter, not embed `#tag` text in note body.
### Authentication & User Management ### Authentication & User Management
- Session cookie auth with bcrypt, first-user-is-admin, orphaned data claiming - Session cookie auth with bcrypt, first-user-is-admin, orphaned data claiming