Files
faggot-chan/main.py
T
2026-10-14 06:57:11 -03:00

215 lines
9.2 KiB
Python

#!/usr/bin/env python3
import asyncio
import base64
import hmac
import json
import re # thank you claude
import ollama
import websockets
from pathlib import Path
from websockets.server import serve
from utils import nots, now_str
chatmodel = "hf.co/mradermacher/Huihui-gemma-4-E4B-it-abliterated-GGUF:IQ4_XS"
embedmodel = 'hf.co/CompendiumLabs/bge-base-en-v1.5-gguf'
# resolve config relative to this script, not whatever dir it happens to get launched from
SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_DIR = SCRIPT_DIR / "config"
try:
AUTH_TOKEN = (CONFIG_DIR / "auth_token.txt").read_text(encoding="utf-8").strip()
except FileNotFoundError:
raise SystemExit("no config/auth_token.txt found — run setup_wizard.py first")
# only one generation at a time, we've got one cpu running one model here
chat_semaphore = asyncio.Semaphore(1)
# THANK YOU GUY FROM HUGGING FACE THAT MADE ALL THIS SO THAT I DON'T HAVE TO THINK I LOVE YOU
# Each element in the VECTOR_DB will be a tuple (chunk, embedding)
# The embedding is a list of floats, for example: [0.1, 0.04, -0.34, 0.21, ...]
VECTOR_DB = []
def add_chunk_to_database(chunk):
embedding = ollama.embed(model=embedmodel, input=chunk)['embeddings'][0]
return embedding
def cosine_similarity(a, b):
dot_product = sum([x * y for x, y in zip(a, b)])
norm_a = sum([x ** 2 for x in a]) ** 0.5
norm_b = sum([x ** 2 for x in b]) ** 0.5
return dot_product / (norm_a * norm_b)
def retrieve(query, top_n=5):
query_embedding = ollama.embed(model=embedmodel, input=query)['embeddings'][0]
similarities = []
for chunk, embedding in VECTOR_DB:
similarity = cosine_similarity(query_embedding, embedding)
similarities.append((chunk, similarity))
similarities.sort(key=lambda x: x[1], reverse=True)
return similarities[:top_n]
def rebuild_vector_db(longterm_lines: list[str]):
global VECTOR_DB
existing = dict(VECTOR_DB) # chunk -> embedding, so we don't re-embed stuff we already have
new_db = []
added = 0
for i, chunk in enumerate(longterm_lines):
embedding = existing.get(chunk)
if embedding is None:
embedding = add_chunk_to_database(chunk)
added += 1
print(f"[{now_str()}] embedded new chunk {added} ({i+1}/{len(longterm_lines)})")
new_db.append((chunk, embedding))
VECTOR_DB = new_db
async def ollamachat(websocket):
# loop over every message on this connection instead of handling one and returning —
# returning from this function is what makes the websockets lib close the connection,
# which was breaking bot.py's persistent connection after every single reply
async for raw in websocket:
try:
payload = json.loads(raw)
if not hmac.compare_digest(str(payload.get("auth_token", "")).encode(), AUTH_TOKEN.encode()):
await websocket.send(json.dumps({"error": "bad auth token"}))
continue
system_prompt = payload["system_prompt"]
user_input = payload["user_input"]
shortterm = payload.get("shortterm", []) # list of strings
longterm = payload.get("longterm", []) # list of strings
media = payload.get("media", []) # list of {"mime_type", "data" (base64)}
# rebuild vector db if bot signals it (every 150 interactions bot sends full longterm),
# or if we come up empty but longterm isn't — e.g. we just restarted and lost VECTOR_DB
if payload.get("rebuild_vectors") or (not VECTOR_DB and longterm):
await asyncio.to_thread(rebuild_vector_db, longterm)
print(f"[{now_str()}] Vector DB reconciled, {len(VECTOR_DB)} chunks")
user_query = nots(user_input)
retrieved_knowledge = await asyncio.to_thread(retrieve, user_query) if VECTOR_DB else []
# rag chunks go in system so they don't pollute turn history
rag_block = ""
if retrieved_knowledge:
rag_block = "\n\nOlder relevant messages for context:\n" + "\n".join(f"- {chunk}" for chunk, _ in retrieved_knowledge)
# lines to skip — not real dialogue
skip_fragments = ("sent sticker:", "sent media:", "sent a random dm to ", "saved media:")
def is_skip(line):
c = nots(line)
if any(f in c for f in skip_fragments):
return True
# empty said: like "username said: "
if "said:" in c and c.split("said:", 1)[1].strip() == "":
return True
return False
BOT_NAME = "Faggot-chan"
# parse shortterm lines into (speaker, text) pairs
def parse_line(line):
c = nots(line).strip()
# "X said: Y", "X said in DM: Y", "X replied to ... with reply: Y"
# look at what comes right after the username (no spaces in those) instead of searching the whole line,
# otherwise a message that happens to contain " with reply: " gets read as a reply
speaker, _, rest = c.partition(" ")
if rest.startswith("said in DM: "):
return speaker.strip(), rest[len("said in DM: "):].strip()
if rest.startswith("replied to ") and ' with reply: "' in rest:
# "X replied to Y saying: "Z" with reply: "W""
text = rest.rsplit(' with reply: "', 1)[-1]
if text.endswith('"'):
text = text[:-1]
return speaker.strip(), text.strip()
if rest.startswith("said: "):
return speaker.strip(), rest[len("said: "):].strip()
return None, None
# build alternating user/assistant turns from shortterm
# group consecutive non-bot lines into one user blob, bot lines become assistant turns
messages = []
current_user_lines = []
seen_last = None # for dedup
for line in shortterm:
if is_skip(line):
continue
speaker, text = parse_line(line)
if not speaker or not text:
continue
entry = f"{speaker}: {text}"
if entry == seen_last: # skip duplicate consecutive lines
continue
seen_last = entry
if speaker == BOT_NAME:
# flush accumulated user lines first
if current_user_lines:
messages.append({"role": "user", "content": "\n".join(current_user_lines)})
current_user_lines = []
messages.append({"role": "assistant", "content": text})
else:
current_user_lines.append(entry)
# flush any trailing user lines before the final turn
if current_user_lines:
messages.append({"role": "user", "content": "\n".join(current_user_lines)})
# final isolated turn — the specific message she needs to reply to
messages.append({"role": "user", "content": f"[responding to]: {nots(user_input)}"})
# ollama only takes images (no audio/video/pdf), and only on the final turn so history stays text
images = [base64.b64decode(m["data"]) for m in media if m["mime_type"].startswith("image/")]
if images:
messages[-1]["images"] = images
# make sure we start with a user turn (model requirement)
if messages and messages[0]["role"] != "user":
messages.insert(0, {"role": "user", "content": ""})
messages.insert(0, {"role": "system", "content": system_prompt + rag_block})
try:
params = json.loads((CONFIG_DIR / "params.json").read_text())
except FileNotFoundError:
params = {} # no params.json, just use ollama's defaults
async with chat_semaphore:
response = await asyncio.to_thread(
ollama.chat,
model=chatmodel,
messages=messages,
options=params,
)
# strip <think> blocks in case the model ignores think=False, thank you claude
bot_reply = re.sub(r'<think>.*?</think>', '', response["message"]["content"], flags=re.DOTALL).strip()
print([{k: v for k, v in m.items() if k != "images"} for m in messages]) # skip the raw image bytes
print(f"[{now_str()}] reply: {bot_reply}\n")
await websocket.send(json.dumps({"ok": bot_reply}))
except Exception as e:
error_msg = f"[{now_str()}] error in ollamachat: {e}"
print(error_msg)
try:
await websocket.send(json.dumps({"error": error_msg}))
except Exception:
pass # if we can't even send the error, nothing we can do
async def main():
print(f"[{now_str()}] hosting on ws://0.0.0.0:8765")
async with serve(ollamachat, "0.0.0.0", 8765, ping_interval=None, ping_timeout=None, max_size=32 * 1024 * 1024): # default 1mb would kill any media payload
await asyncio.Future()
if __name__ == "__main__":
asyncio.run(main())