diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php
index d4a6bb4..75b8d44 100644
--- a/Plugin/unraid/include/ai_chat.php
+++ b/Plugin/unraid/include/ai_chat.php
@@ -74,6 +74,9 @@
// so relying on something else having loaded the registry first works on the AI tab and fatals
// on the dashboard.
require_once __DIR__ . '/ai_profiles.php';
+// The mesh pane the chat card can switch to. Required here rather than by each page, so a page
+// that mounts a chat gets the option without having to know the store exists.
+require_once __DIR__ . '/node_chat.php';
// The conversation store, on its own, with no styling and no chat widget attached.
//
@@ -2400,7 +2403,21 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
+ 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.
+
+
+
+ true,
'profile' => 'varaverk',
// All three stated rather than derived. Large is at the viewport cap here, and leaving medium
// to be worked out from the resting height would land it on that same cap — two settings
diff --git a/Plugin/unraid/pages/arrs.php b/Plugin/unraid/pages/arrs.php
index d428f5b..1b1332f 100644
--- a/Plugin/unraid/pages/arrs.php
+++ b/Plugin/unraid/pages/arrs.php
@@ -140,6 +140,7 @@ span.vv-arr-qbadge { display:inline-block; width:auto; }
// which is a lot of vocabulary to carry to a search box. Scoped to the tab so "why are the lists
// unavailable" resolves against what is on screen.
vv_ai_chat_markup('vv-arr-ai', [
+ 'mesh' => true,
'profile' => 'varaverk',
'compact' => true,
'title' => 'Assistant',
diff --git a/Plugin/unraid/pages/auth.php b/Plugin/unraid/pages/auth.php
index 420cee6..c14c9c2 100644
--- a/Plugin/unraid/pages/auth.php
+++ b/Plugin/unraid/pages/auth.php
@@ -691,6 +691,7 @@ $tabs = ['proxies' => 'Proxies', 'users' => 'Users & Groups',
// different objects that all end up deciding whether one person can open one URL, and the
// question is nearly always "which of these is stopping me".
vv_ai_chat_markup('vv-au-ai', [
+ 'mesh' => true,
'profile' => 'varaverk',
'compact' => true,
'title' => 'Assistant',
diff --git a/Plugin/unraid/pages/monitor.php b/Plugin/unraid/pages/monitor.php
index d774a03..9bd5c2a 100644
--- a/Plugin/unraid/pages/monitor.php
+++ b/Plugin/unraid/pages/monitor.php
@@ -371,6 +371,7 @@ $_vv_doc_vars = array_merge(vv_conf_vars(), ['SCRIPTS_DIR' => SCRIPTS_DIR]);
// to the assistant on its own — so landing on the strict profile here would refuse
// ordinary questions to guard against a mistake the server already prevents.
vv_ai_chat_markup('vv-mon-ai', [
+ 'mesh' => true,
'profile' => 'chat',
'compact' => true,
'title' => 'Assistant',
diff --git a/Plugin/unraid/pages/partnership.php b/Plugin/unraid/pages/partnership.php
index 3b37c8c..b765699 100644
--- a/Plugin/unraid/pages/partnership.php
+++ b/Plugin/unraid/pages/partnership.php
@@ -132,110 +132,16 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; }
Loading…
-
-
-
-
-
-
-
Mesh Chat
-
-
-
-
-
-
-
-
-
Loading…
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- preview
-
-
- 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.
-
-
-
-
'varaverk',
'compact' => true,
+ // This is the partners' page — the mesh is what you came here to read, and the assistant
+ // is the thing you switch to. Every other page has it the other way round.
+ 'mesh' => true,
+ 'meshDefault' => true,
'title' => 'Assistant',
'height' => '300px',
'tall' => '500px',
@@ -1373,200 +1279,6 @@ function vvPtLoad() {
_vvPtReload = vvPtLoad; // expose to top-level toggle fns
-// ── 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 el = document.getElementById('vv-nc-' + k);
- 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']) p[k] = !!document.getElementById('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) {
- 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 =>
- ``).join('');
- if (!_vvNc.ch) vvNcSelect(keep); else vvNcLoad();
- })
- .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 = `
`;
- }).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(() => {});
- }
-}
-
-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(() => {});
-}
-
-_vvNcLoadPrefs();
-vvNcChans();
-setInterval(vvNcChans, 15000);
-
vvPtLoad();
setInterval(vvPtLoad, 10000);
diff --git a/Plugin/unraid/pages/rsync.php b/Plugin/unraid/pages/rsync.php
index 7c7b205..f0dfeb3 100644
--- a/Plugin/unraid/pages/rsync.php
+++ b/Plugin/unraid/pages/rsync.php
@@ -350,6 +350,7 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
// Chat for the same reason as the Watchdog tab: this is a page you open with a question about
// this installation, and the answer should come from its own docs and state with sources.
vv_ai_chat_markup('vv-ry-ai', [
+ 'mesh' => true,
'profile' => 'varaverk',
'compact' => true,
'title' => 'Assistant',
diff --git a/Plugin/unraid/pages/scheduler.php b/Plugin/unraid/pages/scheduler.php
index e49b8df..2a8902e 100644
--- a/Plugin/unraid/pages/scheduler.php
+++ b/Plugin/unraid/pages/scheduler.php
@@ -1101,6 +1101,7 @@ Still the same two servers, two households, the same media stack running itself.
is not a number that exists until layout has run. -->
true,
'profile' => 'varaverk',
'compact' => true,
'scopeLabel' => 'Scheduler',
diff --git a/Plugin/unraid/pages/watchdog.php b/Plugin/unraid/pages/watchdog.php
index 2cb69f7..2f8c4cd 100644
--- a/Plugin/unraid/pages/watchdog.php
+++ b/Plugin/unraid/pages/watchdog.php
@@ -244,6 +244,7 @@ $_vv_wd_installed = vv_wd_installed_containers();
// own defaults apply and the expand control still works against them. The Scheduler is the only
// placement whose size is a share of something and therefore has to be told.
vv_ai_chat_markup('vv-wd-ai', [
+ 'mesh' => true,
'profile' => 'varaverk',
'compact' => true,
'title' => 'Assistant',