diff --git a/app.py b/app.py index 6a02d6e..57d840d 100644 --- a/app.py +++ b/app.py @@ -53,7 +53,7 @@ from flask import Flask, request, send_from_directory from flask_socketio import SocketIO, emit, join_room, disconnect from database import db, init_db -from models import User, Message, UserIgnore, Ban, Mute +from models import User, Message, UserIgnore, Ban, Mute, VioletHistory from config import ( SECRET_KEY, ADMIN_PASSWORD, DATABASE_URL, CORS_ORIGINS, MAX_MSG_LEN, LOBBY, AI_FREE_LIMIT, AI_BOT_NAME, @@ -88,18 +88,17 @@ _app_ref = None # set in create_app() for greenlet app-context access # Ollama integration # --------------------------------------------------------------------------- -def call_ollama(user_message: str) -> str: - """Call the local Ollama API. Returns plaintext AI response.""" +MAX_HISTORY_PER_USER = 20 # last N turns loaded into Violet prompt + +def call_ollama(messages: list) -> str: + """Call the local Ollama API with a full messages list. Returns plaintext AI response.""" import requests as req try: resp = req.post( f"{OLLAMA_URL}/api/chat", json={ "model": VIOLET_MODEL, - "messages": [ - {"role": "system", "content": VIOLET_SYSTEM}, - {"role": "user", "content": user_message}, - ], + "messages": messages, "stream": False, "options": {"temperature": 0.88, "num_predict": 120}, }, @@ -112,6 +111,24 @@ def call_ollama(user_message: str) -> str: return "Give me just a moment, darling... 💜" +def _load_violet_history(user_id: int) -> list: + """Load recent conversation turns from DB. Returns list of {role, content} dicts.""" + rows = ( + VioletHistory.query + .filter_by(user_id=user_id) + .order_by(VioletHistory.id.desc()) + .limit(MAX_HISTORY_PER_USER) + .all() + ) + return [{"role": r.role, "content": r.text} for r in reversed(rows)] + + +def _save_violet_turn(user_id: int, role: str, text: str) -> None: + """Persist a single conversation turn.""" + db.session.add(VioletHistory(user_id=user_id, role=role, text=text)) + db.session.commit() + + # --------------------------------------------------------------------------- # AI inference queue worker (single greenlet, serialises Ollama calls) # --------------------------------------------------------------------------- @@ -119,68 +136,77 @@ def call_ollama(user_message: str) -> str: def _ai_worker() -> None: """Eventlet greenlet – drains ai_queue one task at a time.""" global _app_ref - while True: task = ai_queue.get() # blocks cooperatively until item available - # ── Announce Violet is busy ─────────────────────────────────────────── - room = _pm_room(db.session.get(User, task["user_id"]).username, AI_BOT_NAME) if _app_ref else None - if room: - socketio.emit("violet_typing", {"busy": True, "room": room}, to=room) - else: - socketio.emit("violet_typing", {"busy": True}) + sid = task["sid"] - # ── Decrypt user message (transit; key never stored) ────────────────── try: + # Derive room name (needs app context for DB lookup) + with _app_ref.app_context(): + if task.get("user_id"): + db_user = db.session.get(User, task["user_id"]) + room = _pm_room(db_user.username, AI_BOT_NAME) if db_user else None + else: + uname = connected_users.get(sid, {}).get("username", "unknown") + room = _pm_room(uname, AI_BOT_NAME) + + # ── Announce Violet is busy ─────────────────────────────────────── + if room: + socketio.emit("violet_typing", {"busy": True, "room": room}, to=room) + else: + socketio.emit("violet_typing", {"busy": True}) + + # ── Decrypt user message (transit; key never stored) ────────────── plaintext = aesgcm_decrypt( task["transit_key"], task["ciphertext"], task["nonce_val"] ) - ai_text = call_ollama(plaintext) - except Exception as exc: - print(f"[Violet] processing error: {exc}") - ai_text = "Mmm, something went wrong, darling 💜" - - # ── Re-encrypt AI response ──────────────────────────────────────────── - resp_ct, resp_nonce = aesgcm_encrypt(task["transit_key"], ai_text) - - ai_messages_used = task.get("ai_messages_used", 0) - has_ai_access = task.get("has_ai_access", False) - - # ── DB operations (need explicit app context in greenlet) ───────────── - with _app_ref.app_context(): - bot = User.query.filter_by(username=AI_BOT_NAME).first() - if bot and task.get("user_id"): - _save_pm(task["user_id"], bot.id, - task["ciphertext"], task["nonce_val"]) # user → Violet - _save_pm(bot.id, task["user_id"], - resp_ct, resp_nonce) # Violet → user - - if task.get("user_id") and not has_ai_access: - db_user = db.session.get(User, task["user_id"]) - if db_user and not db_user.has_ai_access: - db_user.ai_messages_used = min( - db_user.ai_messages_used + 1, AI_FREE_LIMIT - ) - db.session.commit() - ai_messages_used = db_user.ai_messages_used - has_ai_access = db_user.has_ai_access - - # Update in-process cache - sid = task["sid"] - if sid in connected_users: - connected_users[sid]["ai_messages_used"] = ai_messages_used - connected_users[sid]["has_ai_access"] = has_ai_access - - # ── Emit response to originating client ─────────────────────────────── - with _app_ref.app_context(): + # ── Build messages array with history ───────────────────────────── + messages = [{"role": "system", "content": VIOLET_SYSTEM}] if task.get("user_id"): - db_user = db.session.get(User, task["user_id"]) - room = _pm_room(db_user.username, AI_BOT_NAME) - else: - # Admin guest without a user_id — derive room from sid cache - uname = connected_users.get(sid, {}).get("username", "unknown") - room = _pm_room(uname, AI_BOT_NAME) + with _app_ref.app_context(): + messages.extend(_load_violet_history(task["user_id"])) + messages.append({"role": "user", "content": plaintext}) + ai_text = call_ollama(messages) + + # ── Save conversation turns ─────────────────────────────────────── + if task.get("user_id"): + with _app_ref.app_context(): + _save_violet_turn(task["user_id"], "user", plaintext) + _save_violet_turn(task["user_id"], "assistant", ai_text) + + # ── Re-encrypt AI response ──────────────────────────────────────── + resp_ct, resp_nonce = aesgcm_encrypt(task["transit_key"], ai_text) + + ai_messages_used = task.get("ai_messages_used", 0) + has_ai_access = task.get("has_ai_access", False) + + # ── DB operations (need explicit app context in greenlet) ───────── + with _app_ref.app_context(): + bot = User.query.filter_by(username=AI_BOT_NAME).first() + if bot and task.get("user_id"): + _save_pm(task["user_id"], bot.id, + task["ciphertext"], task["nonce_val"]) # user → Violet + _save_pm(bot.id, task["user_id"], + resp_ct, resp_nonce) # Violet → user + + if task.get("user_id") and not has_ai_access: + db_user = db.session.get(User, task["user_id"]) + if db_user and not db_user.has_ai_access: + db_user.ai_messages_used = min( + db_user.ai_messages_used + 1, AI_FREE_LIMIT + ) + db.session.commit() + ai_messages_used = db_user.ai_messages_used + has_ai_access = db_user.has_ai_access + + # Update in-process cache + if sid in connected_users: + connected_users[sid]["ai_messages_used"] = ai_messages_used + connected_users[sid]["has_ai_access"] = has_ai_access + + # ── Emit response to originating client ─────────────────────────── if task.get("plaintext_mode"): socketio.emit("pm_message", { "from": AI_BOT_NAME, @@ -196,12 +222,26 @@ def _ai_worker() -> None: "room": room, "ts": _ts() }, to=room) - - socketio.emit("violet_typing", {"busy": False, "room": room}, to=room) - ai_queue.task_done() - # Clear typing indicator when queue drains - # Done in per-room emit above + socketio.emit("violet_typing", {"busy": False, "room": room}, to=room) + + except Exception as exc: + import traceback; traceback.print_exc() + # Try to send error feedback to user + try: + uname = connected_users.get(sid, {}).get("username", "unknown") + room = _pm_room(uname, AI_BOT_NAME) + socketio.emit("pm_message", { + "from": AI_BOT_NAME, + "text": "Mmm, something went wrong, darling 💜", + "room": room, + "ts": _ts() + }, to=room) + socketio.emit("violet_typing", {"busy": False, "room": room}, to=room) + except Exception: + pass + finally: + ai_queue.task_done() # --------------------------------------------------------------------------- @@ -617,7 +657,7 @@ def on_pm_message(data): if not user.get("user_id") and not user.get("is_admin"): emit("error", {"msg": "You must be registered to chat with Violet."}); return if not user.get("has_ai_access") and user.get("ai_messages_used", 0) >= AI_FREE_LIMIT: - emit("pm_message", {"from": AI_BOT_NAME, "text": "ai_limit_reached", "room": room, "system": True}, to=sid) + emit("ai_response", {"error": "ai_limit_reached", "room": room}, to=sid) return # Echo the user's own message back so it appears in their chat @@ -625,8 +665,8 @@ def on_pm_message(data): transit_key = data.get("transit_key", "") if not all([ciphertext, nonce_val, transit_key]): - # Plaintext fallback for admins without crypto keys - if text and user.get("is_admin"): + # Plaintext fallback (e.g. session restore without crypto key) + if text: import base64 as _b64 transit_key = _b64.b64encode(os.urandom(32)).decode() ciphertext_new, nonce_new = aesgcm_encrypt(transit_key, text) @@ -641,7 +681,7 @@ def on_pm_message(data): "plaintext_mode": True, }) return - emit("error", {"msg": "AI Private Messaging requires transit encryption."}); return + emit("error", {"msg": "Message cannot be empty."}); return ai_queue.put({ "sid": sid, @@ -678,6 +718,24 @@ def on_ai_message(data): pass +@socketio.on("violet_reset") +def on_violet_reset(_data=None): + sid = request.sid + user = connected_users.get(sid) + if not user or not user.get("user_id"): + emit("error", {"msg": "You must be registered to reset Violet history."}); return + user_id = user["user_id"] + VioletHistory.query.filter_by(user_id=user_id).delete() + db.session.commit() + room = _pm_room(user["username"], AI_BOT_NAME) + emit("pm_message", { + "from": AI_BOT_NAME, + "text": "Memory cleared, darling. Let's start fresh! 💜", + "room": room, + "ts": _ts(), + }, to=sid) + + # --------------------------------------------------------------------------- # Mod tools # --------------------------------------------------------------------------- diff --git a/models.py b/models.py index 8fd7070..44e429e 100644 --- a/models.py +++ b/models.py @@ -104,3 +104,18 @@ class Mute(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(20), unique=True, nullable=False, index=True) created_at = db.Column(db.DateTime, default=_utcnow, nullable=False) + + +class VioletHistory(db.Model): + """Per-user plaintext conversation history with Violet AI.""" + __tablename__ = "violet_history" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + role = db.Column(db.String(10), nullable=False) # 'user' or 'assistant' + text = db.Column(db.Text, nullable=False) + timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False) + + __table_args__ = ( + db.Index("ix_violet_hist_user_ts", "user_id", "timestamp"), + ) diff --git a/static/chat.js b/static/chat.js index 5547984..7704253 100644 --- a/static/chat.js +++ b/static/chat.js @@ -353,15 +353,16 @@ socket.on("ai_response", async (data) => { state.hasAiAccess = data.has_ai_access; updateVioletBadge(); - let text = "[Decryption Error]"; + const room = data.room || "ai-violet"; + let text = data.text || "[Decryption Error]"; if (data.ciphertext && state.cryptoKey) { text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce); } - addMessage("ai-violet", { + addMessage(room, { sender: AI_BOT_NAME, text: text, - ts: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + ts: data.ts || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), sent: false }); }); @@ -405,6 +406,14 @@ messageForm.addEventListener("submit", async (e) => { } else if (state.currentRoom.startsWith("pm:")) { const isVioletRoom = state.currentRoom.toLowerCase().endsWith(":violet"); + + // /reset command in Violet PM clears conversation memory + if (isVioletRoom && text.toLowerCase() === "/reset") { + socket.emit("violet_reset"); + messageInput.value = ""; + messageInput.style.height = "auto"; + return; + } if (isVioletRoom) { if (state.isRegistered && state.cryptoKey) {