Initial implementation of IRC LLM bot
Full implementation from spec: ZNC/IRC client with TLS, Ollama LLM backend, per-user SQLite conversation memory, and Flask web admin portal with 7 pages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
portal/__init__.py
Normal file
0
portal/__init__.py
Normal file
291
portal/app.py
Normal file
291
portal/app.py
Normal file
@@ -0,0 +1,291 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask, abort, jsonify, redirect, render_template, request, url_for, send_file
|
||||
import io
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||
app.secret_key = os.getenv("PORTAL_SECRET_KEY", "changeme")
|
||||
|
||||
|
||||
@app.template_filter("log_class")
|
||||
def log_class_filter(line: str) -> str:
|
||||
if "IRC IN:" in line: return "log-irc-in"
|
||||
if "IRC OUT:" in line: return "log-irc-out"
|
||||
if "[LLM]" in line: return "log-llm"
|
||||
if "[MEMORY]" in line: return "log-memory"
|
||||
if "[CONFIG]" in line: return "log-config"
|
||||
if "ERROR" in line: return "log-error"
|
||||
return ""
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from portal import config_manager as cm
|
||||
from bot import memory as mem
|
||||
|
||||
# ── Dashboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
cfg = cm.load_config()
|
||||
pid_exists = os.path.exists("data/ircbot.pid")
|
||||
sock_exists = os.path.exists("data/ircbot.sock")
|
||||
return render_template("index.html", cfg=cfg, pid_exists=pid_exists, sock_exists=sock_exists)
|
||||
|
||||
|
||||
@app.route("/api/status")
|
||||
def api_status():
|
||||
cfg = cm.load_config()
|
||||
pid_exists = os.path.exists("data/ircbot.pid")
|
||||
return jsonify({
|
||||
"bot_running": pid_exists,
|
||||
"channels": cfg.get("channels", []),
|
||||
"ollama_model": cfg.get("ollama_model"),
|
||||
"bot_nick": cfg.get("bot_nick"),
|
||||
})
|
||||
|
||||
|
||||
@app.route("/action/reload", methods=["POST"])
|
||||
def action_reload():
|
||||
cm.signal_bot_reload()
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@app.route("/action/reconnect", methods=["POST"])
|
||||
def action_reconnect():
|
||||
cm.signal_bot_reconnect()
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@app.route("/action/clear_log", methods=["POST"])
|
||||
def action_clear_log():
|
||||
try:
|
||||
open("logs/bot.log", "w").close()
|
||||
except Exception:
|
||||
pass
|
||||
return redirect(url_for("logs"))
|
||||
|
||||
|
||||
# ── Channels ───────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/channels")
|
||||
def channels():
|
||||
cfg = cm.load_config()
|
||||
return render_template("channels.html", cfg=cfg)
|
||||
|
||||
|
||||
@app.route("/channels/add", methods=["POST"])
|
||||
def channel_add():
|
||||
ch = request.form.get("channel", "").strip()
|
||||
if not ch.startswith("#"):
|
||||
ch = "#" + ch
|
||||
cfg = cm.load_config()
|
||||
if ch not in cfg.get("channels", []):
|
||||
cfg.setdefault("channels", []).append(ch)
|
||||
cm.save_config(cfg)
|
||||
cm.signal_bot_reload()
|
||||
return redirect(url_for("channels"))
|
||||
|
||||
|
||||
@app.route("/channels/remove", methods=["POST"])
|
||||
def channel_remove():
|
||||
ch = request.form.get("channel", "").strip()
|
||||
cfg = cm.load_config()
|
||||
channels_list = cfg.get("channels", [])
|
||||
if ch in channels_list:
|
||||
channels_list.remove(ch)
|
||||
cfg["channels"] = channels_list
|
||||
cm.save_config(cfg)
|
||||
cm.signal_bot_reload()
|
||||
return redirect(url_for("channels"))
|
||||
|
||||
|
||||
# ── LLM Settings ───────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/llm", methods=["GET", "POST"])
|
||||
def llm():
|
||||
cfg = cm.load_config()
|
||||
errors = []
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
cfg["ollama_host"] = request.form["ollama_host"].strip()
|
||||
cfg["ollama_port"] = int(request.form["ollama_port"])
|
||||
cfg["ollama_model"] = request.form["ollama_model"].strip()
|
||||
cfg["system_prompt"] = request.form["system_prompt"]
|
||||
cfg["max_response_length"] = int(request.form["max_response_length"])
|
||||
cfg["ollama_num_predict"] = int(request.form["ollama_num_predict"])
|
||||
cfg["ollama_num_ctx"] = int(request.form["ollama_num_ctx"])
|
||||
cfg["response_timeout_seconds"] = int(request.form["response_timeout_seconds"])
|
||||
cfg["context_window"] = int(request.form["context_window"])
|
||||
cfg["ollama_temperature"] = float(request.form["ollama_temperature"])
|
||||
cfg["memory_enabled"] = "memory_enabled" in request.form
|
||||
cfg["memory_history_limit"] = int(request.form["memory_history_limit"])
|
||||
cfg["memory_max_age_days"] = int(request.form["memory_max_age_days"])
|
||||
cm.save_config(cfg)
|
||||
cm.signal_bot_reload()
|
||||
except (ValueError, KeyError) as e:
|
||||
errors.append(str(e))
|
||||
|
||||
return render_template("llm.html", cfg=cfg, errors=errors)
|
||||
|
||||
|
||||
# ── Bot Identity ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/bot", methods=["GET", "POST"])
|
||||
def bot():
|
||||
cfg = cm.load_config()
|
||||
errors = []
|
||||
|
||||
if request.method == "POST":
|
||||
cfg["bot_nick"] = request.form.get("bot_nick", "avcbot").strip()
|
||||
bot_realname = request.form.get("bot_realname", "").strip()
|
||||
if bot_realname:
|
||||
cfg["bot_realname"] = bot_realname
|
||||
cfg["trigger_on_nick"] = "trigger_on_nick" in request.form
|
||||
prefix = request.form.get("trigger_prefix", "").strip()
|
||||
cfg["trigger_prefix"] = prefix if prefix else None
|
||||
ignored = request.form.get("ignored_nicks", "")
|
||||
cfg["ignored_nicks"] = [n.strip() for n in ignored.split(",") if n.strip()]
|
||||
cm.save_config(cfg)
|
||||
cm.signal_bot_reload()
|
||||
|
||||
return render_template("bot.html", cfg=cfg, errors=errors)
|
||||
|
||||
|
||||
# ── Logs ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/logs")
|
||||
def logs():
|
||||
lines = []
|
||||
log_path = "logs/bot.log"
|
||||
if os.path.exists(log_path):
|
||||
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
all_lines = f.readlines()
|
||||
lines = all_lines[-200:]
|
||||
return render_template("logs.html", lines=lines)
|
||||
|
||||
|
||||
@app.route("/logs/download")
|
||||
def logs_download():
|
||||
log_path = "logs/bot.log"
|
||||
if not os.path.exists(log_path):
|
||||
abort(404)
|
||||
return send_file(log_path, as_attachment=True, download_name="bot.log")
|
||||
|
||||
|
||||
@app.route("/api/logs")
|
||||
def api_logs():
|
||||
lines = []
|
||||
log_path = "logs/bot.log"
|
||||
if os.path.exists(log_path):
|
||||
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()[-200:]
|
||||
return jsonify({"lines": [l.rstrip() for l in lines]})
|
||||
|
||||
|
||||
# ── Memory ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/memory")
|
||||
def memory():
|
||||
channels_list = mem.list_channels()
|
||||
selected_chan = request.args.get("channel")
|
||||
selected_nick = request.args.get("nick")
|
||||
nicks = []
|
||||
exchanges = []
|
||||
|
||||
if selected_chan:
|
||||
nicks = mem.list_nicks(selected_chan)
|
||||
if selected_chan and selected_nick:
|
||||
exchanges = mem.get_all_exchanges(selected_chan, selected_nick)
|
||||
|
||||
stats = mem.get_stats()
|
||||
return render_template(
|
||||
"memory.html",
|
||||
channels=channels_list,
|
||||
selected_chan=selected_chan,
|
||||
nicks=nicks,
|
||||
selected_nick=selected_nick,
|
||||
exchanges=exchanges,
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/memory/clear_user", methods=["POST"])
|
||||
def memory_clear_user():
|
||||
chan = request.form.get("channel_dir", "")
|
||||
nick = request.form.get("nick", "")
|
||||
if chan and nick:
|
||||
mem.delete_user_history("#" + chan, nick)
|
||||
return redirect(url_for("memory", channel=chan))
|
||||
|
||||
|
||||
@app.route("/memory/clear_channel", methods=["POST"])
|
||||
def memory_clear_channel():
|
||||
chan = request.form.get("channel_dir", "")
|
||||
if chan:
|
||||
mem.delete_channel_history("#" + chan)
|
||||
return redirect(url_for("memory"))
|
||||
|
||||
|
||||
@app.route("/memory/clear_all", methods=["POST"])
|
||||
def memory_clear_all():
|
||||
confirm = request.form.get("confirm", "")
|
||||
if confirm == "yes":
|
||||
mem.delete_all_history()
|
||||
return redirect(url_for("memory"))
|
||||
|
||||
|
||||
# ── Config editor ───────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/config", methods=["GET", "POST"])
|
||||
def config_editor():
|
||||
error = None
|
||||
success = None
|
||||
|
||||
if request.method == "POST":
|
||||
raw = request.form.get("config_raw", "")
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
cm.save_config(parsed)
|
||||
cm.signal_bot_reload()
|
||||
success = "Config saved and bot signaled to reload."
|
||||
except json.JSONDecodeError as e:
|
||||
error = f"Invalid JSON: {e}"
|
||||
else:
|
||||
parsed = cm.load_config()
|
||||
|
||||
raw_json = json.dumps(cm.load_config(), indent=2)
|
||||
return render_template("config.html", raw_json=raw_json, error=error, success=success)
|
||||
|
||||
|
||||
@app.route("/config/download")
|
||||
def config_download():
|
||||
return send_file("config/config.json", as_attachment=True, download_name="config.json")
|
||||
|
||||
|
||||
@app.route("/config/upload", methods=["POST"])
|
||||
def config_upload():
|
||||
f = request.files.get("config_file")
|
||||
if not f:
|
||||
return redirect(url_for("config_editor"))
|
||||
try:
|
||||
data = json.loads(f.read().decode())
|
||||
cm.save_config(data)
|
||||
cm.signal_bot_reload()
|
||||
except Exception:
|
||||
pass
|
||||
return redirect(url_for("config_editor"))
|
||||
|
||||
|
||||
# ── Run ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.getenv("PORTAL_PORT", 8080))
|
||||
app.run(host="0.0.0.0", port=port, debug=False)
|
||||
68
portal/config_manager.py
Normal file
68
portal/config_manager.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_PATH = "config/config.json"
|
||||
PID_PATH = "data/ircbot.pid"
|
||||
SOCK_PATH = "data/ircbot.sock"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
return {}
|
||||
with open(CONFIG_PATH, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_config(cfg: dict) -> None:
|
||||
os.makedirs("config", exist_ok=True)
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
logger.info("[CONFIG] Saved config.json")
|
||||
|
||||
|
||||
def signal_bot_reload() -> bool:
|
||||
"""Signal bot to reload config. Returns True on success."""
|
||||
# Try Unix socket first (Docker mode)
|
||||
if sys.platform != "win32" and os.path.exists(SOCK_PATH):
|
||||
try:
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.connect(SOCK_PATH)
|
||||
s.sendall(b"RELOAD")
|
||||
s.close()
|
||||
logger.info("[CONFIG] Sent RELOAD via Unix socket")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[CONFIG] Socket reload failed: {e}")
|
||||
|
||||
# Fall back to SIGHUP
|
||||
if sys.platform != "win32" and os.path.exists(PID_PATH):
|
||||
try:
|
||||
with open(PID_PATH) as f:
|
||||
pid = int(f.read().strip())
|
||||
os.kill(pid, signal.SIGHUP)
|
||||
logger.info(f"[CONFIG] Sent SIGHUP to PID {pid}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[CONFIG] SIGHUP failed: {e}")
|
||||
|
||||
logger.warning("[CONFIG] Could not signal bot (no socket or PID available)")
|
||||
return False
|
||||
|
||||
|
||||
def signal_bot_reconnect() -> bool:
|
||||
if sys.platform != "win32" and os.path.exists(SOCK_PATH):
|
||||
try:
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.connect(SOCK_PATH)
|
||||
s.sendall(b"RECONNECT")
|
||||
s.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[CONFIG] Reconnect signal failed: {e}")
|
||||
return False
|
||||
52
portal/static/app.js
Normal file
52
portal/static/app.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// Auto-refresh for logs page
|
||||
(function () {
|
||||
const checkbox = document.getElementById("auto-refresh");
|
||||
if (!checkbox) return;
|
||||
|
||||
let timer = null;
|
||||
|
||||
function refresh() {
|
||||
fetch("/api/logs")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const box = document.getElementById("log-box");
|
||||
if (!box) return;
|
||||
box.innerHTML = data.lines
|
||||
.map((l) => `<div class="log-line ${logClass(l)}">${escHtml(l)}</div>`)
|
||||
.join("");
|
||||
box.scrollTop = box.scrollHeight;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
checkbox.addEventListener("change", () => {
|
||||
if (checkbox.checked) {
|
||||
refresh();
|
||||
timer = setInterval(refresh, 3000);
|
||||
} else {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll to bottom on load
|
||||
const box = document.getElementById("log-box");
|
||||
if (box) box.scrollTop = box.scrollHeight;
|
||||
|
||||
function logClass(line) {
|
||||
if (line.includes("IRC IN:")) return "log-irc-in";
|
||||
if (line.includes("IRC OUT:")) return "log-irc-out";
|
||||
if (line.includes("[LLM]")) return "log-llm";
|
||||
if (line.includes("[ERROR]") || line.includes("ERROR")) return "log-error";
|
||||
if (line.includes("[CONFIG]")) return "log-config";
|
||||
if (line.includes("[MEMORY]")) return "log-memory";
|
||||
return "";
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
})();
|
||||
325
portal/static/style.css
Normal file
325
portal/static/style.css
Normal file
@@ -0,0 +1,325 @@
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--sidebar-bg: #161b22;
|
||||
--card-bg: #1c2128;
|
||||
--border: #30363d;
|
||||
--text: #c9d1d9;
|
||||
--muted: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--danger: #f85149;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
min-height: 100vh;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 24px 16px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
list-style: none;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.nav-links li a {
|
||||
display: block;
|
||||
padding: 8px 16px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-links li a:hover {
|
||||
color: var(--text);
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.nav-links li a.active {
|
||||
color: var(--accent);
|
||||
border-left-color: var(--accent);
|
||||
background: rgba(88,166,255,0.08);
|
||||
}
|
||||
|
||||
/* ── Content ─────────────────────────────────────────────────────────── */
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 32px 40px;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 14px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
/* ── Cards ───────────────────────────────────────────────────────────── */
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-online { color: var(--success); }
|
||||
.status-offline { color: var(--danger); }
|
||||
|
||||
/* ── Tables ──────────────────────────────────────────────────────────── */
|
||||
.info-table, .data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-table td, .data-table td, .data-table th {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.info-table tr:last-child td,
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
.info-table td:first-child,
|
||||
.data-table th { color: var(--muted); font-size: 12px; text-transform: uppercase; }
|
||||
|
||||
.data-table thead { background: rgba(255,255,255,0.03); }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover { background: rgba(255,255,255,0.06); border-color: var(--muted); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover { background: #79b8ff; border-color: #79b8ff; }
|
||||
|
||||
.btn-danger { color: var(--danger); border-color: var(--danger); }
|
||||
.btn-danger:hover { background: rgba(248,81,73,0.12); }
|
||||
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Forms ───────────────────────────────────────────────────────────── */
|
||||
.settings-form .field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.settings-form label {
|
||||
padding-top: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-form input[type="text"],
|
||||
.settings-form input[type="number"],
|
||||
.settings-form textarea,
|
||||
.settings-form select {
|
||||
width: 100%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
.settings-form textarea { resize: vertical; font-family: var(--font); }
|
||||
.settings-form textarea.mono { font-family: var(--mono); font-size: 12px; }
|
||||
|
||||
.settings-form input[type="checkbox"] {
|
||||
width: 16px; height: 16px; margin-top: 8px; accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
grid-column: 2;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.inline-form input[type="text"],
|
||||
.inline-form select {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Alerts ──────────────────────────────────────────────────────────── */
|
||||
.alert {
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.alert-error { background: rgba(248,81,73,0.12); border: 1px solid var(--danger); color: var(--danger); }
|
||||
.alert-success { background: rgba(63,185,80,0.12); border: 1px solid var(--success); color: var(--success); }
|
||||
|
||||
/* ── Logs ────────────────────────────────────────────────────────────── */
|
||||
.log-toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.log-box {
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-line { padding: 1px 0; }
|
||||
.log-irc-in { color: #79b8ff; }
|
||||
.log-irc-out { color: #85e89d; }
|
||||
.log-llm { color: #ffab70; }
|
||||
.log-error { color: var(--danger); }
|
||||
.log-config { color: #b392f0; }
|
||||
.log-memory { color: #f97583; }
|
||||
|
||||
/* ── Memory exchanges ────────────────────────────────────────────────── */
|
||||
.exchange-list { margin-top: 16px; }
|
||||
|
||||
.exchange {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.exchange-meta { font-size: 11px; color: var(--muted); margin-bottom: 6px; }
|
||||
.exchange-user { margin-bottom: 4px; }
|
||||
.exchange-bot { color: var(--muted); }
|
||||
|
||||
/* ── Misc ────────────────────────────────────────────────────────────── */
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
|
||||
code {
|
||||
background: rgba(255,255,255,0.06);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
30
portal/templates/base.html
Normal file
30
portal/templates/base.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}IRC Bot Portal{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="logo">▸ avcbot</span>
|
||||
<span class="subtitle">Admin Portal</span>
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="{{ url_for('index') }}" {% if request.endpoint == 'index' %}class="active"{% endif %}>⌂ Dashboard</a></li>
|
||||
<li><a href="{{ url_for('channels') }}" {% if request.endpoint == 'channels' %}class="active"{% endif %}>≡ Channels</a></li>
|
||||
<li><a href="{{ url_for('llm') }}" {% if request.endpoint == 'llm' %}class="active"{% endif %}>⚙ LLM Settings</a></li>
|
||||
<li><a href="{{ url_for('bot') }}" {% if request.endpoint == 'bot' %}class="active"{% endif %}>☯ Bot Identity</a></li>
|
||||
<li><a href="{{ url_for('logs') }}" {% if request.endpoint == 'logs' %}class="active"{% endif %}>⎘ Logs</a></li>
|
||||
<li><a href="{{ url_for('memory') }}" {% if request.endpoint == 'memory' %}class="active"{% endif %}>☷ Memory</a></li>
|
||||
<li><a href="{{ url_for('config_editor') }}" {% if request.endpoint == 'config_editor' %}class="active"{% endif %}>✎ Raw Config</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
52
portal/templates/bot.html
Normal file
52
portal/templates/bot.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Bot Identity — IRC Bot Portal{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Bot Identity</h1>
|
||||
|
||||
{% for e in errors %}
|
||||
<div class="alert alert-error">{{ e }}</div>
|
||||
{% endfor %}
|
||||
|
||||
<form method="post" class="settings-form">
|
||||
<div class="section">
|
||||
<h2>Identity</h2>
|
||||
<div class="field-row">
|
||||
<label>Bot Nick</label>
|
||||
<input type="text" name="bot_nick" value="{{ cfg.get('bot_nick', 'avcbot') }}" required>
|
||||
<span class="hint">Changing sends a live NICK command.</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Real Name</label>
|
||||
<input type="text" name="bot_realname" value="{{ cfg.get('bot_realname', 'Active Blue IRC Bot') }}">
|
||||
<span class="hint">Requires reconnect to take effect.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Trigger Settings</h2>
|
||||
<div class="field-row">
|
||||
<label>Trigger on Nick Mention</label>
|
||||
<input type="checkbox" name="trigger_on_nick" {% if cfg.get('trigger_on_nick', True) %}checked{% endif %}>
|
||||
<span class="hint">Respond when someone says <code>avcbot: ...</code></span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Trigger Prefix</label>
|
||||
<input type="text" name="trigger_prefix" value="{{ cfg.get('trigger_prefix') or '' }}" placeholder="e.g. !ask">
|
||||
<span class="hint">Leave blank to disable prefix trigger.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Ignored Nicks</h2>
|
||||
<div class="field-row">
|
||||
<label>Ignored Nicks</label>
|
||||
<input type="text" name="ignored_nicks" value="{{ cfg.get('ignored_nicks', [])|join(', ') }}" placeholder="ChanServ, NickServ">
|
||||
<span class="hint">Comma-separated. Bot never responds to these nicks.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">Save & Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
39
portal/templates/channels.html
Normal file
39
portal/templates/channels.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Channels — IRC Bot Portal{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Channel Management</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>Add Channel</h2>
|
||||
<form method="post" action="{{ url_for('channel_add') }}" class="inline-form">
|
||||
<input type="text" name="channel" placeholder="#general" required>
|
||||
<button class="btn btn-primary">Join & Save</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Joined Channels</h2>
|
||||
{% if cfg.get('channels') %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Channel</th><th>Action</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ch in cfg['channels'] %}
|
||||
<tr>
|
||||
<td>{{ ch }}</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('channel_remove') }}" style="display:inline">
|
||||
<input type="hidden" name="channel" value="{{ ch }}">
|
||||
<button class="btn btn-danger btn-sm">Part</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">No channels configured.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
29
portal/templates/config.html
Normal file
29
portal/templates/config.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Raw Config — IRC Bot Portal{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Raw Config Editor</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if success %}
|
||||
<div class="alert alert-success">{{ success }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="settings-form">
|
||||
<div class="section">
|
||||
<div class="field-row">
|
||||
<label>config.json</label>
|
||||
<textarea name="config_raw" rows="30" class="mono">{{ raw_json }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">Save & Reload Bot</button>
|
||||
<a href="{{ url_for('config_download') }}" class="btn btn-secondary">Download</a>
|
||||
<form method="post" action="{{ url_for('config_upload') }}" enctype="multipart/form-data" style="display:inline">
|
||||
<input type="file" name="config_file" accept=".json">
|
||||
<button type="submit" class="btn btn-secondary">Upload</button>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
62
portal/templates/index.html
Normal file
62
portal/templates/index.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — IRC Bot Portal{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="card-label">Bot Status</div>
|
||||
<div class="card-value status-{{ 'online' if pid_exists else 'offline' }}">
|
||||
{{ 'Running' if pid_exists else 'Stopped' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Nick</div>
|
||||
<div class="card-value">{{ cfg.get('bot_nick', 'avcbot') }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Ollama Model</div>
|
||||
<div class="card-value">{{ cfg.get('ollama_model', '—') }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Channels</div>
|
||||
<div class="card-value">{{ cfg.get('channels', [])|length }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Memory</div>
|
||||
<div class="card-value">{{ 'Enabled' if cfg.get('memory_enabled', True) else 'Disabled' }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Reload Socket</div>
|
||||
<div class="card-value status-{{ 'online' if sock_exists else 'offline' }}">
|
||||
{{ 'Available' if sock_exists else 'Not available' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Connection</h2>
|
||||
<table class="info-table">
|
||||
<tr><td>ZNC Host</td><td>{{ cfg.get('znc_host', 'ham.activeblue.net') }}</td></tr>
|
||||
<tr><td>Ollama</td><td>{{ cfg.get('ollama_host', '—') }}:{{ cfg.get('ollama_port', '—') }}</td></tr>
|
||||
<tr><td>Joined Channels</td><td>{{ cfg.get('channels', [])|join(', ') or '—' }}</td></tr>
|
||||
<tr><td>Trigger on Nick</td><td>{{ 'Yes' if cfg.get('trigger_on_nick') else 'No' }}</td></tr>
|
||||
<tr><td>Trigger Prefix</td><td>{{ cfg.get('trigger_prefix') or '—' }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Quick Actions</h2>
|
||||
<div class="actions">
|
||||
<form method="post" action="{{ url_for('action_reconnect') }}">
|
||||
<button class="btn btn-secondary">Reconnect</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('action_reload') }}">
|
||||
<button class="btn btn-secondary">Reload Config</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('action_clear_log') }}">
|
||||
<button class="btn btn-danger">Clear Log</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
79
portal/templates/llm.html
Normal file
79
portal/templates/llm.html
Normal file
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}LLM Settings — IRC Bot Portal{% endblock %}
|
||||
{% block content %}
|
||||
<h1>LLM Settings</h1>
|
||||
|
||||
{% for e in errors %}
|
||||
<div class="alert alert-error">{{ e }}</div>
|
||||
{% endfor %}
|
||||
|
||||
<form method="post" class="settings-form">
|
||||
<div class="section">
|
||||
<h2>Ollama Backend</h2>
|
||||
<div class="field-row">
|
||||
<label>Host</label>
|
||||
<input type="text" name="ollama_host" value="{{ cfg.get('ollama_host', '192.168.2.10') }}" required>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Port</label>
|
||||
<input type="number" name="ollama_port" value="{{ cfg.get('ollama_port', 11434) }}" required>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Model</label>
|
||||
<input type="text" name="ollama_model" value="{{ cfg.get('ollama_model', 'llama3.1') }}" required>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Temperature</label>
|
||||
<input type="number" name="ollama_temperature" value="{{ cfg.get('ollama_temperature', 0.7) }}" step="0.05" min="0" max="2" required>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Token Limit (num_predict)</label>
|
||||
<input type="number" name="ollama_num_predict" value="{{ cfg.get('ollama_num_predict', 120) }}" min="1" required>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Context Size (num_ctx tokens)</label>
|
||||
<input type="number" name="ollama_num_ctx" value="{{ cfg.get('ollama_num_ctx', 2048) }}" min="512" required>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Response Timeout (seconds)</label>
|
||||
<input type="number" name="response_timeout_seconds" value="{{ cfg.get('response_timeout_seconds', 30) }}" min="5" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Response Handling</h2>
|
||||
<div class="field-row">
|
||||
<label>System Prompt</label>
|
||||
<textarea name="system_prompt" rows="4">{{ cfg.get('system_prompt', '') }}</textarea>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Max Response Length (chars)</label>
|
||||
<input type="number" name="max_response_length" value="{{ cfg.get('max_response_length', 400) }}" min="50" required>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Channel Context Window (messages)</label>
|
||||
<input type="number" name="context_window" value="{{ cfg.get('context_window', 5) }}" min="0" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Persistent Memory</h2>
|
||||
<div class="field-row">
|
||||
<label>Memory Enabled</label>
|
||||
<input type="checkbox" name="memory_enabled" {% if cfg.get('memory_enabled', True) %}checked{% endif %}>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Memory Depth (exchanges)</label>
|
||||
<input type="number" name="memory_history_limit" value="{{ cfg.get('memory_history_limit', 8) }}" min="0" required>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Memory Max Age (days, 0=forever)</label>
|
||||
<input type="number" name="memory_max_age_days" value="{{ cfg.get('memory_max_age_days', 90) }}" min="0" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">Save & Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
21
portal/templates/logs.html
Normal file
21
portal/templates/logs.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Logs — IRC Bot Portal{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Bot Logs</h1>
|
||||
|
||||
<div class="log-toolbar">
|
||||
<label>
|
||||
<input type="checkbox" id="auto-refresh"> Auto-refresh (3s)
|
||||
</label>
|
||||
<a href="{{ url_for('logs_download') }}" class="btn btn-secondary btn-sm">Download</a>
|
||||
<form method="post" action="{{ url_for('action_clear_log') }}" style="display:inline">
|
||||
<button class="btn btn-danger btn-sm">Clear Log</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="log-box" id="log-box">
|
||||
{% for line in lines %}
|
||||
<div class="log-line {{ line|log_class }}">{{ line|e }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
73
portal/templates/memory.html
Normal file
73
portal/templates/memory.html
Normal file
@@ -0,0 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Memory — IRC Bot Portal{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Conversation Memory</h1>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="card-label">Total Exchanges</div>
|
||||
<div class="card-value">{{ stats.total_exchanges }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Database Size</div>
|
||||
<div class="card-value">{{ (stats.total_size_bytes / 1024)|round(1) }} KB</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Browse History</h2>
|
||||
<form method="get" class="inline-form">
|
||||
<select name="channel" onchange="this.form.submit()">
|
||||
<option value="">— Select channel —</option>
|
||||
{% for ch in channels %}
|
||||
<option value="{{ ch }}" {% if ch == selected_chan %}selected{% endif %}>{{ ch }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if selected_chan %}
|
||||
<select name="nick" onchange="this.form.submit()">
|
||||
<option value="">— Select nick —</option>
|
||||
{% for n in nicks %}
|
||||
<option value="{{ n }}" {% if n == selected_nick %}selected{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if selected_chan and selected_nick and exchanges %}
|
||||
<div class="exchange-list">
|
||||
{% for ex in exchanges %}
|
||||
<div class="exchange">
|
||||
<div class="exchange-meta">{{ ex.timestamp }}</div>
|
||||
<div class="exchange-user"><strong>{{ selected_nick }}:</strong> {{ ex.user }}</div>
|
||||
<div class="exchange-bot"><strong>bot:</strong> {{ ex.assistant }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif selected_chan and selected_nick %}
|
||||
<p class="muted">No exchanges found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Clear History</h2>
|
||||
<div class="actions">
|
||||
{% if selected_chan and selected_nick %}
|
||||
<form method="post" action="{{ url_for('memory_clear_user') }}">
|
||||
<input type="hidden" name="channel_dir" value="{{ selected_chan }}">
|
||||
<input type="hidden" name="nick" value="{{ selected_nick }}">
|
||||
<button class="btn btn-danger">Clear {{ selected_nick }}'s history in {{ selected_chan }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if selected_chan %}
|
||||
<form method="post" action="{{ url_for('memory_clear_channel') }}">
|
||||
<input type="hidden" name="channel_dir" value="{{ selected_chan }}">
|
||||
<button class="btn btn-danger">Clear all history in {{ selected_chan }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('memory_clear_all') }}" onsubmit="return confirm('Wipe ALL conversation history? This cannot be undone.')">
|
||||
<input type="hidden" name="confirm" value="yes">
|
||||
<button class="btn btn-danger">Clear ALL History</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user