forked from ComputerTech/aprhodite
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:
parent
389415f04d
commit
8cd76ff72d
194
app.py
194
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
15
models.py
15
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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue