import asyncio import json import logging import httpx from quart import Blueprint, Response, jsonify, request from fabledassistant.auth import admin_required, login_required, get_current_user_id from fabledassistant.routes.utils import not_found, parse_pagination from fabledassistant.config import Config from fabledassistant.services.chat import ( add_message, bulk_delete_conversations, create_conversation, delete_conversation, get_conversation, list_conversations, save_response_as_note, summarize_conversation_as_note, update_conversation, ) from fabledassistant.services.generation_buffer import ( GenerationState, create_buffer, get_buffer, ) from fabledassistant.services.generation_task import run_generation from fabledassistant.services.notes import get_notes_by_ids from fabledassistant.services.settings import get_setting logger = logging.getLogger(__name__) chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat") @chat_bp.route("/conversations", methods=["GET"]) @login_required async def list_conversations_route(): uid = get_current_user_id() limit, offset = parse_pagination() conv_type = request.args.get("type", "chat") conversations, total = await list_conversations(uid, limit=limit, offset=offset, conv_type=conv_type) return jsonify({ "conversations": conversations, "total": total, }) @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(): uid = get_current_user_id() data = await request.get_json(force=True, silent=True) or {} title = data.get("title", "") model = data.get("model", Config.OLLAMA_MODEL) conversation_type = data.get("conversation_type", "chat") # Only allow known types to prevent accidental misuse if conversation_type not in ("chat", "mcp", "voice"): conversation_type = "chat" conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type) return jsonify(conv.to_dict()), 201 @chat_bp.route("/conversations/", methods=["GET"]) @login_required async def get_conversation_route(conv_id: int): uid = get_current_user_id() conv = await get_conversation(uid, conv_id) if conv is None: return not_found("Conversation") result = conv.to_dict() note_ids = [m.context_note_id for m in conv.messages if m.context_note_id] note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {} result["messages"] = [] for m in conv.messages: msg_dict = m.to_dict() if m.context_note_id and m.context_note_id in note_map: msg_dict["context_note_title"] = note_map[m.context_note_id].title result["messages"].append(msg_dict) return jsonify(result) @chat_bp.route("/conversations/", methods=["DELETE"]) @login_required async def delete_conversation_route(conv_id: int): uid = get_current_user_id() deleted = await delete_conversation(uid, conv_id) if not deleted: return not_found("Conversation") return "", 204 @chat_bp.route("/conversations/", methods=["PATCH"]) @login_required async def update_conversation_route(conv_id: int): from fabledassistant.services.chat import _UNSET uid = get_current_user_id() data = await request.get_json() title = data.get("title") model = data.get("model") rag_project_id = data.get("rag_project_id", _UNSET) if title is None and model is None and rag_project_id is _UNSET: return jsonify({"error": "title, model, or rag_project_id is required"}), 400 conv = await update_conversation(uid, conv_id, title=title, model=model, rag_project_id=rag_project_id) if conv is None: return not_found("Conversation") return jsonify(conv.to_dict()) @chat_bp.route("/conversations//messages", methods=["POST"]) @login_required async def send_message_route(conv_id: int): """Start generation: save user message, launch background task, return 202.""" uid = get_current_user_id() conv = await get_conversation(uid, conv_id) if conv is None: return not_found("Conversation") data = await request.get_json() content = data.get("content", "").strip() if not content: return jsonify({"error": "content is required"}), 400 context_note_id = data.get("context_note_id") include_note_ids = data.get("include_note_ids") or [] excluded_note_ids = data.get("excluded_note_ids") or [] think = bool(data.get("think", False)) rag_project_id = data.get("rag_project_id") or None workspace_project_id = data.get("workspace_project_id") or None user_timezone = data.get("user_timezone") or None if not user_timezone: user_timezone = await get_setting(uid, "user_timezone") or None effective_rag_project_id = workspace_project_id or rag_project_id # Reject if generation already running for this conversation existing = get_buffer(conv_id) if existing and existing.state == GenerationState.RUNNING: return jsonify({"error": "Generation already in progress"}), 409 # Save user message await add_message(conv_id, "user", content, context_note_id=context_note_id) # Create placeholder assistant message assistant_msg = await add_message(conv_id, "assistant", "", status="generating") try: buf = create_buffer(conv_id, assistant_msg.id) except RuntimeError: return jsonify({"error": "Generation already in progress"}), 409 # Build history from existing messages (excluding system and the placeholder) history = [] for msg in conv.messages: if msg.role == "system": continue msg_dict = {"role": msg.role, "content": msg.content or ""} if msg.tool_calls: msg_dict["tool_calls"] = [ {"function": {"name": tc["function"], "arguments": tc["arguments"]}} for tc in msg.tool_calls ] history.append(msg_dict) for tc in msg.tool_calls: history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))}) else: history.append(msg_dict) model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL # Launch background generation task (context building happens inside the task). # # Wrap in a top-level guard: `run_generation` is fire-and-forget via # asyncio.create_task. Without a wrapper, an unhandled exception inside # the coroutine is swallowed by the event loop, the buffer stays in # GenerationState.RUNNING forever, and every subsequent POST to this # conversation returns 409 — locking the user out of the chat surface # with no log trail. Observed in production 2026-05-22 where the # generation hung before any internal log line could fire. async def _run_generation_guarded(): try: await run_generation( buf, history, model, uid, conv_id, conv.title, content, context_note_id=context_note_id, include_note_ids=include_note_ids, excluded_note_ids=excluded_note_ids, think=think, rag_project_id=effective_rag_project_id, workspace_project_id=workspace_project_id, user_timezone=user_timezone, voice_mode=(conv.conversation_type == "voice"), ) except Exception: logger.exception( "run_generation crashed for conv %d msg %d (model=%s); " "transitioning buffer to FAILED so the route can be used again", conv_id, assistant_msg.id, model, ) try: buf.state = GenerationState.ERRORED buf.append_event( "done", {"done": True, "error": "Generation crashed; see server logs"}, ) except Exception: logger.exception("Failed to mark buffer ERRORED for conv %d", conv_id) try: from fabledassistant.services.generation_task import _update_message await _update_message( assistant_msg.id, "", "error", tool_calls=None, ) except Exception: logger.exception( "Failed to mark assistant message %d as error after crash", assistant_msg.id, ) asyncio.create_task(_run_generation_guarded()) return jsonify({ "assistant_message_id": assistant_msg.id, "status": "generating", }), 202 @chat_bp.route("/conversations//generation/stream", methods=["GET"]) @login_required async def generation_stream_route(conv_id: int): """SSE endpoint that tails the generation buffer for a conversation.""" uid = get_current_user_id() conv = await get_conversation(uid, conv_id) if conv is None: return not_found("Conversation") buf = get_buffer(conv_id) if buf is None: return jsonify({"error": "No active generation"}), 404 # Determine starting point from Last-Event-ID header or query param last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id") try: last_id = int(last_id_str) if last_id_str is not None else -1 except (ValueError, TypeError): last_id = -1 async def stream(): cursor = last_id while True: # Replay any buffered events past the cursor pending = buf.events_after(cursor) for event in pending: yield buf.format_sse(event) cursor = event.index # If generation is done and all events delivered, close stream if buf.state != GenerationState.RUNNING: break # Wait for new events or send keepalive on timeout got_new = await buf.wait_for_event(cursor, timeout=15.0) if not got_new: yield ": keepalive\n\n" # Final drain: deliver any events appended between the last yield # and the state check above (e.g. the 'done' event). for event in buf.events_after(cursor): yield buf.format_sse(event) return Response( stream(), content_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, ) @chat_bp.route("/conversations//generation/confirm", methods=["POST"]) @login_required async def confirm_generation_route(conv_id: int): """Resolve a pending tool confirmation (accept or decline).""" uid = get_current_user_id() conv = await get_conversation(uid, conv_id) if conv is None: return not_found("Conversation") buf = get_buffer(conv_id) if buf is None or buf.state != GenerationState.RUNNING: return jsonify({"error": "No active generation"}), 404 if buf.confirmation_future is None or buf.confirmation_future.done(): return jsonify({"error": "No pending tool confirmation"}), 409 data = await request.get_json(force=True, silent=True) or {} decision = bool(data.get("confirmed", False)) buf.confirmation_future.set_result(decision) return jsonify({"status": "ok", "confirmed": decision}) @chat_bp.route("/conversations//generation/cancel", methods=["POST"]) @login_required async def cancel_generation_route(conv_id: int): """Cancel an active generation for a conversation.""" uid = get_current_user_id() conv = await get_conversation(uid, conv_id) if conv is None: return not_found("Conversation") buf = get_buffer(conv_id) if buf is None or buf.state != GenerationState.RUNNING: return jsonify({"error": "No active generation"}), 404 buf.cancel_event.set() return jsonify({"status": "cancelled"}) @chat_bp.route("/messages//save-as-note", methods=["POST"]) @login_required async def save_message_as_note_route(message_id: int): uid = get_current_user_id() try: note = await save_response_as_note(uid, message_id) return jsonify(note), 201 except ValueError as e: return jsonify({"error": str(e)}), 400 @chat_bp.route("/conversations//summarize", methods=["POST"]) @login_required async def summarize_conversation_route(conv_id: int): uid = get_current_user_id() conv = await get_conversation(uid, conv_id) if conv is None: return not_found("Conversation") model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL try: note = await summarize_conversation_as_note(uid, conv_id, model) return jsonify(note), 201 except ValueError as e: return jsonify({"error": str(e)}), 400 @chat_bp.route("/ps", methods=["GET"]) @login_required async def running_models_route(): """Return currently loaded (hot) models from Ollama.""" try: async with httpx.AsyncClient(timeout=5.0) as client: resp = await client.get(f"{Config.OLLAMA_URL}/api/ps") resp.raise_for_status() data = resp.json() models = [ { "name": m["name"], "size": m.get("size", 0), "size_vram": m.get("size_vram", 0), "expires_at": m.get("expires_at", ""), } for m in data.get("models", []) ] return jsonify({"models": models}) except Exception as e: logger.debug("Failed to query Ollama /api/ps: %s", e) return jsonify({"models": []}) @chat_bp.route("/warm", methods=["POST"]) @login_required async def warm_model_route(): """Pre-load a model into Ollama memory.""" data = await request.get_json(force=True, silent=True) or {} model = data.get("model", "").strip() if not model: return jsonify({"error": "model is required"}), 400 async def _warm(): from fabledassistant.services.llm import keep_alive_for try: async with httpx.AsyncClient(timeout=300.0) as client: await client.post( f"{Config.OLLAMA_URL}/api/generate", json={"model": model, "prompt": "", "keep_alive": keep_alive_for(model)}, ) logger.info("Warmed model %s", model) except Exception as e: logger.warning("Failed to warm model %s: %s", model, e) asyncio.create_task(_warm()) return jsonify({"status": "warming"}), 202 @chat_bp.route("/status", methods=["GET"]) @login_required async def chat_status_route(): """Check Ollama availability, model installation, and model load state.""" uid = get_current_user_id() default_model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) # Guard against empty-string rows written by older code when user selected "default" if not default_model: default_model = Config.OLLAMA_MODEL result = { "ollama": "unavailable", "model": "not_found", "default_model": default_model, } try: async with httpx.AsyncClient(timeout=10.0) as client: # Query installed models and loaded models in parallel tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags")) ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps")) tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True) if isinstance(tags_resp, Exception): logger.debug("Ollama /api/tags failed: %s", tags_resp) else: tags_resp.raise_for_status() result["ollama"] = "available" model_names = {m["name"] for m in tags_resp.json().get("models", [])} base = default_model.removesuffix(":latest") if default_model in model_names or f"{base}:latest" in model_names or base in model_names: # Installed — now check if currently loaded in memory result["model"] = "cold" if not isinstance(ps_resp, Exception): try: ps_resp.raise_for_status() loaded_names = {m["name"] for m in ps_resp.json().get("models", [])} if default_model in loaded_names or f"{base}:latest" in loaded_names or base in loaded_names: result["model"] = "loaded" except Exception: logger.debug("Ollama /api/ps check failed", exc_info=True) except Exception: logger.debug("Ollama status check failed", exc_info=True) return jsonify(result) @chat_bp.route("/models", methods=["GET"]) @login_required async def list_models_route(): try: async with httpx.AsyncClient(timeout=10.0) as client: tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags")) ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps")) tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True) loaded_names: set[str] = set() if not isinstance(ps_resp, Exception): try: ps_resp.raise_for_status() loaded_names = {m["name"] for m in ps_resp.json().get("models", [])} except Exception: pass models = [] if not isinstance(tags_resp, Exception): tags_resp.raise_for_status() for m in tags_resp.json().get("models", []): models.append({ "name": m["name"], "size": m.get("size", 0), "modified_at": m.get("modified_at", ""), "loaded": m["name"] in loaded_names, }) return jsonify({"models": models}) except Exception as e: logger.warning("Failed to list Ollama models: %s", e) return jsonify({"models": [], "error": str(e)}), 200 @chat_bp.route("/models/pull", methods=["POST"]) @admin_required async def pull_model_route(): """Pull a model from Ollama, streaming progress via SSE.""" data = await request.get_json() model_name = data.get("model", "").strip() if not model_name: return jsonify({"error": "model is required"}), 400 async def generate(): try: async with httpx.AsyncClient(timeout=1800.0) as client: async with client.stream( "POST", f"{Config.OLLAMA_URL}/api/pull", json={"name": model_name}, ) as resp: resp.raise_for_status() async for line in resp.aiter_lines(): if not line.strip(): continue progress = json.loads(line) event_data = json.dumps(progress) yield f"data: {event_data}\n\n" yield f"data: {json.dumps({'status': 'success'})}\n\n" except Exception as e: logger.exception("Error pulling model %s", model_name) yield f"data: {json.dumps({'error': str(e)})}\n\n" return Response( generate(), content_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, ) @chat_bp.route("/models/delete", methods=["POST"]) @admin_required async def delete_model_route(): """Delete a model from Ollama.""" data = await request.get_json() model_name = data.get("model", "").strip() if not model_name: return jsonify({"error": "model is required"}), 400 try: async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.request( "DELETE", f"{Config.OLLAMA_URL}/api/delete", json={"name": model_name}, ) resp.raise_for_status() return jsonify({"status": "deleted", "model": model_name}) except httpx.HTTPStatusError as e: logger.warning("Failed to delete model %s: %s", model_name, e) return jsonify({"error": f"Failed to delete model: {e.response.status_code}"}), 400 except Exception as e: logger.warning("Failed to delete model %s: %s", model_name, e) return jsonify({"error": str(e)}), 500