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:
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