Add per-user conversation memory for Violet AI

- VioletHistory model: stores plaintext turns (user/assistant) per user_id
- AI worker loads last 20 turns into Ollama prompt for context
- Saves both user message and AI response after each exchange
- /reset command in Violet PM clears conversation memory
- Fix ai_limit_reached: emit ai_response event instead of raw pm_message
- ai_response handler uses correct PM room and supports plaintext text field
- Remove debug print statements
This commit is contained in:
3nd3r 2026-04-12 13:58:44 -05:00
parent 389415f04d
commit 8cd76ff72d
3 changed files with 153 additions and 71 deletions

194
app.py
View File

@ -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
# ---------------------------------------------------------------------------

View File

@ -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"),
)

View File

@ -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) {