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:
+28
-19
@@ -12,7 +12,7 @@
|
||||
> Include file-level details in the commit body when the change is non-trivial.
|
||||
|
||||
## 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
|
||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
||||
@@ -41,10 +41,11 @@ for AI-assisted features.
|
||||
intercepting API GETs.
|
||||
- **LLM integration is a separate service:** The app communicates with Ollama (or
|
||||
compatible) over HTTP.
|
||||
- **Inline tag extraction:** Tags are extracted from note/task body text using
|
||||
`#tag` syntax (Obsidian-style), not manually entered. Backend is source of truth
|
||||
for tag extraction. The `TAG_RE` regex uses `(?<!&)` negative lookbehind to avoid
|
||||
matching HTML entities like `'` as tags.
|
||||
- **First-class tag field:** Tags live in the `tags ARRAY[text]` column and are
|
||||
explicitly set by the client — they are NOT auto-extracted from the body text.
|
||||
`extract_tags()` in `utils/tags.py` is preserved but no longer called on save.
|
||||
`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
|
||||
by `project` matches both `project` and `project/*` children via SQL `unnest` +
|
||||
`LIKE` prefix.
|
||||
@@ -159,7 +160,7 @@ for AI-assisted features.
|
||||
`priority` (nullable str — `none`/`low`/`medium`/`high`), `due_date` (nullable date),
|
||||
`created_at`, `updated_at`
|
||||
- **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`
|
||||
- Lookup by exact title via `get_note_by_title()` for wikilink resolution
|
||||
- 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
|
||||
│ │ ├── 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
|
||||
│ │ ├── 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
|
||||
│ │ ├── 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
|
||||
│ ├── 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)
|
||||
@@ -363,7 +364,8 @@ fabledassistant/
|
||||
│ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
|
||||
│ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none")
|
||||
│ │ ├── 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
|
||||
│ │ ├── SearchBar.vue # Debounced search input
|
||||
│ │ ├── 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/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) |
|
||||
| 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) |
|
||||
| POST | `/api/notes/suggest-tags` | LLM-suggest tags for content (body: `{title, body}`, returns `{suggested_tags: [...]}`) |
|
||||
| POST | `/api/notes/:id/append-tag` | Append `#tag` to note body (body: `{tag}`, returns updated note) |
|
||||
| POST | `/api/notes/suggest-tags` | LLM-suggest tags (body: `{title, body, current_tags?: string[]}`, returns `{suggested_tags: [...]}`) |
|
||||
| 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) |
|
||||
| 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`) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
@@ -420,9 +422,9 @@ fabledassistant/
|
||||
| GET | `/api/admin/invitations` | List pending invitations (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` |
|
||||
| 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 |
|
||||
| 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}`) |
|
||||
| DELETE | `/api/tasks/:id` | Delete task (simple delete) |
|
||||
| GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) |
|
||||
@@ -531,7 +533,7 @@ When adding a new migration, follow these conventions:
|
||||
## Implemented Features
|
||||
|
||||
### 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
|
||||
- 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")
|
||||
@@ -647,12 +649,19 @@ When adding a new migration, follow these conventions:
|
||||
VALARM components added for reminders. Attendees via mailto: vCalAddress.
|
||||
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.
|
||||
- **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
|
||||
and note content, returns 3-5 relevant tag suggestions. Tags already in body are filtered out.
|
||||
Exposed via `POST /api/notes/suggest-tags` and `POST /api/notes/:id/append-tag`. Integrated in:
|
||||
(1) Editor views — "Suggest tags" button shows clickable pills that insert `#tag` into body;
|
||||
and note content, returns 3-5 relevant tag suggestions filtered against `current_tags` (not body text).
|
||||
Exposed via `POST /api/notes/suggest-tags` (accepts `current_tags` list) and `POST /api/notes/:id/append-tag`.
|
||||
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
|
||||
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
|
||||
- Session cookie auth with bcrypt, first-user-is-admin, orphaned data claiming
|
||||
|
||||
Reference in New Issue
Block a user