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:
tocmo0nlord
2026-04-17 22:08:53 -04:00
commit b154f63cfa
25 changed files with 2916 additions and 0 deletions

0
portal/__init__.py Normal file
View File

291
portal/app.py Normal file
View 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
View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
})();

325
portal/static/style.css Normal file
View 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;
}

View 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">&#9656; 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 %}>&#8962; Dashboard</a></li>
<li><a href="{{ url_for('channels') }}" {% if request.endpoint == 'channels' %}class="active"{% endif %}>&#8801; Channels</a></li>
<li><a href="{{ url_for('llm') }}" {% if request.endpoint == 'llm' %}class="active"{% endif %}>&#9881; LLM Settings</a></li>
<li><a href="{{ url_for('bot') }}" {% if request.endpoint == 'bot' %}class="active"{% endif %}>&#9775; Bot Identity</a></li>
<li><a href="{{ url_for('logs') }}" {% if request.endpoint == 'logs' %}class="active"{% endif %}>&#9112; Logs</a></li>
<li><a href="{{ url_for('memory') }}" {% if request.endpoint == 'memory' %}class="active"{% endif %}>&#9783; Memory</a></li>
<li><a href="{{ url_for('config_editor') }}" {% if request.endpoint == 'config_editor' %}class="active"{% endif %}>&#9998; 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
View 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 %}

View 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 %}

View 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 %}

View 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
View 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 %}

View 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 %}

View 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 %}