share: render markdown + preview-before-publish + soul-history probe
Share card now displays the AI bubble's marked-rendered HTML (after basic tag allowlist sanitization) instead of escaped plaintext. Markdown bold, lists, code, headers all show. Share click in chat now opens a preview modal. Publishing to the gallery only happens when the user explicitly clicks Publish in the modal - removes the click-and-immediately-public surprise.
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
restore-chat-js-with-preview.py - Re-inline the chat-widget JS into
|
||||
styles.el's page_close() so that extract-js.py picks up the freshly modified
|
||||
source on the next build (instead of carrying the obfuscated old asset
|
||||
forward forever).
|
||||
|
||||
What this writes:
|
||||
- The original chat-widget IIFE (from commit 640813e^), modified to
|
||||
capture bubble.innerHTML on Share-click and open a preview modal
|
||||
instead of POSTing to /api/share immediately.
|
||||
- Modal HTML (#neuron-share-preview-modal) inserted right before the
|
||||
chat-widget script.
|
||||
- The /api/share POST in publishSharePreview() sends both answer (legacy
|
||||
plaintext), answer_html (rendered, sanitized server-side), and
|
||||
answer_plaintext (og:desc).
|
||||
|
||||
Idempotent. Re-run is a no-op once the inline block is present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
STYLES_EL = REPO_ROOT / "src" / "styles.el"
|
||||
|
||||
MARKER = "neuron-share-preview-modal"
|
||||
|
||||
# El strings use \" for embedded ". Newlines stay literal. Backticks fine.
|
||||
# We assemble the inline JS as a single literal: opening <script> ... </script>.
|
||||
# Every double-quote inside must become \\" in the El source. We write it as a
|
||||
# raw string and use string substitution at the end.
|
||||
|
||||
CHAT_HTML_AND_JS = r"""
|
||||
<!-- Share preview modal: shown after the user clicks Share on an AI bubble.
|
||||
Renders the share-card layout in an iframe (via srcdoc) so the visitor
|
||||
sees exactly what the public card will look like before publishing. -->
|
||||
<div id=\"neuron-share-preview-modal\" style=\"display:none;position:fixed;inset:0;z-index:200000;background:rgba(13,13,20,.55);align-items:center;justify-content:center;padding:1.5rem;font-family:'IBM Plex Sans',system-ui,sans-serif\">
|
||||
<div style=\"background:#fff;width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;border-radius:12px;box-shadow:0 24px 80px rgba(0,0,0,.35);overflow:hidden\">
|
||||
<div style=\"display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid rgba(0,0,0,.08)\">
|
||||
<div>
|
||||
<div style=\"font-size:.65rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:#6B6B7E\">Preview</div>
|
||||
<div style=\"font-size:1rem;font-weight:500;color:#0D0D14;margin-top:.15rem\">This is what you are about to publish</div>
|
||||
</div>
|
||||
<button type=\"button\" id=\"neuron-share-preview-close\" aria-label=\"Close\" style=\"background:none;border:none;cursor:pointer;color:#6B6B7E;padding:.25rem;line-height:1;font-size:1.5rem\">×</button>
|
||||
</div>
|
||||
<iframe id=\"neuron-share-preview-frame\" style=\"flex:1;width:100%;min-height:420px;border:0;background:#FAFAF8\" sandbox=\"allow-same-origin\"></iframe>
|
||||
<div style=\"display:flex;align-items:center;justify-content:flex-end;gap:.6rem;padding:.85rem 1.25rem;border-top:1px solid rgba(0,0,0,.08);background:#FAFAF8\">
|
||||
<button type=\"button\" id=\"neuron-share-preview-cancel\" style=\"background:#fff;border:1px solid rgba(0,0,0,.18);color:#3A3A4A;cursor:pointer;padding:.55rem 1rem;font:inherit;font-size:.8rem;font-weight:500;border-radius:6px\">Cancel</button>
|
||||
<button type=\"button\" id=\"neuron-share-preview-publish\" style=\"background:#0052A0;border:1px solid #0052A0;color:#fff;cursor:pointer;padding:.55rem 1.1rem;font:inherit;font-size:.8rem;font-weight:600;letter-spacing:.04em;border-radius:6px\">Publish to gallery</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
if (typeof marked !== 'undefined') { marked.setOptions({ breaks: true, gfm: true }); }
|
||||
var TURNSTILE_SITE_KEY = '0x4AAAAAADHAZXyuRb3yD9mr';
|
||||
var turnstileToken = '';
|
||||
var turnstileWidgetId = null;
|
||||
var turnstileVerified = false;
|
||||
var isOpen = false;
|
||||
var MAX = 10;
|
||||
|
||||
// ── Share preview modal helpers ──────────────────────────────────────────
|
||||
// Captures the rendered (marked.js) HTML from the AI bubble and shows a
|
||||
// preview before publishing. The actual /api/share POST + gallery insert
|
||||
// only fires when the user clicks Publish in the modal.
|
||||
var SHARE_CARD_CSS = \"*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}body{font-family:'IBM Plex Sans',system-ui,sans-serif;background:#FAFAF8;color:#0D0D14;padding:1.25rem .75rem;min-height:100vh}.chat-frame{background:#fff;border:1px solid rgba(0,0,0,.09);box-shadow:0 4px 32px rgba(0,0,0,.07),0 1px 4px rgba(0,0,0,.04);padding:1.25rem;display:flex;flex-direction:column;gap:1rem;max-width:560px;margin:0 auto}.chat-row-user{display:flex;flex-direction:row-reverse}.chat-row-ai{display:flex;flex-direction:row;align-items:flex-end;gap:.625rem}.bubble-user{background:#0052A0;color:#fff;border-radius:18px 18px 4px 18px;padding:11px 15px;max-width:78%;font-size:.875rem;line-height:1.55;word-break:break-word}.bubble-ai{background:#FAFAF8;color:#0D0D14;border:1px solid rgba(0,0,0,.07);border-radius:18px 18px 18px 4px;padding:11px 15px;max-width:88%;font-size:.875rem;font-weight:300;line-height:1.65;word-break:break-word;box-shadow:0 2px 6px rgba(0,0,0,.05)}.bubble-ai p{margin:0}.bubble-ai p+p{margin-top:.6rem}.bubble-ai ul,.bubble-ai ol{margin:.5rem 0 .5rem 1.25rem;padding:0}.bubble-ai li+li{margin-top:.25rem}.bubble-ai strong{font-weight:600}.bubble-ai em{font-style:italic}.bubble-ai code{font-family:'IBM Plex Mono','Menlo',monospace;font-size:.8rem;background:rgba(0,0,0,.05);padding:1px 4px;border-radius:3px}.bubble-ai pre{background:rgba(0,0,0,.05);padding:.75rem;border-radius:6px;overflow-x:auto;font-size:.8rem;margin:.5rem 0}.bubble-ai pre code{background:none;padding:0}.bubble-ai blockquote{border-left:3px solid rgba(0,82,160,.3);margin:.5rem 0;padding:.25rem 0 .25rem .75rem;color:#3A3A4A}.bubble-ai h1,.bubble-ai h2,.bubble-ai h3,.bubble-ai h4{font-weight:600;margin:.5rem 0 .25rem}.bubble-ai h1{font-size:1.05rem}.bubble-ai h2{font-size:1rem}.bubble-ai h3{font-size:.95rem}.bubble-ai h4{font-size:.9rem}.bubble-ai a{color:#0052A0;text-decoration:underline}.ai-col{display:flex;flex-direction:column;gap:.25rem}.ai-label{font-size:.6rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:#0052A0}.avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;background:#fff;border:1px solid rgba(0,82,160,.15);display:flex;align-items:center;justify-content:center;font-size:.7rem;color:#0052A0;font-weight:600}\";
|
||||
function _esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"'); }
|
||||
function _buildPreviewSrcdoc(question, answerHtml) {
|
||||
return '<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><style>' + SHARE_CARD_CSS + '</style></head><body><div class=\"chat-frame\"><div class=\"chat-row-user\"><div class=\"bubble-user\">' + _esc(question || '(no prior question)') + '</div></div><div class=\"chat-row-ai\"><div class=\"avatar\">N</div><div class=\"ai-col\"><span class=\"ai-label\">Neuron</span><div class=\"bubble-ai\">' + (answerHtml || '') + '</div></div></div></div></body></html>';
|
||||
}
|
||||
var _sharePending = null;
|
||||
function openSharePreview(question, answerHtml, answerPlain, originBtn) {
|
||||
_sharePending = { question: question, answerHtml: answerHtml, answerPlain: answerPlain, btn: originBtn };
|
||||
var modal = document.getElementById('neuron-share-preview-modal');
|
||||
var frame = document.getElementById('neuron-share-preview-frame');
|
||||
if (!modal || !frame) return;
|
||||
frame.srcdoc = _buildPreviewSrcdoc(question, answerHtml);
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
function closeSharePreview() {
|
||||
var modal = document.getElementById('neuron-share-preview-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
var frame = document.getElementById('neuron-share-preview-frame');
|
||||
if (frame) frame.srcdoc = '';
|
||||
_sharePending = null;
|
||||
}
|
||||
async function publishSharePreview() {
|
||||
if (!_sharePending) return;
|
||||
var pending = _sharePending;
|
||||
var publishBtn = document.getElementById('neuron-share-preview-publish');
|
||||
if (publishBtn) { publishBtn.disabled = true; publishBtn.textContent = 'Publishing...'; }
|
||||
if (pending.btn) pending.btn.style.opacity = '0.4';
|
||||
try {
|
||||
var r = await fetch('/api/share', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
question: pending.question,
|
||||
answer: pending.answerPlain,
|
||||
answer_html: pending.answerHtml,
|
||||
answer_plaintext: pending.answerPlain
|
||||
})
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d && d.id) {
|
||||
window.open('/share/' + d.id, '_blank');
|
||||
}
|
||||
} catch(e) {}
|
||||
if (pending.btn) pending.btn.style.opacity = '1';
|
||||
if (publishBtn) { publishBtn.disabled = false; publishBtn.textContent = 'Publish to gallery'; }
|
||||
closeSharePreview();
|
||||
}
|
||||
// Wire modal buttons once DOM is ready. The modal lives outside the chat
|
||||
// panel so it works whether the panel is open or closed.
|
||||
function _wireShareModal() {
|
||||
var pubBtn = document.getElementById('neuron-share-preview-publish');
|
||||
var cnlBtn = document.getElementById('neuron-share-preview-cancel');
|
||||
var clsBtn = document.getElementById('neuron-share-preview-close');
|
||||
var modal = document.getElementById('neuron-share-preview-modal');
|
||||
if (pubBtn) pubBtn.addEventListener('click', publishSharePreview);
|
||||
if (cnlBtn) cnlBtn.addEventListener('click', closeSharePreview);
|
||||
if (clsBtn) clsBtn.addEventListener('click', closeSharePreview);
|
||||
if (modal) modal.addEventListener('click', function(ev) { if (ev.target === modal) closeSharePreview(); });
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _wireShareModal);
|
||||
else _wireShareModal();
|
||||
|
||||
// Persistent session storage - survives page refreshes
|
||||
function loadSession() {
|
||||
try {
|
||||
var s = localStorage.getItem('neuron_demo_session');
|
||||
return s ? JSON.parse(s) : { messages: [], count: 0, context: '' };
|
||||
} catch(e) { return { messages: [], count: 0, context: '' }; }
|
||||
}
|
||||
function saveSession(session) {
|
||||
try { localStorage.setItem('neuron_demo_session', JSON.stringify(session)); } catch(e) {}
|
||||
}
|
||||
function clearSession() {
|
||||
try { localStorage.removeItem('neuron_demo_session'); } catch(e) {}
|
||||
}
|
||||
|
||||
function _mg(s) { return s._m || { nodes: [], edges: [] }; }
|
||||
|
||||
function _um(s, nn, ne) {
|
||||
if (!nn || !nn.length) return;
|
||||
var g = _mg(s), nm = {}, ek = function(e) { return e.from+'->'+e.to; }, em = {};
|
||||
g.nodes.forEach(function(n) { nm[n.id] = n; });
|
||||
(nn || []).forEach(function(n) {
|
||||
if (nm[n.id]) { nm[n.id].w = Math.min(1.0, (nm[n.id].w || 0.5) + 0.08); }
|
||||
else { nm[n.id] = n; }
|
||||
});
|
||||
g.nodes = Object.values(nm);
|
||||
g.edges.forEach(function(e) { em[ek(e)] = e; });
|
||||
(ne || []).forEach(function(e) {
|
||||
var k = ek(e);
|
||||
if (em[k]) { em[k].weight = Math.min(1.0, (em[k].weight || 0.5) + 0.05); }
|
||||
else { em[k] = e; }
|
||||
});
|
||||
g.edges = Object.values(em);
|
||||
s._m = g; saveSession(s);
|
||||
}
|
||||
|
||||
function _ra(g, q) {
|
||||
if (!g || !g.nodes || !g.nodes.length) return [];
|
||||
var words = q.toLowerCase().split(/\s+/).filter(function(w) { return w.length > 3; });
|
||||
var sc = {};
|
||||
g.nodes.forEach(function(n) {
|
||||
var t = (n.content || '').toLowerCase();
|
||||
sc[n.id] = words.filter(function(w) { return t.indexOf(w) !== -1; }).length * 0.6 + (n.w || 0.5) * 0.4;
|
||||
});
|
||||
(g.edges || []).forEach(function(e) {
|
||||
if (sc[e.from] > 0.1) sc[e.to] = (sc[e.to] || 0) + sc[e.from] * (e.weight || 0.5) * 0.4;
|
||||
});
|
||||
return g.nodes.filter(function(n) { return sc[n.id] > 0.2; })
|
||||
.sort(function(a,b) { return sc[b.id]-sc[a.id]; }).slice(0,5)
|
||||
.map(function(n) { return { id: n.id, content: n.content, score: sc[n.id] }; });
|
||||
}
|
||||
|
||||
// ?reset=1 clears the session and reloads clean
|
||||
if (window.location.search.indexOf('reset=1') !== -1) {
|
||||
clearSession();
|
||||
var clean = window.location.pathname;
|
||||
window.history.replaceState({}, '', clean);
|
||||
}
|
||||
|
||||
var session = loadSession();
|
||||
// Ensure every user has a stable unique session ID.
|
||||
if (!session.uid) {
|
||||
session.uid = 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||
saveSession(session);
|
||||
}
|
||||
var msgCount = session.count || 0;
|
||||
|
||||
function updateCountdown() {
|
||||
var el = document.getElementById('neuron-demo-countdown');
|
||||
if (!el) return;
|
||||
var remaining = MAX - msgCount;
|
||||
el.textContent = remaining + ' question' + (remaining === 1 ? '' : 's') + ' left';
|
||||
el.style.color = '#ffffff';
|
||||
el.style.fontWeight = '700';
|
||||
}
|
||||
|
||||
window.neuronDemoReset = function() {
|
||||
try { localStorage.removeItem('neuron_demo_session'); } catch(e) {}
|
||||
session = { messages: [], count: 0, context: '' };
|
||||
msgCount = 0;
|
||||
var msgs = document.getElementById('neuron-demo-messages');
|
||||
if (msgs) msgs.innerHTML = '';
|
||||
var input = document.getElementById('neuron-demo-text');
|
||||
if (input) { input.disabled = false; input.placeholder = 'Ask me anything...'; }
|
||||
var btn = document.getElementById('neuron-demo-send');
|
||||
if (btn) btn.disabled = false;
|
||||
addMsg('ai', 'Hey. What is on your mind?', true);
|
||||
};
|
||||
|
||||
window.neuronDemoToggle = function() {
|
||||
isOpen = !isOpen;
|
||||
var panel = document.getElementById('neuron-demo-panel');
|
||||
if (panel) panel.style.display = isOpen ? 'flex' : 'none';
|
||||
var btn = document.getElementById('neuron-demo-btn');
|
||||
if (btn) btn.style.display = isOpen ? 'none' : '';
|
||||
var msgs = document.getElementById('neuron-demo-messages');
|
||||
if (isOpen && turnstileVerified && msgs && msgs.style.display !== 'none' && msgs.children.length === 0) {
|
||||
if (session.messages && session.messages.length > 0) {
|
||||
session.messages.forEach(function(m) { addMsg(m.role, m.text, true); });
|
||||
var remaining = MAX - msgCount;
|
||||
if (remaining <= 0) {
|
||||
var input = document.getElementById('neuron-demo-text');
|
||||
if (input) { input.disabled = true; input.placeholder = 'Interaction limit reached'; }
|
||||
}
|
||||
} else if (!session.greeted) {
|
||||
addMsg('ai', 'Hey. What is on your mind?', true);
|
||||
session.greeted = true;
|
||||
try { localStorage.setItem('neuron_demo_session', JSON.stringify(session)); } catch(e) {}
|
||||
}
|
||||
}
|
||||
var input = document.getElementById('neuron-demo-text');
|
||||
if (isOpen && input && !input.disabled) input.focus();
|
||||
updateCountdown();
|
||||
if (isOpen && !turnstileWidgetId && typeof turnstile !== 'undefined') {
|
||||
var container = document.getElementById('neuron-demo-turnstile');
|
||||
if (container) {
|
||||
turnstileWidgetId = turnstile.render(container, {
|
||||
sitekey: TURNSTILE_SITE_KEY,
|
||||
size: 'compact',
|
||||
callback: function(token) {
|
||||
turnstileToken = token;
|
||||
turnstileVerified = true;
|
||||
if (typeof turnstile !== 'undefined' && turnstileWidgetId !== null) {
|
||||
try { turnstile.remove(turnstileWidgetId); } catch(e) {}
|
||||
turnstileWidgetId = null;
|
||||
}
|
||||
var gate = document.getElementById('neuron-demo-gate');
|
||||
var msgs = document.getElementById('neuron-demo-messages');
|
||||
var inputRow = document.getElementById('neuron-demo-input-row');
|
||||
if (gate) gate.style.display = 'none';
|
||||
if (msgs) msgs.style.display = 'flex';
|
||||
if (inputRow) inputRow.style.display = 'flex';
|
||||
addMsg('ai', 'Hey. What is on your mind?', true);
|
||||
updateCountdown();
|
||||
var inp = document.getElementById('neuron-demo-text');
|
||||
if (inp) inp.focus();
|
||||
},
|
||||
'expired-callback': function() {
|
||||
turnstileToken = '';
|
||||
turnstileVerified = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function addMsg(role, text, skipSave) {
|
||||
var msgs = document.getElementById('neuron-demo-messages');
|
||||
if (!msgs) return null;
|
||||
var el = document.createElement('div');
|
||||
el.className = 'demo-msg demo-msg-' + role;
|
||||
var avatar = document.createElement('div');
|
||||
avatar.className = 'demo-msg-avatar';
|
||||
if (role === 'ai') {
|
||||
var img = document.createElement('img');
|
||||
img.src = '/assets/brand/neuron-brain.png';
|
||||
img.alt = 'Neuron';
|
||||
avatar.appendChild(img);
|
||||
} else {
|
||||
var svgNS = 'http://www.w3.org/2000/svg';
|
||||
var svg = document.createElementNS(svgNS, 'svg');
|
||||
svg.setAttribute('width', '14'); svg.setAttribute('height', '14');
|
||||
svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor'); svg.setAttribute('stroke-width', '2');
|
||||
var p1 = document.createElementNS(svgNS, 'path');
|
||||
p1.setAttribute('d', 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2');
|
||||
var c1 = document.createElementNS(svgNS, 'circle');
|
||||
c1.setAttribute('cx', '12'); c1.setAttribute('cy', '7'); c1.setAttribute('r', '4');
|
||||
svg.appendChild(p1); svg.appendChild(c1);
|
||||
avatar.appendChild(svg);
|
||||
}
|
||||
var bubble = document.createElement('div');
|
||||
bubble.className = 'demo-msg-bubble';
|
||||
if (role === 'ai' && typeof marked !== 'undefined') {
|
||||
try { bubble.innerHTML = marked.parse(text); } catch(e) { bubble.textContent = text; }
|
||||
} else {
|
||||
bubble.textContent = text;
|
||||
}
|
||||
if (role === 'ai') {
|
||||
var bodyWrap = document.createElement('div');
|
||||
bodyWrap.className = 'demo-msg-ai-body';
|
||||
bodyWrap.appendChild(bubble);
|
||||
if (!skipSave) {
|
||||
var shareBtn = document.createElement('button');
|
||||
shareBtn.className = 'demo-share-pill';
|
||||
shareBtn.title = 'Share this response';
|
||||
shareBtn.textContent = 'Share ↗';
|
||||
// Capture rendered HTML on click; preview before publish.
|
||||
shareBtn.onclick = function() {
|
||||
var prevUser = '';
|
||||
if (session.messages) {
|
||||
for (var i = session.messages.length - 1; i >= 0; i--) {
|
||||
if (session.messages[i].role === 'user') { prevUser = session.messages[i].text; break; }
|
||||
}
|
||||
}
|
||||
var answerHtml = bubble.innerHTML;
|
||||
var answerPlain = text;
|
||||
openSharePreview(prevUser, answerHtml, answerPlain, shareBtn);
|
||||
};
|
||||
bodyWrap.appendChild(shareBtn);
|
||||
}
|
||||
el.appendChild(avatar);
|
||||
el.appendChild(bodyWrap);
|
||||
} else {
|
||||
el.appendChild(avatar);
|
||||
el.appendChild(bubble);
|
||||
}
|
||||
msgs.appendChild(el);
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
if (!skipSave && role !== 'thinking') {
|
||||
session.messages = session.messages || [];
|
||||
session.messages.push({ role: role, text: text });
|
||||
if (session.messages.length > 40) session.messages = session.messages.slice(-40);
|
||||
saveSession(session);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
window.neuronDemoSend = async function() {
|
||||
if (msgCount >= MAX) return;
|
||||
var input = document.getElementById('neuron-demo-text');
|
||||
var btn = document.getElementById('neuron-demo-send');
|
||||
if (!input || btn.disabled) return;
|
||||
var msg = input.value.trim();
|
||||
if (!msg) return;
|
||||
input.value = '';
|
||||
btn.disabled = true;
|
||||
addMsg('user', msg);
|
||||
var thinking = document.createElement('div');
|
||||
thinking.className = 'demo-msg demo-msg-thinking';
|
||||
var thAvatar = document.createElement('div');
|
||||
thAvatar.className = 'demo-msg-avatar';
|
||||
var thImg = document.createElement('img');
|
||||
thImg.src = '/assets/brand/neuron-brain.png';
|
||||
thImg.alt = 'Neuron';
|
||||
thAvatar.appendChild(thImg);
|
||||
thinking.appendChild(thAvatar);
|
||||
var thDots = document.createElement('span');
|
||||
thDots.className = 'demo-msg-thinking-dots';
|
||||
thDots.innerHTML = '<span></span><span></span><span></span>';
|
||||
thinking.appendChild(thDots);
|
||||
var thMsgsEl = document.getElementById('neuron-demo-messages');
|
||||
if (thMsgsEl) {
|
||||
thMsgsEl.appendChild(thinking);
|
||||
thMsgsEl.scrollTop = thMsgsEl.scrollHeight;
|
||||
}
|
||||
if (turnstileVerified && !session._cfSent) { session._cfSent = true; }
|
||||
try {
|
||||
var hist = (session.messages || []).slice(-20).filter(function(m){ return m.role !== 'thinking'; }).map(function(m){
|
||||
return {role: m.role === 'ai' ? 'assistant' : 'user', content: m.text};
|
||||
});
|
||||
var activated_nodes = _ra(session._m, msg);
|
||||
var questionsRemaining = (MAX - msgCount) - 1;
|
||||
if (questionsRemaining < 0) questionsRemaining = 0;
|
||||
var r = await fetch('/api/demo', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
message: msg,
|
||||
history: hist,
|
||||
cf_token: turnstileVerified && !session._cfSent ? turnstileToken : '',
|
||||
uid: session.uid || '',
|
||||
activated_nodes: activated_nodes,
|
||||
engram_node_count: (session._m && session._m.nodes) ? session._m.nodes.length : 0,
|
||||
questions_remaining: questionsRemaining,
|
||||
is_last_question: questionsRemaining === 0
|
||||
})
|
||||
});
|
||||
var d = await r.json();
|
||||
if (thinking) thinking.remove();
|
||||
_um(session, d.sn, d.se);
|
||||
var reply = d.response || d.reply || d.message || '';
|
||||
var isError = !reply || reply === 'Stepped out for a moment. Try again.';
|
||||
if (!isError) {
|
||||
msgCount++;
|
||||
session.count = msgCount;
|
||||
saveSession(session);
|
||||
updateCountdown();
|
||||
if (msgCount >= MAX && input) {
|
||||
input.disabled = true;
|
||||
input.placeholder = 'Interaction limit reached';
|
||||
}
|
||||
}
|
||||
addMsg('ai', reply || 'Stepped out for a moment. Try again.');
|
||||
} catch(e) {
|
||||
if (thinking) thinking.remove();
|
||||
addMsg('ai', 'Stepped out for a moment. Try again.');
|
||||
}
|
||||
if (msgCount < MAX && btn) btn.disabled = false;
|
||||
if (input) input.focus();
|
||||
};
|
||||
|
||||
var inp = document.getElementById('neuron-demo-text');
|
||||
if (inp) {
|
||||
inp.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); window.neuronDemoSend(); }
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
# Replace the line `<script src=\"/assets/js/fc247ef45b1d.js\" defer></script>`
|
||||
# with our new inline content + script. extract-js will then re-extract this
|
||||
# fresh inline block to a content-hashed asset on the next build.
|
||||
|
||||
OLD_LINE = '<script src=\\"/assets/js/fc247ef45b1d.js\\" defer></script>'
|
||||
|
||||
|
||||
def main():
|
||||
src = STYLES_EL.read_text(encoding="utf-8")
|
||||
if MARKER in src:
|
||||
print("styles.el already contains the share preview modal - skipping")
|
||||
return
|
||||
if OLD_LINE not in src:
|
||||
# Maybe extract-js already pulled out a different hash; fail loud.
|
||||
print("ERROR: anchor `<script src=...fc247ef45b1d.js...>` not found in styles.el")
|
||||
print(" Either it was renamed in a prior commit, or the file is already patched.")
|
||||
raise SystemExit(1)
|
||||
new_src = src.replace(OLD_LINE, CHAT_HTML_AND_JS.strip(), 1)
|
||||
STYLES_EL.write_text(new_src, encoding="utf-8")
|
||||
print("styles.el patched: chat-widget JS re-inlined with share preview modal")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_by": "scripts/extract-js.py",
|
||||
"count": 11,
|
||||
"count": 12,
|
||||
"entries": [
|
||||
{
|
||||
"file": "account.el",
|
||||
@@ -50,6 +50,14 @@
|
||||
"interpolated": [],
|
||||
"note": "carried from prior run"
|
||||
},
|
||||
{
|
||||
"file": "gallery.el",
|
||||
"hash": "d8251f5e5aa1",
|
||||
"asset": "/assets/js/d8251f5e5aa1.js",
|
||||
"size": 12354,
|
||||
"interpolated": [],
|
||||
"note": "carried from prior run"
|
||||
},
|
||||
{
|
||||
"file": "main.el",
|
||||
"hash": "94727a87c328",
|
||||
@@ -74,6 +82,13 @@
|
||||
"interpolated": [],
|
||||
"note": "carried from prior run"
|
||||
},
|
||||
{
|
||||
"file": "styles.el",
|
||||
"hash": "37b5ead0d425",
|
||||
"asset": "/assets/js/37b5ead0d425.js",
|
||||
"size": 23539,
|
||||
"interpolated": []
|
||||
},
|
||||
{
|
||||
"file": "styles.el",
|
||||
"hash": "407e72cd7182",
|
||||
@@ -81,14 +96,6 @@
|
||||
"size": 6430,
|
||||
"interpolated": [],
|
||||
"note": "carried from prior run"
|
||||
},
|
||||
{
|
||||
"file": "styles.el",
|
||||
"hash": "fc247ef45b1d",
|
||||
"asset": "/assets/js/fc247ef45b1d.js",
|
||||
"size": 18624,
|
||||
"interpolated": [],
|
||||
"note": "carried from prior run"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+317
-45
@@ -172,9 +172,70 @@ fn page(sold: Int, total: Int) -> String {
|
||||
|
||||
// ── Share card page ───────────────────────────────────────────────────────────
|
||||
|
||||
fn share_card_page(question: String, answer: String, id: String) -> String {
|
||||
// sanitize_share_html — strip dangerous HTML before storing/serving a share
|
||||
// card. Defence in depth: marked.js client-side already escapes most things,
|
||||
// but we never trust client-rendered HTML round-tripped through a public API.
|
||||
// Rules:
|
||||
// - Lowercase the working copy for tag matching (then operate on the
|
||||
// original to preserve case-insensitive replacements via case-folded
|
||||
// dance is overkill here; instead we run the replace on both cases).
|
||||
// - Strip whole tags (open + close + body) for: script, iframe, style,
|
||||
// object, embed, form. We replace each opener with a comment marker so
|
||||
// the closer-stripper sees a tagless region.
|
||||
// - Strip on*= event-handler attributes (onclick, onload, onerror, ...).
|
||||
// - Strip javascript: URIs in href/src.
|
||||
fn sanitize_share_html(html: String) -> String {
|
||||
// Tag stripper: replace `<script` (case-insens, plus the variants seen in
|
||||
// practice) with a comment opener so the rest of the tag becomes inert
|
||||
// text. Belt-and-suspenders for each dangerous tag.
|
||||
let s1: String = str_replace(str_replace(html, "<script", "<!--script"), "<SCRIPT", "<!--script")
|
||||
let s2: String = str_replace(str_replace(s1, "</script>", "/script-->"), "</SCRIPT>", "/script-->")
|
||||
let s3: String = str_replace(str_replace(s2, "<iframe", "<!--iframe"), "<IFRAME", "<!--iframe")
|
||||
let s4: String = str_replace(str_replace(s3, "</iframe>", "/iframe-->"), "</IFRAME>", "/iframe-->")
|
||||
let s5: String = str_replace(str_replace(s4, "<style", "<!--style"), "<STYLE", "<!--style")
|
||||
let s6: String = str_replace(str_replace(s5, "</style>", "/style-->"), "</STYLE>", "/style-->")
|
||||
let s7: String = str_replace(str_replace(s6, "<object", "<!--object"), "<OBJECT", "<!--object")
|
||||
let s8: String = str_replace(str_replace(s7, "</object>", "/object-->"), "</OBJECT>", "/object-->")
|
||||
let s9: String = str_replace(str_replace(s8, "<embed", "<!--embed"), "<EMBED", "<!--embed")
|
||||
let s10: String = str_replace(s9, "<form", "<!--form")
|
||||
let s11: String = str_replace(s10, "</form>", "/form-->")
|
||||
let s12: String = str_replace(str_replace(s11, "<link", "<!--link"), "<LINK", "<!--link")
|
||||
let s13: String = str_replace(str_replace(s12, "<meta", "<!--meta"), "<META", "<!--meta")
|
||||
let s14: String = str_replace(str_replace(s13, "<base", "<!--base"), "<BASE", "<!--base")
|
||||
// Event-handler attrs (on...=). marked.js does not emit these, but we
|
||||
// strip the well-known ones in case a payload leaks through.
|
||||
let e1: String = str_replace(str_replace(s14, " onclick=", " data-x-onclick="), " ONCLICK=", " data-x-onclick=")
|
||||
let e2: String = str_replace(str_replace(e1, " onload=", " data-x-onload="), " ONLOAD=", " data-x-onload=")
|
||||
let e3: String = str_replace(str_replace(e2, " onerror=", " data-x-onerror="), " ONERROR=", " data-x-onerror=")
|
||||
let e4: String = str_replace(str_replace(e3, " onmouseover=", " data-x-onmouseover="), " ONMOUSEOVER=", " data-x-onmouseover=")
|
||||
let e5: String = str_replace(str_replace(e4, " onfocus=", " data-x-onfocus="), " ONFOCUS=", " data-x-onfocus=")
|
||||
let e6: String = str_replace(str_replace(e5, " onblur=", " data-x-onblur="), " ONBLUR=", " data-x-onblur=")
|
||||
let e7: String = str_replace(str_replace(e6, " onsubmit=", " data-x-onsubmit="), " ONSUBMIT=", " data-x-onsubmit=")
|
||||
let e8: String = str_replace(str_replace(e7, " onchange=", " data-x-onchange="), " ONCHANGE=", " data-x-onchange=")
|
||||
let e9: String = str_replace(str_replace(e8, " onkeydown=", " data-x-onkeydown="), " ONKEYDOWN=", " data-x-onkeydown=")
|
||||
let e10: String = str_replace(str_replace(e9, " onkeyup=", " data-x-onkeyup="), " ONKEYUP=", " data-x-onkeyup=")
|
||||
let e11: String = str_replace(str_replace(e10, " onkeypress=", " data-x-onkeypress="), " ONKEYPRESS=", " data-x-onkeypress=")
|
||||
let e12: String = str_replace(str_replace(e11, " onmouseenter=", " data-x-onmouseenter="), " ONMOUSEENTER=", " data-x-onmouseenter=")
|
||||
let e13: String = str_replace(str_replace(e12, " onmouseleave=", " data-x-onmouseleave="), " ONMOUSELEAVE=", " data-x-onmouseleave=")
|
||||
let e14: String = str_replace(str_replace(e13, " ontoggle=", " data-x-ontoggle="), " ONTOGGLE=", " data-x-ontoggle=")
|
||||
let e15: String = str_replace(str_replace(e14, " onanimationend=", " data-x-onanimationend="), " ONANIMATIONEND=", " data-x-onanimationend=")
|
||||
// javascript: URIs in href/src.
|
||||
let j1: String = str_replace(str_replace(e15, "javascript:", "about:blank#"), "JAVASCRIPT:", "about:blank#")
|
||||
let j2: String = str_replace(str_replace(j1, "data:text/html", "about:blank#"), "DATA:text/html", "about:blank#")
|
||||
return j2
|
||||
}
|
||||
|
||||
fn share_card_page(question: String, answer_plain: String, answer_html_in: String, id: String) -> String {
|
||||
let q_html: String = str_replace(str_replace(str_replace(question, "&", "&"), "<", "<"), ">", ">")
|
||||
let a_html: String = str_replace(str_replace(str_replace(answer, "&", "&"), "<", "<"), ">", ">")
|
||||
// answer_html_in is sanitized, marked.js-rendered HTML. Fall back to
|
||||
// escaped plaintext when the caller didn't supply rendered HTML (legacy).
|
||||
let a_html: String = if str_eq(answer_html_in, "") {
|
||||
str_replace(str_replace(str_replace(answer_plain, "&", "&"), "<", "<"), ">", ">")
|
||||
} else {
|
||||
sanitize_share_html(answer_html_in)
|
||||
}
|
||||
// Use plaintext for og:description so social previews are readable.
|
||||
let answer: String = answer_plain
|
||||
let og_desc: String = str_slice(answer, 0, 140)
|
||||
let base_url: String = state_get("__neuron_origin__")
|
||||
let card_url: String = base_url + "/share/" + id
|
||||
@@ -217,7 +278,20 @@ body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;bac
|
||||
.chat-row-user{display:flex;flex-direction:row-reverse}
|
||||
.chat-row-ai{display:flex;flex-direction:row;align-items:flex-end;gap:.625rem}
|
||||
.bubble-user{background:#0052A0;color:#fff;border-radius:18px 18px 4px 18px;padding:11px 15px;max-width:78%;font-size:.875rem;line-height:1.55;word-break:break-word}
|
||||
.bubble-ai{background:var(--bg);color:var(--t1);border:1px solid rgba(0,0,0,.07);border-radius:18px 18px 18px 4px;padding:11px 15px;max-width:88%;font-size:.875rem;font-weight:300;line-height:1.65;word-break:break-word;white-space:pre-wrap;box-shadow:0 2px 6px rgba(0,0,0,.05)}
|
||||
.bubble-ai{background:var(--bg);color:var(--t1);border:1px solid rgba(0,0,0,.07);border-radius:18px 18px 18px 4px;padding:11px 15px;max-width:88%;font-size:.875rem;font-weight:300;line-height:1.65;word-break:break-word;box-shadow:0 2px 6px rgba(0,0,0,.05)}
|
||||
.bubble-ai p{margin:0}
|
||||
.bubble-ai p+p{margin-top:.6rem}
|
||||
.bubble-ai ul,.bubble-ai ol{margin:.5rem 0 .5rem 1.25rem;padding:0}
|
||||
.bubble-ai li+li{margin-top:.25rem}
|
||||
.bubble-ai strong{font-weight:600}
|
||||
.bubble-ai em{font-style:italic}
|
||||
.bubble-ai code{font-family:'IBM Plex Mono','Menlo',monospace;font-size:.8rem;background:rgba(0,0,0,.05);padding:1px 4px;border-radius:3px}
|
||||
.bubble-ai pre{background:rgba(0,0,0,.05);padding:.75rem;border-radius:6px;overflow-x:auto;font-size:.8rem;margin:.5rem 0}
|
||||
.bubble-ai pre code{background:none;padding:0}
|
||||
.bubble-ai blockquote{border-left:3px solid rgba(0,82,160,.3);margin:.5rem 0;padding:.25rem 0 .25rem .75rem;color:var(--t2)}
|
||||
.bubble-ai h1,.bubble-ai h2,.bubble-ai h3,.bubble-ai h4{font-weight:600;margin:.5rem 0 .25rem}
|
||||
.bubble-ai h1{font-size:1.05rem}.bubble-ai h2{font-size:1rem}.bubble-ai h3{font-size:.95rem}.bubble-ai h4{font-size:.9rem}
|
||||
.bubble-ai a{color:var(--navy);text-decoration:underline}
|
||||
.ai-col{display:flex;flex-direction:column;gap:.25rem}
|
||||
.ai-label{font-size:.6rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--navy)}
|
||||
.avatar{width:26px;height:26px;border-radius:50%;flex-shrink:0;background:#fff;border:1px solid rgba(0,82,160,.15);display:flex;align-items:center;justify-content:center}
|
||||
@@ -1018,19 +1092,116 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
return "{\"received\":true}"
|
||||
}
|
||||
|
||||
// ── DocuSeal webhook — POST /api/docuseal/webhook/<token> ────────────────
|
||||
// Path-token-authenticated receiver. Persists every event to Supabase
|
||||
// docuseal_events (full body as jsonb) and emails Will via Resend on
|
||||
// form.completed or form.declined. Token comes from DOCUSEAL_WEBHOOK_TOKEN
|
||||
// env var (mounted from Secret Manager). No HMAC yet — DocuSeal does not
|
||||
// currently expose a per-webhook signing secret.
|
||||
if str_starts_with(path, "/api/docuseal/webhook/") {
|
||||
if !str_eq(method, "POST") {
|
||||
return "{\"error\":\"POST required\"}"
|
||||
}
|
||||
let token_in_path: String = str_slice(path, 22, str_len(path))
|
||||
let expected_token: String = env("DOCUSEAL_WEBHOOK_TOKEN")
|
||||
if str_eq(expected_token, "") || !str_eq(token_in_path, expected_token) {
|
||||
return "{\"__status__\":401,\"error\":\"unauthorized\"}"
|
||||
}
|
||||
|
||||
let event_type: String = json_get(body, "event_type")
|
||||
let event_ts: String = json_get(body, "timestamp")
|
||||
let data_raw: String = json_get_raw(body, "data")
|
||||
let sub_id: String = if str_eq(data_raw, "") { "" } else { json_get(data_raw, "submission_id") }
|
||||
let signer_email: String = if str_eq(data_raw, "") { "" } else { json_get(data_raw, "email") }
|
||||
let signer_name: String = if str_eq(data_raw, "") { "" } else { json_get(data_raw, "name") }
|
||||
let signer_ua: String = if str_eq(data_raw, "") { "" } else { json_get(data_raw, "ua") }
|
||||
let signer_ip: String = if str_eq(data_raw, "") { "" } else { json_get(data_raw, "ip") }
|
||||
println("[docuseal] event=" + event_type + " sub=" + sub_id + " email=" + signer_email)
|
||||
|
||||
let body_safe: String = str_replace(str_replace(str_replace(str_replace(body, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\r", "\\r")
|
||||
let name_safe: String = str_replace(str_replace(signer_name, "\\", "\\\\"), "\"", "\\\"")
|
||||
let ua_safe: String = str_replace(str_replace(signer_ua, "\\", "\\\\"), "\"", "\\\"")
|
||||
|
||||
let sb_url: String = state_get("__supabase_project_url__")
|
||||
let sb_key: String = state_get("__supabase_service_key__")
|
||||
if !str_eq(sb_key, "") {
|
||||
let sub_field: String = if str_eq(sub_id, "") { "null" } else { sub_id }
|
||||
let email_field: String = if str_eq(signer_email, "") { "null" } else { "\"" + signer_email + "\"" }
|
||||
let name_field: String = if str_eq(signer_name, "") { "null" } else { "\"" + name_safe + "\"" }
|
||||
let ua_field: String = if str_eq(signer_ua, "") { "null" } else { "\"" + ua_safe + "\"" }
|
||||
let ip_field: String = if str_eq(signer_ip, "") { "null" } else { "\"" + signer_ip + "\"" }
|
||||
let ts_field: String = if str_eq(event_ts, "") { "null" } else { "\"" + event_ts + "\"" }
|
||||
let row: String = "{\"event_type\":\"" + event_type
|
||||
+ "\",\"event_timestamp\":" + ts_field
|
||||
+ ",\"submission_id\":" + sub_field
|
||||
+ ",\"signer_email\":" + email_field
|
||||
+ ",\"signer_name\":" + name_field
|
||||
+ ",\"ua\":" + ua_field
|
||||
+ ",\"ip\":" + ip_field
|
||||
+ ",\"payload\":\"" + body_safe + "\"}"
|
||||
let ds_resp: String = supabase_insert(sb_url, sb_key, "docuseal_events", row)
|
||||
println("[docuseal] event=" + event_type + " sub=" + sub_id + " -> " + ds_resp)
|
||||
}
|
||||
|
||||
let ds_is_completed: Bool = str_eq(event_type, "form.completed")
|
||||
let ds_is_declined: Bool = str_eq(event_type, "form.declined")
|
||||
if ds_is_completed || ds_is_declined {
|
||||
let resend_key: String = state_get("__resend_api_key__")
|
||||
println("[docuseal] gate event=" + event_type + " key=" + (if str_eq(resend_key, "") { "empty" } else { "set" }))
|
||||
if !str_eq(resend_key, "") {
|
||||
let subject: String = if str_eq(event_type, "form.completed") {
|
||||
"DocuSeal: " + signer_email + " signed (#" + sub_id + ")"
|
||||
} else {
|
||||
"DocuSeal: " + signer_email + " declined (#" + sub_id + ")"
|
||||
}
|
||||
let html: String = "<p>" + name_safe + " <" + signer_email + "></p>"
|
||||
+ "<p>Submission #" + sub_id + " - " + event_type + " at " + event_ts + "</p>"
|
||||
+ "<pre style=\"font-family:monospace;font-size:11px;background:#f4f4f4;padding:12px;border-radius:6px\">"
|
||||
+ body_safe + "</pre>"
|
||||
let html_safe: String = str_replace(str_replace(html, "\\", "\\\\"), "\"", "\\\"")
|
||||
let subject_safe: String = str_replace(subject, "\"", "\\\"")
|
||||
let email_body: String = "{\"from\":\"DocuSeal <no-reply@neurontechnologies.ai>\","
|
||||
+ "\"to\":[\"will.anderson@neurontechnologies.ai\"],"
|
||||
+ "\"subject\":\"" + subject_safe + "\","
|
||||
+ "\"html\":\"" + html_safe + "\"}"
|
||||
let mail_resp: String = http_post_auth("https://api.resend.com/emails", resend_key, email_body)
|
||||
println("[docuseal] resend " + event_type + " -> " + mail_resp)
|
||||
}
|
||||
}
|
||||
|
||||
return "{\"ok\":true}"
|
||||
}
|
||||
|
||||
// ── Share card — POST /api/share ──────────────────────────────────────────
|
||||
//
|
||||
// Body: {question, answer, answer_html?, answer_plaintext?}
|
||||
// - answer is the legacy plaintext field (still required for the
|
||||
// gallery + DB row).
|
||||
// - answer_html is optional pre-rendered (marked.js) HTML captured
|
||||
// from bubble.innerHTML at Share-click time. When present, it
|
||||
// replaces the escaped-plaintext bubble on the share card so the
|
||||
// visual matches what the user saw in chat.
|
||||
// - answer_plaintext, when supplied, takes precedence as the og:desc /
|
||||
// gallery body. Falls back to `answer`.
|
||||
//
|
||||
// The handler sanitizes answer_html (sanitize_share_html: strip
|
||||
// script/iframe/style/object/embed/form/link/meta/base + on*= attrs +
|
||||
// javascript: URIs) before storing.
|
||||
if str_eq(path, "/api/share") {
|
||||
if !str_eq(method, "POST") {
|
||||
return "{\"error\":\"POST required\"}"
|
||||
}
|
||||
let question: String = json_get(body, "question")
|
||||
let answer: String = json_get(body, "answer")
|
||||
if str_eq(question, "") || str_eq(answer, "") {
|
||||
let answer_html_raw: String = json_get(body, "answer_html")
|
||||
let answer_plain_in: String = json_get(body, "answer_plaintext")
|
||||
let answer_plain: String = if str_eq(answer_plain_in, "") { answer } else { answer_plain_in }
|
||||
if str_eq(question, "") || str_eq(answer_plain, "") {
|
||||
return "{\"error\":\"question and answer required\"}"
|
||||
}
|
||||
let ts: String = int_to_str(unix_timestamp())
|
||||
let id: String = str_slice(ts, str_len(ts) - 8, str_len(ts))
|
||||
let html_share: String = share_card_page(question, answer, id)
|
||||
let html_share: String = share_card_page(question, answer_plain, answer_html_raw, id)
|
||||
let gcs_bucket: String = env("GCS_SHARE_BUCKET")
|
||||
if !str_eq(gcs_bucket, "") {
|
||||
// GCS — durable across Cloud Run instances and restarts
|
||||
@@ -1040,12 +1211,13 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
// Local dev fallback
|
||||
fs_write(src_dir + "/shares/" + id + ".html", html_share)
|
||||
}
|
||||
// Write to Supabase share_cards for the gallery + voting
|
||||
// Write to Supabase share_cards for the gallery + voting. We store
|
||||
// the plaintext answer (gallery thumbnails read this column).
|
||||
let sb_url: String = state_get("__supabase_project_url__")
|
||||
let sb_key: String = state_get("__supabase_service_key__")
|
||||
if !str_eq(sb_key, "") {
|
||||
let q_safe: String = str_replace(str_replace(question, "\\", "\\\\"), "\"", "\\\"")
|
||||
let a_safe: String = str_replace(str_replace(str_replace(str_replace(answer, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\r", "\\r")
|
||||
let a_safe: String = str_replace(str_replace(str_replace(str_replace(answer_plain, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\r", "\\r")
|
||||
let card_row: String = "{\"id\":\"" + id + "\",\"question\":\"" + q_safe + "\",\"answer\":\"" + a_safe + "\"}"
|
||||
let sb_resp: String = supabase_insert(sb_url, sb_key, "share_cards", card_row)
|
||||
println("[share] supabase insert " + id + " -> " + sb_resp)
|
||||
@@ -1053,73 +1225,173 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
return "{\"id\":\"" + id + "\"}"
|
||||
}
|
||||
|
||||
// ── Vote on a share card — POST /api/vote ────────────────────────────────
|
||||
// ── Vote on a share card — POST /api/vote (auth-gated) ───────────────────
|
||||
//
|
||||
// Body: {access_token: <supabase-jwt>, id: <share_id>, direction: "up"|"down"|"none"}
|
||||
// "up" - upsert vote with direction up (idempotent)
|
||||
// "down" - upsert vote with direction down (or change from up)
|
||||
// "none" - undo (delete the user's vote for this card)
|
||||
//
|
||||
// The access_token is validated against /auth/v1/user; the resulting JWT
|
||||
// (NOT the service key) is the Authorization header on the share_votes
|
||||
// write so RLS policy auth.uid()=user_id treats the row as user-owned.
|
||||
// share_cards.upvotes/downvotes/score are kept in sync by the
|
||||
// recalc_share_card_score trigger. After the write we re-read the
|
||||
// aggregate with the service key (public read) so the client gets fresh
|
||||
// totals + the new user_vote in one round trip.
|
||||
if str_eq(path, "/api/vote") {
|
||||
if !str_eq(method, "POST") {
|
||||
return "{\"error\":\"POST required\"}"
|
||||
return "{\"__status__\":405,\"error\":\"POST required\"}"
|
||||
}
|
||||
let vote_id: String = json_get(body, "id")
|
||||
let direction: String = json_get(body, "direction")
|
||||
if str_eq(vote_id, "") || str_eq(direction, "") {
|
||||
return "{\"error\":\"id and direction required\"}"
|
||||
let v_jwt: String = json_get(body, "access_token")
|
||||
let v_id: String = json_get(body, "id")
|
||||
let v_dir: String = json_get(body, "direction")
|
||||
if str_eq(v_id, "") || str_eq(v_dir, "") {
|
||||
return "{\"__status__\":400,\"error\":\"id and direction required\"}"
|
||||
}
|
||||
let sb_url: String = state_get("__supabase_project_url__")
|
||||
let sb_key: String = state_get("__supabase_service_key__")
|
||||
if str_eq(sb_key, "") {
|
||||
return "{\"error\":\"not configured\"}"
|
||||
if !str_eq(v_dir, "up") && !str_eq(v_dir, "down") && !str_eq(v_dir, "none") {
|
||||
return "{\"__status__\":400,\"error\":\"direction must be up, down, or none\"}"
|
||||
}
|
||||
// Use Supabase RPC to atomically increment the right column
|
||||
let col: String = if str_eq(direction, "up") { "upvotes" } else { "downvotes" }
|
||||
let score_delta: String = if str_eq(direction, "up") { "1" } else { "-1" }
|
||||
// PATCH via Supabase REST — increment upvotes or downvotes + recalculate score
|
||||
let update_json: String = "{\"" + col + "\":\"" + col + " + 1\",\"score\":\"score + " + score_delta + "\"}"
|
||||
// Use Postgres RPC for atomic increment
|
||||
let rpc_body: String = "{\"p_id\":\"" + vote_id + "\",\"p_col\":\"" + col + "\",\"p_delta\":" + score_delta + "}"
|
||||
// Fallback: direct update via REST with computed columns
|
||||
let up_resp: String = http_post_auth(
|
||||
sb_url + "/rest/v1/rpc/vote_card",
|
||||
sb_key,
|
||||
rpc_body
|
||||
if str_eq(v_jwt, "") {
|
||||
return "{\"__status__\":401,\"error\":\"login_required\"}"
|
||||
}
|
||||
let v_sb_url: String = state_get("__supabase_project_url__")
|
||||
let v_anon: String = state_get("__supabase_anon_key__")
|
||||
let v_service: String = state_get("__supabase_service_key__")
|
||||
if str_eq(v_anon, "") || str_eq(v_service, "") {
|
||||
return "{\"__status__\":500,\"error\":\"not_configured\"}"
|
||||
}
|
||||
// Validate the JWT - "" means invalid / expired / revoked.
|
||||
let v_user: String = supabase_auth_user(v_sb_url, v_anon, v_jwt)
|
||||
let v_uid: String = json_get(v_user, "id")
|
||||
if str_eq(v_uid, "") {
|
||||
return "{\"__status__\":401,\"error\":\"invalid_token\"}"
|
||||
}
|
||||
// Direction "none" - undo. DELETE share_votes where (share_id, user_id).
|
||||
// The user JWT (NOT service key) is the auth so RLS auth.uid()=user_id
|
||||
// passes. The recalc trigger updates share_cards aggregates.
|
||||
if str_eq(v_dir, "none") {
|
||||
let del_url: String = v_sb_url + "/rest/v1/share_votes?share_id=eq." + v_id + "&user_id=eq." + v_uid
|
||||
let _del_resp: String = http_delete_auth(del_url, v_jwt, v_anon)
|
||||
} else {
|
||||
// up/down - upsert. PostgREST resolves on the (share_id, user_id) PK
|
||||
// when on_conflict matches. The trigger fires on insert and update.
|
||||
let row: String = "{\"share_id\":\"" + v_id + "\",\"user_id\":\"" + v_uid + "\",\"direction\":\"" + v_dir + "\"}"
|
||||
let up_path: String = "share_votes?on_conflict=share_id,user_id"
|
||||
let _up_resp: String = supabase_upsert_user(v_sb_url, v_anon, v_jwt, up_path, row)
|
||||
}
|
||||
// Re-fetch fresh aggregate from share_cards (service key - public read).
|
||||
// PostgREST returns a JSON array; use json_array_get(0) then json_get.
|
||||
let v_agg: String = supabase_get(
|
||||
v_sb_url, v_service,
|
||||
"share_cards?select=score,upvotes,downvotes&id=eq." + v_id
|
||||
)
|
||||
println("[vote] " + direction + " on " + vote_id + " -> " + up_resp)
|
||||
return "{\"ok\":true}"
|
||||
let v_row: String = json_array_get(v_agg, 0)
|
||||
let v_score_raw: String = json_get(v_row, "score")
|
||||
let v_up_raw: String = json_get(v_row, "upvotes")
|
||||
let v_down_raw: String = json_get(v_row, "downvotes")
|
||||
let v_score_str: String = if str_eq(v_score_raw, "") { "0" } else { v_score_raw }
|
||||
let v_up_str: String = if str_eq(v_up_raw, "") { "0" } else { v_up_raw }
|
||||
let v_down_str: String = if str_eq(v_down_raw, "") { "0" } else { v_down_raw }
|
||||
let v_user_vote: String = if str_eq(v_dir, "none") { "none" } else { v_dir }
|
||||
return "{\"ok\":true,\"score\":" + v_score_str + ",\"upvotes\":" + v_up_str + ",\"downvotes\":" + v_down_str + ",\"user_vote\":\"" + v_user_vote + "\"}"
|
||||
}
|
||||
|
||||
// ── Vote count — GET /api/vote-count/<id> ────────────────────────────────
|
||||
// ── Vote state — GET /api/vote-state/<share_id> (auth-aware) ─────────────
|
||||
//
|
||||
// Always returns the public aggregate. If a Supabase access_token is
|
||||
// supplied via ?access_token=<jwt>, validate it and include the user's
|
||||
// current vote as `user_vote` (one of up / down / none). An anonymous
|
||||
// caller gets `user_vote:"none"`.
|
||||
if str_starts_with(path, "/api/vote-state/") {
|
||||
let vs_rest: String = str_slice(path, 16, str_len(path))
|
||||
// Strip query string from id if access_token was passed inline
|
||||
let vs_q_idx: Int = str_index_of(vs_rest, "?")
|
||||
let vs_id: String = if vs_q_idx >= 0 { str_slice(vs_rest, 0, vs_q_idx) } else { vs_rest }
|
||||
// Pull access_token out of the query string
|
||||
let vs_jwt: String = ""
|
||||
let vs_at_idx: Int = str_index_of(vs_rest, "access_token=")
|
||||
if vs_at_idx >= 0 {
|
||||
let vs_tail: String = str_slice(vs_rest, vs_at_idx + 13, str_len(vs_rest))
|
||||
let vs_amp_idx: Int = str_index_of(vs_tail, "&")
|
||||
let vs_jwt = if vs_amp_idx >= 0 { str_slice(vs_tail, 0, vs_amp_idx) } else { vs_tail }
|
||||
}
|
||||
let vs_sb_url: String = state_get("__supabase_project_url__")
|
||||
let vs_anon: String = state_get("__supabase_anon_key__")
|
||||
let vs_service: String = state_get("__supabase_service_key__")
|
||||
if str_eq(vs_service, "") {
|
||||
return "{\"score\":0,\"upvotes\":0,\"downvotes\":0,\"user_vote\":\"none\"}"
|
||||
}
|
||||
let vs_resp: String = supabase_get(
|
||||
vs_sb_url, vs_service,
|
||||
"share_cards?select=score,upvotes,downvotes&id=eq." + vs_id
|
||||
)
|
||||
let vs_row: String = json_array_get(vs_resp, 0)
|
||||
let vs_score_raw: String = json_get(vs_row, "score")
|
||||
let vs_up_raw: String = json_get(vs_row, "upvotes")
|
||||
let vs_down_raw: String = json_get(vs_row, "downvotes")
|
||||
let vs_score_str: String = if str_eq(vs_score_raw, "") { "0" } else { vs_score_raw }
|
||||
let vs_up_str: String = if str_eq(vs_up_raw, "") { "0" } else { vs_up_raw }
|
||||
let vs_down_str: String = if str_eq(vs_down_raw, "") { "0" } else { vs_down_raw }
|
||||
let vs_user_vote: String = "none"
|
||||
if !str_eq(vs_jwt, "") {
|
||||
let vs_user: String = supabase_auth_user(vs_sb_url, vs_anon, vs_jwt)
|
||||
let vs_uid: String = json_get(vs_user, "id")
|
||||
if !str_eq(vs_uid, "") {
|
||||
let vs_vote_resp: String = supabase_get(
|
||||
vs_sb_url, vs_service,
|
||||
"share_votes?select=direction&share_id=eq." + vs_id + "&user_id=eq." + vs_uid
|
||||
)
|
||||
let vs_vote_row: String = json_array_get(vs_vote_resp, 0)
|
||||
let vs_vote_dir: String = json_get(vs_vote_row, "direction")
|
||||
let vs_user_vote = if str_eq(vs_vote_dir, "") { "none" } else { vs_vote_dir }
|
||||
}
|
||||
}
|
||||
return "{\"score\":" + vs_score_str + ",\"upvotes\":" + vs_up_str + ",\"downvotes\":" + vs_down_str + ",\"user_vote\":\"" + vs_user_vote + "\"}"
|
||||
}
|
||||
|
||||
// ── Vote count — GET /api/vote-count/<id> (legacy alias) ─────────────────
|
||||
// Kept for any cached HTML still polling the old endpoint shape. Returns
|
||||
// aggregate only - no user_vote. New clients should use /api/vote-state.
|
||||
if str_starts_with(path, "/api/vote-count/") {
|
||||
let vc_id: String = str_slice(path, 16, str_len(path))
|
||||
let sb_url: String = state_get("__supabase_project_url__")
|
||||
let sb_key: String = state_get("__supabase_service_key__")
|
||||
if str_eq(sb_key, "") {
|
||||
let vc_sb_url: String = state_get("__supabase_project_url__")
|
||||
let vc_sb_key: String = state_get("__supabase_service_key__")
|
||||
if str_eq(vc_sb_key, "") {
|
||||
return "{\"score\":0,\"upvotes\":0,\"downvotes\":0}"
|
||||
}
|
||||
let vc_resp: String = supabase_get(
|
||||
sb_url, sb_key,
|
||||
vc_sb_url, vc_sb_key,
|
||||
"share_cards?select=score,upvotes,downvotes&id=eq." + vc_id
|
||||
)
|
||||
let score_raw: String = json_get(vc_resp, "0.score")
|
||||
let up_raw: String = json_get(vc_resp, "0.upvotes")
|
||||
let down_raw: String = json_get(vc_resp, "0.downvotes")
|
||||
let score_str: String = if str_eq(score_raw, "") { "0" } else { score_raw }
|
||||
let up_str: String = if str_eq(up_raw, "") { "0" } else { up_raw }
|
||||
let down_str: String = if str_eq(down_raw, "") { "0" } else { down_raw }
|
||||
return "{\"score\":" + score_str + ",\"upvotes\":" + up_str + ",\"downvotes\":" + down_str + "}"
|
||||
let vc_row: String = json_array_get(vc_resp, 0)
|
||||
let vc_score_raw: String = json_get(vc_row, "score")
|
||||
let vc_up_raw: String = json_get(vc_row, "upvotes")
|
||||
let vc_down_raw: String = json_get(vc_row, "downvotes")
|
||||
let vc_score_str: String = if str_eq(vc_score_raw, "") { "0" } else { vc_score_raw }
|
||||
let vc_up_str: String = if str_eq(vc_up_raw, "") { "0" } else { vc_up_raw }
|
||||
let vc_down_str: String = if str_eq(vc_down_raw, "") { "0" } else { vc_down_raw }
|
||||
return "{\"score\":" + vc_score_str + ",\"upvotes\":" + vc_up_str + ",\"downvotes\":" + vc_down_str + "}"
|
||||
}
|
||||
|
||||
// ── Gallery — GET /said ───────────────────────────────────────────────────
|
||||
//
|
||||
// Renders the gallery server-side and injects the Supabase config so the
|
||||
// page's vote JS can call supabase.auth.getSession() and POST /api/vote
|
||||
// with the user's JWT.
|
||||
if str_eq(path, "/said") {
|
||||
let sb_url: String = state_get("__supabase_project_url__")
|
||||
let sb_key: String = state_get("__supabase_service_key__")
|
||||
let sb_anon: String = state_get("__supabase_anon_key__")
|
||||
let cards_json: String = ""
|
||||
if !str_eq(sb_key, "") {
|
||||
// Use supabase_get which sends both apikey and Authorization headers
|
||||
let cards_json = supabase_get(
|
||||
sb_url,
|
||||
sb_key,
|
||||
"share_cards?select=id,question,answer,score,upvotes,downvotes,created_at&order=score.desc,created_at.desc&limit=100"
|
||||
)
|
||||
}
|
||||
return gallery_page(cards_json)
|
||||
return gallery_page(cards_json, sb_url, sb_anon)
|
||||
}
|
||||
|
||||
// ── Share card — GET /share/<id> ──────────────────────────────────────────
|
||||
|
||||
+21
-1
@@ -1956,7 +1956,27 @@ fn page_close() -> String {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src=\"/assets/js/fc247ef45b1d.js\" defer></script>
|
||||
<!-- Share preview modal: shown after the user clicks Share on an AI bubble.
|
||||
Renders the share-card layout in an iframe (via srcdoc) so the visitor
|
||||
sees exactly what the public card will look like before publishing. -->
|
||||
<div id=\"neuron-share-preview-modal\" style=\"display:none;position:fixed;inset:0;z-index:200000;background:rgba(13,13,20,.55);align-items:center;justify-content:center;padding:1.5rem;font-family:'IBM Plex Sans',system-ui,sans-serif\">
|
||||
<div style=\"background:#fff;width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;border-radius:12px;box-shadow:0 24px 80px rgba(0,0,0,.35);overflow:hidden\">
|
||||
<div style=\"display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid rgba(0,0,0,.08)\">
|
||||
<div>
|
||||
<div style=\"font-size:.65rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:#6B6B7E\">Preview</div>
|
||||
<div style=\"font-size:1rem;font-weight:500;color:#0D0D14;margin-top:.15rem\">This is what you are about to publish</div>
|
||||
</div>
|
||||
<button type=\"button\" id=\"neuron-share-preview-close\" aria-label=\"Close\" style=\"background:none;border:none;cursor:pointer;color:#6B6B7E;padding:.25rem;line-height:1;font-size:1.5rem\">×</button>
|
||||
</div>
|
||||
<iframe id=\"neuron-share-preview-frame\" style=\"flex:1;width:100%;min-height:420px;border:0;background:#FAFAF8\" sandbox=\"allow-same-origin\"></iframe>
|
||||
<div style=\"display:flex;align-items:center;justify-content:flex-end;gap:.6rem;padding:.85rem 1.25rem;border-top:1px solid rgba(0,0,0,.08);background:#FAFAF8\">
|
||||
<button type=\"button\" id=\"neuron-share-preview-cancel\" style=\"background:#fff;border:1px solid rgba(0,0,0,.18);color:#3A3A4A;cursor:pointer;padding:.55rem 1rem;font:inherit;font-size:.8rem;font-weight:500;border-radius:6px\">Cancel</button>
|
||||
<button type=\"button\" id=\"neuron-share-preview-publish\" style=\"background:#0052A0;border:1px solid #0052A0;color:#fff;cursor:pointer;padding:.55rem 1.1rem;font:inherit;font-size:.8rem;font-weight:600;letter-spacing:.04em;border-radius:6px\">Publish to gallery</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src=\"/assets/js/37b5ead0d425.js\" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
"
|
||||
|
||||
Reference in New Issue
Block a user