diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index db17802..e7a62ed 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -27,6 +27,8 @@ from fabledassistant.routes.groups import groups_bp from fabledassistant.routes.shares import shares_bp from fabledassistant.routes.in_app_notifications import notifications_bp from fabledassistant.routes.users import users_bp +from fabledassistant.routes.api_keys import api_keys_bp +from fabledassistant.routes.search import search_bp STATIC_DIR = Path(__file__).parent / "static" logger = logging.getLogger(__name__) @@ -77,6 +79,8 @@ def create_app() -> Quart: app.register_blueprint(shares_bp) app.register_blueprint(notifications_bp) app.register_blueprint(users_bp) + app.register_blueprint(api_keys_bp) + app.register_blueprint(search_bp) @app.before_request async def before_request(): diff --git a/src/fabledassistant/routes/api_keys.py b/src/fabledassistant/routes/api_keys.py new file mode 100644 index 0000000..d6be639 --- /dev/null +++ b/src/fabledassistant/routes/api_keys.py @@ -0,0 +1,42 @@ +from quart import Blueprint, jsonify, request + +from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key + +api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys") + + +@api_keys_bp.route("", methods=["GET"]) +@login_required +async def list_keys_route(): + uid = get_current_user_id() + keys = await list_api_keys(uid) + return jsonify({"api_keys": keys}) + + +@api_keys_bp.route("", methods=["POST"]) +@login_required +async def create_key_route(): + uid = get_current_user_id() + data = await request.get_json(force=True, silent=True) or {} + name = (data.get("name") or "").strip() + scope = (data.get("scope") or "").strip() + + if not name: + return jsonify({"error": "name is required"}), 400 + if scope not in ("read", "write"): + return jsonify({"error": "scope must be 'read' or 'write'"}), 400 + + full_key, key_dict = await create_api_key(uid, name=name, scope=scope) + # Return the full key ONCE — it is never retrievable again + return jsonify({"key": full_key, "api_key": key_dict}), 201 + + +@api_keys_bp.route("/", methods=["DELETE"]) +@login_required +async def revoke_key_route(key_id: int): + uid = get_current_user_id() + deleted = await revoke_api_key(uid, key_id) + if not deleted: + return jsonify({"error": "API key not found"}), 404 + return "", 204 diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py index a25253e..d3d5652 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -75,7 +75,11 @@ async def create_conversation_route(): data = await request.get_json(force=True, silent=True) or {} title = data.get("title", "") model = data.get("model", Config.OLLAMA_MODEL) - conv = await create_conversation(uid, title=title, model=model) + conversation_type = data.get("conversation_type", "chat") + # Only allow known types to prevent accidental misuse + if conversation_type not in ("chat", "mcp"): + conversation_type = "chat" + conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type) return jsonify(conv.to_dict()), 201 diff --git a/src/fabledassistant/routes/search.py b/src/fabledassistant/routes/search.py new file mode 100644 index 0000000..fb1c66a --- /dev/null +++ b/src/fabledassistant/routes/search.py @@ -0,0 +1,46 @@ +from quart import Blueprint, jsonify, request + +from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.services.embeddings import semantic_search_notes + +search_bp = Blueprint("search", __name__, url_prefix="/api/search") + + +def _content_type_to_is_task(content_type: str) -> bool | None: + """Map content_type query param to semantic_search_notes is_task arg.""" + if content_type == "note": + return False + if content_type == "task": + return True + return None # "all" or unknown → no filter + + +@search_bp.route("", methods=["GET"]) +@login_required +async def search_route(): + uid = get_current_user_id() + q = (request.args.get("q") or "").strip() + if not q: + return jsonify({"error": "q is required"}), 400 + + content_type = request.args.get("content_type", "all") + limit = min(request.args.get("limit", 10, type=int), 50) + is_task = _content_type_to_is_task(content_type) + + results = await semantic_search_notes( + uid, q, limit=limit, is_task=is_task, threshold=0.3 + ) + return jsonify({ + "results": [ + { + "id": note.id, + "title": note.title, + "body": note.body or "", + "is_task": note.is_task, + "tags": note.tags or [], + "similarity": score, + } + for score, note in results # semantic_search_notes returns list[tuple[float, Note]] + ], + "total": len(results), + }) diff --git a/src/fabledassistant/services/chat.py b/src/fabledassistant/services/chat.py index e244ca9..7375c8f 100644 --- a/src/fabledassistant/services/chat.py +++ b/src/fabledassistant/services/chat.py @@ -15,10 +15,10 @@ logger = logging.getLogger(__name__) async def create_conversation( - user_id: int, title: str = "", model: str = "" + user_id: int, title: str = "", model: str = "", conversation_type: str = "chat" ) -> Conversation: async with async_session() as session: - conv = Conversation(user_id=user_id, title=title, model=model) + conv = Conversation(user_id=user_id, title=title, model=model, conversation_type=conversation_type) session.add(conv) await session.commit() # Re-fetch with messages eagerly loaded to avoid lazy-load in async context @@ -128,7 +128,11 @@ async def cleanup_old_conversations(user_id: int, days: int) -> int: async with async_session() as session: result = await session.execute( sa_delete(Conversation) - .where(Conversation.user_id == user_id, Conversation.updated_at < cutoff) + .where( + Conversation.user_id == user_id, + Conversation.updated_at < cutoff, + Conversation.conversation_type != "mcp", # preserve MCP audit trail + ) .returning(Conversation.id) ) await session.commit() diff --git a/tests/test_search_route.py b/tests/test_search_route.py new file mode 100644 index 0000000..f82c108 --- /dev/null +++ b/tests/test_search_route.py @@ -0,0 +1,18 @@ +"""Unit tests for the search route parameter mapping.""" +from fabledassistant.routes.search import _content_type_to_is_task + + +def test_content_type_note(): + assert _content_type_to_is_task("note") is False + + +def test_content_type_task(): + assert _content_type_to_is_task("task") is True + + +def test_content_type_all(): + assert _content_type_to_is_task("all") is None + + +def test_content_type_unknown_defaults_to_all(): + assert _content_type_to_is_task("unknown") is None