Merge remote-tracking branch 'origin/dev'
This commit is contained in:
@@ -912,6 +912,78 @@ function initEndpointForm() {
|
||||
btn.disabled = false; btn.textContent = 'Add';
|
||||
});
|
||||
|
||||
// GitHub Copilot — device-flow login. Starts the flow, shows the user a
|
||||
// code + verification link, and polls until they authorise (or it expires).
|
||||
const copilotBtn = el('adm-copilotConnectBtn');
|
||||
if (copilotBtn) {
|
||||
let copilotPolling = false;
|
||||
copilotBtn.addEventListener('click', async () => {
|
||||
if (copilotPolling) return;
|
||||
const status = el('adm-copilotStatus');
|
||||
const reset = () => { copilotBtn.disabled = false; copilotBtn.textContent = 'Connect GitHub Copilot'; copilotPolling = false; };
|
||||
status.textContent = ''; status.className = 'adm-ep-inline-msg';
|
||||
copilotBtn.disabled = true; copilotBtn.textContent = 'Starting...';
|
||||
copilotPolling = true;
|
||||
let start;
|
||||
try {
|
||||
const res = await fetch('/api/copilot/device/start', { method: 'POST', body: new FormData(), credentials: 'same-origin' });
|
||||
start = await res.json();
|
||||
if (!res.ok) { status.textContent = start.detail || 'Failed to start login'; status.className = 'admin-error'; reset(); return; }
|
||||
} catch (e) { status.textContent = 'Request failed'; status.className = 'admin-error'; reset(); return; }
|
||||
|
||||
const { poll_id, user_code, verification_uri, verification_uri_complete, interval, expires_in } = start;
|
||||
// Prefer the "complete" URL — it embeds the code so the user only has to
|
||||
// click "Authorize" (no manual code entry).
|
||||
const authUrl = verification_uri_complete || verification_uri || '';
|
||||
const esc = (s) => String(s || '').replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]));
|
||||
copilotBtn.textContent = 'Waiting…';
|
||||
|
||||
// Cohesive waiting panel: spinner + status line, the device code as a
|
||||
// copyable chip, and a primary "Authorize on GitHub" action.
|
||||
status.className = '';
|
||||
status.innerHTML =
|
||||
'<div class="adm-copilot-panel">' +
|
||||
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
|
||||
'<span>Waiting for GitHub authorization…</span></div>' +
|
||||
'<div class="adm-copilot-coderow">' +
|
||||
'<span class="adm-copilot-code-label">Code</span>' +
|
||||
'<code class="adm-copilot-code">' + esc(user_code) + '</code>' +
|
||||
'<button type="button" class="admin-btn-sm adm-copilot-copy">Copy</button>' +
|
||||
'</div>' +
|
||||
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl) + '" target="_blank" rel="noopener">Authorize on GitHub ↗</a>' +
|
||||
'<div class="adm-copilot-hint">A new tab opened on GitHub — approve there to finish. Didn\'t open? Use the button above.</div>' +
|
||||
'</div>';
|
||||
const copyBtn = status.querySelector('.adm-copilot-copy');
|
||||
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||
try { await navigator.clipboard.writeText(user_code || ''); copyBtn.textContent = 'Copied'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); } catch (e) {}
|
||||
});
|
||||
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
|
||||
|
||||
const deadline = Date.now() + (expires_in || 900) * 1000;
|
||||
const stepMs = Math.max((interval || 5), 2) * 1000;
|
||||
const done = (cls, text) => { status.className = cls; status.textContent = text; reset(); };
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) { done('admin-error', 'Authorization expired — try again.'); return; }
|
||||
try {
|
||||
const fd = new FormData(); fd.append('poll_id', poll_id);
|
||||
const r = await fetch('/api/copilot/device/poll', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await r.json();
|
||||
if (d.status === 'authorized') {
|
||||
const n = ((d.endpoint && d.endpoint.models) || []).length;
|
||||
done('admin-success', '✓ Connected — ' + n + ' Copilot model' + (n !== 1 ? 's' : '') + ' available.');
|
||||
if (d.endpoint && d.endpoint.id) _recentlyAddedEpId = String(d.endpoint.id);
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(d.endpoint || {});
|
||||
return;
|
||||
}
|
||||
if (d.status === 'failed') { done('admin-error', 'Authorization failed (' + (d.error || 'denied') + ').'); return; }
|
||||
} catch (e) { /* transient — keep polling */ }
|
||||
setTimeout(poll, stepMs);
|
||||
};
|
||||
setTimeout(poll, stepMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Local "Add" button — sibling form for self-hosted base URLs.
|
||||
const localAddBtn = el('adm-epLocalAddBtn');
|
||||
const localTestBtn = el('adm-epLocalTestBtn');
|
||||
@@ -1133,11 +1205,11 @@ const _GOOGLE_OAUTH_HELP = `To get Google OAuth credentials:
|
||||
|
||||
const MCP_PRESETS = [
|
||||
{ name: "Gmail", command: "npx", args: ["-y", "@gongrzhe/server-gmail-autoauth-mcp"], env: { GOOGLE_CLIENT_ID: "", GOOGLE_CLIENT_SECRET: "" },
|
||||
oauthFile: { dir: "~/.gmail-mcp", filename: "gcp-oauth.keys.json" },
|
||||
oauthFile: { dir: "gmail", filename: "gcp-oauth.keys.json" },
|
||||
oauth: {
|
||||
provider: "google",
|
||||
keys_file: "~/.gmail-mcp/gcp-oauth.keys.json",
|
||||
token_file: "~/.gmail-mcp/credentials.json",
|
||||
keys_file: "gmail/gcp-oauth.keys.json",
|
||||
token_file: "gmail/credentials.json",
|
||||
scopes: ["https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.settings.basic"],
|
||||
},
|
||||
help: `Setup:
|
||||
|
||||
@@ -82,13 +82,15 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
|
||||
// Background streaming support
|
||||
const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
|
||||
const _resumingStreams = new Set(); // sessionId -> a resumeStream() reader is live (re-attach lock)
|
||||
let _streamSessionId = null; // Session ID for the currently active reader loop
|
||||
let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams
|
||||
let _webLockRelease = null; // Function to release the Web Lock held during streaming
|
||||
|
||||
/** Check if an SSE reader is still actively connected for a session. */
|
||||
function hasActiveStream(sessionId) {
|
||||
return _streamSessionId === sessionId || _backgroundStreams.has(sessionId);
|
||||
return _streamSessionId === sessionId || _backgroundStreams.has(sessionId) ||
|
||||
_resumingStreams.has(sessionId);
|
||||
}
|
||||
|
||||
// Sources box builder and toggleSources are now in chatRenderer.js
|
||||
@@ -779,6 +781,10 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
if (incognitoChk && incognitoChk.checked) {
|
||||
fd.append('incognito', 'true');
|
||||
}
|
||||
const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || '';
|
||||
if (_ws) {
|
||||
fd.append('workspace', _ws);
|
||||
}
|
||||
if (presetsModule.getSelectedPreset()) {
|
||||
fd.append('preset_id', presetsModule.getSelectedPreset());
|
||||
}
|
||||
@@ -842,7 +848,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
var _charNameInit = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
|
||||
if (_charNameInit) roleLabel = _charNameInit;
|
||||
const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
holder.innerHTML = `<div class="role">${roleLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
holder.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
_applyModelColor(holder.querySelector('.role'), modelName);
|
||||
holder.style.position = 'relative';
|
||||
|
||||
@@ -1118,7 +1124,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
let _measureDiv = null;
|
||||
|
||||
function _replyAfterClosedThinking(text) {
|
||||
const closeRe = /<\/think(?:ing)?>/gi;
|
||||
const closeRe = /<\/(?:think(?:ing)?|thought)>|<channel\|>/gi;
|
||||
let match = null;
|
||||
let last = null;
|
||||
while ((match = closeRe.exec(text || '')) !== null) last = match;
|
||||
@@ -1145,7 +1151,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
replyTrimmed = (replyText || '').trim();
|
||||
} else {
|
||||
// Non-tag: check for garbled <think> (reasoning\n<think>reply)
|
||||
const _gm = dt.match(/^[\s\S]+?<think(?:ing)?>\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i);
|
||||
const _gm = dt.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i);
|
||||
if (_gm && _gm[1].trim()) {
|
||||
replyTrimmed = _gm[1].trim();
|
||||
} else {
|
||||
@@ -1186,8 +1192,11 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
const prevLen = contentEl._prevTextLen || 0;
|
||||
// If thinking is still streaming (unclosed <think>), show indicator instead of raw text
|
||||
if (markdownModule.hasUnclosedThinkTag && markdownModule.hasUnclosedThinkTag(dt)) {
|
||||
const thinkStart = dt.search(/<think(?:ing)?>/i);
|
||||
const thinkContent = dt.substring(thinkStart).replace(/<think(?:ing)?>/i, '').trim();
|
||||
const thinkStart = dt.search(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i);
|
||||
const thinkContent = dt.substring(Math.max(thinkStart, 0))
|
||||
.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought\s*\n?/i, '')
|
||||
.replace(/<channel\|>/gi, '')
|
||||
.trim();
|
||||
const lines = thinkContent.split('\n').length;
|
||||
// Don't show beforeThink text during streaming — it'll appear in the final render
|
||||
// This prevents the "split into two" duplication
|
||||
@@ -1447,7 +1456,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
// Detect non-tag thinking patterns: "Thinking:", "Thinking Process:", Gemma-style reasoning
|
||||
// These patterns don't use <think> tags, so we simulate unclosed thinking during streaming
|
||||
const _replyPrefixes = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"];
|
||||
if (!hasUnclosedThink && !roundText.includes('<think')) {
|
||||
if (!hasUnclosedThink && !/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i.test(roundText)) {
|
||||
const _trimmedRT = roundText.trimStart();
|
||||
const _isReasoning = markdownModule.startsWithReasoningPrefix(_trimmedRT);
|
||||
if (_isReasoning) {
|
||||
@@ -1473,10 +1482,10 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUnclosedThink && /^<think(?:ing)?>\s*<\/think(?:ing)?>/i.test(roundText)) {
|
||||
if (!hasUnclosedThink && /^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i.test(roundText)) {
|
||||
// Empty <think></think> — the model likely put thinking outside the tags
|
||||
const afterEmpty = roundText.replace(/^<think(?:ing)?>\s*<\/think(?:ing)?>/i, '').trim();
|
||||
const closeTags = (afterEmpty.match(/<\/think(?:ing)?>/gi) || []).length;
|
||||
const afterEmpty = roundText.replace(/^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i, '').trim();
|
||||
const closeTags = (afterEmpty.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length;
|
||||
if (closeTags === 0 && afterEmpty.length > 0) {
|
||||
hasUnclosedThink = true; // still waiting for real closing tag
|
||||
}
|
||||
@@ -1485,13 +1494,13 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
// Only applies when there's a second </think> later (model leaked thinking outside tags)
|
||||
// Do NOT trigger if the text after </think> contains tool calls (that's real content)
|
||||
if (!hasUnclosedThink && isThinking) {
|
||||
const _thinkMatch = roundText.match(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i);
|
||||
const _thinkMatch = roundText.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i);
|
||||
const _thinkLen = _thinkMatch ? _thinkMatch[1].trim().length : 0;
|
||||
if (_thinkLen < 20) {
|
||||
const _afterClose = roundText.replace(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i, '').trim();
|
||||
const _afterClose = roundText.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i, '').trim();
|
||||
// Only keep waiting if there's trailing text that looks like thinking (not tool calls)
|
||||
const _hasToolCall = /```(?:bash|python|web_search|read_file|write_file|create_document|edit_document|manage_|generate_image)/i.test(_afterClose);
|
||||
const _hasOrphanClose = /<\/think(?:ing)?>/i.test(_afterClose);
|
||||
const _hasOrphanClose = /<\/(?:think(?:ing)?|thought)>/i.test(_afterClose);
|
||||
if (!_hasToolCall && (_hasOrphanClose || (Date.now() - thinkingStartTime) < 500)) {
|
||||
hasUnclosedThink = true; // keep waiting for real </think>
|
||||
}
|
||||
@@ -1548,8 +1557,12 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
}
|
||||
} else if (hasUnclosedThink && isThinking) {
|
||||
if (_liveThinkInner) {
|
||||
// Extract raw thinking text (strip all <think>/<thinking> open/close tags and prefixes)
|
||||
var thinkText = roundText.replace(/<\/?think(?:ing)?>/gi, '');
|
||||
// Extract raw thinking text (strip known thinking wrappers and prefixes)
|
||||
var thinkText = roundText
|
||||
.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '')
|
||||
.replace(/<\|channel>thought\s*\n?/gi, '')
|
||||
.replace(/<\|channel>response\s*\n?/gi, '')
|
||||
.replace(/<channel\|>/gi, '');
|
||||
thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
|
||||
_liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
|
||||
// Keep thinking box scrolled to bottom
|
||||
@@ -1827,6 +1840,44 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (json.type === 'rounds_exhausted') {
|
||||
// The agent hit the per-turn step limit while still working.
|
||||
// Offer a Continue button instead of stalling silently.
|
||||
// NOTE: append to the chat-history container (bottom), NOT the
|
||||
// message body — the body innerHTML is re-rendered at stream
|
||||
// finalize, which would wipe a note placed inside it.
|
||||
const _chatBox = document.getElementById('chat-history');
|
||||
if (!_isBg && _chatBox) {
|
||||
// Drop any prior box so repeated cap-hits each get a fresh
|
||||
// Continue at the bottom (multiple continues in a row).
|
||||
const _old = _chatBox.querySelector('.rounds-exhausted');
|
||||
if (_old) _old.remove();
|
||||
const note = document.createElement('div');
|
||||
note.className = 'stopped-indicator rounds-exhausted';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'rounds-exhausted-label';
|
||||
label.textContent = `Reached the ${json.rounds || ''}-step limit — not finished.`;
|
||||
note.appendChild(label);
|
||||
const contBtn = document.createElement('button');
|
||||
contBtn.className = 'continue-btn';
|
||||
contBtn.title = 'Continue the task';
|
||||
contBtn.textContent = 'Continue ▸';
|
||||
const _holder = currentHolder;
|
||||
contBtn.addEventListener('click', () => {
|
||||
note.remove();
|
||||
_hideUserBubble = true;
|
||||
_pendingContinue = _holder;
|
||||
const msgInput = uiModule.el('message');
|
||||
if (msgInput) {
|
||||
msgInput.value = 'You hit the step limit before finishing — the task is not complete. Continue from exactly where you left off and keep going until it is done. Do NOT repeat work already done.';
|
||||
const sb = document.querySelector('.send-btn');
|
||||
if (sb) sb.click();
|
||||
}
|
||||
});
|
||||
note.appendChild(contBtn);
|
||||
_chatBox.appendChild(note);
|
||||
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
|
||||
}
|
||||
} else if (json.type === 'attachments') {
|
||||
if (_isBg) continue;
|
||||
// Update user bubble — replace file chips with image previews
|
||||
@@ -1993,7 +2044,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
const node = document.createElement('div')
|
||||
node.className = 'agent-thread-node running';
|
||||
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${toolLabel}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${esc(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||
// Expand/collapse via delegated click handler (init at module bottom).
|
||||
threadWrap.appendChild(node);
|
||||
currentToolBubble = node;
|
||||
@@ -2072,7 +2123,33 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
if (json.output && json.output.trim()) {
|
||||
outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(json.output)}</pre></details>`;
|
||||
}
|
||||
const cmdHtml2 = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
|
||||
// File-write diff (write_file): show a before/after unified diff.
|
||||
let diffHtml = '';
|
||||
if (json.diff && json.diff.text) {
|
||||
const d = json.diff;
|
||||
// Collapsed summary: filename + +adds (green) / −dels (red).
|
||||
const stat = [
|
||||
d.new_file ? '<span class="diff-stat-new">new</span>' : '',
|
||||
d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '',
|
||||
d.removed ? `<span class="diff-stat-del">−${d.removed}</span>` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const rows = d.text.split('\n').map(line => {
|
||||
let cls = 'diff-ctx', text = line;
|
||||
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
|
||||
else if (line.startsWith('@@')) cls = 'diff-hunk';
|
||||
// Drop the leading diff marker (+/-/space) — the row colour
|
||||
// already encodes add/del, and keeping it doubles up with
|
||||
// markdown "- " bullets (reads as "+-"/"--").
|
||||
else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); }
|
||||
else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); }
|
||||
else if (line.startsWith(' ')) { text = line.slice(1); }
|
||||
return `<span class="${cls}">${esc(text) || ' '}</span>`;
|
||||
}).join(''); // spans are display:block — a literal \n here would double-space the diff
|
||||
diffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`;
|
||||
}
|
||||
// For file edits the "command" is the raw JSON args — redundant
|
||||
// next to the diff, so hide it when we have a diff to show.
|
||||
const cmdHtml2 = (cmd && !(json.diff && json.diff.text)) ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
|
||||
// Preserve the user's .open choice across the innerHTML
|
||||
// rewrite \u2014 otherwise expanding a running tool collapses
|
||||
// it as soon as the result lands, forcing the user to
|
||||
@@ -2080,7 +2157,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
// bottom of file) so no per-node listener needed.
|
||||
const _wasOpen = currentToolBubble.classList.contains('open');
|
||||
currentToolBubble.className = 'agent-thread-node' + (ok ? '' : ' error') + (_wasOpen ? ' open' : '');
|
||||
currentToolBubble.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(json.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${cmdHtml2}${outHtml}</div>`;
|
||||
currentToolBubble.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(json.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${cmdHtml2}${outHtml}${diffHtml}</div>`;
|
||||
// Reset so thinking spinner between tools says "Thinking" not the old tool's label
|
||||
_lastToolName = '';
|
||||
uiModule.scrollHistory();
|
||||
@@ -2097,10 +2174,19 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
if (json.screenshot && currentToolBubble) {
|
||||
const contentEl = currentToolBubble.querySelector('.agent-thread-content');
|
||||
if (contentEl) {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'agent-tool-output';
|
||||
details.innerHTML = `<summary>Screenshot</summary><img src="${json.screenshot}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" />`;
|
||||
contentEl.appendChild(details);
|
||||
const screenshotSrc = chatRenderer.safeToolScreenshotSrc(json.screenshot);
|
||||
if (screenshotSrc) {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'agent-tool-output';
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = 'Screenshot';
|
||||
const img = document.createElement('img');
|
||||
img.src = screenshotSrc;
|
||||
img.style.cssText = 'max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)';
|
||||
details.appendChild(summary);
|
||||
details.appendChild(img);
|
||||
contentEl.appendChild(details);
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- Reload sessions after manage_session tool (delete, rename, etc.) ---
|
||||
@@ -2374,8 +2460,8 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
_finalReply = (_extracted.content || '').trim();
|
||||
} else {
|
||||
// Non-tag thinking: extract reply from raw text
|
||||
// Handle garbled <think> tag: "Thinking: reasoning\n<think>reply"
|
||||
const _garbledMatch = finalDisplay.match(/^[\s\S]+?<think(?:ing)?>\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i);
|
||||
// Handle garbled thinking tag: "Thinking: reasoning\n<think>reply"
|
||||
const _garbledMatch = finalDisplay.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i);
|
||||
if (_garbledMatch && _garbledMatch[1].trim()) {
|
||||
_finalReply = _garbledMatch[1].trim();
|
||||
} else {
|
||||
@@ -2424,8 +2510,8 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
_body4b.innerHTML = _sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded2) : _sourcesHtml;
|
||||
} else if (roundHolder !== holder) {
|
||||
// Check if there's thinking content worth showing
|
||||
const _thinkMatch = roundText.match(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i);
|
||||
if (_thinkMatch && _thinkMatch[1].trim()) {
|
||||
const _thinkingOnly = markdownModule.extractThinkingBlocks(roundText);
|
||||
if (_thinkingOnly.thinkingBlocks?.length && !_thinkingOnly.content) {
|
||||
// Show thinking in a collapsed section even if no visible reply text
|
||||
const _body4c = roundHolder.querySelector('.body');
|
||||
if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(roundText);
|
||||
@@ -3045,6 +3131,152 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
var _notifyStreamComplete = chatStream.notifyStreamComplete;
|
||||
var _insertStreamDoneToast = chatStream.insertStreamDoneToast;
|
||||
|
||||
/**
|
||||
* Live-resume a chat run still streaming detached on the server (#2539).
|
||||
*
|
||||
* On session re-entry, GET /api/chat/resume/{id} replays the run's buffer then
|
||||
* streams live; reply tokens render as they arrive. On completion a plain text
|
||||
* reply is finalized in place (canonical bubble via chatRenderer.addMessage, no
|
||||
* reload); a "rich" reply (tool calls, sources, doc streaming, multi-round) is
|
||||
* reloaded from the DB so its full render stays faithful. Returns true if it
|
||||
* attached, false to let the caller fall back to spinner+poll.
|
||||
*/
|
||||
export async function resumeStream(sessionId) {
|
||||
if (!sessionId) return false;
|
||||
if (hasActiveStream(sessionId)) return false;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/api/chat/resume/${sessionId}`);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
if (!res.ok || !res.body) return false;
|
||||
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) return false;
|
||||
|
||||
// Block duplicate re-attach attempts while this reader is live. A dedicated
|
||||
// set (not _backgroundStreams) so checkBackgroundStream doesn't mistake this
|
||||
// for a same-tab POST stream and spawn its own spinner+poll on re-entry.
|
||||
_resumingStreams.add(sessionId);
|
||||
|
||||
const holder = document.createElement('div');
|
||||
holder.className = 'msg msg-ai';
|
||||
const meta = sessionModule.getSessions().find(s => s.id === sessionId);
|
||||
const roleLabel = _shortModel(meta && meta.model);
|
||||
const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) +
|
||||
' <span class="role-timestamp">' + roleTs + '</span></div>' +
|
||||
'<div class="body"><div class="stream-content"></div></div>';
|
||||
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
|
||||
const contentDiv = holder.querySelector('.stream-content');
|
||||
box.appendChild(holder);
|
||||
|
||||
const spinner = spinnerModule.create('Generating response...', 'right');
|
||||
holder.querySelector('.body').appendChild(spinner.createElement());
|
||||
spinner.start();
|
||||
uiModule.scrollHistory();
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let roundText = '';
|
||||
let gotDelta = false;
|
||||
let leftSession = false;
|
||||
let metricsData = null;
|
||||
// "Rich" responses (tool calls, sources, doc streaming, multi-round) need the
|
||||
// full canonical render, which is rebuilt from the saved DB record on reload.
|
||||
// Plain text replies can be finalized in place without a reload.
|
||||
let rich = false;
|
||||
|
||||
const cleanup = () => {
|
||||
try { spinner.destroy(); } catch (_) {}
|
||||
_resumingStreams.delete(sessionId);
|
||||
};
|
||||
|
||||
const renderDelta = () => {
|
||||
const dt = stripToolBlocks(roundText);
|
||||
contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt));
|
||||
uiModule.scrollHistory();
|
||||
};
|
||||
|
||||
try {
|
||||
readLoop:
|
||||
while (true) {
|
||||
// User left this session: stop rendering, the run continues server-side.
|
||||
if (sessionModule.getCurrentSessionId &&
|
||||
sessionModule.getCurrentSessionId() !== sessionId) {
|
||||
leftSession = true;
|
||||
try { await reader.cancel(); } catch (_) {}
|
||||
break;
|
||||
}
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop();
|
||||
for (const part of parts) {
|
||||
const line = part.split('\n').find(l => l.startsWith('data: '));
|
||||
if (!line) continue;
|
||||
const payload = line.slice(6);
|
||||
if (payload === '[DONE]') {
|
||||
try { await reader.cancel(); } catch (_) {}
|
||||
break readLoop;
|
||||
}
|
||||
let json;
|
||||
try { json = JSON.parse(payload); } catch (_) { continue; }
|
||||
if (json.delta) {
|
||||
roundText += json.delta;
|
||||
if (!gotDelta) { gotDelta = true; try { spinner.destroy(); } catch (_) {} }
|
||||
renderDelta();
|
||||
} else if (json.type === 'doc_stream_open') {
|
||||
rich = true;
|
||||
if (documentModule) documentModule.streamDocOpen(json.title || '', json.lang || '');
|
||||
} else if (json.type === 'doc_stream_delta') {
|
||||
rich = true;
|
||||
if (documentModule && json.delta) documentModule.streamDocDelta(json.delta);
|
||||
} else if (json.type === 'metrics') {
|
||||
metricsData = json.data || metricsData;
|
||||
} else if (json.type === 'tool_start' || json.type === 'tool_output' ||
|
||||
json.type === 'tool_progress' || json.type === 'agent_step' ||
|
||||
json.type === 'web_sources' || json.type === 'rag_sources' ||
|
||||
json.type === 'research_progress' || json.type === 'research_sources' ||
|
||||
json.type === 'research_findings' || json.type === 'research_done') {
|
||||
rich = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Network drop or parse failure: fall through to the reload below.
|
||||
}
|
||||
|
||||
cleanup();
|
||||
if (leftSession) { if (holder.parentNode) holder.remove(); return true; }
|
||||
|
||||
const onThisSession = sessionModule.getCurrentSessionId &&
|
||||
sessionModule.getCurrentSessionId() === sessionId;
|
||||
|
||||
// Plain text reply: finalize in place. Replace the live bubble with a
|
||||
// canonical single message (markdown + footer actions + metrics) using the
|
||||
// same renderer history does. No history refetch, no end-of-stream flicker.
|
||||
if (onThisSession && !rich && roundText.trim()) {
|
||||
if (holder.parentNode) holder.remove();
|
||||
const model = meta && meta.model;
|
||||
const meta_ = metricsData ? Object.assign({ model }, metricsData) : { model };
|
||||
chatRenderer.addMessage('assistant', roundText, model, meta_);
|
||||
uiModule.scrollHistory();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rich response (tools, sources, docs, multi-round) or user moved on:
|
||||
// reload from the DB for the full canonical render.
|
||||
if (holder.parentNode) holder.remove();
|
||||
if (onThisSession) sessionModule.selectSession(sessionId);
|
||||
else sessionModule.loadSessions();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for background streams when switching to a session.
|
||||
* Called after history loads on session switch.
|
||||
@@ -3090,7 +3322,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
var meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; });
|
||||
var roleLabel = _shortModel(meta && meta.model);
|
||||
var roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
holder.innerHTML = '<div class="role">' + roleLabel + ' <span class="role-timestamp">' + roleTs + '</span></div><div class="body"></div>';
|
||||
holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) + ' <span class="role-timestamp">' + roleTs + '</span></div><div class="body"></div>';
|
||||
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
|
||||
|
||||
var bodyDiv = holder.querySelector('.body');
|
||||
@@ -3892,7 +4124,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
const agentMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
|
||||
const agentModelLabel = _shortModel(agentMeta?.model);
|
||||
holder.innerHTML = `<div class="role">${agentModelLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
holder.innerHTML = `<div class="role">${uiModule.esc(agentModelLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
_applyModelColor(holder.querySelector('.role'), agentMeta?.model);
|
||||
box.appendChild(holder);
|
||||
|
||||
@@ -4360,9 +4592,10 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
// never closes (so it would otherwise hide the whole answer). Peel all of
|
||||
// those off so what's left is just the rewritten text.
|
||||
const _stripThink = (t) => {
|
||||
t = t.replace(/<think>[\s\S]*?<\/think>/gi, ''); // complete blocks
|
||||
if (/<\/think>/i.test(t)) t = t.replace(/^[\s\S]*?<\/think>/i, ''); // reasoning w/o opener
|
||||
return t.replace(/<\/?think>/gi, '').trim(); // any orphan tag
|
||||
t = markdownModule.normalizeThinkingMarkup(t || '');
|
||||
t = t.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>[\s\S]*?<\/(?:think(?:ing)?|thought)>/gi, ''); // complete blocks
|
||||
if (/<\/(?:think(?:ing)?|thought)>/i.test(t)) t = t.replace(/^[\s\S]*?<\/(?:think(?:ing)?|thought)>/i, ''); // reasoning w/o opener
|
||||
return t.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '').trim(); // any orphan tag
|
||||
};
|
||||
newText = _stripThink(newText);
|
||||
|
||||
@@ -4528,6 +4761,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
abortCurrentRequest,
|
||||
detachCurrentStream,
|
||||
checkBackgroundStream,
|
||||
resumeStream,
|
||||
hideWelcomeScreen: chatRenderer.hideWelcomeScreen,
|
||||
showWelcomeScreen: chatRenderer.showWelcomeScreen,
|
||||
checkPendingResearch,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import uiModule from './ui.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { providerLogo, providerLabel } from './providers.js';
|
||||
import settingsModule from './settings.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
@@ -26,6 +26,29 @@ function _safeHref(url) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
export function safeToolScreenshotSrc(raw) {
|
||||
const src = String(raw || '').trim();
|
||||
if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) {
|
||||
return src;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function safeDisplayImageSrc(raw) {
|
||||
const src = String(raw || '').trim();
|
||||
if (!src) return '';
|
||||
if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) {
|
||||
return src;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(src, window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return parsed.href;
|
||||
}
|
||||
} catch (_) {}
|
||||
return '';
|
||||
}
|
||||
|
||||
function _makeActionBtn(className, title, text, handler) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = className;
|
||||
@@ -577,6 +600,12 @@ export function applyModelColor(roleEl, modelName) {
|
||||
if (logoHtml) html += '<span class="role-provider-logo" style="opacity:0.7">' + logoHtml + '</span>';
|
||||
html += short + '</div>';
|
||||
html += '<div><span class="ctx-label">Model</span> ' + modelName.split('/').pop() + '</div>';
|
||||
// Provider = the serving endpoint, distinct from the model vendor/logo
|
||||
// (e.g. the same model via OpenRouter vs Copilot vs Anthropic direct).
|
||||
const _epUrl = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
|
||||
? window.sessionModule.getCurrentEndpointUrl() : null;
|
||||
const _provLabel = providerLabel(_epUrl);
|
||||
if (_provLabel) html += '<div><span class="ctx-label">Provider</span> ' + uiModule.esc(_provLabel) + '</div>';
|
||||
// Show static context initially, then fetch real from server
|
||||
const _realCtx = window._realContextLengths && window._realContextLengths[modelName];
|
||||
if (_realCtx) {
|
||||
@@ -1052,12 +1081,19 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
|
||||
const body = document.createElement('div');
|
||||
body.className = 'body';
|
||||
|
||||
const safeImageUrl = safeDisplayImageSrc(imageUrl);
|
||||
if (!safeImageUrl) {
|
||||
body.textContent = '[Image unavailable]';
|
||||
wrap.appendChild(body);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.className = 'generated-image';
|
||||
img.alt = prompt || 'Generated image';
|
||||
img.title = prompt || 'Generated image';
|
||||
img.src = imageUrl;
|
||||
img.addEventListener('click', () => { window.open(img.src, '_blank'); });
|
||||
img.src = safeImageUrl;
|
||||
img.addEventListener('click', () => { window.open(safeImageUrl, '_blank', 'noopener,noreferrer'); });
|
||||
body.appendChild(img);
|
||||
|
||||
if (prompt) {
|
||||
@@ -1947,13 +1983,37 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
if (ev.output && ev.output.trim()) {
|
||||
outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(ev.output)}</pre></details>`;
|
||||
}
|
||||
if (ev.screenshot) {
|
||||
outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(ev.screenshot)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
|
||||
const screenshotSrc = safeToolScreenshotSrc(ev.screenshot);
|
||||
if (screenshotSrc) {
|
||||
outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(screenshotSrc)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
|
||||
}
|
||||
// File-write/edit diff (persisted in the tool event) \u2014 re-render it
|
||||
// so it survives reload, matching the live stream.
|
||||
let evDiffHtml = '';
|
||||
if (ev.diff && ev.diff.text) {
|
||||
const d = ev.diff;
|
||||
const stat = [
|
||||
d.new_file ? '<span class="diff-stat-new">new</span>' : '',
|
||||
d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '',
|
||||
d.removed ? `<span class="diff-stat-del">\u2212${d.removed}</span>` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const rows = d.text.split('\n').map(line => {
|
||||
let cls = 'diff-ctx', text = line;
|
||||
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
|
||||
else if (line.startsWith('@@')) cls = 'diff-hunk';
|
||||
// Drop the leading diff marker (+/-/space) — colour encodes add/del.
|
||||
else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); }
|
||||
else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); }
|
||||
else if (line.startsWith(' ')) { text = line.slice(1); }
|
||||
return `<span class="${cls}">${esc(text) || ' '}</span>`;
|
||||
}).join(''); // spans are display:block \u2014 a literal \n would double-space
|
||||
evDiffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`;
|
||||
}
|
||||
const node = document.createElement('div');
|
||||
node.className = 'agent-thread-node' + (ok ? '' : ' error');
|
||||
const evCmdHtml = ev.command ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : '';
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(ev.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}</div>`;
|
||||
// Hide the raw JSON command when a diff says it better (same as live).
|
||||
const evCmdHtml = (ev.command && !(ev.diff && ev.diff.text)) ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : '';
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(ev.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}${evDiffHtml}</div>`;
|
||||
// Click handling is delegated globally \u2014 see chat.js init.
|
||||
threadWrap.appendChild(node);
|
||||
}
|
||||
@@ -2279,6 +2339,8 @@ const chatRenderer = {
|
||||
updateSessionCostUI,
|
||||
roleTimestamp,
|
||||
stripToolBlocks,
|
||||
safeToolScreenshotSrc,
|
||||
safeDisplayImageSrc,
|
||||
buildSourcesBox,
|
||||
buildFindingsBox,
|
||||
appendReportButton,
|
||||
|
||||
@@ -362,6 +362,7 @@ export function runHTML(code, panel) {
|
||||
addCloseBtn(panel);
|
||||
return;
|
||||
}
|
||||
try { win.opener = null; } catch (_) {}
|
||||
win.document.open();
|
||||
win.document.write(code);
|
||||
win.document.close();
|
||||
|
||||
@@ -1090,6 +1090,7 @@ function _exportPrint() {
|
||||
// the system print dialog — user can pick "Save as PDF" from there.
|
||||
const w = window.open('', '_blank');
|
||||
if (!w) return;
|
||||
try { w.opener = null; } catch (_) {}
|
||||
const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const html = '<!doctype html><meta charset="utf-8"><title>Compare export</title>' +
|
||||
'<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:780px;margin:32px auto;padding:0 24px;line-height:1.55;color:#222}' +
|
||||
|
||||
@@ -1195,7 +1195,7 @@ async function showModelSelector() {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'compare-probe-row';
|
||||
row.dataset.idx = 'p' + i;
|
||||
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${p.label || p.id}</span><span class="compare-probe-status"></span>`;
|
||||
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${escapeHtml(p.label || p.id)}</span><span class="compare-probe-status"></span>`;
|
||||
const waveEl = row.querySelector('.compare-probe-spinner');
|
||||
const waveFrames = WAVE_FRAMES;
|
||||
let wIdx = 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// compare/stream.js — SSE streaming to panes
|
||||
import state from './state.js';
|
||||
import { addFinishBadge } from './vote.js';
|
||||
import { getModelCost } from '../chatRenderer.js';
|
||||
import { getModelCost, safeDisplayImageSrc } from '../chatRenderer.js';
|
||||
import markdownModule from '../markdown.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import uiModule from '../ui.js';
|
||||
@@ -11,6 +11,16 @@ var escapeHtml = uiModule.esc;
|
||||
|
||||
const WAVE_FRAMES = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
|
||||
|
||||
function _safeHttpHref(raw) {
|
||||
try {
|
||||
const parsed = new URL(String(raw || '').trim(), window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return parsed.href;
|
||||
}
|
||||
} catch (_) {}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
|
||||
let _rerollPane = null;
|
||||
let _autoPreviewHtml = null;
|
||||
@@ -36,9 +46,12 @@ function _renderSearchResults(data) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'compare-search-result';
|
||||
const titleLink = document.createElement('a');
|
||||
titleLink.href = r.url || '#';
|
||||
titleLink.target = '_blank';
|
||||
titleLink.rel = 'noopener';
|
||||
const safeUrl = _safeHttpHref(r.url);
|
||||
if (safeUrl) {
|
||||
titleLink.href = safeUrl;
|
||||
titleLink.target = '_blank';
|
||||
titleLink.rel = 'noopener noreferrer';
|
||||
}
|
||||
titleLink.className = 'search-result-title';
|
||||
titleLink.textContent = r.title || 'Untitled';
|
||||
card.appendChild(titleLink);
|
||||
@@ -344,7 +357,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${escapeHtml(cmd)}</pre>` : '';
|
||||
const node = document.createElement('div');
|
||||
node.className = 'agent-thread-node running';
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${toolLabel}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${escapeHtml(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||
node.querySelector('.agent-thread-header').addEventListener('click', () => node.classList.toggle('open'));
|
||||
// Animate wave
|
||||
const waveEl = node.querySelector('.agent-thread-wave');
|
||||
@@ -363,28 +376,33 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
if (json.image_url) {
|
||||
// Stop image spinner and render generated image in pane
|
||||
if (aiMsgEl._imgSpinner) { aiMsgEl._imgSpinner.destroy(); aiMsgEl._imgSpinner = null; }
|
||||
const safeImageUrl = safeDisplayImageSrc(json.image_url);
|
||||
aiBody.innerHTML = '';
|
||||
const img = document.createElement('img');
|
||||
img.className = 'compare-gen-image';
|
||||
img.src = json.image_url;
|
||||
img.alt = json.image_prompt || '';
|
||||
img.title = json.image_prompt || '';
|
||||
img.addEventListener('click', () => window.open(img.src, '_blank'));
|
||||
aiBody.appendChild(img);
|
||||
if (json.image_prompt) {
|
||||
const caption = document.createElement('div');
|
||||
caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
|
||||
caption.textContent = json.image_prompt;
|
||||
aiBody.appendChild(caption);
|
||||
if (!safeImageUrl) {
|
||||
aiBody.textContent = '[Image unavailable]';
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'compare-gen-image';
|
||||
img.src = safeImageUrl;
|
||||
img.alt = json.image_prompt || '';
|
||||
img.title = json.image_prompt || '';
|
||||
img.addEventListener('click', () => window.open(safeImageUrl, '_blank', 'noopener,noreferrer'));
|
||||
aiBody.appendChild(img);
|
||||
if (json.image_prompt) {
|
||||
const caption = document.createElement('div');
|
||||
caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
|
||||
caption.textContent = json.image_prompt;
|
||||
aiBody.appendChild(caption);
|
||||
}
|
||||
// Show model name below image (hidden in blind mode until vote)
|
||||
if (json.image_model && !state._blindMode) {
|
||||
const modelLabel = document.createElement('div');
|
||||
modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
|
||||
modelLabel.textContent = json.image_model;
|
||||
aiBody.appendChild(modelLabel);
|
||||
}
|
||||
aiMsgEl._imageData = { url: safeImageUrl, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
|
||||
}
|
||||
// Show model name below image (hidden in blind mode until vote)
|
||||
if (json.image_model && !state._blindMode) {
|
||||
const modelLabel = document.createElement('div');
|
||||
modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
|
||||
modelLabel.textContent = json.image_model;
|
||||
aiBody.appendChild(modelLabel);
|
||||
}
|
||||
aiMsgEl._imageData = { url: json.image_url, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
|
||||
} else if (currentToolBlock) {
|
||||
// Stop wave animation
|
||||
if (currentToolBlock._waveInterval) { clearInterval(currentToolBlock._waveInterval); currentToolBlock._waveInterval = null; }
|
||||
|
||||
@@ -2246,7 +2246,9 @@ import * as Modals from './modalManager.js';
|
||||
// WYSIWYG body — use it verbatim. (Checking a leading '<' isn't enough: a
|
||||
// rich body often starts with plain text, e.g. "Hi <b>there</b>".)
|
||||
if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) return t;
|
||||
try { return markdownModule.mdToHtml(text); }
|
||||
// Email body: keep author-typed `:shortcode:` text literal. Issue #345
|
||||
// (shortcode → emoji) is scoped to chat; do not rewrite colons in mail.
|
||||
try { return markdownModule.mdToHtml(text, { shortcodes: false }); }
|
||||
catch (_) {
|
||||
const d = document.createElement('div'); d.textContent = text;
|
||||
return d.innerHTML.replace(/\n/g, '<br>');
|
||||
@@ -8386,7 +8388,7 @@ import * as Modals from './modalManager.js';
|
||||
const text = textarea.value || '';
|
||||
let body;
|
||||
if (lang === 'markdown' && markdownModule?.mdToHtml) {
|
||||
body = markdownModule.mdToHtml(text);
|
||||
body = markdownModule.mdToHtml(text, { shortcodes: false }); // export: keep :shortcodes: literal
|
||||
} else {
|
||||
body = '<pre style="white-space:pre-wrap;font-size:12px;font-family:monospace;">' +
|
||||
text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</pre>';
|
||||
@@ -8417,7 +8419,7 @@ import * as Modals from './modalManager.js';
|
||||
// Render content as HTML for PDF
|
||||
let html;
|
||||
if (lang === 'markdown' && markdownModule?.mdToHtml) {
|
||||
html = markdownModule.mdToHtml(text);
|
||||
html = markdownModule.mdToHtml(text, { shortcodes: false }); // export: keep :shortcodes: literal
|
||||
} else {
|
||||
html = '<pre style="white-space:pre-wrap;font-size:11px;font-family:monospace;color:#000;background:#fff;">' +
|
||||
text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</pre>';
|
||||
@@ -8547,7 +8549,7 @@ import * as Modals from './modalManager.js';
|
||||
if (active) {
|
||||
const md = textarea.value || '';
|
||||
if (markdownModule && markdownModule.mdToHtml) {
|
||||
preview.innerHTML = markdownModule.mdToHtml(md);
|
||||
preview.innerHTML = markdownModule.mdToHtml(md, { shortcodes: false }); // doc preview: keep :shortcodes: literal
|
||||
} else {
|
||||
preview.innerHTML = md.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
@@ -76,6 +76,15 @@ function _hlSearch(text) {
|
||||
'<mark class="doclib-search-hl">$1</mark>');
|
||||
} catch { return esc; }
|
||||
}
|
||||
|
||||
function _safeResearchHref(raw) {
|
||||
try {
|
||||
const parsed = new URL(String(raw || '').trim(), window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href);
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
|
||||
let _libraryEscHandler = null;
|
||||
let _librarySelectMode = false;
|
||||
let _librarySelectedIds = new Set();
|
||||
@@ -2649,7 +2658,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
const data = await res.json();
|
||||
_researchItems = data.research || data || [];
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div class="hwfit-loading">Failed to load: ${e.message}</div>`;
|
||||
grid.innerHTML = `<div class="hwfit-loading">Failed to load: ${_esc(e.message)}</div>`;
|
||||
return;
|
||||
}
|
||||
_renderResearchGrid();
|
||||
@@ -2691,9 +2700,9 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
const sources = Array.isArray(detail.sources) ? detail.sources : [];
|
||||
const sourcesList = sources.slice(0, 12).map((src, i) => {
|
||||
const title = _esc(src.title || src.url || `Source ${i + 1}`);
|
||||
const url = src.url || '';
|
||||
const url = _safeResearchHref(src.url);
|
||||
return url
|
||||
? `<li><a href="${_esc(url)}" target="_blank" rel="noopener">${title}</a></li>`
|
||||
? `<li><a href="${url}" target="_blank" rel="noopener">${title}</a></li>`
|
||||
: `<li>${title}</li>`;
|
||||
}).join('');
|
||||
const sourcesHtml = sources.length
|
||||
|
||||
@@ -30,6 +30,28 @@ export function _esc(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function _attrEsc(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/`/g, '`');
|
||||
}
|
||||
|
||||
function _compactUrlSchemeValue(value) {
|
||||
return String(value || '').replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function _isDangerousUrl(value) {
|
||||
const compact = _compactUrlSchemeValue(value);
|
||||
return compact.startsWith('javascript:') || compact.startsWith('vbscript:') || compact.startsWith('data:');
|
||||
}
|
||||
|
||||
function _isDangerousSrcset(value) {
|
||||
return String(value || '').split(',').some(candidate => _isDangerousUrl(candidate));
|
||||
}
|
||||
|
||||
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
|
||||
export function _escLinkify(text) {
|
||||
const escaped = _esc(text);
|
||||
@@ -39,9 +61,9 @@ export function _escLinkify(text) {
|
||||
return escaped
|
||||
.replace(urlRe, (m) => {
|
||||
const href = m.startsWith('www.') ? `https://${m}` : m;
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${m}</a>`;
|
||||
return `<a href="${_attrEsc(href)}" target="_blank" rel="noopener noreferrer">${m}</a>`;
|
||||
})
|
||||
.replace(mailRe, (m) => `<a href="mailto:${m}">${m}</a>`);
|
||||
.replace(mailRe, (m) => `<a href="${_attrEsc(`mailto:${m}`)}">${m}</a>`);
|
||||
}
|
||||
|
||||
// Pull display name out of "Name <email@x>"; fallback to local-part of
|
||||
@@ -133,19 +155,14 @@ export function _initials(s) {
|
||||
// `data:` URLs on every known URL attribute, scrubs inline colour/font/
|
||||
// position styles so the theme can take over, and wraps highlight-bearing
|
||||
// inline tags in <mark> so they render legibly across themes.
|
||||
export function _sanitizeHtml(html) {
|
||||
function _sanitizeHtmlOnce(html) {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll(
|
||||
'script, iframe, object, embed, form, style, link, ' +
|
||||
'svg, math, base, meta, noscript, frame, frameset, applet, portal'
|
||||
).forEach(el => el.remove());
|
||||
|
||||
const URL_ATTRS = ['href', 'src', 'srcset', 'action', 'formaction', 'background', 'poster', 'data'];
|
||||
const isDangerousUrl = (val) => {
|
||||
if (!val) return false;
|
||||
const v = val.trim().toLowerCase();
|
||||
return v.startsWith('javascript:') || v.startsWith('vbscript:') || v.startsWith('data:');
|
||||
};
|
||||
const URL_ATTRS = ['href', 'src', 'xlink:href', 'srcset', 'action', 'formaction', 'background', 'poster', 'data'];
|
||||
|
||||
const STRIP_CSS_PROPS = ['color', 'background', 'background-color',
|
||||
'font-family', 'font', '-webkit-text-fill-color',
|
||||
@@ -160,7 +177,7 @@ export function _sanitizeHtml(html) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name.startsWith('on')) { el.removeAttribute(attr.name); continue; }
|
||||
if (name === 'srcdoc') { el.removeAttribute(attr.name); continue; }
|
||||
if (URL_ATTRS.includes(name) && isDangerousUrl(attr.value)) {
|
||||
if (URL_ATTRS.includes(name) && (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value))) {
|
||||
el.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
@@ -177,8 +194,8 @@ export function _sanitizeHtml(html) {
|
||||
if (style) {
|
||||
const kept = style.split(';').map(s => s.trim()).filter(decl => {
|
||||
if (!decl) return false;
|
||||
const lower = decl.toLowerCase();
|
||||
if (lower.includes('javascript:') || lower.includes('expression(')) return false;
|
||||
const lower = _compactUrlSchemeValue(decl);
|
||||
if (lower.includes('javascript:') || lower.includes('vbscript:') || lower.includes('data:') || lower.includes('expression(')) return false;
|
||||
const prop = decl.split(':', 1)[0].trim().toLowerCase();
|
||||
return !STRIP_CSS_PROPS.includes(prop);
|
||||
});
|
||||
@@ -200,3 +217,13 @@ export function _sanitizeHtml(html) {
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
export function _sanitizeHtml(html) {
|
||||
let out = String(html ?? '');
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const next = _sanitizeHtmlOnce(out);
|
||||
if (next === out) break;
|
||||
out = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
458
static/js/emojiShortcodes.js
Normal file
458
static/js/emojiShortcodes.js
Normal file
@@ -0,0 +1,458 @@
|
||||
// static/js/emojiShortcodes.js
|
||||
//
|
||||
// Emoji shortcode → Unicode conversion (issue #345).
|
||||
//
|
||||
// Chat models frequently emit GitHub/Slack-style `:shortcode:` text — e.g.
|
||||
// `:blush:`, `:fire:`, `:microphone:` — instead of the actual emoji character.
|
||||
// Nothing in the render pipeline used to translate these, so they showed up as
|
||||
// literal `:blush:` text in the chat bubble.
|
||||
//
|
||||
// This module turns the common shortcode set into the real Unicode emoji. The
|
||||
// chat renderer (markdown.js → svgifyEmoji) runs this BEFORE its existing
|
||||
// Unicode-emoji → monochrome-SVG pass, so a converted `:blush:` renders as the
|
||||
// same theme-tinted single-color line icon as any other emoji (project rule:
|
||||
// never colorful emoji), not as a colored system glyph.
|
||||
//
|
||||
// Pure and browser-free on purpose: no DOM, no imports, so it can be unit
|
||||
// tested with plain `node` (see tests/test_emoji_shortcodes_js.py).
|
||||
|
||||
// Canonical map of common shortcode → Unicode emoji. Names follow the GitHub
|
||||
// convention (lowercase, underscore-separated). A handful of well-known aliases
|
||||
// (`+1`, `thumbsup`, `grinning_face`, …) point at the same glyph so the most
|
||||
// frequent model spellings all resolve.
|
||||
export const EMOJI_SHORTCODES = {
|
||||
// ── Smileys & emotion ──
|
||||
grinning: '😀', grinning_face: '😀',
|
||||
smiley: '😃', smiley_face: '😃',
|
||||
smile: '😄',
|
||||
grin: '😁',
|
||||
laughing: '😆', satisfied: '😆',
|
||||
sweat_smile: '😅',
|
||||
rofl: '🤣', rolling_on_the_floor_laughing: '🤣',
|
||||
joy: '😂',
|
||||
slightly_smiling_face: '🙂', slight_smile: '🙂',
|
||||
upside_down_face: '🙃', upside_down: '🙃',
|
||||
wink: '😉', winking_face: '😉',
|
||||
blush: '😊', smiling_face_with_smiling_eyes: '😊',
|
||||
innocent: '😇',
|
||||
smiling_face_with_three_hearts: '🥰',
|
||||
heart_eyes: '😍', heart_eyes_face: '😍',
|
||||
star_struck: '🤩',
|
||||
kissing_heart: '😘',
|
||||
kissing: '😗',
|
||||
kissing_closed_eyes: '😚',
|
||||
kissing_smiling_eyes: '😙',
|
||||
yum: '😋',
|
||||
stuck_out_tongue: '😛',
|
||||
stuck_out_tongue_winking_eye: '😜',
|
||||
zany_face: '🤪',
|
||||
stuck_out_tongue_closed_eyes: '😝',
|
||||
money_mouth_face: '🤑',
|
||||
hugs: '🤗', hugging_face: '🤗',
|
||||
hand_over_mouth: '🤭',
|
||||
shushing_face: '🤫',
|
||||
thinking: '🤔', thinking_face: '🤔',
|
||||
zipper_mouth_face: '🤐',
|
||||
raised_eyebrow: '🤨',
|
||||
neutral_face: '😐',
|
||||
expressionless: '😑',
|
||||
no_mouth: '😶',
|
||||
smirk: '😏', smirk_face: '😏',
|
||||
unamused: '😒',
|
||||
roll_eyes: '🙄', face_with_rolling_eyes: '🙄',
|
||||
grimacing: '😬',
|
||||
lying_face: '🤥',
|
||||
relieved: '😌',
|
||||
pensive: '😔',
|
||||
sleepy: '😪',
|
||||
drooling_face: '🤤',
|
||||
sleeping: '😴',
|
||||
mask: '😷',
|
||||
face_with_thermometer: '🤒',
|
||||
face_with_head_bandage: '🤕',
|
||||
nauseated_face: '🤢',
|
||||
vomiting_face: '🤮',
|
||||
sneezing_face: '🤧',
|
||||
hot_face: '🥵',
|
||||
cold_face: '🥶',
|
||||
woozy_face: '🥴',
|
||||
dizzy_face: '😵',
|
||||
exploding_head: '🤯',
|
||||
cowboy_hat_face: '🤠',
|
||||
partying_face: '🥳',
|
||||
sunglasses: '😎',
|
||||
nerd_face: '🤓',
|
||||
monocle_face: '🧐',
|
||||
confused: '😕',
|
||||
worried: '😟',
|
||||
slightly_frowning_face: '🙁',
|
||||
frowning_face: '☹️',
|
||||
open_mouth: '😮',
|
||||
hushed: '😯',
|
||||
astonished: '😲',
|
||||
flushed: '😳',
|
||||
pleading_face: '🥺',
|
||||
frowning: '😦',
|
||||
anguished: '😧',
|
||||
fearful: '😨',
|
||||
cold_sweat: '😰',
|
||||
disappointed_relieved: '😥',
|
||||
cry: '😢',
|
||||
sob: '😭',
|
||||
scream: '😱',
|
||||
confounded: '😖',
|
||||
persevere: '😣',
|
||||
disappointed: '😞',
|
||||
sweat: '😓',
|
||||
weary: '😩',
|
||||
tired_face: '😫',
|
||||
yawning_face: '🥱',
|
||||
triumph: '😤',
|
||||
rage: '😡', pout: '😡', pouting_face: '😡',
|
||||
angry: '😠',
|
||||
cursing_face: '🤬',
|
||||
smiling_imp: '😈',
|
||||
imp: '👿',
|
||||
skull: '💀',
|
||||
skull_and_crossbones: '☠️',
|
||||
hankey: '💩', poop: '💩', shit: '💩',
|
||||
clown_face: '🤡',
|
||||
japanese_ogre: '👹',
|
||||
japanese_goblin: '👺',
|
||||
ghost: '👻',
|
||||
alien: '👽',
|
||||
space_invader: '👾',
|
||||
robot: '🤖', robot_face: '🤖',
|
||||
// ── Cats ──
|
||||
smiley_cat: '😺',
|
||||
smile_cat: '😸',
|
||||
joy_cat: '😹',
|
||||
heart_eyes_cat: '😻',
|
||||
smirk_cat: '😼',
|
||||
kissing_cat: '😽',
|
||||
scream_cat: '🙀',
|
||||
crying_cat_face: '😿',
|
||||
pouting_cat: '😾',
|
||||
see_no_evil: '🙈',
|
||||
hear_no_evil: '🙉',
|
||||
speak_no_evil: '🙊',
|
||||
// ── Hands & body ──
|
||||
wave: '👋', wave_hand: '👋',
|
||||
raised_back_of_hand: '🤚',
|
||||
raised_hand_with_fingers_splayed: '🖐️',
|
||||
hand: '✋', raised_hand: '✋',
|
||||
vulcan_salute: '🖖',
|
||||
ok_hand: '👌',
|
||||
pinched_fingers: '🤌',
|
||||
pinching_hand: '🤏',
|
||||
v: '✌️', victory_hand: '✌️',
|
||||
crossed_fingers: '🤞',
|
||||
love_you_gesture: '🤟',
|
||||
metal: '🤘',
|
||||
call_me_hand: '🤙',
|
||||
point_left: '👈',
|
||||
point_right: '👉',
|
||||
point_up_2: '👆',
|
||||
middle_finger: '🖕', fu: '🖕',
|
||||
point_down: '👇',
|
||||
point_up: '☝️',
|
||||
'+1': '👍', thumbsup: '👍', thumbup: '👍', thumbs_up: '👍',
|
||||
'-1': '👎', thumbsdown: '👎', thumbdown: '👎', thumbs_down: '👎',
|
||||
fist_raised: '✊', fist: '✊',
|
||||
fist_oncoming: '👊', facepunch: '👊', punch: '👊',
|
||||
fist_left: '🤛',
|
||||
fist_right: '🤜',
|
||||
clap: '👏', clapping_hands: '👏',
|
||||
raised_hands: '🙌',
|
||||
open_hands: '👐',
|
||||
palms_up_together: '🤲',
|
||||
handshake: '🤝',
|
||||
pray: '🙏', folded_hands: '🙏',
|
||||
writing_hand: '✍️',
|
||||
nail_care: '💅',
|
||||
selfie: '🤳',
|
||||
muscle: '💪', flexed_biceps: '💪',
|
||||
// ── Hearts & symbols of feeling ──
|
||||
heart: '❤️', red_heart: '❤️',
|
||||
orange_heart: '🧡',
|
||||
yellow_heart: '💛',
|
||||
green_heart: '💚',
|
||||
blue_heart: '💙',
|
||||
purple_heart: '💜',
|
||||
black_heart: '🖤',
|
||||
white_heart: '🤍',
|
||||
brown_heart: '🤎',
|
||||
broken_heart: '💔',
|
||||
heart_on_fire: '❤️🔥',
|
||||
two_hearts: '💕',
|
||||
revolving_hearts: '💞',
|
||||
heartbeat: '💓',
|
||||
heartpulse: '💗',
|
||||
sparkling_heart: '💖',
|
||||
cupid: '💘',
|
||||
gift_heart: '💝',
|
||||
heart_decoration: '💟',
|
||||
heavy_heart_exclamation: '❣️',
|
||||
// ── Celebration & misc objects ──
|
||||
fire: '🔥', flame: '🔥',
|
||||
'100': '💯', hundred: '💯',
|
||||
sparkles: '✨',
|
||||
star: '⭐',
|
||||
star2: '🌟', glowing_star: '🌟',
|
||||
dizzy: '💫',
|
||||
boom: '💥', collision: '💥',
|
||||
anger: '💢',
|
||||
sweat_drops: '💦',
|
||||
dash: '💨',
|
||||
zzz: '💤',
|
||||
tada: '🎉', party_popper: '🎉',
|
||||
confetti_ball: '🎊',
|
||||
balloon: '🎈',
|
||||
gift: '🎁',
|
||||
trophy: '🏆',
|
||||
'1st_place_medal': '🥇',
|
||||
'2nd_place_medal': '🥈',
|
||||
'3rd_place_medal': '🥉',
|
||||
medal_sports: '🏅',
|
||||
zap: '⚡', lightning: '⚡',
|
||||
bulb: '💡', light_bulb: '💡',
|
||||
key: '🔑',
|
||||
lock: '🔒',
|
||||
unlock: '🔓',
|
||||
bell: '🔔',
|
||||
no_bell: '🔕',
|
||||
loudspeaker: '📢',
|
||||
mega: '📣', megaphone: '📣',
|
||||
speech_balloon: '💬',
|
||||
thought_balloon: '💭',
|
||||
white_check_mark: '✅',
|
||||
heavy_check_mark: '✔️', check_mark: '✔️',
|
||||
ballot_box_with_check: '☑️',
|
||||
x: '❌', cross_mark: '❌',
|
||||
negative_squared_cross_mark: '❎',
|
||||
question: '❓',
|
||||
grey_question: '❔',
|
||||
exclamation: '❗', heavy_exclamation_mark: '❗',
|
||||
grey_exclamation: '❕',
|
||||
warning: '⚠️',
|
||||
no_entry: '⛔',
|
||||
no_entry_sign: '🚫',
|
||||
red_circle: '🔴',
|
||||
green_circle: '🟢',
|
||||
large_blue_circle: '🔵',
|
||||
yellow_circle: '🟡',
|
||||
white_circle: '⚪',
|
||||
black_circle: '⚫',
|
||||
orange_circle: '🟠',
|
||||
purple_circle: '🟣',
|
||||
brown_circle: '🟤',
|
||||
// ── Tech, work, study ──
|
||||
rocket: '🚀',
|
||||
eyes: '👀',
|
||||
eye: '👁️',
|
||||
brain: '🧠',
|
||||
books: '📚',
|
||||
book: '📖', open_book: '📖',
|
||||
memo: '📝', pencil: '📝',
|
||||
pencil2: '✏️',
|
||||
page_facing_up: '📄',
|
||||
paperclip: '📎',
|
||||
pushpin: '📌',
|
||||
round_pushpin: '📍',
|
||||
link: '🔗',
|
||||
bar_chart: '📊',
|
||||
chart_with_upwards_trend: '📈',
|
||||
chart_with_downwards_trend: '📉',
|
||||
mag: '🔍',
|
||||
mag_right: '🔎',
|
||||
globe_with_meridians: '🌐',
|
||||
earth_africa: '🌍',
|
||||
earth_americas: '🌎',
|
||||
earth_asia: '🌏',
|
||||
alarm_clock: '⏰',
|
||||
hourglass_flowing_sand: '⏳',
|
||||
hourglass: '⌛',
|
||||
microphone: '🎤', mic: '🎤',
|
||||
musical_note: '🎵',
|
||||
notes: '🎶', musical_notes: '🎶',
|
||||
headphones: '🎧',
|
||||
camera: '📷',
|
||||
camera_flash: '📸',
|
||||
clapper: '🎬',
|
||||
tv: '📺',
|
||||
computer: '💻', laptop: '💻',
|
||||
desktop_computer: '🖥️',
|
||||
iphone: '📱', mobile_phone: '📱',
|
||||
telephone: '☎️',
|
||||
wrench: '🔧',
|
||||
hammer: '🔨',
|
||||
gear: '⚙️',
|
||||
nut_and_bolt: '🔩',
|
||||
magnet: '🧲',
|
||||
test_tube: '🧪',
|
||||
microscope: '🔬',
|
||||
dart: '🎯', bullseye: '🎯',
|
||||
game_die: '🎲',
|
||||
jigsaw: '🧩',
|
||||
// ── Food & drink ──
|
||||
pizza: '🍕',
|
||||
hamburger: '🍔',
|
||||
fries: '🍟',
|
||||
taco: '🌮',
|
||||
sushi: '🍣',
|
||||
doughnut: '🍩', donut: '🍩',
|
||||
coffee: '☕',
|
||||
beer: '🍺',
|
||||
wine_glass: '🍷',
|
||||
// ── Animals & nature ──
|
||||
dog: '🐶',
|
||||
cat: '🐱',
|
||||
mouse: '🐭',
|
||||
hamster: '🐹',
|
||||
rabbit: '🐰',
|
||||
fox_face: '🦊',
|
||||
bear: '🐻',
|
||||
panda_face: '🐼',
|
||||
koala: '🐨',
|
||||
tiger: '🐯',
|
||||
lion: '🦁',
|
||||
cow: '🐮',
|
||||
pig: '🐷',
|
||||
frog: '🐸',
|
||||
monkey_face: '🐵',
|
||||
chicken: '🐔',
|
||||
penguin: '🐧',
|
||||
bird: '🐦',
|
||||
eagle: '🦅',
|
||||
duck: '🦆',
|
||||
owl: '🦉',
|
||||
wolf: '🐺',
|
||||
horse: '🐴',
|
||||
unicorn: '🦄',
|
||||
bee: '🐝', honeybee: '🐝',
|
||||
bug: '🐛',
|
||||
butterfly: '🦋',
|
||||
snail: '🐌',
|
||||
lady_beetle: '🐞',
|
||||
snake: '🐍',
|
||||
turtle: '🐢',
|
||||
octopus: '🐙',
|
||||
crab: '🦀',
|
||||
tropical_fish: '🐠',
|
||||
whale: '🐳',
|
||||
shark: '🦈',
|
||||
cherry_blossom: '🌸',
|
||||
rose: '🌹',
|
||||
sunflower: '🌻',
|
||||
hibiscus: '🌺',
|
||||
tulip: '🌷',
|
||||
seedling: '🌱',
|
||||
evergreen_tree: '🌲',
|
||||
deciduous_tree: '🌳',
|
||||
four_leaf_clover: '🍀',
|
||||
apple: '🍎',
|
||||
green_apple: '🍏',
|
||||
pear: '🍐',
|
||||
tangerine: '🍊',
|
||||
lemon: '🍋',
|
||||
banana: '🍌',
|
||||
watermelon: '🍉',
|
||||
grapes: '🍇',
|
||||
strawberry: '🍓',
|
||||
blueberries: '🫐',
|
||||
peach: '🍑',
|
||||
rainbow: '🌈',
|
||||
sunny: '☀️', sun: '☀️',
|
||||
partly_sunny: '⛅',
|
||||
cloud: '☁️',
|
||||
snowflake: '❄️',
|
||||
ocean: '🌊',
|
||||
// ── Arrows & signs ──
|
||||
arrow_right: '➡️',
|
||||
arrow_left: '⬅️',
|
||||
arrow_up: '⬆️',
|
||||
arrow_down: '⬇️',
|
||||
arrow_upper_right: '↗️',
|
||||
arrow_lower_right: '↘️',
|
||||
arrow_lower_left: '↙️',
|
||||
arrow_upper_left: '↖️',
|
||||
leftwards_arrow_with_hook: '↩️',
|
||||
arrow_right_hook: '↪️',
|
||||
arrows_counterclockwise: '🔄',
|
||||
arrows_clockwise: '🔃',
|
||||
heavy_plus_sign: '➕',
|
||||
heavy_minus_sign: '➖',
|
||||
heavy_division_sign: '➗',
|
||||
heavy_multiplication_x: '✖️',
|
||||
infinity: '♾️',
|
||||
copyright: '©️',
|
||||
registered: '®️',
|
||||
tm: '™️',
|
||||
recycle: '♻️',
|
||||
checkered_flag: '🏁',
|
||||
triangular_flag_on_post: '🚩',
|
||||
white_flag: '🏳️',
|
||||
black_flag: '🏴',
|
||||
// ── People & wearables ──
|
||||
baby: '👶',
|
||||
boy: '👦',
|
||||
girl: '👧',
|
||||
man: '👨',
|
||||
woman: '👩',
|
||||
older_man: '👴',
|
||||
older_woman: '👵',
|
||||
crown: '👑',
|
||||
gem: '💎',
|
||||
graduation_cap: '🎓', mortar_board: '🎓',
|
||||
};
|
||||
|
||||
// `:name:` where name is letters/digits/`_`/`+`/`-`. Length ≥1 so `:+1:` and
|
||||
// `:-1:` match. Global + case-insensitive for replace; a separate non-global
|
||||
// literal is used for the cheap presence check so there's no shared lastIndex
|
||||
// state to reset.
|
||||
const SHORTCODE_RE = /:([a-z0-9_+-]{1,40}):/gi;
|
||||
|
||||
/**
|
||||
* Cheap test for whether `text` could contain any emoji shortcode at all.
|
||||
* Lets callers skip the replace pass entirely on the common no-shortcode path.
|
||||
*/
|
||||
export function hasEmojiShortcode(text) {
|
||||
return !!text && text.indexOf(':') !== -1 && /:[a-z0-9_+-]{1,40}:/i.test(text);
|
||||
}
|
||||
|
||||
// A shortcode must stand on its own — flanked by whitespace, punctuation, a
|
||||
// string edge, or markup, never glued to an ASCII word character. Without this
|
||||
// guard, real `:name:` shortcodes that happen to sit inside a longer run of
|
||||
// digits/letters get converted by mistake and mangle perfectly literal text:
|
||||
// "1:100:2" → the `:100:` would become 💯 ("1💯2")
|
||||
// "host:fire:port", URL authorities, `key:value:` pairs, etc.
|
||||
// Chat models always emit shortcodes delimited by spaces/punctuation (":fire:",
|
||||
// "**:microphone:**", "nice :tada:!"), so requiring a boundary keeps every real
|
||||
// shortcode working while leaving embedded colon runs untouched. `_` counts as a
|
||||
// word char too (identifier-like), but `+`/`-` do not, so "C++ :fire:" still works.
|
||||
const _WORDISH = /[A-Za-z0-9_]/;
|
||||
function _boundedOnBothSides(str, start, end) {
|
||||
const before = start > 0 ? str[start - 1] : '';
|
||||
const after = end < str.length ? str[end] : '';
|
||||
return !_WORDISH.test(before) && !_WORDISH.test(after);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every known `:shortcode:` in `text` with its Unicode emoji. Unknown
|
||||
* shortcodes (`:definitely_not_emoji:`), colon runs that don't form a shortcode
|
||||
* (`10:30:45`, `16:9`), and known shortcodes embedded mid-token (`1:100:2`) are
|
||||
* all left exactly as-is.
|
||||
*/
|
||||
export function replaceEmojiShortcodes(text) {
|
||||
if (!text || text.indexOf(':') === -1) return text;
|
||||
return text.replace(SHORTCODE_RE, (whole, name, offset, str) => {
|
||||
const key = name.toLowerCase();
|
||||
if (!Object.prototype.hasOwnProperty.call(EMOJI_SHORTCODES, key)) return whole;
|
||||
// Only convert when the `:shortcode:` is a standalone token, not glued to a
|
||||
// surrounding word/number (which would mean it's literal text, not an emoji).
|
||||
if (!_boundedOnBothSides(str, offset, offset + whole.length)) return whole;
|
||||
return EMOJI_SHORTCODES[key];
|
||||
});
|
||||
}
|
||||
|
||||
export default { EMOJI_SHORTCODES, replaceEmojiShortcodes, hasEmojiShortcode };
|
||||
@@ -676,7 +676,7 @@ function _createGroupBubble(model, box) {
|
||||
// Role label — use character name if assigned, otherwise model name
|
||||
const roleLabel = model._groupName || (model.character ? model.character.characterName : chatRenderer.shortModel(model.mid));
|
||||
const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
wrap.innerHTML = `<div class="role">${roleLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
wrap.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
chatRenderer.applyModelColor(wrap.querySelector('.role'), model.mid);
|
||||
|
||||
// Spinner — identical to chat.js line 3062
|
||||
@@ -860,11 +860,14 @@ async function _streamToHolder(modelIdx, sessionId, msg, holderEl, abortCtrl) {
|
||||
}
|
||||
// Generated image
|
||||
else if (json.type === 'generated_image' && json.url) {
|
||||
const img = document.createElement('img');
|
||||
img.src = json.url;
|
||||
img.style.cssText = 'max-width:100%;border-radius:8px;margin:8px 0;';
|
||||
img.loading = 'lazy';
|
||||
bodyEl.appendChild(img);
|
||||
const safeImageUrl = chatRenderer.safeDisplayImageSrc(json.url);
|
||||
if (safeImageUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = safeImageUrl;
|
||||
img.style.cssText = 'max-width:100%;border-radius:8px;margin:8px 0;';
|
||||
img.loading = 'lazy';
|
||||
bodyEl.appendChild(img);
|
||||
}
|
||||
}
|
||||
// Error
|
||||
else if (json.error) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import { splitTableRow } from './markdown/tableRow.js';
|
||||
import { replaceEmojiShortcodes, hasEmojiShortcode } from './emojiShortcodes.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -60,9 +61,21 @@ const _ALLOWED_HTML_BAD_TAGS = new Set([
|
||||
'SVG', 'MATH',
|
||||
]);
|
||||
const _ALLOWED_HTML_URL_ATTRS = new Set([
|
||||
'href', 'src', 'xlink:href', 'action', 'formaction', 'background', 'poster',
|
||||
'href', 'src', 'srcset', 'xlink:href', 'action', 'formaction', 'background', 'poster',
|
||||
]);
|
||||
|
||||
function _compactUrlSchemeValue(value) {
|
||||
return String(value || '').replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function _isDangerousUrl(value) {
|
||||
return /^(javascript|vbscript|data):/.test(_compactUrlSchemeValue(value));
|
||||
}
|
||||
|
||||
function _isDangerousSrcset(value) {
|
||||
return String(value || '').split(',').some(candidate => _isDangerousUrl(candidate));
|
||||
}
|
||||
|
||||
function _cleanAllowedHtmlOnce(htmlString) {
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = htmlString;
|
||||
@@ -82,11 +95,17 @@ function _cleanAllowedHtmlOnce(htmlString) {
|
||||
el.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
if (name === 'style') {
|
||||
const value = _compactUrlSchemeValue(attr.value);
|
||||
if (/javascript:|vbscript:|data:|expression\(/.test(value)) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Neutralize javascript:/vbscript:/data: in URL-bearing attributes.
|
||||
// Strip control/space chars first so e.g. "java\tscript:" can't slip by.
|
||||
if (_ALLOWED_HTML_URL_ATTRS.has(name)) {
|
||||
const value = (attr.value || '').replace(/[\x00-\x20]+/g, '').toLowerCase();
|
||||
if (/^(javascript|vbscript|data):/.test(value)) {
|
||||
if (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value)) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
@@ -116,8 +135,13 @@ function sanitizeAllowedHtml(html) {
|
||||
* Check if text has unclosed think tag
|
||||
*/
|
||||
export function hasUnclosedThinkTag(text) {
|
||||
const openCount = (text.match(/<think(?:ing)?>/gi) || []).length;
|
||||
const closeCount = (text.match(/<\/think(?:ing)?>/gi) || []).length;
|
||||
text = text || '';
|
||||
const openCount =
|
||||
(text.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi) || []).length
|
||||
+ (text.match(/<\|channel>thought/gi) || []).length;
|
||||
const closeCount =
|
||||
(text.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length
|
||||
+ (text.match(/<channel\|>/gi) || []).length;
|
||||
return openCount > closeCount;
|
||||
}
|
||||
|
||||
@@ -125,8 +149,25 @@ export function startsWithReasoningPrefix(text) {
|
||||
return /^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )/i.test(text || '');
|
||||
}
|
||||
|
||||
export function normalizeThinkingMarkup(text) {
|
||||
if (!text) return text;
|
||||
let normalized = text;
|
||||
normalized = normalized.replace(/<thought(\s+[^>]*)?>/gi, (_m, attrs = '') => `<think${attrs || ''}>`);
|
||||
normalized = normalized.replace(/<\/thought>/gi, '</think>');
|
||||
normalized = normalized.replace(/<\|channel>thought\s*\n?([\s\S]*?)<channel\|>\s*/gi, (_m, content = '') => {
|
||||
const thought = String(content || '').trim();
|
||||
return thought ? `<think>${thought}</think>\n` : '';
|
||||
});
|
||||
normalized = normalized.replace(/<\|channel>response\s*\n?([\s\S]*?)<channel\|>/gi, (_m, content = '') => content || '');
|
||||
normalized = normalized.replace(/<\|channel>response\s*\n?/gi, '');
|
||||
normalized = normalized.replace(/<channel\|>/gi, '');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePlainThinking(text) {
|
||||
if (!text || /<think/i.test(text)) return text;
|
||||
if (!text) return text;
|
||||
text = normalizeThinkingMarkup(text);
|
||||
if (/<think/i.test(text)) return text;
|
||||
|
||||
const trimmed = text.trimStart();
|
||||
if (!startsWithReasoningPrefix(trimmed)) return text;
|
||||
@@ -220,11 +261,21 @@ export function extractThinkingBlocks(text) {
|
||||
// (b) Cut-off mid-generation — there's already real reply text before the
|
||||
// opener. Drop from the tag onward as before (it's truncated thinking).
|
||||
if (hasUnclosedThinkTag(normalized)) {
|
||||
const strayOpener = cleanContent.match(/^\s*<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*)$/i);
|
||||
if (strayOpener) {
|
||||
cleanContent = strayOpener[1];
|
||||
const gemmaThoughtStart = cleanContent.search(/<\|channel>thought/i);
|
||||
if (gemmaThoughtStart >= 0) {
|
||||
const leakedThought = cleanContent
|
||||
.slice(gemmaThoughtStart)
|
||||
.replace(/^<\|channel>thought\s*\n?/i, '')
|
||||
.trim();
|
||||
if (gemmaThoughtStart === 0 && leakedThought) thinkingBlocks.push(leakedThought);
|
||||
cleanContent = cleanContent.slice(0, gemmaThoughtStart);
|
||||
} else {
|
||||
cleanContent = cleanContent.replace(/<think(?:ing)?(?:\s+[^>]*)?>[\s\S]*$/gi, '');
|
||||
const strayOpener = cleanContent.match(/^\s*<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*)$/i);
|
||||
if (strayOpener) {
|
||||
cleanContent = strayOpener[1];
|
||||
} else {
|
||||
cleanContent = cleanContent.replace(/<think(?:ing)?(?:\s+[^>]*)?>[\s\S]*$/gi, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,8 +367,19 @@ function _useSvgEmoji() {
|
||||
return typeof document === 'undefined' || !document.body?.classList.contains('text-emojis');
|
||||
}
|
||||
|
||||
export function svgifyEmoji(html) {
|
||||
if (!_useSvgEmoji() || !html || !_EMOJI_RE.test(html)) return html;
|
||||
// `opts.shortcodes` (default true) controls the issue-#345 `:name:` → emoji
|
||||
// expansion. Chat passes it through as true; document/email body renderers pass
|
||||
// false so author-typed `:shortcode:` text stays literal (see mdToHtml callers).
|
||||
// The Unicode-emoji → monochrome-SVG pass always runs regardless, so a real 😀
|
||||
// in a document still renders as the themed line icon as it always has.
|
||||
export function svgifyEmoji(html, opts) {
|
||||
if (!_useSvgEmoji() || !html) return html;
|
||||
const allowShortcodes = !opts || opts.shortcodes !== false;
|
||||
// Two reasons to walk the HTML: real Unicode emoji to turn into SVG icons,
|
||||
// or `:shortcode:` text the model emitted instead of an emoji (issue #345).
|
||||
const hasUnicode = _EMOJI_RE.test(html);
|
||||
const hasShortcode = allowShortcodes && hasEmojiShortcode(html);
|
||||
if (!hasUnicode && !hasShortcode) return html;
|
||||
const parts = html.split(/(<[^>]*>)/); // odd indices = tags
|
||||
let codeDepth = 0;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
@@ -327,7 +389,13 @@ export function svgifyEmoji(html) {
|
||||
else if (/^<\/(pre|code)\s*>/.test(t)) codeDepth = Math.max(0, codeDepth - 1);
|
||||
continue;
|
||||
}
|
||||
if (codeDepth === 0 && _EMOJI_RE.test(parts[i])) parts[i] = _svgifyText(parts[i]);
|
||||
if (codeDepth !== 0) continue;
|
||||
let seg = parts[i];
|
||||
// Expand shortcodes to Unicode first, then both they and any pre-existing
|
||||
// Unicode emoji get rendered as the same monochrome line icons below.
|
||||
if (hasShortcode) seg = replaceEmojiShortcodes(seg);
|
||||
if (_EMOJI_RE.test(seg)) seg = _svgifyText(seg);
|
||||
parts[i] = seg;
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
@@ -371,7 +439,7 @@ export function processWithThinking(text) {
|
||||
/**
|
||||
* Convert markdown to HTML
|
||||
*/
|
||||
export function mdToHtml(src) {
|
||||
export function mdToHtml(src, opts) {
|
||||
const allowedHtmlBlocks = [];
|
||||
const codeBlocks = [];
|
||||
const mermaidBlocks = [];
|
||||
@@ -456,9 +524,11 @@ export function mdToHtml(src) {
|
||||
// allowlist keeps it from matching file names / versions ("package.json",
|
||||
// "node.js", "v1.2.3"); the required start/[\s(<] prefix means domains
|
||||
// already inside an http link (preceded by "//") or an email ("@") are
|
||||
// skipped. Trailing sentence punctuation is kept outside the link.
|
||||
// skipped. Require the TLD to end at a real domain boundary so dotted code
|
||||
// identifiers like `sklearn.metrics` do not link `sklearn.me` and leave
|
||||
// placeholder fragments in the remaining text.
|
||||
s = s.replace(
|
||||
/(^|[\s(<])((?:www\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)*\.(?:com|org|net|io|ai|co|dev|app|gov|edu|news|info|tech|xyz|me)(?:\/[^\s<>"'`\])]*)?)/gi,
|
||||
/(^|[\s(<])((?:www\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)*\.(?:com|org|net|io|ai|co|dev|app|gov|edu|news|info|tech|xyz|me)(?=$|[\/\s<>"'`\]).,;:!?])(?:\/[^\s<>"'`\])]*)?)/gi,
|
||||
(match, prefix, domain) => {
|
||||
const trail = (domain.match(/[.,;:!?)]+$/) || [''])[0];
|
||||
const core = trail ? domain.slice(0, -trail.length) : domain;
|
||||
@@ -628,7 +698,7 @@ export function mdToHtml(src) {
|
||||
s = s.replace(`___CODE_BLOCK_${index}___`, block);
|
||||
});
|
||||
|
||||
return _useSvgEmoji() ? svgifyEmoji(s) : s;
|
||||
return _useSvgEmoji() ? svgifyEmoji(s, opts) : s;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -686,6 +756,7 @@ const markdownModule = {
|
||||
createCollapsible,
|
||||
hasUnclosedThinkTag,
|
||||
extractThinkingBlocks,
|
||||
normalizeThinkingMarkup,
|
||||
startsWithReasoningPrefix,
|
||||
renderMermaid
|
||||
};
|
||||
|
||||
@@ -438,13 +438,22 @@ async function _patchNote(id, patch) {
|
||||
// ---- Helpers ----
|
||||
|
||||
function _esc(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
// Image src guard — reject anything that isn't a relative path or http(s)/data URL
|
||||
// so an AI-saved note can't slip a `javascript:` URL into the rendered <img>.
|
||||
function _attrEsc(s) {
|
||||
return String(s || '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/`/g, '`');
|
||||
}
|
||||
// Image src guard — reject anything that isn't a relative path, http(s), or
|
||||
// raster data URL so an AI-saved note can't slip script-capable media into the
|
||||
// rendered <img>.
|
||||
function _safeImgSrc(s) {
|
||||
const v = (s || '').trim();
|
||||
if (!v) return '';
|
||||
if (v.startsWith('/') || v.startsWith('./') || v.startsWith('../')) return v;
|
||||
if (/^https?:\/\//i.test(v) || /^data:image\//i.test(v)) return v;
|
||||
if (/^https?:\/\//i.test(v) || /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(v)) return v;
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -461,7 +470,7 @@ function _linkify(s) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
const href = url.startsWith('www.') ? `https://${url}` : url;
|
||||
return `<a href="${href}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
});
|
||||
}
|
||||
function _uid() { return Math.random().toString(36).slice(2, 10); }
|
||||
@@ -2779,7 +2788,7 @@ function _buildForm(note = null) {
|
||||
form.className = 'note-form';
|
||||
if (color && !_isBgImage(color)) form.classList.add('note-color-' + color);
|
||||
if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color));
|
||||
let currentImageUrl = note?.image_url || '';
|
||||
let currentImageUrl = _safeImgSrc(note?.image_url || '');
|
||||
form.innerHTML = `
|
||||
<div class="note-form-header">
|
||||
<input type="text" class="note-form-title" placeholder="Title" value="${_esc(note?.title || '')}" />
|
||||
@@ -2861,7 +2870,7 @@ function _buildForm(note = null) {
|
||||
let _stashedGoalItems = (type === 'goal' && Array.isArray(note?.items)) ? note.items.slice() : null;
|
||||
|
||||
// Drawing also stashes the saved image URL so it survives Note↔Draw flips.
|
||||
let _stashedDrawUrl = (type === 'draw') ? (note?.image_url || null) : null;
|
||||
let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null;
|
||||
const _refreshFormLayout = () => {
|
||||
const body = form.closest('.notes-pane-body');
|
||||
if (!body) return;
|
||||
@@ -2913,7 +2922,7 @@ function _buildForm(note = null) {
|
||||
// toggled to Draw, paint that photo onto the canvas so they can draw
|
||||
// on top of it. _stashedDrawUrl wins if they were drawing earlier in
|
||||
// the same edit session.
|
||||
_wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || note?.image_url || null);
|
||||
_wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || _safeImgSrc(note?.image_url) || null);
|
||||
} else {
|
||||
const text = (_stashedNoteText !== null && _stashedNoteText !== undefined && _stashedNoteText !== '')
|
||||
? _stashedNoteText
|
||||
@@ -3003,7 +3012,7 @@ function _buildForm(note = null) {
|
||||
if (currentType === 'todo') _wireChecklist(form.querySelector('.note-form-body'));
|
||||
if (currentType === 'goal') _wireGoalForm(form, form.querySelector('.note-form-body'));
|
||||
if (currentType === 'draw') {
|
||||
_wireCanvas(form.querySelector('.note-form-body'), note?.image_url || null);
|
||||
_wireCanvas(form.querySelector('.note-form-body'), _safeImgSrc(note?.image_url) || null);
|
||||
// Same hides we apply on type-switch — keep them consistent on initial open.
|
||||
const _ip = form.querySelector('.note-form-image-wrap'); if (_ip) _ip.style.display = 'none';
|
||||
const _cp = form.querySelector('.note-color-picker'); if (_cp) _cp.style.display = 'none';
|
||||
@@ -3894,11 +3903,12 @@ function _wireCanvas(container, initialImageUrl) {
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// Load prior drawing as starting point so consecutive edits compose.
|
||||
if (initialImageUrl) {
|
||||
const safeInitialImageUrl = _safeImgSrc(initialImageUrl);
|
||||
if (safeInitialImageUrl) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => { try { ctx.drawImage(img, 0, 0, cssW, cssH); } catch {} };
|
||||
img.src = initialImageUrl;
|
||||
img.src = safeInitialImageUrl;
|
||||
// Float an X over the canvas so the user can blank it out and go back to
|
||||
// a clean draw surface. Removes itself once clicked.
|
||||
const wrap = container.querySelector('.note-form-draw-wrap');
|
||||
|
||||
@@ -90,4 +90,51 @@ export function providerLogo(modelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default { providerLogo };
|
||||
// Host suffix → friendly provider label. The model-info card shows this so the
|
||||
// SAME model name served by DIFFERENT routes is distinguishable (e.g.
|
||||
// `claude-haiku` via OpenRouter vs GitHub Copilot vs Anthropic direct); the logo
|
||||
// only reflects the model vendor, not the actual endpoint. Patterns are anchored
|
||||
// to the end of the hostname (^|.)domain$ so a host like `max.airlines.com`
|
||||
// doesn't match `x.ai`.
|
||||
const _ENDPOINT_LABELS = [
|
||||
[/(^|\.)githubcopilot\.com$/i, "GitHub Copilot"],
|
||||
[/(^|\.)openrouter\.ai$/i, "OpenRouter"],
|
||||
[/(^|\.)anthropic\.com$/i, "Anthropic"],
|
||||
[/(^|\.)openai\.com$/i, "OpenAI"],
|
||||
[/(^|\.)(generativelanguage|aiplatform)\.googleapis\.com$/i, "Google"],
|
||||
[/(^|\.)bedrock[\w.-]*\.amazonaws\.com$/i, "AWS Bedrock"],
|
||||
[/(^|\.)deepseek\.com$/i, "DeepSeek"],
|
||||
[/(^|\.)mistral\.ai$/i, "Mistral"],
|
||||
[/(^|\.)groq\.com$/i, "Groq"],
|
||||
[/(^|\.)together\.(ai|xyz)$/i, "Together"],
|
||||
[/(^|\.)fireworks\.ai$/i, "Fireworks"],
|
||||
[/(^|\.)perplexity\.ai$/i, "Perplexity"],
|
||||
[/(^|\.)x\.ai$/i, "xAI"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Friendly label for the endpoint that served a model, from its URL.
|
||||
* Returns "Local" for loopback/LAN hosts, a known provider name when matched,
|
||||
* else the bare host. Null when no URL is available.
|
||||
*/
|
||||
export function providerLabel(endpointUrl) {
|
||||
if (!endpointUrl || typeof endpointUrl !== "string") return null;
|
||||
let host;
|
||||
try {
|
||||
host = new URL(endpointUrl).hostname;
|
||||
} catch (_) {
|
||||
// Not a full URL (e.g. bare host[:port]) — strip scheme/path/port best-effort.
|
||||
host = endpointUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0];
|
||||
}
|
||||
if (!host) return null;
|
||||
if (/^(localhost|127\.|0\.0\.0\.0|::1|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(host)) {
|
||||
return "Local";
|
||||
}
|
||||
for (const [re, label] of _ENDPOINT_LABELS) {
|
||||
if (re.test(host)) return label;
|
||||
}
|
||||
// Unknown host → drop a leading "api." for a cleaner readout.
|
||||
return host.replace(/^api\./i, "");
|
||||
}
|
||||
|
||||
export default { providerLogo, providerLabel };
|
||||
|
||||
@@ -1103,8 +1103,10 @@ function _renderResult(job) {
|
||||
html += '<div class="research-job-sources">';
|
||||
for (const s of job.sources.slice(0, 10)) {
|
||||
const title = _esc(s.title || s.url || '');
|
||||
const url = _esc(s.url || '');
|
||||
html += `<a href="${url}" target="_blank" rel="noopener" class="research-source-link">${title}</a>`;
|
||||
const url = _safeSourceHref(s.url);
|
||||
html += url
|
||||
? `<a href="${url}" target="_blank" rel="noopener" class="research-source-link">${title}</a>`
|
||||
: `<span class="research-source-link">${title}</span>`;
|
||||
}
|
||||
if (job.sources.length > 10) html += `<span class="research-source-more">+${job.sources.length - 10} more</span>`;
|
||||
html += '</div>';
|
||||
@@ -1231,3 +1233,11 @@ function _esc(s) {
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function _safeSourceHref(raw) {
|
||||
try {
|
||||
const parsed = new URL(String(raw || '').trim(), window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href);
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -2157,7 +2157,14 @@ async function _checkServerStream(sessionId) {
|
||||
// Skip if this is a research stream — research has its own progress UI
|
||||
if (info.mode === 'research' || info.is_research) return;
|
||||
|
||||
// Server is still streaming — show spinner and poll
|
||||
// Live-resume the detached run: replay its buffer then stream live tokens
|
||||
// (#2539). Falls back to the spinner+poll path below if unavailable.
|
||||
if (window.chatModule && window.chatModule.resumeStream) {
|
||||
const attached = await window.chatModule.resumeStream(sessionId);
|
||||
if (attached) return;
|
||||
}
|
||||
|
||||
// Fallback: server is still streaming, show spinner and poll.
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) return;
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ let modalEl = null;
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(s) { return uiModule.esc(s); }
|
||||
function safeRasterDataUrl(raw) {
|
||||
const value = String(raw || '').trim();
|
||||
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
|
||||
}
|
||||
|
||||
/* ── Tab switching ── */
|
||||
const ADMIN_TABS = new Set(['services', 'integrations', 'tools', 'users', 'system']);
|
||||
@@ -1554,6 +1558,7 @@ async function initResearchSearchSettings() {
|
||||
/* ── Agent Settings (AI tab) ── */
|
||||
async function initAgentSettings() {
|
||||
var toolsInput = el('set-agentMaxTools');
|
||||
var roundsInput = el('set-agentMaxRounds');
|
||||
var msg = el('set-agentMsg');
|
||||
if (!toolsInput) return;
|
||||
|
||||
@@ -1561,23 +1566,41 @@ async function initAgentSettings() {
|
||||
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await res.json();
|
||||
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
|
||||
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
|
||||
} catch (e) {}
|
||||
|
||||
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
|
||||
// when blank/non-numeric. Mirrors the server-side validation.
|
||||
function clampInt(raw, lo, hi, dflt) {
|
||||
var n = parseInt(raw, 10);
|
||||
if (isNaN(n)) return dflt;
|
||||
return Math.max(lo, Math.min(n, hi));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
var val = parseInt(toolsInput.value, 10) || 0;
|
||||
var tools = clampInt(toolsInput.value, 0, 1000, 0);
|
||||
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
|
||||
toolsInput.value = tools; // reflect the clamped value
|
||||
if (roundsInput) roundsInput.value = rounds;
|
||||
var payload = { agent_max_tool_calls: tools };
|
||||
if (rounds != null) payload.agent_max_rounds = rounds;
|
||||
try {
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agent_max_tool_calls: val })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
msg.textContent = val > 0 ? 'Limit: ' + val + ' tool calls per message' : 'Unlimited';
|
||||
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
|
||||
(rounds != null ? ' · ' + rounds + ' steps/message' : '');
|
||||
msg.style.color = 'var(--fg)';
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
toolsInput.addEventListener('change', save);
|
||||
if (roundsInput) roundsInput.addEventListener('change', save);
|
||||
var cur = parseInt(toolsInput.value, 10) || 0;
|
||||
msg.textContent = cur > 0 ? 'Limit: ' + cur + ' tool calls per message' : 'Unlimited';
|
||||
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
|
||||
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
|
||||
(curR != null ? ' · ' + curR + ' steps/message' : '');
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
@@ -2069,15 +2092,16 @@ function initAccount() {
|
||||
const r = await fetch('/api/auth/2fa/setup', { method: 'POST', credentials: 'same-origin' });
|
||||
if (!r.ok) { const d = await r.json(); throw new Error(d.detail || 'Failed'); }
|
||||
const setup = await r.json();
|
||||
const qrCode = safeRasterDataUrl(setup.qr_code);
|
||||
// Show QR code + manual secret + verify input
|
||||
tfaContent.innerHTML = `
|
||||
<div style="text-align:center;margin-bottom:12px;">
|
||||
<img src="${setup.qr_code}" alt="QR Code" style="border-radius:8px;max-width:200px;">
|
||||
${qrCode ? `<img src="${esc(qrCode)}" alt="QR Code" style="border-radius:8px;max-width:200px;">` : ''}
|
||||
</div>
|
||||
<div style="font-size:11px;opacity:0.5;text-align:center;margin-bottom:8px;">
|
||||
Scan with your authenticator app, or enter manually:
|
||||
</div>
|
||||
<div style="font-family:monospace;font-size:12px;text-align:center;padding:6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;margin-bottom:12px;word-break:break-all;user-select:all;cursor:text;">${setup.secret}</div>
|
||||
<div style="font-family:monospace;font-size:12px;text-align:center;padding:6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;margin-bottom:12px;word-break:break-all;user-select:all;cursor:text;">${esc(setup.secret)}</div>
|
||||
<input id="tfa-verify-code" type="text" placeholder="Enter 6-digit code to verify" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:13px;box-sizing:border-box;text-align:center;letter-spacing:3px;margin-bottom:6px;">
|
||||
<div class="settings-row" style="justify-content:flex-end;">
|
||||
<span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span>
|
||||
@@ -4424,6 +4448,68 @@ async function initUnifiedIntegrations() {
|
||||
|
||||
// ── MCP form — full management view ──
|
||||
async function showMcpForm(editId) {
|
||||
// Toggle an in-flight loading state on a button (disabled + dimmed + label).
|
||||
function _setBtnLoading(btn, loading, label) {
|
||||
if (!btn) return;
|
||||
btn.disabled = loading;
|
||||
btn.style.opacity = loading ? '0.6' : '';
|
||||
btn.style.cursor = loading ? 'progress' : '';
|
||||
if (label != null) btn.textContent = label;
|
||||
}
|
||||
function _showMcpPasteback(id) {
|
||||
const msg = el('uf-mcp-msg'); if (!msg) return;
|
||||
if (el('uf-mcp-pasteback')) return; // already shown
|
||||
msg.innerHTML =
|
||||
'Authorize in the opened tab. If the redirect fails (remote access), paste the resulting URL here: ' +
|
||||
'<input id="uf-mcp-pasteback" class="settings-input" placeholder="http://localhost:7000/api/mcp/oauth/callback?code=..." style="margin-top:4px">' +
|
||||
'<button class="admin-btn-sm" id="uf-mcp-paste-go" style="margin-top:4px">Submit</button>';
|
||||
const pasteGo = el('uf-mcp-paste-go');
|
||||
if (pasteGo) pasteGo.addEventListener('click', async () => {
|
||||
const cb = el('uf-mcp-pasteback').value.trim();
|
||||
if (!cb) return;
|
||||
const pf = new FormData(); pf.append('callback_url', cb);
|
||||
_setBtnLoading(pasteGo, true, 'Submitting…');
|
||||
try {
|
||||
await fetch(`/api/mcp/oauth/exchange/${id}`, { method: 'POST', credentials: 'same-origin', body: pf });
|
||||
} finally {
|
||||
_setBtnLoading(pasteGo, false, 'Submit');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drives the OAuth flow: waits for the auth_url (discovery+DCR may lag),
|
||||
// opens it once, then resolves on connected/error.
|
||||
async function _handleMcpAuth(id, initialAuthUrl, tries = 90) {
|
||||
let opened = false;
|
||||
const openAuth = (u) => { if (!opened && u) { opened = true; window.open(u, '_blank', 'noopener'); _showMcpPasteback(id); } };
|
||||
openAuth(initialAuthUrl);
|
||||
const msg = el('uf-mcp-msg');
|
||||
let fails = 0;
|
||||
for (let i = 0; i < tries; i++) {
|
||||
await new Promise(res => setTimeout(res, 2000));
|
||||
try {
|
||||
const r = await fetch('/api/mcp/servers', { credentials: 'same-origin' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const list = await r.json();
|
||||
fails = 0;
|
||||
const s = Array.isArray(list) ? list.find(x => x.id === id) : null;
|
||||
if (!s) continue;
|
||||
if (s.auth_url) openAuth(s.auth_url);
|
||||
if (s.status === 'connected') {
|
||||
if (msg) msg.textContent = `Connected (${s.tool_count || 0} tools)`;
|
||||
await renderList(); return;
|
||||
}
|
||||
if (s.status === 'error') {
|
||||
if (msg) msg.textContent = `Failed: ${s.error || 'unknown'}`; return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Tolerate a single blip, but surface persistent failures instead of
|
||||
// silently polling until timeout.
|
||||
if (++fails >= 5 && msg) msg.textContent = `Status check failing (${e.message || 'network error'}) — still retrying…`;
|
||||
}
|
||||
}
|
||||
if (msg) msg.textContent = 'Authorization timed out. Reconnect from the server list to retry.';
|
||||
}
|
||||
if (editId && editId !== 'new') {
|
||||
// Show management view for existing server
|
||||
formEl.innerHTML = '<div class="admin-card" style="margin-top:8px"><span style="opacity:0.5;font-size:11px">Loading...</span></div>';
|
||||
@@ -4501,7 +4587,7 @@ async function initUnifiedIntegrations() {
|
||||
<h2 style="font-size:13px">Add MCP Server</h2>
|
||||
<div class="settings-col">
|
||||
<div class="settings-row"><label class="settings-label">Name</label><input id="uf-mcp-name" class="settings-input" placeholder="Server name"></div>
|
||||
<div class="settings-row"><label class="settings-label">Transport</label><select id="uf-mcp-transport" class="settings-input"><option value="stdio">stdio</option><option value="sse">SSE</option></select></div>
|
||||
<div class="settings-row"><label class="settings-label">Transport</label><select id="uf-mcp-transport" class="settings-input"><option value="stdio">stdio</option><option value="sse">SSE</option><option value="http">Streamable HTTP</option></select></div>
|
||||
<div id="uf-mcp-stdio-fields" style="display:flex;flex-direction:column;gap:6px;">
|
||||
<div class="settings-row"><label class="settings-label">Command</label><input id="uf-mcp-cmd" class="settings-input" placeholder="npx"></div>
|
||||
<div class="settings-row"><label class="settings-label">Args</label><input id="uf-mcp-args" class="settings-input" placeholder='["-y", "@modelcontextprotocol/server-filesystem"]'></div>
|
||||
@@ -4514,9 +4600,12 @@ async function initUnifiedIntegrations() {
|
||||
</div>
|
||||
</div>`;
|
||||
el('uf-mcp-transport').addEventListener('change', () => {
|
||||
const sse = el('uf-mcp-transport').value === 'sse';
|
||||
el('uf-mcp-stdio-fields').style.display = sse ? 'none' : 'flex';
|
||||
el('uf-mcp-sse-fields').style.display = sse ? 'flex' : 'none';
|
||||
const v = el('uf-mcp-transport').value;
|
||||
const isUrl = (v === 'sse' || v === 'http');
|
||||
el('uf-mcp-stdio-fields').style.display = isUrl ? 'none' : 'flex';
|
||||
el('uf-mcp-sse-fields').style.display = isUrl ? 'flex' : 'none';
|
||||
const urlInput = el('uf-mcp-url');
|
||||
if (urlInput) urlInput.placeholder = (v === 'http') ? 'https://mcp.example.com/mcp' : 'http://localhost:3001/sse';
|
||||
});
|
||||
el('uf-mcp-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
|
||||
el('uf-mcp-save').addEventListener('click', async () => {
|
||||
@@ -4534,14 +4623,25 @@ async function initUnifiedIntegrations() {
|
||||
} else {
|
||||
fd.append('url', el('uf-mcp-url').value);
|
||||
}
|
||||
const saveBtn = el('uf-mcp-save'), cancelBtn = el('uf-mcp-cancel');
|
||||
const _origLabel = saveBtn.textContent;
|
||||
_setBtnLoading(saveBtn, true, 'Saving…'); if (cancelBtn) cancelBtn.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/api/mcp/servers', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
if (r.ok) {
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (r.ok && data.needs_auth) {
|
||||
el('uf-mcp-msg').textContent = 'Preparing authorization…';
|
||||
_handleMcpAuth(data.id, data.auth_url);
|
||||
} else if (r.ok && (data.connected || data.status === 'connected')) {
|
||||
el('uf-mcp-msg').textContent = `Connected (${data.tool_count || 0} tools)`;
|
||||
formEl.style.display = 'none'; await renderList();
|
||||
} else if (r.ok) {
|
||||
el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList();
|
||||
} else {
|
||||
el('uf-mcp-msg').textContent = `Failed (${r.status})`;
|
||||
}
|
||||
} catch (_) { el('uf-mcp-msg').textContent = 'Failed'; }
|
||||
finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,20 @@
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
function _esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function _safeSignatureDataUrl(raw) {
|
||||
const value = String(raw || '').trim();
|
||||
return /^data:image\/(?:png|jpe?g);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
|
||||
}
|
||||
|
||||
// Last signature the user picked or created in this session. Lets the export
|
||||
// modal pre-fill subsequent signature fields with the same one — sign once,
|
||||
// applies everywhere.
|
||||
@@ -446,13 +460,17 @@ export function capture(opts = {}) {
|
||||
export function pick(opts = {}) {
|
||||
return new Promise(async (resolve) => {
|
||||
const sigs = await _listSignatures();
|
||||
const tiles = sigs.map((s) => `
|
||||
<div class="sig-tile" data-id="${s.id}">
|
||||
<img src="${s.data_url}"/>
|
||||
<div style="margin-top:4px;font-size:0.72rem;color:var(--fg);opacity:0.85;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${(s.name || '').replace(/[<>&]/g, '')}</div>
|
||||
<button class="sig-tile-del" data-id="${s.id}" title="Delete">×</button>
|
||||
const tiles = sigs.map((s) => {
|
||||
const dataUrl = _safeSignatureDataUrl(s.data_url);
|
||||
if (!dataUrl) return '';
|
||||
return `
|
||||
<div class="sig-tile" data-id="${_esc(s.id)}">
|
||||
<img src="${_esc(dataUrl)}"/>
|
||||
<div style="margin-top:4px;font-size:0.72rem;color:var(--fg);opacity:0.85;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(s.name || '')}</div>
|
||||
<button class="sig-tile-del" data-id="${_esc(s.id)}" title="Delete">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const overlay = _modal(`
|
||||
<div class="modal-content" style="width:min(560px,94vw);">
|
||||
@@ -477,7 +495,9 @@ export function pick(opts = {}) {
|
||||
const id = tile.dataset.id;
|
||||
const s = sigs.find((x) => x.id === id);
|
||||
if (s) {
|
||||
const out = { id: s.id, dataUrl: s.data_url, width: s.width, height: s.height, name: s.name };
|
||||
const dataUrl = _safeSignatureDataUrl(s.data_url);
|
||||
if (!dataUrl) return;
|
||||
const out = { id: s.id, dataUrl, width: s.width, height: s.height, name: s.name };
|
||||
setLastUsed(out);
|
||||
close(out);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import chatRenderer from './chatRenderer.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import themeModule from './theme.js';
|
||||
import documentModule from './document.js';
|
||||
import workspaceModule from './workspace.js';
|
||||
import settingsModule from './settings.js';
|
||||
import cookbookModule from './cookbook.js';
|
||||
import { EVAL_PROMPTS } from './compare/index.js';
|
||||
@@ -1141,6 +1142,35 @@ async function _cmdToggleDoc(args, ctx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Workspace: confine the agent's file/shell tools to a folder. Not a boolean —
|
||||
// show / set <path> / clear / pick (open the directory browser).
|
||||
async function _cmdWorkspace(args, ctx) {
|
||||
const sub = (args[0] || '').toLowerCase();
|
||||
const rest = args.slice(1).join(' ').trim();
|
||||
const cur = workspaceModule.getWorkspace();
|
||||
if (!sub || sub === 'show' || sub === 'status' || sub === 'info') {
|
||||
slashReply(cur ? `Workspace: <code>${uiModule.esc(cur)}</code>` : 'No workspace set. <code>/workspace pick</code> or <code>/workspace set /path</code>.');
|
||||
return true;
|
||||
}
|
||||
if (sub === 'set' || sub === 'cd' || sub === 'use') {
|
||||
if (!rest) { slashReply('Usage: <code>/workspace set /absolute/path</code>'); return true; }
|
||||
workspaceModule.setWorkspace(rest);
|
||||
slashReply(`Workspace set: <code>${uiModule.esc(rest)}</code>`);
|
||||
return true;
|
||||
}
|
||||
if (sub === 'clear' || sub === 'off' || sub === 'none' || sub === 'unset') {
|
||||
workspaceModule.clearWorkspace();
|
||||
slashReply('Workspace cleared.');
|
||||
return true;
|
||||
}
|
||||
if (sub === 'pick' || sub === 'browse' || sub === 'open') {
|
||||
workspaceModule.openWorkspaceBrowser();
|
||||
return true;
|
||||
}
|
||||
slashReply('Usage: <code>/workspace</code> · <code>set /path</code> · <code>clear</code> · <code>pick</code>');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _cmdToggleShow(args, ctx) {
|
||||
const name = (args[0] || '').toLowerCase();
|
||||
const val = (args[1] || '').toLowerCase();
|
||||
@@ -4735,11 +4765,47 @@ function _clearSetupCommandInput() {
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub Copilot device-flow sign-in, driven from chat (mirrors the Settings
|
||||
// "Connect GitHub Copilot" button). Replies via the setup guide messages.
|
||||
async function _setupCopilot() {
|
||||
_clearSetupGuideMessages();
|
||||
await _setupReply('Starting GitHub Copilot sign-in…');
|
||||
let start;
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/copilot/device/start`, { method: 'POST', body: new FormData(), credentials: 'same-origin' });
|
||||
start = await r.json();
|
||||
if (!r.ok) { await _setupReply(start.detail || 'Failed to start Copilot sign-in.'); return; }
|
||||
} catch (e) { await _setupReply('Request failed.'); return; }
|
||||
const authUrl = start.verification_uri_complete || start.verification_uri || '';
|
||||
await _setupReply(`Opening GitHub — approve the request (code ${start.user_code}). Waiting…`);
|
||||
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
|
||||
const deadline = Date.now() + (start.expires_in || 900) * 1000;
|
||||
const stepMs = Math.max((start.interval || 5), 2) * 1000;
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) { await _setupReply('Copilot sign-in expired — run /setup copilot again.'); return; }
|
||||
try {
|
||||
const fd = new FormData(); fd.append('poll_id', start.poll_id);
|
||||
const r = await fetch(`${API_BASE}/api/copilot/device/poll`, { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await r.json();
|
||||
if (d.status === 'authorized') {
|
||||
const n = ((d.endpoint && d.endpoint.models) || []).length;
|
||||
await _setupReply(`Connected — ${n} Copilot model${n !== 1 ? 's' : ''} available.`);
|
||||
if (modelsModule) modelsModule.refreshModels(true);
|
||||
return;
|
||||
}
|
||||
if (d.status === 'failed') { await _setupReply('Copilot sign-in failed (' + (d.error || 'denied') + ').'); return; }
|
||||
} catch (e) { /* transient — keep polling */ }
|
||||
setTimeout(poll, stepMs);
|
||||
};
|
||||
setTimeout(poll, stepMs);
|
||||
}
|
||||
|
||||
async function _cmdSetup(args, ctx) {
|
||||
_hideWelcomeScreen();
|
||||
_clearSetupCommandInput();
|
||||
const topic = (args[0] || '').trim().toLowerCase();
|
||||
const topicArgs = args.slice(1);
|
||||
if (topic === 'copilot' || topic === 'github') { await _setupCopilot(); return true; }
|
||||
const provider = _setupProviderFromInput(topic);
|
||||
if (provider) {
|
||||
_clearSetupGuideMessages();
|
||||
@@ -5419,6 +5485,14 @@ const COMMANDS = {
|
||||
'_show': { handler: _cmdToggleShow, alias: [], help: 'Show all toggle states', usage: '/toggle' }
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
alias: ['ws'],
|
||||
category: 'Agent',
|
||||
help: 'Set the folder the agent works in',
|
||||
handler: _cmdWorkspace,
|
||||
noUserBubble: true,
|
||||
usage: '/workspace [set <path> | clear | pick]',
|
||||
},
|
||||
memory: {
|
||||
alias: ['m'],
|
||||
category: 'Memory',
|
||||
@@ -5464,7 +5538,7 @@ const COMMANDS = {
|
||||
category: 'Getting started',
|
||||
help: 'Add local or API model endpoints',
|
||||
handler: _cmdSetup,
|
||||
usage: '/setup local URL · /setup groq KEY · /setup endpoint'
|
||||
usage: '/setup local URL · /setup groq KEY · /setup copilot · /setup endpoint'
|
||||
},
|
||||
demo: {
|
||||
alias: ['tour'],
|
||||
@@ -5844,7 +5918,9 @@ async function handleSlashCommand(input) {
|
||||
let args = parts.slice(1);
|
||||
const ctx = _makeCtx();
|
||||
let _userShown = false;
|
||||
function _showUser() { if (!_userShown) { _userShown = true; _addMessage('user', input); _persistMsg('user', input); } }
|
||||
// Tag the echoed command with source:'slash' so it renders in the transcript
|
||||
// but is excluded from LLM context (get_context_messages), like the replies.
|
||||
function _showUser() { if (!_userShown) { _userShown = true; _addMessage('user', input); _persistMsg('user', input, { source: 'slash' }); } }
|
||||
|
||||
try {
|
||||
// --- Check for --help / -h on any command ---
|
||||
|
||||
@@ -23,7 +23,8 @@ export const KEYS = {
|
||||
MCP_ACTIVE: 'odysseus-mcp-active',
|
||||
SECTION_ORDER: 'sidebar-section-order',
|
||||
ADMIN_LAST_TAB: 'admin-last-tab',
|
||||
DENSITY: 'odysseus-density'
|
||||
DENSITY: 'odysseus-density',
|
||||
WORKSPACE: 'odysseus-workspace'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -149,6 +149,13 @@ export function makeWindowDraggable(modal, options = {}) {
|
||||
const _startDrag = (cx, cy) => {
|
||||
dragging = true;
|
||||
if (modal) modal.classList.add('modal-dragging');
|
||||
// Cancel any in-flight open animation so we don't pin a mid-animation
|
||||
// rect and then jump once the animation settles.
|
||||
try {
|
||||
content.getAnimations()
|
||||
.filter(a => a.playState !== 'finished')
|
||||
.forEach(a => a.cancel());
|
||||
} catch (_) {}
|
||||
const rect = content.getBoundingClientRect();
|
||||
if (onDragStart) {
|
||||
try { onDragStart({ rect, cx, cy }); } catch (_) {}
|
||||
|
||||
160
static/js/workspace.js
Normal file
160
static/js/workspace.js
Normal file
@@ -0,0 +1,160 @@
|
||||
// static/js/workspace.js
|
||||
//
|
||||
// Workspace picker: browse server directories in a draggable modal, choose a
|
||||
// folder, and show it as a removable pill in the chat input bar. While set, the
|
||||
// chat request sends `workspace` so the agent's file/shell tools are confined
|
||||
// to that folder (see routes/chat_routes.py + src/tool_execution.py).
|
||||
|
||||
import Storage, { KEYS } from './storage.js';
|
||||
import uiModule from './ui.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
// Same folder glyph as the overflow menu item + pill (not an emoji).
|
||||
const _FOLDER_SVG = '<svg class="workspace-row-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
|
||||
let _modal = null;
|
||||
let _curPath = '';
|
||||
|
||||
export function getWorkspace() {
|
||||
return Storage.get(KEYS.WORKSPACE, '') || '';
|
||||
}
|
||||
|
||||
function _basename(p) {
|
||||
if (!p) return '';
|
||||
// Handle both POSIX (/) and Windows (\) separators.
|
||||
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/);
|
||||
return parts[parts.length - 1] || p;
|
||||
}
|
||||
|
||||
export function syncWorkspaceIndicator(path) {
|
||||
const pill = document.getElementById('workspace-indicator-btn');
|
||||
const name = document.getElementById('workspace-indicator-name');
|
||||
const overflow = document.getElementById('overflow-workspace-btn');
|
||||
if (pill) {
|
||||
pill.style.display = path ? '' : 'none';
|
||||
pill.classList.toggle('active', !!path);
|
||||
if (path) pill.title = `Workspace: ${path} — click to clear`;
|
||||
}
|
||||
if (name) name.textContent = path ? _basename(path) : '';
|
||||
if (overflow) overflow.classList.toggle('active', !!path);
|
||||
// Recompute the "+" overflow dot (app.js owns updatePlusDot via this event).
|
||||
try { document.dispatchEvent(new CustomEvent('overflow-state-change')); } catch (_) {}
|
||||
}
|
||||
|
||||
export function setWorkspace(path) {
|
||||
if (path) Storage.set(KEYS.WORKSPACE, path);
|
||||
else Storage.remove(KEYS.WORKSPACE);
|
||||
syncWorkspaceIndicator(path || '');
|
||||
}
|
||||
|
||||
export function clearWorkspace() {
|
||||
setWorkspace('');
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast('Workspace cleared');
|
||||
}
|
||||
|
||||
async function _load(path) {
|
||||
const url = `${API_BASE}/api/workspace/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`;
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`browse failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function _render(data) {
|
||||
_curPath = data.path;
|
||||
const body = _modal.querySelector('#workspace-body');
|
||||
const pathEl = _modal.querySelector('#workspace-cur-path');
|
||||
if (pathEl) {
|
||||
// Reflect the resolved (realpath) location back into the editable field.
|
||||
pathEl.value = data.path;
|
||||
pathEl.title = data.path;
|
||||
}
|
||||
let rows = '';
|
||||
if (data.parent) {
|
||||
rows += `<div class="workspace-row workspace-up" data-path="${encodeURIComponent(data.parent)}">↑ ..</div>`;
|
||||
}
|
||||
for (const d of data.dirs) {
|
||||
// Backend supplies the full child path (os.path.join → cross-platform).
|
||||
rows += `<div class="workspace-row" data-path="${encodeURIComponent(d.path)}">${_FOLDER_SVG}<span>${uiModule.esc(d.name)}</span></div>`;
|
||||
}
|
||||
if (!data.dirs.length && !data.parent) rows = '<div class="workspace-empty">No subfolders</div>';
|
||||
body.innerHTML = rows || '<div class="workspace-empty">No subfolders</div>';
|
||||
body.querySelectorAll('.workspace-row').forEach((row) => {
|
||||
row.addEventListener('click', () => _navigate(decodeURIComponent(row.dataset.path)));
|
||||
});
|
||||
}
|
||||
|
||||
async function _navigate(path) {
|
||||
try {
|
||||
_render(await _load(path));
|
||||
} catch (e) {
|
||||
if (uiModule && uiModule.showError) uiModule.showError('Could not open folder');
|
||||
}
|
||||
}
|
||||
|
||||
function _getModal() {
|
||||
if (_modal) return _modal;
|
||||
_modal = document.createElement('div');
|
||||
_modal.id = 'workspace-modal';
|
||||
_modal.className = 'modal';
|
||||
_modal.style.display = 'none';
|
||||
_modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>Select workspace</h4>
|
||||
<button class="close-btn" id="workspace-close" aria-label="Close">✖</button>
|
||||
</div>
|
||||
<input type="text" class="styled-prompt-input workspace-cur" id="workspace-cur-path"
|
||||
spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
|
||||
placeholder="Type or paste a folder path, then press Enter" />
|
||||
<div class="modal-body workspace-body" id="workspace-body"></div>
|
||||
<div class="modal-footer workspace-footer">
|
||||
<button type="button" class="confirm-btn confirm-btn-secondary" id="workspace-cancel">Cancel</button>
|
||||
<button type="button" class="confirm-btn confirm-btn-primary" id="workspace-use">Use this folder</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(_modal);
|
||||
_modal.querySelector('#workspace-close').addEventListener('click', closeWorkspaceBrowser);
|
||||
_modal.querySelector('#workspace-cancel').addEventListener('click', closeWorkspaceBrowser);
|
||||
// Editable path bar: Enter navigates to a typed/pasted folder.
|
||||
_modal.querySelector('#workspace-cur-path').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const v = e.target.value.trim();
|
||||
if (v) _navigate(v);
|
||||
}
|
||||
});
|
||||
_modal.querySelector('#workspace-use').addEventListener('click', () => {
|
||||
setWorkspace(_curPath);
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast(`Workspace set: ${_basename(_curPath)}`);
|
||||
closeWorkspaceBrowser();
|
||||
});
|
||||
const content = _modal.querySelector('.modal-content');
|
||||
const header = _modal.querySelector('.modal-header');
|
||||
if (content && header) makeWindowDraggable(_modal, { content, header });
|
||||
return _modal;
|
||||
}
|
||||
|
||||
export async function openWorkspaceBrowser() {
|
||||
const modal = _getModal();
|
||||
modal.style.display = 'flex';
|
||||
try {
|
||||
_render(await _load(getWorkspace() || ''));
|
||||
} catch (e) {
|
||||
if (uiModule && uiModule.showError) uiModule.showError('Could not browse folders');
|
||||
}
|
||||
}
|
||||
|
||||
export function closeWorkspaceBrowser() {
|
||||
if (_modal) _modal.style.display = 'none';
|
||||
}
|
||||
|
||||
export function initWorkspace() {
|
||||
// Restore persisted workspace into the pill on load.
|
||||
syncWorkspaceIndicator(getWorkspace());
|
||||
const overflow = document.getElementById('overflow-workspace-btn');
|
||||
if (overflow) overflow.addEventListener('click', openWorkspaceBrowser);
|
||||
const pill = document.getElementById('workspace-indicator-btn');
|
||||
if (pill) pill.addEventListener('click', clearWorkspace);
|
||||
}
|
||||
|
||||
export default { initWorkspace, openWorkspaceBrowser, getWorkspace, setWorkspace, clearWorkspace, syncWorkspaceIndicator };
|
||||
Reference in New Issue
Block a user