Files
Varaverk/Plugin/unraid/include/node_chat.php
T
Gmer4Lfe 4eb0a9c608 Offer the assistant the same formatting the mesh composer has, and drop the mark lower
Colour is chosen by name and mapped to a fixed value here, so the model never writes a style property — the same closed-set rule the mesh store uses.
2026-08-17 18:01:45 -04:00

697 lines
34 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// The mesh's message store. A place for the people who run these servers to talk to each
// other — "mine is down for a week", "who has seen this error", "the key you wanted is X" —
// in the one window that already knows which machines are in the partnership.
//
// OPERATIONAL MODEL
// Every node keeps its own copy. Sending is: append here, then push a copy to each recipient
// over the SSH trust the onboard already established. There is no server and no election —
// the store is a log, and a log replicated by append is the shape that survives a partner
// being switched off.
//
// data/db/node_chat/global.jsonl every HOST* in master.conf
// data/db/node_chat/dm-host1-host3.jsonl one pair, names sorted so both sides agree
// data/db/node_chat/.spool/<host>/*.json undelivered, retried by the minute job
//
// DESIGN PRINCIPLES
// Never two hosts.
// Channels are derived from vv_known_hosts(), so a third machine gets a global channel
// that includes it and a DM channel with each existing node, with no code change. The
// pair name is sorted rather than "mine-theirs" precisely so both ends compute the same
// filename without having to agree on who spoke first.
//
// Delete is local, and that is the feature.
// Removing a message everywhere needs tombstones, ordering and a story for a node that
// was offline when the delete happened. Removing it from this machine is one file write.
// The UI says "delete here" so nobody expects otherwise.
//
// Sender identity comes from the transport, not the payload's word for it.
// The record carries `from`, but anything writing to this store already had root over
// SSH — the trust boundary is the key, and a machine that holds it can say anything
// regardless of what this file validates.
//
// OPERATIONAL SAFEGUARDS
// Messages are stored PLAINTEXT on every recipient. Tailscale encrypts the wire; the file on
// disk is readable by anyone with the box. The card says so, because the first thing anyone
// wants to send a partner is a credential.
//
// Channel names are derived, never taken from the caller — a name is rebuilt from known host
// ids, so no request can address a path outside the store.
//
// Retention is per channel and enforced on write, so a busy channel cannot fill the flash.
//
// Delivery failures spool rather than throw. A partner that is asleep is the normal case for
// the message "my server is going down".
//
// EXPORTS
// vv_nc_channels() the channels this host participates in
// vv_nc_read() messages for one channel, newest last
// vv_nc_append() store one record locally
// vv_nc_send() store + deliver to the channel's other members
// vv_nc_delete_local() forget one message on this machine
// vv_nc_flush_spool() retry undelivered
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/config.php';
define('VV_NC_DIR', DATA_DIR . '/db/node_chat');
define('VV_NC_SPOOL', VV_NC_DIR . '/.spool');
define('VV_NC_KEEP', 300); // messages retained per channel, per node
function vv_nc_me(): string { return strtolower(vv_detect_host()); }
// One validator, used by both the sender and the receiver.
//
// Both ends must agree on what a style is, and they had two copies of the rules — the sender's
// and the receiver's. Two copies of a whitelist is one that drifts, and the half that drifts is
// the half a partner running newer code writes into. Every value here ends up interpolated into
// a style attribute, so the allowed set is closed rather than sanitised.
function vv_nc_clean_style(array $s): array {
return [
'color' => preg_match('/^#[0-9a-f]{6}$/i', $s['color'] ?? '') ? strtolower($s['color']) : '',
'font' => in_array($s['font'] ?? '', ['mono', 'sans', 'serif'], true) ? $s['font'] : '',
'size' => in_array($s['size'] ?? '', ['sm', 'lg'], true) ? $s['size'] : '',
'bold' => !empty($s['bold']),
'italic' => !empty($s['italic']),
'underline' => !empty($s['underline']),
];
}
// host1/host2/... → the channels this host can see. Global plus one DM per other node.
function vv_nc_channels(): array {
$me = vv_nc_me();
$hosts = array_keys(vv_known_hosts());
$out = [['id' => 'global', 'label' => 'Global', 'members' => $hosts]];
foreach ($hosts as $h) {
if ($h === $me) continue;
$pair = [$me, $h];
sort($pair);
$out[] = ['id' => 'dm-' . implode('-', $pair), 'label' => strtoupper($h), 'members' => $pair];
}
return $out;
}
// Rebuilt from the known channel list rather than trusted — the caller names a channel, and the
// only names that resolve are ones this host is actually a member of.
function vv_nc_channel(string $id): ?array {
foreach (vv_nc_channels() as $c) if ($c['id'] === $id) return $c;
return null;
}
function vv_nc_path(string $channelId): string {
return VV_NC_DIR . '/' . $channelId . '.jsonl';
}
function vv_nc_read(string $channelId, int $limit = 200): array {
$p = vv_nc_path($channelId);
if (!is_file($p)) return [];
$lines = @file($p, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
if (count($lines) > $limit) $lines = array_slice($lines, -$limit);
$out = [];
foreach ($lines as $l) {
$j = json_decode($l, true);
if (is_array($j) && !empty($j['id'])) $out[] = $j;
}
return $out;
}
function vv_nc_append(string $channelId, array $msg): bool {
if (!vv_nc_channel($channelId)) return false;
@mkdir(VV_NC_DIR, 0755, true);
$p = vv_nc_path($channelId);
// Idempotent on id: a spool retry that actually landed the first time must not double-post.
foreach (vv_nc_read($channelId, VV_NC_KEEP) as $m) {
if (($m['id'] ?? '') === ($msg['id'] ?? '')) return true;
}
if (@file_put_contents($p, json_encode($msg, JSON_UNESCAPED_SLASHES) . "\n",
FILE_APPEND | LOCK_EX) === false) return false;
$lines = @file($p, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
if (count($lines) > VV_NC_KEEP) {
$tmp = $p . '.tmp';
if (@file_put_contents($tmp, implode("\n", array_slice($lines, -VV_NC_KEEP)) . "\n") !== false)
@rename($tmp, $p);
}
return true;
}
function vv_nc_delete_local(string $channelId, string $msgId): bool {
$p = vv_nc_path($channelId);
if (!vv_nc_channel($channelId) || !is_file($p)) return false;
$keep = [];
foreach (@file($p, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $l) {
$j = json_decode($l, true);
if (is_array($j) && ($j['id'] ?? '') === $msgId) continue;
$keep[] = $l;
}
$tmp = $p . '.tmp';
if (@file_put_contents($tmp, $keep ? implode("\n", $keep) . "\n" : '') === false) return false;
return @rename($tmp, $p);
}
// Deliver one record to one host. Returns false on any failure so the caller can spool.
function vv_nc_deliver(string $host, string $channelId, array $msg): bool {
$vars = vv_conf_vars();
$me = strtoupper(vv_nc_me());
$sshKey = $vars[$me . '_SSH_KEY'] ?? '';
if (!$sshKey || !is_file($sshKey)) return false;
$hostname = $vars[strtoupper($host)] ?? '';
if ($hostname === '') return false;
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) return false;
// The receiving script lives wherever that host installed Varaverk, which is not necessarily
// where this one did — read from the partner's varaverk.cfg, which stays on flash whatever
// layout it uses. Assuming our own path is the bug that broke Step 1b on an appdata mirror.
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=8 -o BatchMode=yes -o StrictHostKeyChecking=no root@' . escapeshellarg($ip);
$cfg = trim((string)shell_exec($sshBase
. ' ' . escapeshellarg('grep -oP \'(?<=SCRIPTS_DIR=")[^"]+\' /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null')));
$sd = $cfg !== '' ? $cfg : '/boot/config/plugins/varaverk';
$remote = $sd . '/Plugin/unraid/Tools/node_chat_receive.php';
$cmd = $sshBase . ' ' . escapeshellarg('[ -f ' . $remote . ' ] || exit 127; php ' . $remote
. ' --channel=' . escapeshellarg($channelId));
$desc = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','w']];
$pr = @proc_open($cmd, $desc, $pipes);
if (!is_resource($pr)) return false;
fwrite($pipes[0], json_encode($msg, JSON_UNESCAPED_SLASHES));
fclose($pipes[0]);
stream_get_contents($pipes[1]); fclose($pipes[1]);
stream_get_contents($pipes[2]); fclose($pipes[2]);
return proc_close($pr) === 0;
}
function vv_nc_spool(string $host, string $channelId, array $msg): void {
$dir = VV_NC_SPOOL . '/' . $host;
@mkdir($dir, 0755, true);
@file_put_contents($dir . '/' . $msg['id'] . '.json',
json_encode(['channel' => $channelId, 'msg' => $msg], JSON_UNESCAPED_SLASHES));
}
// Store locally, then deliver to everyone else in the channel. Always returns the record: a
// message that reached no one is still a message this operator wrote and must be able to see.
function vv_nc_send(string $channelId, string $text, array $style = []): ?array {
$ch = vv_nc_channel($channelId);
if (!$ch || trim($text) === '') return null;
$msg = [
'id' => bin2hex(random_bytes(8)),
'ts' => time(),
'from' => vv_nc_me(),
// Reserved from day one so a board or an announcement is a filter later rather than a
// migration. Everything written today is a message.
'kind' => 'msg',
'text' => mb_substr(trim($text), 0, 4000),
'style' => vv_nc_clean_style($style),
];
vv_nc_append($channelId, $msg);
foreach ($ch['members'] as $h) {
if ($h === vv_nc_me()) continue;
if (!vv_nc_deliver($h, $channelId, $msg)) vv_nc_spool($h, $channelId, $msg);
}
return $msg;
}
function vv_nc_flush_spool(): int {
$sent = 0;
foreach (glob(VV_NC_SPOOL . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
$host = basename($dir);
foreach (glob($dir . '/*.json') ?: [] as $f) {
$j = json_decode((string)@file_get_contents($f), true);
if (!is_array($j) || empty($j['msg'])) { @unlink($f); continue; }
if (vv_nc_deliver($host, (string)$j['channel'], $j['msg'])) { @unlink($f); $sent++; }
}
}
return $sent;
}
// ── Card pane + client ────────────────────────────────────────────────────────
//
// Rendered inside the assistant card rather than as a card of its own, so a page carries one
// conversation surface and a switch, not two boxes competing for the same corner of the screen.
// Emitted once per page: there is one mesh store, so a second instance would be two views of the
// same thing fighting over the same element ids.
function vv_nc_pane_markup(string $prefix, bool $meshDefault = false,
string $height = '300px', string $tall = ''): void {
static $done = false;
if ($done) return;
$done = true;
?>
<div id="<?= htmlspecialchars($prefix, ENT_QUOTES) ?>-pane-mesh" hidden
data-h="<?= htmlspecialchars($height, ENT_QUOTES) ?>"
data-h-tall="<?= htmlspecialchars($tall !== '' ? $tall : $height, ENT_QUOTES) ?>"
style="--vv-nc-h:<?= htmlspecialchars($height, ENT_QUOTES) ?>;">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px;">
<select id="vv-nc-ch" class="vv-cf-sel" style="max-width:200px;font-size:11px;padding:3px 8px;"
onchange="vvNcSelect(this.value)"></select>
<span id="vv-nc-status" style="margin-left:auto;font-size:9px;color:#3a3a3a;"></span>
</div>
<!-- Fixed height, not max-height. The assistant's transcript is a fixed box, so a mesh log
that grew with its content made the card jump size every time the mode was switched or a
message arrived — and on a dashboard a card that resizes shifts everything under it. -->
<!-- The mark sits behind the conversation rather than beside it. Faint enough to read
through — this is the one surface that is about the people rather than the machines, and
it can carry the badge without competing with the text. -->
<div style="position:relative;">
<img src="/plugins/varaverk/icons/varaverk.png" alt="" aria-hidden="true"
style="position:absolute;left:50%;top:74%;transform:translate(-50%,-50%);
width:min(94%,520px);opacity:.038;pointer-events:none;filter:grayscale(1);
z-index:0;">
<div id="vv-nc-log"
style="position:relative;z-index:1;height:var(--vv-nc-h,300px);overflow-y:auto;
padding:6px 2px;font-size:12px;">
<div style="color:#444;font-size:11px;">Loading…</div>
</div>
</div><?php /* close the logo-backed frame */ ?>
<div style="display:flex;gap:6px;align-items:flex-start;margin-top:8px;flex-wrap:wrap;">
<textarea id="vv-nc-text" rows="2" placeholder="Message the mesh…"
onkeydown="if((event.ctrlKey||event.metaKey)&&event.key==='Enter')vvNcSend()"
style="flex:1 1 260px;min-width:0;background:#0d0d0d;border:1px solid #2a2a2a;
color:#ddd;border-radius:4px;padding:6px 8px;font-family:inherit;
font-size:12px;resize:vertical;"></textarea>
</div>
<!-- Under the input and centred, the way the assistant's controls sit. Same three-group
layout: equal-flex sides with the actions taking only what they need, so the middle is
centred against the input above rather than against whatever the left group measures. -->
<div class="vv-ai-ctrls" style="margin-top:7px;">
<span class="vv-ai-side"></span>
<span class="vv-ai-actions">
<!-- One button opens the formatting row, so the common case — type and send — is not
fronted by six controls nobody changes most of the time. -->
<button class="vv-pt-action-btn info" id="vv-nc-fmt-btn" style="font-size:10px;"
title="Text formatting and view options" onclick="vvNcToggleFmt()">Aa ▸</button>
<select id="vv-nc-emoji" class="vv-cf-sel" style="font-size:12px;padding:2px 6px;"
title="Insert an emoji" onchange="vvNcEmoji(this)">
<option value="">🙂</option>
<option value="👍">👍</option><option value="👋">👋</option><option value="✅">✅</option>
<option value="⚠️">⚠️</option><option value="🔥">🔥</option><option value="🎉">🎉</option>
<option value="🛠️">🛠️</option><option value="💾">💾</option><option value="🔑">🔑</option>
<option value="😀">😀</option><option value="😅">😅</option><option value="🤔">🤔</option>
</select>
<button class="vv-pt-action-btn run" style="font-size:11px;" onclick="vvNcSend()">Send</button>
</span>
<!-- Same control, same corner as the assistant's. A card that expands one way when you are
reading the model and another way when you are reading your partners is two cards
wearing one frame. -->
<span class="vv-ai-side vv-ai-tail">
<button class="vv-ai-btn ghost vv-ai-grow" id="vv-nc-grow" type="button"
title="Give the conversation more room" onclick="vvNcGrow()">⤢</button>
</span>
</div>
<!-- Formatting row, hidden until asked for. Choices persist per browser so a house style
does not have to be re-picked every message. -->
<div id="vv-nc-fmt" style="display:none;margin-top:6px;gap:5px;flex-wrap:wrap;align-items:center;">
<select id="vv-nc-color" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Colour"
onchange="vvNcPref('color')">
<option value="">Colour</option>
<option value="#4caf50">Green</option>
<option value="#4a9eff">Blue</option>
<option value="#ff9800">Amber</option>
<option value="#ef5350">Red</option>
<option value="#ab7df6">Violet</option>
<option value="#bdbdbd">Grey</option>
</select>
<select id="vv-nc-font" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Font"
onchange="vvNcPref('font')">
<option value="">Font</option>
<option value="sans">Sans</option>
<option value="mono">Mono</option>
<option value="serif">Serif</option>
</select>
<select id="vv-nc-size" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Size"
onchange="vvNcPref('size')">
<option value="">Size</option>
<option value="sm">Small</option>
<option value="lg">Large</option>
</select>
<button class="vv-btn-sm" id="vv-nc-bold" onclick="vvNcTog('bold')"
style="font-weight:700;min-width:26px;">B</button>
<button class="vv-btn-sm" id="vv-nc-italic" onclick="vvNcTog('italic')"
style="font-style:italic;min-width:26px;">I</button>
<button class="vv-btn-sm" id="vv-nc-underline" onclick="vvNcTog('underline')"
style="text-decoration:underline;min-width:26px;">U</button>
<span id="vv-nc-preview" style="margin-left:6px;font-size:11px;color:#444;">preview</span>
<span style="width:1px;height:16px;background:#262626;margin:0 4px;"></span>
<label class="vv-ai-cb" style="font-size:10px;"
title="Show the machine's hostname instead of its slot — unRAID-Jayred36 rather than HOST2.">
<input type="checkbox" id="vv-nc-names" onchange="vvNcPref('names');vvNcRender()"> <span>Hostnames</span>
</label>
</div>
<div style="margin-top:5px;font-size:9px;color:#3a3a3a;line-height:1.5;">
Encrypted over Tailscale between mesh nodes only — but stored as plain text on every
machine that receives it. Delete removes it from this machine, not from theirs.
</div>
</div>
<script>
// ── Mesh chat ─────────────────────────────────────────────────────────────────
// Kept apart from vvPtLoad's 10s poll: that redraws the whole page, and a redraw mid-sentence
// would take the textarea with it.
let _vvNc = { ch: '', me: '', msgs: [], lastRead: 0, chans: [], hosts: {},
fmt: { bold: false, italic: false, underline: false } };
// Formatting and display choices are per browser, not per message — picking a house style once
// and having it forgotten on reload is the same complaint the web-search tick had.
const _VV_NC_PREF = 'vvNcPrefs';
function _vvNcLoadPrefs() {
let p = {};
try { p = JSON.parse(localStorage.getItem(_VV_NC_PREF) || '{}') || {}; } catch (_) {}
for (const k of ['color', 'font', 'size']) {
const el = document.getElementById('vv-nc-' + k);
if (el && p[k] != null) el.value = p[k];
}
for (const k of ['names', 'follow']) {
const id = k === 'fmtOn' ? 'vv-nc-fmt-on' : 'vv-nc-' + k;
const el = document.getElementById(id);
if (el && p[k] != null) el.checked = !!p[k];
}
_vvNc.fmt = Object.assign(_vvNc.fmt, p.fmt || {});
_vvNcPaintFmt();
}
function vvNcPref() {
const p = { fmt: _vvNc.fmt };
for (const k of ['color', 'font', 'size']) p[k] = document.getElementById('vv-nc-' + k)?.value ?? '';
for (const k of ['names', 'follow', 'fmtOn'])
p[k] = !!document.getElementById(k === 'fmtOn' ? 'vv-nc-fmt-on' : 'vv-nc-' + k)?.checked;
try { localStorage.setItem(_VV_NC_PREF, JSON.stringify(p)); } catch (_) {}
_vvNcPaintFmt();
}
function vvNcTog(k) { _vvNc.fmt[k] = !_vvNc.fmt[k]; vvNcPref(); }
// The preview is the only way to see what B/I/U plus a colour actually look like together
// before committing them to a message everyone else keeps a copy of.
function _vvNcPaintFmt() {
for (const k of ['bold', 'italic', 'underline']) {
document.getElementById('vv-nc-' + k)?.classList.toggle('active', !!_vvNc.fmt[k]);
}
const pv = document.getElementById('vv-nc-preview');
if (!pv) return;
const s = _vvNcStyle({ style: {
color: document.getElementById('vv-nc-color')?.value || '',
font: document.getElementById('vv-nc-font')?.value || '',
size: document.getElementById('vv-nc-size')?.value || '',
bold: _vvNc.fmt.bold, italic: _vvNc.fmt.italic, underline: _vvNc.fmt.underline,
}}, false);
pv.setAttribute('style', 'margin-left:6px;' + s);
}
function vvNcToggleFmt() {
const row = document.getElementById('vv-nc-fmt');
const btn = document.getElementById('vv-nc-fmt-btn');
const open = row.style.display !== 'none';
row.style.display = open ? 'none' : 'flex';
btn.textContent = open ? 'Aa ▸' : 'Aa ▾';
}
// Style string for a message. Closed set on both ends — the server validates the same values,
// so nothing here can emit a property the store did not agree to.
function _vvNcStyle(m, mine) {
// Reader wins over sender. Someone else's purple italic large is their idea of emphasis, and
// on a wall-mounted dashboard it is noise — this renders every message plain here without
// changing what they sent or what anyone else sees. The only thing kept is the you/them colour
// distinction, which is orientation rather than decoration.
if (!document.getElementById('vv-nc-fmt-on')?.checked)
return `color:${mine ? '#7a9a7a' : '#9aa'};font-size:12px;`;
const st = m.style || {};
const sizes = { sm: '11px', lg: '14px' };
let s = `color:${st.color || (mine ? '#7a9a7a' : '#9aa')};`;
if (fonts[st.font]) s += `font-family:${fonts[st.font]};`;
s += `font-size:${sizes[st.size] || '12px'};`;
if (st.bold) s += 'font-weight:700;';
if (st.italic) s += 'font-style:italic;';
if (st.underline) s += 'text-decoration:underline;';
return s;
}
// HOST2 or unRAID-Jayred36, depending on the tick. The mapping comes from master.conf via the
// API rather than being reconstructed here.
function _vvNcWho(id) {
const full = document.getElementById('vv-nc-names')?.checked;
return full ? (_vvNc.hosts[id] || id) : id.toUpperCase();
}
function vvNcChans() {
fetch('/plugins/varaverk/api/node_chat.php?_=' + Date.now())
.then(r => r.json())
.then(d => {
if (!d.ok) return;
_vvNc.me = d.me; _vvNc.chans = d.channels || []; _vvNc.hosts = d.hostnames || {};
const sel = document.getElementById('vv-nc-ch');
if (!sel) return;
const keep = _vvNc.ch || (_vvNc.chans[0] || {}).id || '';
sel.innerHTML = _vvNc.chans.map(c =>
`<option value="${vvEscAttr(c.id)}"${c.id === keep ? ' selected' : ''}>`
+ `${vvEscHtml(c.label)}${c.unread ? ' (' + c.unread + ')' : ''}</option>`).join('');
if (!_vvNc.ch) vvNcSelect(keep); else if (_vvNcMeshOn) vvNcLoad();
// Total across every channel: the badge exists so an unread message is visible while the
// card is showing the assistant, which is where it will usually be.
const tot = _vvNc.chans.reduce((n, c) => n + (c.unread || 0), 0);
const b = document.getElementById('vv-nc-badge');
if (b) { b.hidden = !tot || _vvNcMeshOn; b.textContent = '✉ ' + tot; }
})
.catch(() => {});
}
function vvNcSelect(ch) { _vvNc.ch = ch; vvNcLoad(); }
function vvNcLoad() {
if (!_vvNc.ch) return;
fetch('/plugins/varaverk/api/node_chat.php?channel=' + encodeURIComponent(_vvNc.ch) + '&_=' + Date.now())
.then(r => r.json())
.then(d => { if (d.ok) { _vvNc.msgs = d.messages || []; _vvNc.lastRead = d.last_read || 0; vvNcRender(); } })
.catch(() => {});
}
function vvNcRender() {
const box = document.getElementById('vv-nc-log');
if (!box) return;
const unreadOnly = document.getElementById('vv-nc-unread')?.checked;
let msgs = _vvNc.msgs;
if (unreadOnly) msgs = msgs.filter(m => (m.ts || 0) > _vvNc.lastRead && m.from !== _vvNc.me);
if (!msgs.length) {
box.innerHTML = `<div style="color:#3a3a3a;font-size:11px;padding:8px 0;">`
+ (unreadOnly ? 'Nothing new.' : 'No messages yet — say hello.') + `</div>`;
return;
}
const fonts = { mono: 'monospace', sans: 'system-ui,sans-serif', serif: 'Georgia,serif' };
box.innerHTML = msgs.map(m => {
const mine = m.from === _vvNc.me;
const fresh = (m.ts || 0) > _vvNc.lastRead && !mine;
return `<div style="padding:4px 6px;margin-bottom:3px;border-left:2px solid ${fresh ? '#4caf50' : '#232323'};
background:${fresh ? 'rgba(76,175,80,.04)' : 'transparent'};border-radius:0 3px 3px 0;">
<div style="display:flex;align-items:baseline;gap:8px;">
<span style="font-size:10px;font-weight:600;color:${mine ? '#4caf50' : '#7a8fa6'};">${vvEscHtml(_vvNcWho(m.from))}</span>
<span style="font-size:9px;color:#333;">${new Date((m.ts || 0) * 1000).toLocaleString()}</span>
<span style="margin-left:auto;font-size:10px;color:#333;cursor:pointer;"
title="Delete on this machine only" onclick="vvNcDel('${vvEscAttr(m.id)}')">×</span>
</div>
<div style="${_vvNcStyle(m, mine)}white-space:pre-wrap;word-break:break-word;margin-top:1px;">${vvEscHtml(m.text)}</div>
</div>`;
}).join('');
// Only when asked. Someone reading back through a thread must not be yanked to the bottom by
// the 15s poll landing a new line.
if (document.getElementById('vv-nc-follow')?.checked) box.scrollTop = box.scrollHeight;
// Marking read is what makes "unread only" mean anything next time, so it happens on view —
// but not while the filter is on, or the list would empty itself as you read it.
if (!unreadOnly && msgs.length) {
fetch('/plugins/varaverk/api/node_chat.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({ csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'read', channel: _vvNc.ch })
}).catch(() => {});
}
}
// Expand/collapse, mirroring the assistant's control down to the glyph swap and the lit state.
// Both heights are carried on the pane as data attributes, so the card's own mount options decide
// how big "more room" is rather than this file inventing a number.
// One card, one size. Expanding while reading the assistant and finding the mesh collapsed on
// switching — or the reverse — reads as the card forgetting, because to the operator there is only
// one box on screen. The two controls therefore drive each other.
//
// _vvNcSyncing guards the round trip: each side reacts to the other by clicking its button, and
// without the flag the first press would ping-pong.
let _vvNcBig = false, _vvNcSyncing = false;
function _vvNcApplyBig() {
const pane = document.getElementById(_vvNcPrefix + '-pane-mesh');
const btn = document.getElementById('vv-nc-grow');
if (pane) pane.style.setProperty('--vv-nc-h',
_vvNcBig ? (pane.dataset.hTall || pane.dataset.h) : pane.dataset.h);
if (btn) {
btn.textContent = _vvNcBig ? '⤡' : '⤢';
btn.title = _vvNcBig ? 'Back to the smaller view' : 'Give the conversation more room';
btn.classList.toggle('vv-ai-grow-on', _vvNcBig);
}
const box = document.getElementById('vv-nc-log');
if (box && document.getElementById('vv-nc-follow')?.checked) box.scrollTop = box.scrollHeight;
}
// fromAi: the assistant's control moved first and has already resized itself, so mirror its state
// rather than toggling and pushing back.
function vvNcGrow(fromAi) {
if (fromAi === undefined) _vvNcBig = !_vvNcBig;
else _vvNcBig = !!fromAi;
_vvNcApplyBig();
try { localStorage.setItem('vvNcBig:' + _vvNcPrefix, _vvNcBig ? '1' : '0'); } catch (_) {}
// Push the change onto the assistant, unless it is the one that sent it.
if (fromAi === undefined && !_vvNcSyncing) {
const aiBtn = document.getElementById(_vvNcPrefix + '-grow');
if (aiBtn) {
const aiBig = aiBtn.classList.contains('vv-ai-grow-on');
if (aiBig !== _vvNcBig) { _vvNcSyncing = true; aiBtn.click(); _vvNcSyncing = false; }
}
}
}
function vvNcEmoji(sel) {
const t = document.getElementById('vv-nc-text');
if (t && sel.value) { t.value += sel.value; t.focus(); }
sel.selectedIndex = 0;
}
function vvNcSend() {
const t = document.getElementById('vv-nc-text');
const text = (t.value || '').trim();
if (!text) return;
const st = document.getElementById('vv-nc-status');
st.textContent = 'sending…';
fetch('/plugins/varaverk/api/node_chat.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'send', channel: _vvNc.ch, text,
color: document.getElementById('vv-nc-color').value,
font: document.getElementById('vv-nc-font').value,
size: document.getElementById('vv-nc-size').value,
bold: _vvNc.fmt.bold ? '1' : '',
italic: _vvNc.fmt.italic ? '1' : '',
underline: _vvNc.fmt.underline ? '1' : '',
})
}).then(r => r.json()).then(d => {
if (!d.ok) { st.textContent = d.error || 'failed'; return; }
t.value = '';
// Queued is not failed. A partner that is asleep gets it on the next flush, and saying so is
// the difference between "it did not send" and "they are not awake".
st.textContent = d.queued ? `queued for ${d.queued} offline node(s)` : 'sent';
setTimeout(() => { st.textContent = ''; }, 4000);
vvNcLoad();
}).catch(e => { st.textContent = 'error'; });
}
function vvNcDel(id) {
fetch('/plugins/varaverk/api/node_chat.php', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({ csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
action: 'delete', channel: _vvNc.ch, id })
}).then(() => vvNcLoad()).catch(() => {});
}
// ── Mode switch ───────────────────────────────────────────────────────────────
// One card, two panes. The label always names where the button GOES, not where you are — a
// button reading "Mesh" while showing mesh is the commonest way a toggle gets pressed twice.
let _vvNcPrefix = '', _vvNcMeshOn = false;
function vvNcMode(force) {
const p = _vvNcPrefix;
if (!p) return;
const ai = document.getElementById(p + '-pane-ai');
const me = document.getElementById(p + '-pane-mesh');
if (!ai || !me) return;
_vvNcMeshOn = (force === undefined) ? !_vvNcMeshOn : !!force;
ai.hidden = _vvNcMeshOn;
me.hidden = !_vvNcMeshOn;
// The banner's controls belong to whichever conversation is showing. Leaving the assistant's
// three over a mesh transcript is what made the switch look like nothing had changed.
const cbA = document.getElementById(p + '-cb-ai');
const cbM = document.getElementById(p + '-cb-mesh');
if (cbA) cbA.hidden = _vvNcMeshOn;
if (cbM) cbM.hidden = !_vvNcMeshOn;
const lbl = document.getElementById(p + '-mode-l');
if (lbl) lbl.textContent = _vvNcMeshOn ? 'Assistant' : 'Mesh Chat';
const t = document.getElementById(p + '-title');
if (t) t.textContent = _vvNcMeshOn ? 'Mesh Chat' : (t.dataset.ai || t.textContent);
try { localStorage.setItem('vvNcMode:' + p, _vvNcMeshOn ? '1' : '0'); } catch (_) {}
// Opening the mesh resets the backoff, so switching to it never waits on a 90-second timer.
if (_vvNcMeshOn) { _vvNcStep = 0; vvNcLoad(); }
if (typeof _vvNcSchedule === 'function') _vvNcSchedule();
}
function vvNcInit(prefix, meshDefault) {
_vvNcPrefix = prefix;
const t = document.getElementById(prefix + '-title');
if (t && !t.dataset.ai) t.dataset.ai = t.textContent.trim();
// meshDefault means "this page IS the mesh page" — Partnership opens on it every time rather
// than on whatever was last toggled. Everywhere else the choice is remembered, because there
// the mesh is a thing you switch to and being returned to it is the surprise.
let want = !!meshDefault;
if (!meshDefault) {
try {
const s = localStorage.getItem('vvNcMode:' + prefix);
if (s !== null) want = s === '1';
} catch (_) {}
}
_vvNcLoadPrefs();
// The assistant's control is the other half of the same size. Listening rather than wrapping its
// handler: VvAiChat owns that button, and reading the class it sets afterwards is the one signal
// that cannot drift from what it actually did.
const aiBtn = document.getElementById(prefix + '-grow');
if (aiBtn) {
aiBtn.addEventListener('click', () => {
if (_vvNcSyncing) return;
// After its own handler has run, so the class reflects the new state.
setTimeout(() => vvNcGrow(aiBtn.classList.contains('vv-ai-grow-on')), 0);
});
}
// Restore the expanded state before the first paint, so the card does not visibly resize.
try {
if (localStorage.getItem('vvNcBig:' + prefix) === '1') {
_vvNcBig = true;
_vvNcApplyBig();
// Bring the assistant along, so both halves start the session agreeing.
if (aiBtn && !aiBtn.classList.contains('vv-ai-grow-on')) {
_vvNcSyncing = true; aiBtn.click(); _vvNcSyncing = false;
}
}
} catch (_) {}
vvNcMode(want);
vvNcChans();
_vvNcSchedule();
}
// Poll fast while the mesh is on screen, and back off while it is not.
//
// Eight pages each asking every 15 seconds is eight times the traffic for one store, and while
// the card is showing the assistant the only thing the poll feeds is the unread badge — which
// nobody is watching to the second. Backs off 30 → 60 → 90 and stays there; snaps back to 15 the
// moment the mesh pane is opened, so switching to it is never waiting on a slow timer.
let _vvNcTimer = null, _vvNcStep = 0;
const _VV_NC_BACKOFF = [30000, 60000, 90000];
function _vvNcSchedule() {
if (_vvNcTimer) clearTimeout(_vvNcTimer);
const wait = _vvNcMeshOn ? 15000 : _VV_NC_BACKOFF[Math.min(_vvNcStep, _VV_NC_BACKOFF.length - 1)];
_vvNcTimer = setTimeout(() => {
vvNcChans();
if (!_vvNcMeshOn) _vvNcStep++;
_vvNcSchedule();
}, wait);
}
vvNcInit(<?= json_encode($prefix) ?>, <?= $meshDefault ? 'true' : 'false' ?>);
</script>
<?php
}