567 lines
21 KiB
JavaScript
567 lines
21 KiB
JavaScript
// === FRONTEND_JS_TEMPLATE ===
|
|
(async () => {
|
|
const is_interactive = __IS_INTERACTIVE__;
|
|
const q_init = __JS_Q__;
|
|
const lang_init = __JS_LANG__;
|
|
let urls = __JS_URLS__;
|
|
const b64_init = __B64_CONTEXT__;
|
|
const tk_init = __TK__;
|
|
const script_root = __SCRIPT_ROOT__;
|
|
const model_init = __MODEL_INIT__;
|
|
const conversation = {
|
|
originalQuery: q_init,
|
|
originalContext: new TextDecoder().decode(Uint8Array.from(atob(b64_init), c => c.charCodeAt(0))),
|
|
originalSources: [...urls],
|
|
turns: [{role: 'user', content: q_init, ts: Date.now()}]
|
|
};
|
|
const box = document.getElementById('sxng-stream-box');
|
|
const data = document.getElementById('sxng-stream-data');
|
|
const wrapper = box.closest('.answer');
|
|
if (wrapper) wrapper.style.display = 'none';
|
|
let restored = false;
|
|
let isStreaming = false;
|
|
|
|
__CITATION_HELPER_JS__
|
|
|
|
__INTERACTIVE_JS_INIT__
|
|
|
|
function synthesizeQuery(original, followup) {
|
|
const cleanOrig = original.replace(/^(what|how|why|when|where|who|which|is|are|can|does|do)(\s+(is|are|do|does|can|to|a|an|the))?\s+/i, '');
|
|
const origWords = cleanOrig.split(' ').slice(0, 12);
|
|
return `${origWords.join(' ')} ${followup}`.trim();
|
|
}
|
|
|
|
__STREAM_FN_SIG__ {
|
|
if (isStreaming) {
|
|
console.warn('[AI Answers] Stream already in progress, ignoring duplicate call');
|
|
return;
|
|
}
|
|
|
|
isStreaming = true;
|
|
try {
|
|
const ctx = auxContext || conversation.originalContext;
|
|
if (wrapper) wrapper.style.display = '';
|
|
box.style.display = 'block';
|
|
|
|
const controller = new AbortController();
|
|
let timeoutId = setTimeout(() => controller.abort(), 60000);
|
|
const finalQ = __STREAM_Q__;
|
|
|
|
const _selMdl = (document.getElementById('sxng-model-select') || {value: ''}).value;
|
|
const bodyObj = { q: finalQ, lang: lang_init, context: ctx, tk: tk_init, model: _selMdl__STREAM_BODY__ };
|
|
const res = await fetch(script_root + '/ai-stream', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(bodyObj),
|
|
signal: controller.signal
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
if (!res.ok) {
|
|
const errSpan = document.createElement('span');
|
|
errSpan.style.color = '#bf616a';
|
|
errSpan.textContent = "Error: " + res.statusText;
|
|
data.appendChild(errSpan);
|
|
return;
|
|
}
|
|
|
|
const respJson = await res.json();
|
|
|
|
if (respJson.error) {
|
|
const cursorErr = data.querySelector('.sxng-cursor');
|
|
if (cursorErr) cursorErr.remove();
|
|
const errSpan = document.createElement('span');
|
|
errSpan.style.color = '#bf616a';
|
|
errSpan.textContent = "⚠️ " + respJson.error;
|
|
data.appendChild(errSpan);
|
|
return;
|
|
}
|
|
|
|
const fullText = (respJson.text || '').trim();
|
|
|
|
if (!fullText) {
|
|
const cursorErr = data.querySelector('.sxng-cursor');
|
|
if (cursorErr) cursorErr.remove();
|
|
const errSpan = document.createElement('span');
|
|
errSpan.style.color = '#bf616a';
|
|
errSpan.textContent = 'No response received. Check API configuration and server logs.';
|
|
data.appendChild(errSpan);
|
|
return;
|
|
}
|
|
|
|
let mainText = fullText;
|
|
const thinkMatch = mainText.match(/^<think>([\s\S]*?)<\/think>\s*/);
|
|
if (thinkMatch) {
|
|
const cursorTh = data.querySelector('.sxng-cursor');
|
|
const details = document.createElement('details');
|
|
details.className = 'sxng-reasoning';
|
|
details.innerHTML = '<summary>Thought Process</summary>';
|
|
const thoughtDiv = document.createElement('div');
|
|
thoughtDiv.className = 'sxng-thought-content';
|
|
thoughtDiv.textContent = thinkMatch[1];
|
|
details.appendChild(thoughtDiv);
|
|
if (cursorTh) cursorTh.before(details);
|
|
else data.appendChild(details);
|
|
mainText = mainText.substring(thinkMatch[0].length);
|
|
}
|
|
|
|
let cursor = data.querySelector('.sxng-cursor');
|
|
if (!cursor) {
|
|
cursor = document.createElement('span');
|
|
cursor.className = 'sxng-cursor';
|
|
data.appendChild(cursor);
|
|
}
|
|
|
|
let buffer = '';
|
|
const flushBuffer = (force = false) => {
|
|
if (!buffer) return;
|
|
|
|
if (force) {
|
|
const fragment = renderCitations(buffer, urls);
|
|
if (cursor) cursor.before(fragment);
|
|
else data.appendChild(fragment);
|
|
buffer = '';
|
|
return;
|
|
}
|
|
|
|
while (true) {
|
|
const match = buffer.match(/(\[\d+(?:,\s*\d+)*\])/);
|
|
|
|
if (!match) break;
|
|
|
|
const preText = buffer.substring(0, match.index);
|
|
if (preText) {
|
|
const s = document.createElement('span');
|
|
s.className = 'sxng-chunk';
|
|
s.textContent = preText;
|
|
cursor.before(s);
|
|
}
|
|
|
|
const citationText = match[0];
|
|
const fragment = renderCitations(citationText, urls);
|
|
cursor.before(fragment);
|
|
|
|
buffer = buffer.substring(match.index + match[0].length);
|
|
}
|
|
|
|
const openIdx = buffer.lastIndexOf('[');
|
|
if (openIdx === -1) {
|
|
if (buffer) {
|
|
const s = document.createElement('span');
|
|
s.className = 'sxng-chunk';
|
|
s.textContent = buffer;
|
|
cursor.before(s);
|
|
buffer = '';
|
|
}
|
|
} else {
|
|
const safeChunk = buffer.substring(0, openIdx);
|
|
if (safeChunk) {
|
|
const s = document.createElement('span');
|
|
s.className = 'sxng-chunk';
|
|
s.textContent = safeChunk;
|
|
cursor.before(s);
|
|
}
|
|
buffer = buffer.substring(openIdx);
|
|
|
|
if (buffer.length > 50) {
|
|
const s = document.createElement('span');
|
|
s.className = 'sxng-chunk';
|
|
s.textContent = buffer[0];
|
|
cursor.before(s);
|
|
buffer = buffer.substring(1);
|
|
}
|
|
}
|
|
};
|
|
|
|
let twPos = 0;
|
|
const twBatch = 4;
|
|
await new Promise(resolve => {
|
|
function twTick() {
|
|
if (twPos >= mainText.length) {
|
|
flushBuffer(true);
|
|
resolve();
|
|
return;
|
|
}
|
|
const end = Math.min(twPos + twBatch, mainText.length);
|
|
buffer += mainText.substring(twPos, end);
|
|
twPos = end;
|
|
flushBuffer(false);
|
|
setTimeout(twTick, 8);
|
|
}
|
|
twTick();
|
|
});
|
|
|
|
if (cursor) cursor.remove();
|
|
|
|
let last = data.lastChild;
|
|
while (last) {
|
|
if (last.textContent && last.textContent.trim().length === 0) {
|
|
const prev = last.previousSibling;
|
|
last.remove();
|
|
last = prev;
|
|
} else {
|
|
if (last.textContent) last.textContent = last.textContent.trimEnd();
|
|
break;
|
|
}
|
|
}
|
|
|
|
renderCitationFooter(mainText, urls, data);
|
|
|
|
const collectedResponse = mainText;
|
|
|
|
__INTERACTIVE_JS_COMPLETE__
|
|
|
|
if (collectedResponse) {
|
|
conversation.turns.push({role: 'assistant', content: collectedResponse.trim(), ts: Date.now()});
|
|
}
|
|
|
|
// Save state if this was an initial generation or a regeneration
|
|
if (arguments.length === 0 && typeof updateState === 'function') {
|
|
updateState();
|
|
}
|
|
|
|
} catch (e) {
|
|
console.error('[AI Answers] Fatal stream exception:', e);
|
|
const errSpan = document.createElement('span');
|
|
errSpan.style.cssText = 'color: #bf616a; font-weight: bold; display: block; margin-top: 0.5rem;';
|
|
|
|
if (e.name === 'AbortError') {
|
|
errSpan.textContent = "⚠️ Connection to AI provider timed out.";
|
|
} else {
|
|
errSpan.textContent = "⚠️ AI Widget encountered a fatal error. Check browser console.";
|
|
}
|
|
|
|
if (data) {
|
|
const cursor = data.querySelector('.sxng-cursor');
|
|
if (cursor) cursor.remove();
|
|
data.appendChild(errSpan);
|
|
}
|
|
} finally {
|
|
isStreaming = false;
|
|
}
|
|
}
|
|
|
|
if (!restored) startStream();
|
|
})();
|
|
// === CITATION_HELPER_JS ===
|
|
function renderCitations(text, urls) {
|
|
const fragment = document.createDocumentFragment();
|
|
const re = /\[(\d{1,2}(?:\s*,\s*\d{1,2})*)\]/g;
|
|
let lastIdx = 0;
|
|
const matches = [...text.matchAll(re)];
|
|
|
|
matches.forEach(match => {
|
|
if (match.index > lastIdx) {
|
|
const s = document.createElement('span');
|
|
s.className = 'sxng-chunk';
|
|
s.textContent = text.substring(lastIdx, match.index);
|
|
fragment.appendChild(s);
|
|
}
|
|
match[1].split(/\s*,\s*/).forEach(n => {
|
|
const idx = parseInt(n.trim());
|
|
if (idx >= 1 && idx <= urls.length) {
|
|
const url = urls[idx-1];
|
|
if (url) {
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.target = '_blank';
|
|
a.style.cssText = 'text-decoration:none;color:var(--color-result-link);font-weight:bold;';
|
|
a.textContent = `[${n.trim()}]`;
|
|
a.className = 'sxng-chunk';
|
|
fragment.appendChild(a);
|
|
} else {
|
|
const s = document.createElement('span');
|
|
s.className = 'sxng-chunk';
|
|
s.textContent = `[${n.trim()}]`;
|
|
fragment.appendChild(s);
|
|
}
|
|
} else {
|
|
const s = document.createElement('span');
|
|
s.className = 'sxng-chunk';
|
|
s.textContent = `[${n.trim()}]`;
|
|
fragment.appendChild(s);
|
|
}
|
|
});
|
|
lastIdx = match.index + match[0].length;
|
|
});
|
|
|
|
if (lastIdx < text.length) {
|
|
const s = document.createElement('span');
|
|
s.className = 'sxng-chunk';
|
|
// Preserve whitespace by not trimming
|
|
s.textContent = text.substring(lastIdx);
|
|
fragment.appendChild(s);
|
|
}
|
|
return fragment;
|
|
}
|
|
|
|
function renderCitationFooter(textContent, urls, container) {
|
|
const re = /\[(\d{1,2}(?:\s*,\s*\d{1,2})*)\]/g;
|
|
const usedIndices = new Set();
|
|
let m;
|
|
while ((m = re.exec(textContent)) !== null) {
|
|
m[1].split(/\s*,\s*/).forEach(n => {
|
|
const idx = parseInt(n.trim());
|
|
if (idx >= 1 && idx <= urls.length && urls[idx - 1]) {
|
|
usedIndices.add(idx);
|
|
}
|
|
});
|
|
}
|
|
if (usedIndices.size === 0) return;
|
|
const sorted = [...usedIndices].sort((a, b) => a - b);
|
|
const footer = document.createElement('div');
|
|
footer.className = 'sxng-citation-footer';
|
|
sorted.forEach(n => {
|
|
const url = urls[n - 1];
|
|
if (!url) return;
|
|
let domain;
|
|
try { domain = new URL(url).hostname.replace('www.', ''); } catch(e) { domain = url; }
|
|
const item = document.createElement('span');
|
|
item.className = 'sxng-citation-item';
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.target = '_blank';
|
|
a.textContent = `[${n}] ${domain}`;
|
|
item.appendChild(a);
|
|
footer.appendChild(item);
|
|
});
|
|
container.appendChild(footer);
|
|
}
|
|
// === INTERACTIVE_JS ===
|
|
const footer = document.getElementById('sxng-footer');
|
|
const input = document.getElementById('sxng-action-input');
|
|
if (typeof model_init !== 'undefined' && model_init) {
|
|
const _ms = document.getElementById('sxng-model-select');
|
|
if (_ms) {
|
|
const _o = document.createElement('option');
|
|
_o.value = model_init;
|
|
_o.textContent = model_init;
|
|
_o.selected = true;
|
|
_ms.appendChild(_o);
|
|
}
|
|
}
|
|
if (window.getComputedStyle && box) {
|
|
try {
|
|
const docStyles = getComputedStyle(document.documentElement);
|
|
let accent = docStyles.getPropertyValue('--color-result-link').trim();
|
|
if (!accent) {
|
|
const a = document.createElement('a');
|
|
document.body.appendChild(a);
|
|
accent = getComputedStyle(a).color;
|
|
document.body.removeChild(a);
|
|
}
|
|
if (accent) {
|
|
box.style.setProperty('--color-result-link', accent);
|
|
box.style.setProperty('--sxng-ai-accent', accent);
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
|
|
// conversation saved as base64 URL fragment.
|
|
const updateState = () => {
|
|
try {
|
|
let state = {
|
|
t: conversation.turns.map(t => ({
|
|
r: t.role === 'user' ? 'u' : 'a',
|
|
c: t.content.replace(/\s+/g, ' ').trim()
|
|
})),
|
|
u: urls
|
|
};
|
|
const encodeB64 = (obj) => {
|
|
const u8 = new TextEncoder().encode(JSON.stringify(obj));
|
|
let bin = '';
|
|
// Use a loop to avoid RangeError: Maximum call stack size exceeded
|
|
for (let i = 0; i < u8.byteLength; i++) {
|
|
bin += String.fromCharCode(u8[i]);
|
|
}
|
|
return btoa(bin);
|
|
};
|
|
|
|
let b64 = encodeB64(state);
|
|
while (b64.length > 2000 && state.t.length > 2) {
|
|
state.t.splice(1, 2); // Delete in Q&A pairs
|
|
b64 = encodeB64(state);
|
|
}
|
|
|
|
history.replaceState(null, null, '#ai=' + b64);
|
|
} catch(e) {}
|
|
};
|
|
|
|
if (location.hash.includes('ai=')) {
|
|
try {
|
|
const b64 = location.hash.split('ai=')[1];
|
|
const uint8 = new Uint8Array(atob(b64).split('').map(c => c.charCodeAt(0)));
|
|
const json = new TextDecoder().decode(uint8);
|
|
const state = JSON.parse(json);
|
|
if (state.t && state.t.length > 0) {
|
|
// Restore URLs for citation indexing
|
|
if (state.u && Array.isArray(state.u)) {
|
|
urls = state.u;
|
|
}
|
|
|
|
conversation.turns = state.t.map(t => ({
|
|
role: t.r === 'u' ? 'user' : 'assistant',
|
|
content: t.c.trim(),
|
|
ts: 0
|
|
}));
|
|
|
|
const injectCitations = (text) => {
|
|
return renderCitations(text, urls);
|
|
};
|
|
|
|
data.innerHTML = '';
|
|
conversation.turns.forEach((turn, i) => {
|
|
if (turn.role === 'user') {
|
|
if (turn.content !== conversation.originalQuery) {
|
|
const u = document.createElement('span');
|
|
u.className = 'sxng-user-msg';
|
|
u.textContent = turn.content;
|
|
data.appendChild(u);
|
|
const clr = document.createElement('div');
|
|
clr.style.clear = 'both';
|
|
data.appendChild(clr);
|
|
}
|
|
} else {
|
|
data.appendChild(injectCitations(turn.content));
|
|
}
|
|
});
|
|
box.style.display = 'block';
|
|
if(wrapper) wrapper.style.display = '';
|
|
if(footer && is_interactive) footer.style.display = 'flex';
|
|
restored = true;
|
|
}
|
|
} catch(e) { console.warn('Restore failed', e); }
|
|
}
|
|
document.getElementById('btn-copy').onclick = async (e) => {
|
|
const btn = e.currentTarget;
|
|
const originalContent = btn.innerHTML;
|
|
const text = Array.from(data.childNodes)
|
|
.filter(n => n.nodeType === 3 || n.tagName === 'SPAN')
|
|
.map(n => n.textContent)
|
|
.join('');
|
|
await navigator.clipboard.writeText(text);
|
|
btn.innerHTML = '<svg viewBox="0 0 24 24" style="color:#a3be8c;"><path d="M9 16.17L4.83 12L3.41 13.41L9 19L21 7L19.59 5.59L9 16.17Z"/></svg>';
|
|
setTimeout(() => btn.innerHTML = originalContent, 2000);
|
|
};
|
|
|
|
document.getElementById('btn-regen').onclick = async () => {
|
|
data.innerHTML = '<span class="sxng-cursor"></span>';
|
|
footer.style.display = 'none';
|
|
|
|
if (conversation.turns.length > 0 && conversation.turns[conversation.turns.length - 1].role === 'assistant') {
|
|
conversation.turns.pop();
|
|
}
|
|
|
|
updateState();
|
|
|
|
if (conversation.turns.length <= 1) {
|
|
await startStream();
|
|
} else {
|
|
const val = conversation.turns[conversation.turns.length - 1].content;
|
|
const currentText = conversation.turns.slice(0, -1).slice(-6)
|
|
.map(t => (t.role === 'user' ? 'Q' : 'A') + ': ' + t.content)
|
|
.join('\\n\\n');
|
|
await startStream(val, currentText);
|
|
}
|
|
updateState();
|
|
};
|
|
|
|
const handleAction = async (e) => {
|
|
if (e) e.preventDefault();
|
|
const val = input.value.trim();
|
|
|
|
conversation.turns.push({role: 'user', content: val, ts: Date.now()});
|
|
updateState();
|
|
|
|
const currentText = conversation.turns.slice(0, -1).slice(-6)
|
|
.map(t => (t.role === 'user' ? 'Q' : 'A') + ': ' + t.content)
|
|
.join('\\n\\n');
|
|
|
|
input.value = '';
|
|
input.blur();
|
|
footer.style.display = 'none';
|
|
|
|
if (val) {
|
|
const cursor = data.querySelector('.sxng-cursor');
|
|
if (cursor) cursor.remove();
|
|
const userMsg = document.createElement('span');
|
|
userMsg.className = 'sxng-user-msg';
|
|
userMsg.textContent = val;
|
|
data.appendChild(userMsg);
|
|
const clr = document.createElement('div');
|
|
clr.style.clear = 'both';
|
|
data.appendChild(clr);
|
|
|
|
const newCursor = document.createElement('span');
|
|
newCursor.className = 'sxng-cursor';
|
|
data.appendChild(newCursor);
|
|
|
|
const synthesized = synthesizeQuery(q_init, val);
|
|
let auxContext = null;
|
|
try {
|
|
const auxData = await fetch(script_root + '/ai-auxiliary-search', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({query: synthesized, lang: lang_init, offset: urls.length, tk: tk_init})
|
|
}).then(r => r.json());
|
|
if (auxData.context) {
|
|
const originalBackground = conversation.originalContext.substring(0, 1500);
|
|
auxContext = `FRESH SOURCES (most relevant):\\n${auxData.context}\\n\\nBACKGROUND (for reference):\\n${originalBackground}`;
|
|
if (auxData.new_urls && Array.isArray(auxData.new_urls)) {
|
|
urls = urls.concat(auxData.new_urls);
|
|
}
|
|
}
|
|
} catch (err) {}
|
|
|
|
await startStream(val, currentText, auxContext);
|
|
updateState();
|
|
} else {
|
|
const cursor = data.querySelector('.sxng-cursor');
|
|
if (cursor) cursor.remove();
|
|
data.appendChild(document.createElement('br'));
|
|
data.appendChild(document.createElement('br'));
|
|
const newCursor = document.createElement('span');
|
|
newCursor.className = 'sxng-cursor';
|
|
data.appendChild(newCursor);
|
|
await startStream("Continue", currentText);
|
|
updateState();
|
|
}
|
|
};
|
|
|
|
document.getElementById('sxng-action-form').onsubmit = handleAction;
|
|
input.onfocus = () => {
|
|
setTimeout(() => {
|
|
input.scrollIntoView({behavior: 'smooth', block: 'center'});
|
|
}, 300);
|
|
};
|
|
|
|
(function fetchModels() {
|
|
const _msel2 = document.getElementById('sxng-model-select');
|
|
if (!_msel2) return;
|
|
const _modelsUrl = script_root + '/ai-models?tk=' + encodeURIComponent(tk_init);
|
|
console.log('[AI Answers] Fetching models from', _modelsUrl);
|
|
fetch(_modelsUrl)
|
|
.then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status))
|
|
.then(d => {
|
|
const models = (d && d.models && d.models.length > 0) ? d.models : [model_init];
|
|
const _cur = _msel2.value || model_init;
|
|
_msel2.innerHTML = '';
|
|
models.forEach(m => {
|
|
const o = document.createElement('option');
|
|
o.value = m; o.textContent = m;
|
|
if (m === _cur) o.selected = true;
|
|
_msel2.appendChild(o);
|
|
});
|
|
_msel2.style.display = 'inline-block';
|
|
})
|
|
.catch(() => {
|
|
if (model_init) {
|
|
const o = document.createElement('option');
|
|
o.value = model_init; o.textContent = model_init;
|
|
o.selected = true;
|
|
_msel2.appendChild(o);
|
|
_msel2.style.display = 'inline-block';
|
|
}
|
|
});
|
|
})();
|