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

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