Profiles/bots (profiles.py, main.py): - Surface real send errors; fix group send and member-count staleness (refresh on view) - Group/channel actions: join, leave, owner delete; consistent member counts - Contacts: always-visible Chat, plus Clear chat and Delete contact - Support bot: OpenAI-compatible LLM backend (Grok/Ollama/OpenAI) per-bot config - Deadmans bot: check-in window, trigger message, recipients, owner - Directory bot: add-to-group registration, super-user /approve /reject /list, search, publishes listing.json in the website schema (directory.py registry module) - Profile edit (name/bio/avatar) + avatars on list pages - Global status + /network page (operators, SMP/XFTP servers) and Settings network info UI (templates): - Chat rooms; collapsible left sidebar with notifications + network widget - Per-directory-bot website generated on creation (name substituted) - Matrix theme; copy/hyperlink addresses; site footer Ignore runtime bot state and generated directory sites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
317 lines
14 KiB
HTML
317 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ tab | title }} — SimpleX Manager{% endblock %}
|
|
|
|
{% block head %}
|
|
<style>
|
|
.empty-state { text-align: center; padding: 56px 24px; color: var(--muted); }
|
|
.empty-state p { margin-top: 8px; font-size: 13px; }
|
|
|
|
.profile-card { cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
|
|
border: 1px solid transparent; }
|
|
.profile-card:hover { border-color: var(--accent); }
|
|
.profile-card:active { transform: translateY(1px); }
|
|
|
|
.addr-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
|
.addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace; font-size: 12px;
|
|
text-decoration: none; word-break: break-all; }
|
|
.addr-link:hover { color: var(--accent); text-decoration: underline; }
|
|
.copy-btn { flex-shrink: 0; padding: 4px 9px; font-size: 13px; line-height: 1; }
|
|
|
|
.bot-types-card table td { vertical-align: top; }
|
|
.bot-types-card .tag { white-space: nowrap; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="flex-between" style="margin-bottom: 24px;">
|
|
<h1 style="margin:0;">{{ tab | title }}</h1>
|
|
<button class="btn btn-primary" onclick="openCreate()">
|
|
+ New {{ 'User' if tab == 'users' else 'Bot' }}
|
|
</button>
|
|
</div>
|
|
|
|
{% if tab == 'bots' %}
|
|
<div class="card bot-types-card" style="margin-bottom:24px;">
|
|
<h2 style="font-size:15px;margin-bottom:12px;">Available bot types</h2>
|
|
<table>
|
|
<tr><td><span class="tag">echo</span></td><td class="muted">Repeats every message back to the sender — handy for testing a connection end to end.</td></tr>
|
|
<tr><td><span class="tag">broadcast</span></td><td class="muted">Relays messages from authorized publishers out to all of the bot's contacts.</td></tr>
|
|
<tr><td><span class="tag">support</span></td><td class="muted">Business inbox — auto-replies with a welcome message and collects incoming inquiries.</td></tr>
|
|
<tr><td><span class="tag">directory</span></td><td class="muted">Directory service for discovering and listing groups or contacts.</td></tr>
|
|
<tr><td><span class="tag">deadmans</span></td><td class="muted">Dead man's switch — triggers an action if expected check-ins stop arriving.</td></tr>
|
|
</table>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if items %}
|
|
{% for p in items %}
|
|
<div class="card profile-card" id="profile-{{ p.id }}"
|
|
onclick="location.href='/profile/{{ p.id }}'">
|
|
<div class="flex-between">
|
|
<div class="flex gap-8">
|
|
{% if p.config.avatar %}
|
|
<img src="{{ p.config.avatar }}" alt=""
|
|
style="width:32px;height:32px;border-radius:50%;object-fit:cover;border:1px solid var(--border);flex-shrink:0;">
|
|
{% endif %}
|
|
<strong>{{ p.name }}</strong>
|
|
<span class="tag {% if p.bot_type == 'user' %}tag-user{% endif %}">{{ p.bot_type }}</span>
|
|
<span class="badge {% if p.running %}badge-green{% else %}badge-red{% endif %}"
|
|
id="status-{{ p.id }}"
|
|
hx-get="/api/profiles/{{ p.id }}/status"
|
|
hx-trigger="every 5s"
|
|
hx-swap="none"
|
|
hx-on::after-request="updateStatus({{ p.id }}, event)">
|
|
{% if p.running %}running{% else %}stopped{% endif %}
|
|
</span>
|
|
</div>
|
|
<div class="flex gap-8" onclick="event.stopPropagation()">
|
|
<button class="btn btn-success" style="padding:6px 14px;font-size:13px;"
|
|
hx-post="/api/profiles/{{ p.id }}/start" hx-swap="none"
|
|
onclick="this.textContent='Starting…'">Start</button>
|
|
<button class="btn btn-danger" style="padding:6px 14px;font-size:13px;"
|
|
hx-post="/api/profiles/{{ p.id }}/stop" hx-swap="none"
|
|
onclick="this.textContent='Stopping…'">Stop</button>
|
|
</div>
|
|
</div>
|
|
{% if p.address %}
|
|
<div class="addr-row" onclick="event.stopPropagation()">
|
|
<button class="btn btn-ghost copy-btn" title="Copy address"
|
|
onclick="copyAddr(event, this, '{{ p.address | e }}')">📋</button>
|
|
<a class="addr-link" href="{{ p.address }}" target="_blank" rel="noopener">{{ p.address }}</a>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<div class="empty-state card">
|
|
{% if tab == 'users' %}
|
|
<strong>No users yet</strong>
|
|
<p>Create a SimpleX user account to manage contacts and channels.</p>
|
|
{% else %}
|
|
<strong>No bots yet</strong>
|
|
<p>Bots can echo messages, broadcast to subscribers, or run automated tasks.</p>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Create dialog -->
|
|
<dialog id="create-dialog">
|
|
<h2 style="margin-bottom:20px;">New {{ 'User' if tab == 'users' else 'Bot' }}</h2>
|
|
<form id="create-form">
|
|
<div class="field">
|
|
<label>Name</label>
|
|
<input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else 'My Bot' }}" required>
|
|
</div>
|
|
<div class="field">
|
|
<label>Bio / Description <span class="muted" style="font-weight:400;">(optional)</span></label>
|
|
<textarea name="bio" rows="2" placeholder="A short description shown on the profile"></textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label>Avatar <span class="muted" style="font-weight:400;">(optional image)</span></label>
|
|
<div class="flex gap-8">
|
|
<img id="avatar-preview" alt="" style="display:none;width:48px;height:48px;border-radius:50%;object-fit:cover;border:1px solid var(--border);">
|
|
<input type="file" name="avatar_file" accept="image/*" onchange="onAvatarChange(this)" style="flex:1;">
|
|
</div>
|
|
</div>
|
|
{% if tab == 'bots' %}
|
|
<div class="field">
|
|
<label>Bot Type</label>
|
|
<select name="profile_type" id="type-select" onchange="onTypeChange()">
|
|
{% for t in create_types %}
|
|
<option value="{{ t }}">{{ t }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="field" id="welcome-field">
|
|
<label>Welcome Message</label>
|
|
<input type="text" name="welcome_message" placeholder="Welcome! How can I help?">
|
|
</div>
|
|
<div id="support-fields" style="display:none;">
|
|
<div style="border-top:1px solid var(--border);margin:4px 0 14px;padding-top:14px;">
|
|
<p class="muted" style="margin-bottom:12px;">
|
|
LLM backend (OpenAI-compatible). Leave the URL blank for a static welcome-only bot.
|
|
</p>
|
|
</div>
|
|
<div class="field">
|
|
<label>API Base URL</label>
|
|
<input type="text" name="api_base" placeholder="https://api.x.ai/v1 (Ollama: http://localhost:11434/v1)">
|
|
</div>
|
|
<div class="field">
|
|
<label>API Key</label>
|
|
<input type="password" name="api_key" placeholder="xai-… (any value for Ollama)">
|
|
</div>
|
|
<div class="field">
|
|
<label>Model</label>
|
|
<input type="text" name="model" placeholder="grok-2 (Ollama: llama3.2)">
|
|
</div>
|
|
<div class="field">
|
|
<label>System Prompt</label>
|
|
<textarea name="system_prompt" rows="3" placeholder="You are a helpful customer-support assistant…"></textarea>
|
|
</div>
|
|
</div>
|
|
<div id="deadmans-fields" style="display:none;">
|
|
<div style="border-top:1px solid var(--border);margin:4px 0 14px;padding-top:14px;">
|
|
<p class="muted" style="margin-bottom:12px;">
|
|
Fires a message to recipients if no check-in arrives in time. Check in by messaging the bot.
|
|
</p>
|
|
</div>
|
|
<div class="field">
|
|
<label>Check-in window (hours)</label>
|
|
<input type="number" name="checkin_hours" min="0.1" step="0.1" value="24">
|
|
</div>
|
|
<div class="field">
|
|
<label>Trigger message</label>
|
|
<textarea name="dms_message" rows="2" placeholder="If you receive this, I haven't checked in…"></textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label>Recipients <span class="muted" style="font-weight:400;">(comma-separated names; blank = all contacts)</span></label>
|
|
<input type="text" name="recipients" placeholder="Alice, Bob">
|
|
</div>
|
|
<div class="field">
|
|
<label>Owner <span class="muted" style="font-weight:400;">(only this contact's messages count as check-in; blank = anyone)</span></label>
|
|
<input type="text" name="owner" placeholder="Alice">
|
|
</div>
|
|
</div>
|
|
<div id="directory-fields" style="display:none;">
|
|
<div style="border-top:1px solid var(--border);margin:4px 0 14px;padding-top:14px;">
|
|
<p class="muted" style="margin-bottom:12px;">
|
|
Group owners register by adding this bot to their group. Listings stay pending until a super-user approves.
|
|
</p>
|
|
</div>
|
|
<div class="field">
|
|
<label>Super-users <span class="muted" style="font-weight:400;">(comma-separated contact names who can /approve)</span></label>
|
|
<input type="text" name="superusers" placeholder="Alice, Bob">
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
<div class="flex gap-8 mt-16" style="justify-content:flex-end;">
|
|
<button type="button" class="btn btn-ghost"
|
|
onclick="document.getElementById('create-dialog').close()">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Create</button>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
|
|
<script>
|
|
function updateStatus(id, event) {
|
|
try {
|
|
const data = JSON.parse(event.detail.xhr.responseText);
|
|
const badge = document.getElementById('status-' + id);
|
|
if (!badge) return;
|
|
badge.textContent = data.running ? 'running' : 'stopped';
|
|
badge.className = 'badge ' + (data.running ? 'badge-green' : 'badge-red');
|
|
} catch(e) {}
|
|
}
|
|
|
|
let avatarDataUri = '';
|
|
|
|
function openCreate() {
|
|
document.getElementById('create-form').reset();
|
|
avatarDataUri = '';
|
|
const prev = document.getElementById('avatar-preview');
|
|
prev.style.display = 'none'; prev.src = '';
|
|
{% if tab == 'bots' %}onTypeChange();{% endif %}
|
|
document.getElementById('create-dialog').showModal();
|
|
}
|
|
|
|
// Read an image file, downscale it to a small square data URI (avatars are sent
|
|
// over the wire to every contact, so keep them tiny). Stores result in avatarDataUri.
|
|
function onAvatarChange(input) {
|
|
const file = input.files && input.files[0];
|
|
if (!file) { avatarDataUri = ''; return; }
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const size = 256;
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = size; canvas.height = size;
|
|
const ctx = canvas.getContext('2d');
|
|
// center-crop to square
|
|
const m = Math.min(img.width, img.height);
|
|
const sx = (img.width - m) / 2, sy = (img.height - m) / 2;
|
|
ctx.drawImage(img, sx, sy, m, m, 0, 0, size, size);
|
|
avatarDataUri = canvas.toDataURL('image/jpeg', 0.85);
|
|
const prev = document.getElementById('avatar-preview');
|
|
prev.src = avatarDataUri; prev.style.display = 'block';
|
|
};
|
|
img.src = e.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function copyAddr(ev, btn, addr) {
|
|
ev.stopPropagation();
|
|
navigator.clipboard.writeText(addr).then(() => {
|
|
btn.textContent = '✓';
|
|
setTimeout(() => btn.textContent = '📋', 1500);
|
|
});
|
|
}
|
|
|
|
{% if tab == 'bots' %}
|
|
function onTypeChange() {
|
|
const val = document.getElementById('type-select').value;
|
|
document.getElementById('welcome-field').style.display = (val === 'echo') ? 'none' : '';
|
|
document.getElementById('support-fields').style.display = (val === 'support') ? 'block' : 'none';
|
|
document.getElementById('deadmans-fields').style.display = (val === 'deadmans') ? 'block' : 'none';
|
|
document.getElementById('directory-fields').style.display = (val === 'directory') ? 'block' : 'none';
|
|
}
|
|
{% endif %}
|
|
|
|
document.getElementById('create-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
{% if tab == 'users' %}
|
|
const botType = 'user';
|
|
const config = {};
|
|
{% else %}
|
|
const botType = fd.get('profile_type') || '{{ create_types[0] }}';
|
|
const config = {};
|
|
const welcome = fd.get('welcome_message');
|
|
if (welcome) config.welcome_message = welcome;
|
|
if (botType === 'support') {
|
|
const apiBase = (fd.get('api_base') || '').trim();
|
|
if (apiBase) config.api_base = apiBase;
|
|
const apiKey = (fd.get('api_key') || '').trim();
|
|
if (apiKey) config.api_key = apiKey;
|
|
const model = (fd.get('model') || '').trim();
|
|
if (model) config.model = model;
|
|
const sysPrompt = (fd.get('system_prompt') || '').trim();
|
|
if (sysPrompt) config.system_prompt = sysPrompt;
|
|
}
|
|
if (botType === 'deadmans') {
|
|
const hrs = parseFloat(fd.get('checkin_hours'));
|
|
if (!isNaN(hrs) && hrs > 0) config.checkin_hours = hrs;
|
|
const dmsMsg = (fd.get('dms_message') || '').trim();
|
|
if (dmsMsg) config.message = dmsMsg;
|
|
const recips = (fd.get('recipients') || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
if (recips.length) config.recipients = recips;
|
|
const owner = (fd.get('owner') || '').trim();
|
|
if (owner) config.owner = owner;
|
|
}
|
|
if (botType === 'directory') {
|
|
const su = (fd.get('superusers') || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
if (su.length) config.superusers = su;
|
|
}
|
|
{% endif %}
|
|
// Shared profile fields (users and bots)
|
|
const bio = (fd.get('bio') || '').trim();
|
|
if (bio) config.bio = bio;
|
|
if (avatarDataUri) config.avatar = avatarDataUri;
|
|
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
|
const resp = await fetch('/api/profiles', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'X-Token': token},
|
|
body: JSON.stringify({name: fd.get('name'), bot_type: botType, config}),
|
|
});
|
|
if (resp.ok) {
|
|
document.getElementById('create-dialog').close();
|
|
location.reload();
|
|
} else {
|
|
const err = await resp.json();
|
|
alert('Error: ' + (err.detail || 'unknown'));
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|