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 flask_socketio import SocketIO, emit, join_room, disconnect
|
||||||
|
|
||||||
from database import db, init_db
|
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 (
|
from config import (
|
||||||
SECRET_KEY, ADMIN_PASSWORD, DATABASE_URL, CORS_ORIGINS,
|
SECRET_KEY, ADMIN_PASSWORD, DATABASE_URL, CORS_ORIGINS,
|
||||||
MAX_MSG_LEN, LOBBY, AI_FREE_LIMIT, AI_BOT_NAME,
|
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
|
# Ollama integration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def call_ollama(user_message: str) -> str:
|
MAX_HISTORY_PER_USER = 20 # last N turns loaded into Violet prompt
|
||||||
"""Call the local Ollama API. Returns plaintext AI response."""
|
|
||||||
|
def call_ollama(messages: list) -> str:
|
||||||
|
"""Call the local Ollama API with a full messages list. Returns plaintext AI response."""
|
||||||
import requests as req
|
import requests as req
|
||||||
try:
|
try:
|
||||||
resp = req.post(
|
resp = req.post(
|
||||||
f"{OLLAMA_URL}/api/chat",
|
f"{OLLAMA_URL}/api/chat",
|
||||||
json={
|
json={
|
||||||
"model": VIOLET_MODEL,
|
"model": VIOLET_MODEL,
|
||||||
"messages": [
|
"messages": messages,
|
||||||
{"role": "system", "content": VIOLET_SYSTEM},
|
|
||||||
{"role": "user", "content": user_message},
|
|
||||||
],
|
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"options": {"temperature": 0.88, "num_predict": 120},
|
"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... 💜"
|
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)
|
# AI inference queue worker (single greenlet, serialises Ollama calls)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -119,68 +136,77 @@ def call_ollama(user_message: str) -> str:
|
||||||
def _ai_worker() -> None:
|
def _ai_worker() -> None:
|
||||||
"""Eventlet greenlet – drains ai_queue one task at a time."""
|
"""Eventlet greenlet – drains ai_queue one task at a time."""
|
||||||
global _app_ref
|
global _app_ref
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
task = ai_queue.get() # blocks cooperatively until item available
|
task = ai_queue.get() # blocks cooperatively until item available
|
||||||
|
|
||||||
# ── Announce Violet is busy ───────────────────────────────────────────
|
sid = task["sid"]
|
||||||
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})
|
|
||||||
|
|
||||||
# ── Decrypt user message (transit; key never stored) ──────────────────
|
|
||||||
try:
|
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(
|
plaintext = aesgcm_decrypt(
|
||||||
task["transit_key"], task["ciphertext"], task["nonce_val"]
|
task["transit_key"], task["ciphertext"], task["nonce_val"]
|
||||||
)
|
)
|
||||||
ai_text = call_ollama(plaintext)
|
# ── Build messages array with history ─────────────────────────────
|
||||||
except Exception as exc:
|
messages = [{"role": "system", "content": VIOLET_SYSTEM}]
|
||||||
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():
|
|
||||||
if task.get("user_id"):
|
if task.get("user_id"):
|
||||||
db_user = db.session.get(User, task["user_id"])
|
with _app_ref.app_context():
|
||||||
room = _pm_room(db_user.username, AI_BOT_NAME)
|
messages.extend(_load_violet_history(task["user_id"]))
|
||||||
else:
|
messages.append({"role": "user", "content": plaintext})
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
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"):
|
if task.get("plaintext_mode"):
|
||||||
socketio.emit("pm_message", {
|
socketio.emit("pm_message", {
|
||||||
"from": AI_BOT_NAME,
|
"from": AI_BOT_NAME,
|
||||||
|
|
@ -196,12 +222,26 @@ def _ai_worker() -> None:
|
||||||
"room": room,
|
"room": room,
|
||||||
"ts": _ts()
|
"ts": _ts()
|
||||||
}, to=room)
|
}, to=room)
|
||||||
|
|
||||||
socketio.emit("violet_typing", {"busy": False, "room": room}, to=room)
|
|
||||||
ai_queue.task_done()
|
|
||||||
|
|
||||||
# Clear typing indicator when queue drains
|
socketio.emit("violet_typing", {"busy": False, "room": room}, to=room)
|
||||||
# Done in per-room emit above
|
|
||||||
|
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"):
|
if not user.get("user_id") and not user.get("is_admin"):
|
||||||
emit("error", {"msg": "You must be registered to chat with Violet."}); return
|
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:
|
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
|
return
|
||||||
|
|
||||||
# Echo the user's own message back so it appears in their chat
|
# 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", "")
|
transit_key = data.get("transit_key", "")
|
||||||
if not all([ciphertext, nonce_val, transit_key]):
|
if not all([ciphertext, nonce_val, transit_key]):
|
||||||
# Plaintext fallback for admins without crypto keys
|
# Plaintext fallback (e.g. session restore without crypto key)
|
||||||
if text and user.get("is_admin"):
|
if text:
|
||||||
import base64 as _b64
|
import base64 as _b64
|
||||||
transit_key = _b64.b64encode(os.urandom(32)).decode()
|
transit_key = _b64.b64encode(os.urandom(32)).decode()
|
||||||
ciphertext_new, nonce_new = aesgcm_encrypt(transit_key, text)
|
ciphertext_new, nonce_new = aesgcm_encrypt(transit_key, text)
|
||||||
|
|
@ -641,7 +681,7 @@ def on_pm_message(data):
|
||||||
"plaintext_mode": True,
|
"plaintext_mode": True,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
emit("error", {"msg": "AI Private Messaging requires transit encryption."}); return
|
emit("error", {"msg": "Message cannot be empty."}); return
|
||||||
|
|
||||||
ai_queue.put({
|
ai_queue.put({
|
||||||
"sid": sid,
|
"sid": sid,
|
||||||
|
|
@ -678,6 +718,24 @@ def on_ai_message(data):
|
||||||
pass
|
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
|
# Mod tools
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
15
models.py
15
models.py
|
|
@ -104,3 +104,18 @@ class Mute(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
username = db.Column(db.String(20), unique=True, nullable=False, index=True)
|
username = db.Column(db.String(20), unique=True, nullable=False, index=True)
|
||||||
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False)
|
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;
|
state.hasAiAccess = data.has_ai_access;
|
||||||
updateVioletBadge();
|
updateVioletBadge();
|
||||||
|
|
||||||
let text = "[Decryption Error]";
|
const room = data.room || "ai-violet";
|
||||||
|
let text = data.text || "[Decryption Error]";
|
||||||
if (data.ciphertext && state.cryptoKey) {
|
if (data.ciphertext && state.cryptoKey) {
|
||||||
text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce);
|
text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce);
|
||||||
}
|
}
|
||||||
|
|
||||||
addMessage("ai-violet", {
|
addMessage(room, {
|
||||||
sender: AI_BOT_NAME,
|
sender: AI_BOT_NAME,
|
||||||
text: text,
|
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
|
sent: false
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -405,6 +406,14 @@ messageForm.addEventListener("submit", async (e) => {
|
||||||
}
|
}
|
||||||
else if (state.currentRoom.startsWith("pm:")) {
|
else if (state.currentRoom.startsWith("pm:")) {
|
||||||
const isVioletRoom = state.currentRoom.toLowerCase().endsWith(":violet");
|
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 (isVioletRoom) {
|
||||||
if (state.isRegistered && state.cryptoKey) {
|
if (state.isRegistered && state.cryptoKey) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue