UI polish pass: word count, slash commands, task lists, graph peek, bulk delete, export

- WordCount component (toggle words/chars vs read time, persisted mode)
- TipTap: TaskList/TaskItem extensions, slash command menu (H1-H3, lists, code, quote, task)
- Markdown serializer: task list → `- [ ]` / `- [x]` roundtrip
- GraphView: slide-in peek panel for note/task nodes (body, tags, linked nodes); tag nodes still navigate
- ChatView: bulk-select conversations with two-click confirm delete + chat retention policy (default 90d)
- NoteEditorView: pill tab bar with animated edit/preview toggle + WordCount in toolbar
- WorkspaceNoteEditor: inline search with tag matching, inline new-note creation, WordCount
- WorkspaceTaskPanel: task body rendered in slide-over + Edit link
- Settings: data export (Markdown ZIP / JSON) via GET /api/export
- Accessibility: skip-to-content link, aria-labels on all icon-only buttons
- Fix: export route used non-existent fabledassistant.database — corrected to async_session()
- Fix: ToolCallRecord.status type now includes "running" (was causing TS build error)
- Dockerfile: upgrade npm to latest before install to suppress major-version notice
- npm audit fix: patched minimatch (ReDoS) and rollup (path traversal) — 0 vulnerabilities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 23:15:01 -05:00
parent de7709039a
commit ef141f07f8
28 changed files with 2980 additions and 160 deletions
+2
View File
@@ -12,6 +12,7 @@ from fabledassistant.routes.admin import admin_bp
from fabledassistant.routes.api import api
from fabledassistant.routes.auth import auth_bp
from fabledassistant.routes.chat import chat_bp
from fabledassistant.routes.export import export_bp
from fabledassistant.routes.notes import notes_bp
from fabledassistant.routes.images import images_bp
from fabledassistant.routes.milestones import milestones_bp
@@ -56,6 +57,7 @@ def create_app() -> Quart:
app.register_blueprint(api)
app.register_blueprint(auth_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(export_bp)
app.register_blueprint(images_bp)
app.register_blueprint(milestones_bp)
app.register_blueprint(notes_bp)
+22
View File
@@ -10,6 +10,8 @@ from fabledassistant.routes.utils import not_found
from fabledassistant.config import Config
from fabledassistant.services.chat import (
add_message,
bulk_delete_conversations,
cleanup_old_conversations,
create_conversation,
delete_conversation,
get_conversation,
@@ -38,6 +40,14 @@ async def list_conversations_route():
uid = get_current_user_id()
limit = min(request.args.get("limit", 50, type=int), 500)
offset = request.args.get("offset", 0, type=int)
# Apply retention policy before returning list
retention_str = await get_setting(uid, "chat_retention_days", "90")
try:
retention_days = int(retention_str)
except (ValueError, TypeError):
retention_days = 90
if retention_days > 0:
await cleanup_old_conversations(uid, retention_days)
conversations, total = await list_conversations(uid, limit=limit, offset=offset)
return jsonify({
"conversations": conversations,
@@ -45,6 +55,18 @@ async def list_conversations_route():
})
@chat_bp.route("/conversations/bulk-delete", methods=["POST"])
@login_required
async def bulk_delete_conversations_route():
uid = get_current_user_id()
data = await request.get_json()
ids = data.get("ids", []) if isinstance(data, dict) else []
if not isinstance(ids, list) or not all(isinstance(i, int) for i in ids):
return jsonify({"error": "ids must be a list of integers"}), 400
count = await bulk_delete_conversations(uid, ids)
return jsonify({"deleted": count})
@chat_bp.route("/conversations", methods=["POST"])
@login_required
async def create_conversation_route():
+117
View File
@@ -0,0 +1,117 @@
import io
import json
import re
import zipfile
from datetime import datetime, timezone
from quart import Blueprint, Response, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
from sqlalchemy import select
export_bp = Blueprint("export", __name__)
def _safe_filename(title: str) -> str:
name = re.sub(r"[^\w\s-]", "", title or "untitled").strip()
name = re.sub(r"[\s]+", "_", name) or "untitled"
return name[:80]
def _note_frontmatter(note: Note) -> str:
lines = ["---"]
lines.append(f"title: {json.dumps(note.title or '')}")
lines.append(f"type: {'task' if note.is_task else 'note'}")
if note.tags:
lines.append(f"tags: [{', '.join(note.tags)}]")
if note.is_task:
lines.append(f"status: {note.status or 'todo'}")
lines.append(f"priority: {note.priority or 'none'}")
if note.due_date:
lines.append(f"due_date: {note.due_date}")
if note.project_id:
lines.append(f"project_id: {note.project_id}")
lines.append(f"created_at: {note.created_at.isoformat() if note.created_at else ''}")
lines.append(f"updated_at: {note.updated_at.isoformat() if note.updated_at else ''}")
lines.append("---")
return "\n".join(lines) + "\n\n"
async def _fetch_notes(uid: int):
async with async_session() as db:
result = await db.execute(
select(Note)
.where(Note.user_id == uid)
.order_by(Note.updated_at.desc())
)
return result.scalars().all()
@export_bp.get("/api/export")
@login_required
async def export_data():
uid = get_current_user_id()
fmt = request.args.get("format", "markdown")
notes = await _fetch_notes(uid)
if fmt == "json":
payload = [
{
"id": n.id,
"title": n.title,
"body": n.body,
"is_task": n.is_task,
"status": n.status,
"priority": n.priority,
"tags": n.tags or [],
"due_date": str(n.due_date) if n.due_date else None,
"project_id": n.project_id,
"milestone_id": n.milestone_id,
"parent_id": n.parent_id,
"created_at": n.created_at.isoformat() if n.created_at else None,
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
}
for n in notes
]
data = json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8")
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
return Response(
data,
status=200,
headers={
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": f'attachment; filename="fabledassistant-{stamp}.json"',
},
)
# Markdown ZIP
buf = io.BytesIO()
used_names: dict[str, int] = {}
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
index_rows = []
for note in notes:
base = _safe_filename(note.title)
count = used_names.get(base, 0)
used_names[base] = count + 1
fname = f"{base}_{count}.md" if count else f"{base}.md"
content = _note_frontmatter(note) + (note.body or "")
zf.writestr(fname, content.encode("utf-8"))
index_rows.append({"file": fname, "title": note.title, "id": note.id})
zf.writestr("index.json", json.dumps(index_rows, indent=2, ensure_ascii=False))
buf.seek(0)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
return Response(
buf.read(),
status=200,
headers={
"Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="fabledassistant-{stamp}.zip"',
},
)
+31 -2
View File
@@ -1,7 +1,7 @@
import logging
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy import func, select, delete as sa_delete
from sqlalchemy.orm import selectinload
from fabledassistant.models import async_session
@@ -103,6 +103,35 @@ async def delete_conversation(user_id: int, conversation_id: int) -> bool:
return True
async def bulk_delete_conversations(user_id: int, ids: list[int]) -> int:
"""Delete multiple conversations by ID for a user. Returns count deleted."""
if not ids:
return 0
async with async_session() as session:
result = await session.execute(
sa_delete(Conversation)
.where(Conversation.user_id == user_id, Conversation.id.in_(ids))
.returning(Conversation.id)
)
await session.commit()
return len(result.fetchall())
async def cleanup_old_conversations(user_id: int, days: int) -> int:
"""Delete conversations older than `days` days. Returns count deleted."""
if days <= 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
async with async_session() as session:
result = await session.execute(
sa_delete(Conversation)
.where(Conversation.user_id == user_id, Conversation.updated_at < cutoff)
.returning(Conversation.id)
)
await session.commit()
return len(result.fetchall())
async def update_conversation(
user_id: int,
conversation_id: int,