One chat card, two modes: assistant and mesh, with a purple unread badge on the switch
Mesh is a mode rather than an AI profile — it has no model, tokens, reasoning or sources, so making it a profile would branch every profile-aware path on the one that has no model.
This commit is contained in:
@@ -74,6 +74,9 @@
|
|||||||
// so relying on something else having loaded the registry first works on the AI tab and fatals
|
// so relying on something else having loaded the registry first works on the AI tab and fatals
|
||||||
// on the dashboard.
|
// on the dashboard.
|
||||||
require_once __DIR__ . '/ai_profiles.php';
|
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.
|
// 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 {
|
|||||||
<div class="vv-ai-head">
|
<div class="vv-ai-head">
|
||||||
<span class="vv-ai-head-t">
|
<span class="vv-ai-head-t">
|
||||||
<?php if (!empty($o['icon'])): ?><span class="vv-ico"><?= $o['icon'] ?></span><?php endif; ?>
|
<?php if (!empty($o['icon'])): ?><span class="vv-ico"><?= $o['icon'] ?></span><?php endif; ?>
|
||||||
<?= htmlspecialchars($o['title'] ?? 'Assistant') ?>
|
<span id="<?= $p ?>-title"><?= htmlspecialchars($o['title'] ?? 'Assistant') ?></span>
|
||||||
|
<?php if (!empty($o['mesh'])): ?>
|
||||||
|
<!-- The badge is the whole reason a toggle is acceptable here. Behind a switch, an arriving
|
||||||
|
message is invisible until somebody happens to flip it — and the messages this carries
|
||||||
|
are the ones you most need to not miss ("mine is going down for a week"). Purple because
|
||||||
|
nothing else on these pages is, so it reads as new rather than as another warning. -->
|
||||||
|
<button type="button" id="<?= $p ?>-mode" class="vv-ai-btn ghost"
|
||||||
|
onclick="vvNcMode()" title="Switch between the assistant and the mesh"
|
||||||
|
style="margin-left:8px;font-size:10px;padding:2px 8px;">
|
||||||
|
<span id="<?= $p ?>-mode-l">Mesh</span>
|
||||||
|
<span id="vv-nc-badge" hidden
|
||||||
|
style="margin-left:5px;background:#7c4dff;color:#fff;border-radius:8px;
|
||||||
|
padding:0 6px;font-size:9px;font-weight:700;">✉ 0</span>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Centred by giving the title and the right-hand group equal flex, not by margins — the
|
<!-- Centred by giving the title and the right-hand group equal flex, not by margins — the
|
||||||
@@ -2460,6 +2477,9 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
|
|||||||
|
|
||||||
<!-- Positioned wrapper so the jump pill can sit over the transcript's bottom edge without
|
<!-- Positioned wrapper so the jump pill can sit over the transcript's bottom edge without
|
||||||
being clipped by its overflow, and without taking a row of its own when hidden. -->
|
being clipped by its overflow, and without taking a row of its own when hidden. -->
|
||||||
|
<!-- Two panes, one card. The mesh pane is a sibling of this one rather than a second card, so
|
||||||
|
switching what the card is does not give the page a second header to look at. -->
|
||||||
|
<div id="<?= $p ?>-pane-ai">
|
||||||
<div class="vv-ai-chat-wrap">
|
<div class="vv-ai-chat-wrap">
|
||||||
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
|
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
|
||||||
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
|
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
|
||||||
@@ -2529,6 +2549,8 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div><?php /* close -pane-ai */ ?>
|
||||||
|
<?php if (!empty($o['mesh']) && function_exists('vv_nc_pane_markup')) vv_nc_pane_markup($p, !empty($o['meshDefault'])); ?>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,3 +229,340 @@ function vv_nc_flush_spool(): int {
|
|||||||
}
|
}
|
||||||
return $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): void {
|
||||||
|
static $done = false;
|
||||||
|
if ($done) return;
|
||||||
|
$done = true;
|
||||||
|
?>
|
||||||
|
<div id="<?= htmlspecialchars($prefix, ENT_QUOTES) ?>-pane-mesh" hidden>
|
||||||
|
<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>
|
||||||
|
<label class="vv-ai-cb" style="font-size:10px;"
|
||||||
|
title="Show only what has arrived since you last looked. Unticked shows the whole conversation.">
|
||||||
|
<input type="checkbox" id="vv-nc-unread" onchange="vvNcRender()"> <span>Unread only</span>
|
||||||
|
</label>
|
||||||
|
<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>
|
||||||
|
<label class="vv-ai-cb" style="font-size:10px;"
|
||||||
|
title="Follow the newest message. Unticks when you scroll up, re-ticks at the bottom.">
|
||||||
|
<input type="checkbox" id="vv-nc-follow" checked onchange="vvNcPref('follow')"> <span>Auto Scroll</span>
|
||||||
|
</label>
|
||||||
|
<span id="vv-nc-status" style="margin-left:auto;font-size:9px;color:#3a3a3a;"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="vv-nc-log"
|
||||||
|
style="max-height:260px;overflow-y:auto;padding:6px 2px;font-size:12px;">
|
||||||
|
<div style="color:#444;font-size:11px;">Loading…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 style="display:flex;flex-direction:column;gap:5px;">
|
||||||
|
<div style="display:flex;gap:5px;align-items:center;">
|
||||||
|
<!-- 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" 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</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 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 =>
|
||||||
|
`<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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const lbl = document.getElementById(p + '-mode-l');
|
||||||
|
if (lbl) lbl.textContent = _vvNcMeshOn ? 'Assistant' : 'Mesh';
|
||||||
|
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 (_) {}
|
||||||
|
if (_vvNcMeshOn) vvNcLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
function vvNcInit(prefix, meshDefault) {
|
||||||
|
_vvNcPrefix = prefix;
|
||||||
|
const t = document.getElementById(prefix + '-title');
|
||||||
|
if (t && !t.dataset.ai) t.dataset.ai = t.textContent.trim();
|
||||||
|
let want = !!meshDefault;
|
||||||
|
try {
|
||||||
|
const s = localStorage.getItem('vvNcMode:' + prefix);
|
||||||
|
if (s !== null) want = s === '1';
|
||||||
|
} catch (_) {}
|
||||||
|
_vvNcLoadPrefs();
|
||||||
|
vvNcMode(want);
|
||||||
|
vvNcChans();
|
||||||
|
setInterval(vvNcChans, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
vvNcInit(<?= json_encode($prefix) ?>, <?= $meshDefault ? 'true' : 'false' ?>);
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ if (is_dir('/var/log/varaverk')) {
|
|||||||
// Heights are this tab's to choose; the component's shape is not. This is the surface with the
|
// Heights are this tab's to choose; the component's shape is not. This is the surface with the
|
||||||
// most room, so it rests taller than the Monitor card and expands to most of the viewport.
|
// most room, so it rests taller than the Monitor card and expands to most of the viewport.
|
||||||
vv_ai_chat_markup('vv-ai', [
|
vv_ai_chat_markup('vv-ai', [
|
||||||
|
'mesh' => true,
|
||||||
'profile' => 'varaverk',
|
'profile' => 'varaverk',
|
||||||
// All three stated rather than derived. Large is at the viewport cap here, and leaving medium
|
// 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
|
// to be worked out from the resting height would land it on that same cap — two settings
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// unavailable" resolves against what is on screen.
|
||||||
vv_ai_chat_markup('vv-arr-ai', [
|
vv_ai_chat_markup('vv-arr-ai', [
|
||||||
|
'mesh' => true,
|
||||||
'profile' => 'varaverk',
|
'profile' => 'varaverk',
|
||||||
'compact' => true,
|
'compact' => true,
|
||||||
'title' => 'Assistant',
|
'title' => 'Assistant',
|
||||||
|
|||||||
@@ -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
|
// 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".
|
// question is nearly always "which of these is stopping me".
|
||||||
vv_ai_chat_markup('vv-au-ai', [
|
vv_ai_chat_markup('vv-au-ai', [
|
||||||
|
'mesh' => true,
|
||||||
'profile' => 'varaverk',
|
'profile' => 'varaverk',
|
||||||
'compact' => true,
|
'compact' => true,
|
||||||
'title' => 'Assistant',
|
'title' => 'Assistant',
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// ordinary questions to guard against a mistake the server already prevents.
|
||||||
vv_ai_chat_markup('vv-mon-ai', [
|
vv_ai_chat_markup('vv-mon-ai', [
|
||||||
|
'mesh' => true,
|
||||||
'profile' => 'chat',
|
'profile' => 'chat',
|
||||||
'compact' => true,
|
'compact' => true,
|
||||||
'title' => 'Assistant',
|
'title' => 'Assistant',
|
||||||
|
|||||||
@@ -132,110 +132,16 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; }
|
|||||||
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
|
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mesh chat — the partners' own channel -->
|
|
||||||
<div class="vv-card" id="vv-nc-card" style="margin-bottom:12px;position:relative;overflow:hidden;">
|
|
||||||
<!-- The logo sits behind the conversation rather than beside it: this is the one card on the
|
|
||||||
page that is about the people rather than the machines, and it can afford the room. -->
|
|
||||||
<img src="/plugins/varaverk/icons/varaverk.png" alt="" aria-hidden="true"
|
|
||||||
style="position:absolute;right:-30px;bottom:-40px;width:260px;opacity:.035;
|
|
||||||
pointer-events:none;filter:grayscale(1);">
|
|
||||||
<div style="position:relative;">
|
|
||||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px;">
|
|
||||||
<h3 style="margin:0;">Mesh Chat</h3>
|
|
||||||
<select id="vv-nc-ch" class="vv-cf-sel" style="max-width:200px;font-size:11px;padding:3px 8px;"
|
|
||||||
onchange="vvNcSelect(this.value)"></select>
|
|
||||||
<label class="vv-ai-cb" style="font-size:10px;"
|
|
||||||
title="Show only what has arrived since you last looked. Unticked shows the whole conversation.">
|
|
||||||
<input type="checkbox" id="vv-nc-unread" onchange="vvNcRender()"> <span>Unread only</span>
|
|
||||||
</label>
|
|
||||||
<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>
|
|
||||||
<label class="vv-ai-cb" style="font-size:10px;"
|
|
||||||
title="Follow the newest message. Unticks when you scroll up, re-ticks at the bottom.">
|
|
||||||
<input type="checkbox" id="vv-nc-follow" checked onchange="vvNcPref('follow')"> <span>Auto Scroll</span>
|
|
||||||
</label>
|
|
||||||
<span id="vv-nc-status" style="margin-left:auto;font-size:9px;color:#3a3a3a;"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="vv-nc-log"
|
|
||||||
style="max-height:260px;overflow-y:auto;padding:6px 2px;font-size:12px;">
|
|
||||||
<div style="color:#444;font-size:11px;">Loading…</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 style="display:flex;flex-direction:column;gap:5px;">
|
|
||||||
<div style="display:flex;gap:5px;align-items:center;">
|
|
||||||
<!-- 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" 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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assistant -->
|
<!-- Assistant -->
|
||||||
<?php if (vv_ai_ui_on()): ?>
|
<?php if (vv_ai_ui_on()): ?>
|
||||||
<div class="vv-card" id="vv-pt-ai-card" style="margin-bottom:12px;">
|
<div class="vv-card" id="vv-pt-ai-card" style="margin-bottom:12px;">
|
||||||
<?php vv_ai_chat_markup('vv-pt-ai', [
|
<?php vv_ai_chat_markup('vv-pt-ai', [
|
||||||
'profile' => 'varaverk',
|
'profile' => 'varaverk',
|
||||||
'compact' => true,
|
'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',
|
'title' => 'Assistant',
|
||||||
'height' => '300px',
|
'height' => '300px',
|
||||||
'tall' => '500px',
|
'tall' => '500px',
|
||||||
@@ -1373,200 +1279,6 @@ function vvPtLoad() {
|
|||||||
|
|
||||||
_vvPtReload = vvPtLoad; // expose to top-level toggle fns
|
_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 =>
|
|
||||||
`<option value="${vvEscAttr(c.id)}"${c.id === keep ? ' selected' : ''}>`
|
|
||||||
+ `${vvEscHtml(c.label)}${c.unread ? ' (' + c.unread + ')' : ''}</option>`).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 = `<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(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
vvPtLoad();
|
||||||
setInterval(vvPtLoad, 10000);
|
setInterval(vvPtLoad, 10000);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// this installation, and the answer should come from its own docs and state with sources.
|
||||||
vv_ai_chat_markup('vv-ry-ai', [
|
vv_ai_chat_markup('vv-ry-ai', [
|
||||||
|
'mesh' => true,
|
||||||
'profile' => 'varaverk',
|
'profile' => 'varaverk',
|
||||||
'compact' => true,
|
'compact' => true,
|
||||||
'title' => 'Assistant',
|
'title' => 'Assistant',
|
||||||
|
|||||||
@@ -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. -->
|
is not a number that exists until layout has run. -->
|
||||||
<div id="vv-ai-dock">
|
<div id="vv-ai-dock">
|
||||||
<?php vv_ai_chat_markup('vv-sched-ai', [
|
<?php vv_ai_chat_markup('vv-sched-ai', [
|
||||||
|
'mesh' => true,
|
||||||
'profile' => 'varaverk',
|
'profile' => 'varaverk',
|
||||||
'compact' => true,
|
'compact' => true,
|
||||||
'scopeLabel' => 'Scheduler',
|
'scopeLabel' => 'Scheduler',
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// placement whose size is a share of something and therefore has to be told.
|
||||||
vv_ai_chat_markup('vv-wd-ai', [
|
vv_ai_chat_markup('vv-wd-ai', [
|
||||||
|
'mesh' => true,
|
||||||
'profile' => 'varaverk',
|
'profile' => 'varaverk',
|
||||||
'compact' => true,
|
'compact' => true,
|
||||||
'title' => 'Assistant',
|
'title' => 'Assistant',
|
||||||
|
|||||||
Reference in New Issue
Block a user