import json import logging import httpx from quart import Blueprint, Response, jsonify, request from fabledassistant.config import Config from fabledassistant.services.chat import ( add_message, create_conversation, delete_conversation, get_conversation, list_conversations, save_response_as_note, summarize_conversation_as_note, update_conversation_title, ) from fabledassistant.services.llm import build_context, stream_chat 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"]) async def list_conversations_route(): limit = request.args.get("limit", 50, type=int) offset = request.args.get("offset", 0, type=int) conversations, total = await list_conversations(limit=limit, offset=offset) return jsonify({ "conversations": [c.to_dict() for c in conversations], "total": total, }) @chat_bp.route("/conversations", methods=["POST"]) 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(title=title, model=model) return jsonify(conv.to_dict()), 201 @chat_bp.route("/conversations/", methods=["GET"]) async def get_conversation_route(conv_id: int): conv = await get_conversation(conv_id) if conv is None: return jsonify({"error": "Conversation not found"}), 404 result = conv.to_dict() result["messages"] = [m.to_dict() for m in conv.messages] return jsonify(result) @chat_bp.route("/conversations/", methods=["DELETE"]) async def delete_conversation_route(conv_id: int): deleted = await delete_conversation(conv_id) if not deleted: return jsonify({"error": "Conversation not found"}), 404 return "", 204 @chat_bp.route("/conversations/", methods=["PATCH"]) async def update_conversation_route(conv_id: int): data = await request.get_json() title = data.get("title") if title is None: return jsonify({"error": "title is required"}), 400 conv = await update_conversation_title(conv_id, title) if conv is None: return jsonify({"error": "Conversation not found"}), 404 return jsonify(conv.to_dict()) @chat_bp.route("/conversations//messages", methods=["POST"]) async def send_message_route(conv_id: int): """Send a user message, stream the LLM response via SSE.""" conv = await get_conversation(conv_id) if conv is None: return jsonify({"error": "Conversation not found"}), 404 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") # Save user message await add_message(conv_id, "user", content, context_note_id=context_note_id) # Build history from existing messages (excluding system) history = [] for msg in conv.messages: if msg.role != "system": history.append({"role": msg.role, "content": msg.content}) # Build context with note search, URL fetching, etc. messages = await build_context(history, context_note_id, content) model = conv.model or await get_setting("default_model", Config.OLLAMA_MODEL) async def generate(): full_response = [] try: async for chunk in stream_chat(messages, model): full_response.append(chunk) event_data = json.dumps({"chunk": chunk}) yield f"data: {event_data}\n\n" # Save complete assistant message full_text = "".join(full_response) msg = await add_message(conv_id, "assistant", full_text) # Auto-generate title from first user message if conversation has no title if not conv.title: title = content[:80] if len(content) > 80: title += "..." await update_conversation_title(conv_id, title) done_data = json.dumps({"done": True, "message_id": msg.id}) yield f"data: {done_data}\n\n" except Exception as e: logger.exception("Error streaming LLM response") error_data = json.dumps({"error": str(e)}) yield f"data: {error_data}\n\n" return Response( generate(), content_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, ) @chat_bp.route("/messages//save-as-note", methods=["POST"]) async def save_message_as_note_route(message_id: int): try: note = await save_response_as_note(message_id) return jsonify(note), 201 except ValueError as e: return jsonify({"error": str(e)}), 400 @chat_bp.route("/conversations//summarize", methods=["POST"]) async def summarize_conversation_route(conv_id: int): conv = await get_conversation(conv_id) if conv is None: return jsonify({"error": "Conversation not found"}), 404 model = conv.model or await get_setting("default_model", Config.OLLAMA_MODEL) try: note = await summarize_conversation_as_note(conv_id, model) return jsonify(note), 201 except ValueError as e: return jsonify({"error": str(e)}), 400 @chat_bp.route("/status", methods=["GET"]) async def chat_status_route(): """Check Ollama availability and model readiness.""" default_model = await get_setting("default_model", Config.OLLAMA_MODEL) result = { "ollama": "unavailable", "model": "not_found", "default_model": default_model, } try: async with httpx.AsyncClient(timeout=5.0) as client: resp = await client.get(f"{Config.OLLAMA_URL}/api/tags") resp.raise_for_status() result["ollama"] = "available" data = resp.json() model_names = [m["name"] for m in data.get("models", [])] # Match with or without :latest tag if default_model in model_names or f"{default_model}:latest" in model_names: result["model"] = "ready" except Exception: logger.debug("Ollama status check failed", exc_info=True) return jsonify(result) @chat_bp.route("/models", methods=["GET"]) async def list_models_route(): try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(f"{Config.OLLAMA_URL}/api/tags") resp.raise_for_status() data = resp.json() models = [ {"name": m["name"], "size": m.get("size", 0)} for m in data.get("models", []) ] 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"]) 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"]) 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