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
+9 -9
View File
@@ -30,7 +30,6 @@ from fabledassistant.services.notes import (
)
from fabledassistant.services.settings import get_setting
from fabledassistant.services.tag_suggestions import suggest_tags
from fabledassistant.utils.tags import extract_tags
logger = logging.getLogger(__name__)
@@ -67,7 +66,7 @@ async def create_note_route():
uid = get_current_user_id()
data = await request.get_json()
body = data.get("body", "")
tags = extract_tags(body)
tags = data.get("tags", [])
# Optional task fields
status = data.get("status")
@@ -111,7 +110,8 @@ async def suggest_tags_route():
data = await request.get_json()
title = data.get("title", "")
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})
@@ -128,10 +128,10 @@ async def append_tag_route(note_id: int):
if note is None:
return jsonify({"error": "Note not found"}), 404
# Append #tag to body
new_body = note.body.rstrip() + f"\n#{tag}" if note.body else f"#{tag}"
tags = extract_tags(new_body)
updated = await update_note(uid, note_id, body=new_body, tags=tags)
existing = list(note.tags or [])
if tag not in existing:
existing.append(tag)
updated = await update_note(uid, note_id, tags=existing)
return jsonify(updated.to_dict())
@@ -189,8 +189,8 @@ async def update_note_route(note_id: int):
else:
fields["due_date"] = None
if "body" in fields:
fields["tags"] = extract_tags(fields["body"])
if "tags" in data:
fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields)
if note is None:
return jsonify({"error": "Note not found"}), 404
+3 -4
View File
@@ -11,7 +11,6 @@ from fabledassistant.services.notes import (
list_notes,
update_note,
)
from fabledassistant.utils.tags import extract_tags
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
@@ -60,7 +59,7 @@ async def create_task_route():
uid = get_current_user_id()
data = await request.get_json()
body = data.get("body", "") or data.get("description", "")
tags = extract_tags(body)
tags = data.get("tags", [])
due_date = None
if data.get("due_date"):
@@ -121,8 +120,8 @@ async def update_task_route(task_id: int):
else:
fields["due_date"] = None
if "body" in fields:
fields["tags"] = extract_tags(fields["body"])
if "tags" in data:
fields["tags"] = data["tags"]
task = await update_note(uid, task_id, **fields)
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("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("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(
"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. "
@@ -8,16 +8,15 @@ from fabledassistant.config import Config
from fabledassistant.services.llm import generate_completion
from fabledassistant.services.notes import get_all_tags
from fabledassistant.services.settings import get_setting
from fabledassistant.utils.tags import extract_tags
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.
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():
return []
@@ -54,9 +53,9 @@ async def suggest_tags(user_id: int, title: str, body: str) -> list[str]:
tags = _parse_tag_list(response)
# Filter out tags already in the body
body_tags = set(extract_tags(body))
tags = [t for t in tags if t not in body_tags]
# Filter out tags already applied
existing = set(current_tags or [])
tags = [t for t in tags if t not in existing]
return tags[:5]
+7
View File
@@ -81,6 +81,11 @@ _CORE_TOOLS = [
"type": "string",
"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"],
},
@@ -710,10 +715,12 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
elif tool_name == "create_note":
note_title = arguments.get("title", "Untitled Note")
note_body = arguments.get("body", "")
note_tags = arguments.get("tags", [])
note = await create_note(
user_id=user_id,
title=note_title,
body=note_body,
tags=note_tags,
)
suggested = await suggest_tags(user_id, note_title, note_body)
_schedule_embedding(note.id, user_id, note_title, note_body)