diff --git a/backend/app.py b/backend/app.py index fa2db16..45544b4 100644 --- a/backend/app.py +++ b/backend/app.py @@ -9,8 +9,9 @@ from urllib.error import URLError from urllib.parse import urlencode from urllib.request import urlopen -from flask import Flask, jsonify, send_from_directory +from flask import Flask, jsonify, request, send_from_directory from flask_cors import CORS +import httpx app = Flask(__name__, static_folder="../frontend/dist", static_url_path="") @@ -26,6 +27,13 @@ HTTP_CHECK_TIMEOUT_SEC = float(os.getenv("HTTP_CHECK_TIMEOUT_SEC", "2")) LAB_STATUS_CACHE_SEC = float(os.getenv("LAB_STATUS_CACHE_SEC", "30")) GRAFANA_HEALTH_URL = os.getenv("GRAFANA_HEALTH_URL", "https://metrics.bstein.dev/api/health") OCEANUS_NODE_EXPORTER_URL = os.getenv("OCEANUS_NODE_EXPORTER_URL", "http://192.168.22.24:9100/metrics") +AI_CHAT_API = os.getenv("AI_CHAT_API", "http://ollama.ai.svc.cluster.local:11434").rstrip("/") +AI_CHAT_MODEL = os.getenv("AI_CHAT_MODEL", "phi3:mini") +AI_CHAT_SYSTEM_PROMPT = os.getenv( + "AI_CHAT_SYSTEM_PROMPT", + "You are the Titan Lab assistant for bstein.dev. Be concise and helpful.", +) +AI_CHAT_TIMEOUT_SEC = float(os.getenv("AI_CHAT_TIMEOUT_SEC", "20")) _LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None} @@ -137,6 +145,42 @@ def lab_status() -> Any: return jsonify(payload) +@app.route("/api/ai/chat", methods=["POST"]) +def ai_chat() -> Any: + payload = request.get_json(silent=True) or {} + user_message = (payload.get("message") or "").strip() + history = payload.get("history") or [] + + if not user_message: + return jsonify({"error": "message required"}), 400 + + messages: list[dict[str, str]] = [] + if AI_CHAT_SYSTEM_PROMPT: + messages.append({"role": "system", "content": AI_CHAT_SYSTEM_PROMPT}) + + for item in history: + role = item.get("role") + content = (item.get("content") or "").strip() + if role in ("user", "assistant") and content: + messages.append({"role": role, "content": content}) + + messages.append({"role": "user", "content": user_message}) + + body = {"model": AI_CHAT_MODEL, "messages": messages, "stream": False} + started = time.time() + + try: + with httpx.Client(timeout=AI_CHAT_TIMEOUT_SEC) as client: + resp = client.post(f"{AI_CHAT_API}/api/chat", json=body) + resp.raise_for_status() + data = resp.json() + reply = (data.get("message") or {}).get("content") or "" + elapsed_ms = int((time.time() - started) * 1000) + return jsonify({"reply": reply, "latency_ms": elapsed_ms}) + except (httpx.RequestError, httpx.HTTPStatusError, ValueError) as exc: + return jsonify({"error": str(exc)}), 502 + + @app.route("/", defaults={"path": ""}) @app.route("/") def serve_frontend(path: str) -> Any: diff --git a/backend/requirements.txt b/backend/requirements.txt index b26517a..2ccdf32 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +1,4 @@ flask==3.0.3 flask-cors==4.0.0 gunicorn==21.2.0 +httpx==0.27.2 diff --git a/frontend/src/data/sample.js b/frontend/src/data/sample.js index b52aa7c..ee92620 100644 --- a/frontend/src/data/sample.js +++ b/frontend/src/data/sample.js @@ -122,14 +122,14 @@ export function fallbackServices() { link: "https://meet.bstein.dev", status: "degraded", }, - { - name: "AI Chat", - category: "ai", - summary: "LLM Chat - Planned", - link: "/ai", - host: "chat.ai.bstein.dev", - status: "planned", - }, + { + name: "AI Chat", + category: "ai", + summary: "LLM chat (public beta)", + link: "/ai", + host: "bstein.dev/ai", + status: "beta", + }, { name: "AI Image", category: "ai", diff --git a/frontend/src/views/AiView.vue b/frontend/src/views/AiView.vue index 9f85277..7328bee 100644 --- a/frontend/src/views/AiView.vue +++ b/frontend/src/views/AiView.vue @@ -1,25 +1,271 @@