mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 09:45:24 -04:00
Merge remote-tracking branch 'origin/dev'
This commit is contained in:
+34
-75
@@ -4,6 +4,7 @@
|
||||
// ============================================
|
||||
import Storage from './js/storage.js';
|
||||
import uiModule from './js/ui.js';
|
||||
import workspaceModule from './js/workspace.js';
|
||||
import fileHandlerModule from './js/fileHandler.js';
|
||||
import modelsModule from './js/models.js';
|
||||
import ragModule from './js/rag.js';
|
||||
@@ -13,6 +14,7 @@ import chatModule from './js/chat.js';
|
||||
import compareModule from './js/compare/index.js';
|
||||
import documentModule from './js/document.js';
|
||||
import searchChatModule from './js/search-chat.js';
|
||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||
import markdownModule from './js/markdown.js';
|
||||
import chatRenderer from './js/chatRenderer.js';
|
||||
import sessionModule from './js/sessions.js';
|
||||
@@ -1686,6 +1688,7 @@ function initializeEventListeners() {
|
||||
}
|
||||
setupToggle('web-toggle-btn', 'web-toggle', 'web');
|
||||
setupToggle('bash-toggle-btn', 'bash-toggle', 'bash');
|
||||
try { workspaceModule.initWorkspace(); } catch (_) {}
|
||||
|
||||
// Document editor toggle (special: uses module panel, not a checkbox)
|
||||
const overflowDocBtn = el('overflow-doc-btn');
|
||||
@@ -2683,82 +2686,38 @@ function initializeEventListeners() {
|
||||
// Apply saved visibility on load
|
||||
applyUIVis(loadUIVis());
|
||||
|
||||
// Generic draggable for all .modal elements
|
||||
const _sharedDragModalIds = new Set(['settings-modal']);
|
||||
try { document.querySelectorAll('.modal').forEach(m => {
|
||||
if (_sharedDragModalIds.has(m.id)) return;
|
||||
const content = m.querySelector('.modal-content');
|
||||
const header = m.querySelector('.modal-header');
|
||||
if (!content || !header) return;
|
||||
let dragX, dragY, startLeft, startTop, dragging = false;
|
||||
|
||||
// Reset to flex-centered position each time modal opens
|
||||
new MutationObserver(() => {
|
||||
if (!m.classList.contains('hidden')) {
|
||||
content.style.position = '';
|
||||
content.style.left = '';
|
||||
content.style.top = '';
|
||||
content.style.right = '';
|
||||
content.style.bottom = '';
|
||||
content.style.margin = '';
|
||||
}
|
||||
}).observe(m, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
function startDrag(clientX, clientY) {
|
||||
dragging = true;
|
||||
const rect = content.getBoundingClientRect();
|
||||
dragX = clientX; dragY = clientY;
|
||||
startLeft = rect.left; startTop = rect.top;
|
||||
// Switch to fixed so it can be freely positioned
|
||||
content.style.position = 'fixed';
|
||||
content.style.left = startLeft + 'px';
|
||||
content.style.top = startTop + 'px';
|
||||
content.style.margin = '0';
|
||||
}
|
||||
|
||||
header.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest('.close-btn')) return;
|
||||
e.preventDefault();
|
||||
startDrag(e.clientX, e.clientY);
|
||||
document.addEventListener('mousemove', onDrag);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
});
|
||||
function onDrag(e) {
|
||||
if (!dragging) return;
|
||||
content.style.left = (startLeft + e.clientX - dragX) + 'px';
|
||||
content.style.top = (startTop + e.clientY - dragY) + 'px';
|
||||
}
|
||||
function stopDrag() {
|
||||
dragging = false;
|
||||
document.removeEventListener('mousemove', onDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
}
|
||||
|
||||
// Touch drag is desktop-only — on mobile, modals are bottom sheets and
|
||||
// ui.js handles swipe-down-to-dismiss. Attaching this listener fights
|
||||
// the swipe-dismiss gesture.
|
||||
if (window.innerWidth > 768) {
|
||||
header.addEventListener('touchstart', (e) => {
|
||||
if (e.target.closest('.close-btn')) return;
|
||||
const t = e.touches[0];
|
||||
startDrag(t.clientX, t.clientY);
|
||||
document.addEventListener('touchmove', onTouchDrag, { passive: false });
|
||||
document.addEventListener('touchend', stopTouchDrag);
|
||||
// The only two modals without a per-module makeWindowDraggable call. Wire
|
||||
// them onto the shared helper, drag-only, to match their old behavior.
|
||||
try {
|
||||
['custom-preset-modal', 'rename-session-modal'].forEach((id) => {
|
||||
const m = document.getElementById(id);
|
||||
if (!m) return;
|
||||
const content = m.querySelector('.modal-content');
|
||||
const header = m.querySelector('.modal-header');
|
||||
if (!content || !header) return;
|
||||
makeWindowDraggable(m, {
|
||||
content, header,
|
||||
skipSelector: '.close-btn',
|
||||
enableDock: false,
|
||||
enableResize: false,
|
||||
});
|
||||
}
|
||||
function onTouchDrag(e) {
|
||||
if (!dragging) return;
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
content.style.left = (startLeft + t.clientX - dragX) + 'px';
|
||||
content.style.top = (startTop + t.clientY - dragY) + 'px';
|
||||
}
|
||||
function stopTouchDrag() {
|
||||
dragging = false;
|
||||
document.removeEventListener('touchmove', onTouchDrag);
|
||||
document.removeEventListener('touchend', stopTouchDrag);
|
||||
}
|
||||
}); } catch(e) { console.error('Modal drag init error:', e); }
|
||||
// Re-center on open (these persist in the DOM). Guard on the
|
||||
// hidden→visible edge so it never fires mid-drag.
|
||||
let wasHidden = m.classList.contains('hidden');
|
||||
new MutationObserver(() => {
|
||||
const isHidden = m.classList.contains('hidden');
|
||||
if (wasHidden && !isHidden) {
|
||||
content.style.position = '';
|
||||
content.style.left = '';
|
||||
content.style.top = '';
|
||||
content.style.right = '';
|
||||
content.style.bottom = '';
|
||||
content.style.margin = '';
|
||||
}
|
||||
wasHidden = isHidden;
|
||||
}).observe(m, { attributes: true, attributeFilter: ['class'] });
|
||||
});
|
||||
} catch (e) { console.error('Dialog drag init error:', e); }
|
||||
})();
|
||||
|
||||
// ── Modal minimize → dock ──
|
||||
|
||||
+18
-1
@@ -1031,6 +1031,13 @@
|
||||
<span>RAG</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<button type="button" class="overflow-menu-item" id="overflow-workspace-btn">
|
||||
<svg width="16" height="16" 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>
|
||||
<span>Workspace</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<!-- Inline "deep research mode" toggle removed (superseded by the
|
||||
Deep Research sidebar / trigger_research). The hidden
|
||||
#research-toggle checkbox is kept inert so existing JS refs
|
||||
@@ -1062,6 +1069,12 @@
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Workspace indicator (hidden until a folder is set) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="Workspace — click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
|
||||
<svg width="16" height="16" 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>
|
||||
<span style="font-size:11px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" id="workspace-indicator-name"></span>
|
||||
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
</button>
|
||||
<!-- RAG toolbar indicator (hidden until active) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="RAG active — click to deactivate" id="rag-indicator-btn" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -1478,6 +1491,10 @@
|
||||
<label class="settings-label">Tool call limit</label>
|
||||
<input id="set-agentMaxTools" type="text" inputmode="numeric" placeholder="0 = unlimited" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Max steps per message</label>
|
||||
<input id="set-agentMaxRounds" type="text" inputmode="numeric" placeholder="20" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2264,7 +2281,7 @@
|
||||
<script type="module" src="/static/js/chatRenderer.js"></script>
|
||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||
<script type="module" src="/static/js/chatStream.js"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260520m"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260604s"></script>
|
||||
<script type="module" src="/static/js/cookbook.js"></script>
|
||||
<script type="module" src="/static/js/search-chat.js"></script>
|
||||
<script type="module" src="/static/js/compare/index.js"></script>
|
||||
|
||||
+75
-3
@@ -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:
|
||||
|
||||
+265
-31
@@ -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;
|
||||
|
||||
+43
-25
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
+9
-6
@@ -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) {
|
||||
|
||||
+88
-17
@@ -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
|
||||
};
|
||||
|
||||
+20
-10
@@ -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');
|
||||
|
||||
+48
-1
@@ -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;
|
||||
|
||||
|
||||
+111
-11
@@ -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; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+27
-7
@@ -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 (_) {}
|
||||
|
||||
@@ -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 };
|
||||
@@ -1,824 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Odysseus — a self-hosted AI workspace: chat, agents, tools, model serving, email, research, and more. Your models, your hardware, your data.">
|
||||
<title>Odysseus — A Self-Hosted AI Workspace</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root {
|
||||
/* Odysseus default theme — exact app tokens */
|
||||
--bg: #282c34;
|
||||
--bg2: #1e2228; /* app code/hl background */
|
||||
--panel: #111; /* app panel surface */
|
||||
--panel2: #1e2228;
|
||||
--fg: #9cdef2; /* signature cyan text */
|
||||
--heading: #9cdef2;
|
||||
--muted: #6b8a94; /* app subheader */
|
||||
--border: #355a66; /* teal border */
|
||||
--accent: #e06c75; /* app accent (the send-button coral) */
|
||||
--accent2: #f0989e; /* lighter coral for gradients */
|
||||
--green: #50fa7b;
|
||||
--gold: #f0ad4e; /* app --warn */
|
||||
--red: #e06c75;
|
||||
--radius: 8px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; scroll-snap-type: y mandatory; scroll-padding-top: 60px; }
|
||||
/* Each section is a full-viewport "page" with its content centered, so only
|
||||
one shows at a time and the snap is obvious. */
|
||||
.hero, section {
|
||||
scroll-snap-align: start; min-height: 100vh;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
}
|
||||
/* Alternate the page backgrounds: slate (the body) ↔ black, to make each
|
||||
page boundary obvious. */
|
||||
section:nth-of-type(odd) { background: #111111; }
|
||||
section:nth-of-type(even) { background: var(--bg); }
|
||||
/* Domino reveal — each section fades/slides up as it scrolls into view. */
|
||||
.hero, section { opacity: 0; transform: translateY(24px); transition: opacity .6s cubic-bezier(.2,.7,.2,1), transform .6s cubic-bezier(.2,.7,.2,1); }
|
||||
.hero.in, section.in { opacity: 1; transform: none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-snap-type: none; }
|
||||
.hero, section { opacity: 1 !important; transform: none !important; transition: none; }
|
||||
}
|
||||
/* Capabilities cards cascade in like the app's domino expand. */
|
||||
#features .feature { opacity: 0; transform: translateY(16px); }
|
||||
#features.in .feature { animation: domino-in .5s cubic-bezier(.2,.7,.2,1) forwards; }
|
||||
#features.in .feature:nth-child(1) { animation-delay: .04s; }
|
||||
#features.in .feature:nth-child(2) { animation-delay: .09s; }
|
||||
#features.in .feature:nth-child(3) { animation-delay: .14s; }
|
||||
#features.in .feature:nth-child(4) { animation-delay: .19s; }
|
||||
#features.in .feature:nth-child(5) { animation-delay: .24s; }
|
||||
#features.in .feature:nth-child(6) { animation-delay: .29s; }
|
||||
#features.in .feature:nth-child(7) { animation-delay: .34s; }
|
||||
#features.in .feature:nth-child(8) { animation-delay: .39s; }
|
||||
#features.in .feature:nth-child(9) { animation-delay: .44s; }
|
||||
@keyframes domino-in { to { opacity: 1; transform: none; } }
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(1100px 520px at 82% -10%, rgba(224,108,117,0.12), transparent 60%),
|
||||
radial-gradient(900px 520px at 0% 0%, rgba(53,90,102,0.30), transparent 55%),
|
||||
var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 22px; }
|
||||
|
||||
/* Nav */
|
||||
nav {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(17,17,17,0.88);
|
||||
border-bottom: 1px solid #9cdef2;
|
||||
}
|
||||
nav .wrap { display: flex; align-items: center; justify-content: space-between; height: 60px; }
|
||||
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 17px; letter-spacing: 0.2px; color: var(--heading); }
|
||||
.brand .boat { color: var(--accent); flex-shrink: 0; }
|
||||
.nav-links { display: flex; align-items: center; gap: 22px; }
|
||||
.nav-links a { color: var(--muted); font-size: 14px; font-weight: 500; }
|
||||
.nav-links a:hover { color: var(--fg); }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 9px 16px; border-radius: 10px; font-weight: 600; font-size: 14px;
|
||||
border: 1px solid var(--border); color: var(--fg); background: var(--panel);
|
||||
transition: transform .12s ease, border-color .12s ease, background .12s ease;
|
||||
}
|
||||
.btn:hover { transform: translateY(-1px); border-color: var(--accent); }
|
||||
.btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
color: #fff; border: none;
|
||||
}
|
||||
.btn.primary:hover { filter: brightness(1.07); }
|
||||
.nav-links-hamburger {
|
||||
display: none;
|
||||
position: relative;
|
||||
}
|
||||
.hamburger-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.hamburger-btn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.hamburger-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
background: rgba(17,17,17,0.98);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.4);
|
||||
}
|
||||
.hamburger-menu a {
|
||||
color: var(--muted);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.hamburger-menu a:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.nav-links-hamburger.open .hamburger-menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero { padding: 86px 0 40px; text-align: center; }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: 12.5px; color: var(--muted); border: 1px solid var(--border);
|
||||
background: var(--panel); padding: 5px 12px; border-radius: 999px; margin-bottom: 22px;
|
||||
}
|
||||
.badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||
.hero-logo { display: flex; align-items: center; justify-content: center; gap: 14px; color: var(--accent); margin-bottom: 4px; }
|
||||
.hero-logo svg { filter: drop-shadow(0 4px 18px rgba(224,108,117,0.35)); }
|
||||
.hero-logo .wordmark { font-size: clamp(30px, 6vw, 44px); font-weight: 700; color: var(--heading); letter-spacing: -0.01em; line-height: 1; }
|
||||
.hero h1 {
|
||||
font-size: clamp(32px, 5.4vw, 52px); line-height: 1.12; margin: 0 0 18px;
|
||||
letter-spacing: -0.01em; font-weight: 700; color: var(--heading);
|
||||
}
|
||||
.hero h1 .grad {
|
||||
background: linear-gradient(120deg, var(--accent), var(--accent2));
|
||||
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.hero .slogan { font-style: italic; color: var(--accent); font-size: 12px; margin: 0 0 24px; letter-spacing: 0.3px; opacity: 0.9; }
|
||||
.hero p.lede { font-size: clamp(16px, 2.4vw, 20px); color: var(--muted); max-width: 680px; margin: 0 auto 30px; }
|
||||
.hero-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
|
||||
|
||||
/* terminal origin card */
|
||||
.term-intro { color: var(--fg); font-size: clamp(13px, 1.8vw, 15px); margin: 34px auto 0; max-width: 560px; }
|
||||
.term {
|
||||
max-width: 620px; margin: 12px auto 0; text-align: left;
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
overflow: hidden; box-shadow: 0 24px 60px rgba(0,0,0,0.4);
|
||||
}
|
||||
.term-bar { display: flex; align-items: center; justify-content: space-between; padding: 5px 6px 5px 12px; border-bottom: 1px solid var(--border); background: #20242c; }
|
||||
.term-bar .ttl { color: var(--muted); font-size: 12px; font-family: 'Fira Code', ui-monospace, monospace; }
|
||||
.term-bar .winbtns { display: flex; gap: 1px; }
|
||||
.term-bar .winbtns span { cursor: pointer; }
|
||||
.term { transition: opacity .18s ease, transform .18s ease; }
|
||||
/* Minimized = a rounded "pill", like the app's tab-down dock chip. */
|
||||
.term.term-min { max-width: max-content; border-radius: 999px; box-shadow: 0 6px 22px rgba(0,0,0,0.4); }
|
||||
.term.term-min .term-bar { border-bottom: none; border-radius: 999px; padding: 7px 10px 7px 16px; gap: 12px; background: var(--panel); }
|
||||
.term.term-min pre { display: none; }
|
||||
.term.term-closed { opacity: 0; transform: scale(0.96); pointer-events: none; height: 0; margin: 0 auto; border: 0; overflow: hidden; }
|
||||
.term-reopen {
|
||||
display: none; margin: 14px auto 0; font-family: 'Fira Code', monospace; font-size: 12px;
|
||||
color: var(--muted); background: none; border: 1px dashed var(--border); border-radius: 6px;
|
||||
padding: 5px 12px; cursor: pointer;
|
||||
}
|
||||
.term-reopen:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.term-reopen.show { display: inline-block; }
|
||||
.term-bar .winbtns span {
|
||||
width: 28px; height: 20px; display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 4px; color: var(--muted); font-size: 12px; line-height: 1;
|
||||
}
|
||||
.term-bar .winbtns span:hover { background: rgba(156,222,242,0.12); color: var(--fg); }
|
||||
.term-bar .winbtns span.x:hover { background: #c0392b; color: #fff; }
|
||||
.term pre {
|
||||
margin: 0; padding: 18px 16px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13.5px; color: var(--fg); line-height: 1.7; white-space: pre-wrap;
|
||||
}
|
||||
.term .cs { color: var(--green); } .term .cm { color: #828997; }
|
||||
.term-cursor { display: inline-block; color: var(--fg); font-weight: 400; animation: term-blink 1.05s steps(1) infinite; }
|
||||
@keyframes term-blink { 50% { opacity: 0; } }
|
||||
|
||||
/* Sections */
|
||||
section { padding: 60px 0; }
|
||||
.eyebrow { color: var(--accent); font-weight: 700; font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
h2.h { font-size: clamp(24px, 3.6vw, 32px); margin: 8px 0 12px; letter-spacing: -0.01em; color: var(--heading); font-weight: 700; }
|
||||
.sub { color: var(--muted); max-width: 620px; }
|
||||
.center { text-align: center; }
|
||||
.center .sub { margin: 0 auto; }
|
||||
|
||||
/* Testimonial gag — single featured testimonial, click/swipe to cycle (all sizes) */
|
||||
.tcarousel-wrap { position: relative; max-width: 820px; margin: 36px auto 0; }
|
||||
.tarrow {
|
||||
position: absolute; top: 50%; transform: translateY(-50%); z-index: 4;
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
background: rgba(17,17,17,0.85); border: 1px solid var(--border); color: var(--fg);
|
||||
font-size: 20px; line-height: 1; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: border-color .12s ease, color .12s ease;
|
||||
}
|
||||
.tarrow:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.tarrow.prev { left: 0; }
|
||||
.tarrow.next { right: 0; }
|
||||
.tgrid {
|
||||
display: block; position: relative; overflow: hidden; cursor: pointer;
|
||||
margin: 0 auto; max-width: 740px;
|
||||
}
|
||||
.tgrid .tcard {
|
||||
display: none;
|
||||
flex-direction: row-reverse; align-items: center; gap: 24px; text-align: left;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 28px;
|
||||
}
|
||||
.tgrid .tcard.active { display: flex; animation: tslide .25s ease both; }
|
||||
.tgrid .tcard.active.shake { animation: tshake .5s ease-in-out 2 both; }
|
||||
.tcard .av {
|
||||
width: 84px; height: 84px; border-radius: 50%; overflow: hidden;
|
||||
border: 1px solid var(--border); background: var(--panel2); flex: 0 0 auto;
|
||||
}
|
||||
.tcard .av img, .tcard .av svg { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.tcard .tmeta { flex: 1 1 auto; }
|
||||
.tcard .q { font-size: 18px; color: var(--fg); margin: 0 0 12px; }
|
||||
.tcard .stars { font-size: 15px; letter-spacing: 3px; margin: 0 0 8px; color: var(--gold); }
|
||||
.tcard .stars.zero { color: var(--muted); opacity: 0.5; }
|
||||
.tcard .nm { font-weight: 700; font-size: 14.5px; }
|
||||
.tcard .rl { color: var(--muted); font-size: 12.5px; }
|
||||
.tcard.cyclops { border-color: rgba(255,90,90,0.45); background: linear-gradient(180deg, rgba(255,80,80,0.06), var(--panel)); }
|
||||
.tcard.cyclops .q { color: #ff8a8a; font-weight: 700; letter-spacing: 0.4px; word-break: break-word; }
|
||||
.tnav { display: block; text-align: center; margin-top: 18px; }
|
||||
.tdot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: #39414d; margin: 0 4px; cursor: pointer; }
|
||||
.tdot.on { background: var(--accent); }
|
||||
.thint { font-size: 12px; color: var(--muted); margin-top: 8px; }
|
||||
@keyframes tshake {
|
||||
0%,100% { transform: translateX(0) rotate(0); }
|
||||
10% { transform: translateX(-9px) rotate(-1.5deg); }
|
||||
20% { transform: translateX(9px) rotate(1.5deg); }
|
||||
35% { transform: translateX(-7px) rotate(-1deg); }
|
||||
50% { transform: translateX(7px) rotate(1deg); }
|
||||
65% { transform: translateX(-5px); } 80% { transform: translateX(4px); } 92% { transform: translateX(-2px); }
|
||||
}
|
||||
@keyframes tslide { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 36px; }
|
||||
.feature {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 22px; transition: transform .14s ease, border-color .14s ease;
|
||||
}
|
||||
.feature:hover { transform: translateY(-3px); border-color: var(--accent); }
|
||||
.feature .ico {
|
||||
width: 40px; height: 40px; border-radius: 10px; display: inline-flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(224,108,117,0.18), rgba(53,90,102,0.28));
|
||||
border: 1px solid var(--border); color: var(--accent); margin-bottom: 14px;
|
||||
}
|
||||
.feature h3 { margin: 0 0 6px; font-size: 16.5px; }
|
||||
.feature p { margin: 0; color: var(--muted); font-size: 14px; }
|
||||
|
||||
/* Screenshot strip */
|
||||
.shotrow { display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: 16px; margin-top: 8px; }
|
||||
.shot {
|
||||
border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
|
||||
background: linear-gradient(180deg, var(--panel), var(--panel2));
|
||||
aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--muted); font-size: 13px; position: relative;
|
||||
}
|
||||
.shot .ph { display: flex; flex-direction: column; align-items: center; gap: 8px; opacity: 0.7; }
|
||||
.shot .frame-dots { position: absolute; top: 10px; left: 12px; display: flex; gap: 5px; }
|
||||
.shot .frame-dots i { width: 8px; height: 8px; border-radius: 50%; background: #39414d; display: inline-block; }
|
||||
|
||||
/* Previews — expanding hover carousel that plays a video on hover */
|
||||
.previews { display: flex; align-items: center; gap: 12px; height: 480px; max-width: 1000px; margin: 36px auto 0; }
|
||||
.preview-panel {
|
||||
position: relative; flex: 1 1 0; min-width: 0; height: 360px; overflow: hidden;
|
||||
border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer;
|
||||
background: linear-gradient(180deg, var(--panel), var(--panel2));
|
||||
transition: flex-grow .5s cubic-bezier(.2,.7,.2,1), height .5s cubic-bezier(.2,.7,.2,1), border-color .25s ease;
|
||||
}
|
||||
.previews:hover .preview-panel { flex-grow: 0.55; height: 300px; }
|
||||
.preview-panel:hover, .preview-panel:focus-visible { flex-grow: 3.4 !important; height: 480px !important; border-color: var(--accent); }
|
||||
.preview-panel .ph {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 10px;
|
||||
color: var(--muted); font-size: 12.5px; opacity: 0.7; text-align: center; padding: 8px;
|
||||
}
|
||||
.preview-panel video {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
|
||||
z-index: 1; opacity: 0; transition: opacity .3s ease; background: transparent;
|
||||
}
|
||||
.preview-panel.has-video video { opacity: 1; }
|
||||
.preview-panel .label {
|
||||
position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; padding: 14px 16px;
|
||||
background: linear-gradient(0deg, rgba(0,0,0,0.8), transparent);
|
||||
color: var(--heading); font-weight: 700; font-size: 14px;
|
||||
display: flex; align-items: center; gap: 8px; white-space: nowrap;
|
||||
}
|
||||
.preview-panel .label .ico { color: var(--accent); flex-shrink: 0; }
|
||||
@media (max-width: 760px) {
|
||||
.previews { flex-direction: column; height: auto; }
|
||||
.preview-panel { height: 200px; flex: none; }
|
||||
.previews:hover .preview-panel, .preview-panel:hover { flex: none !important; }
|
||||
}
|
||||
|
||||
/* Get started */
|
||||
.start {
|
||||
background: linear-gradient(180deg, var(--panel), var(--bg2));
|
||||
border: 1px solid var(--border); border-radius: 18px; padding: 40px; text-align: center;
|
||||
}
|
||||
.codeblock {
|
||||
display: inline-flex; align-items: center; gap: 14px; margin: 18px auto 8px;
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 12px 16px; font-family: ui-monospace, monospace; font-size: 14px; color: var(--fg);
|
||||
}
|
||||
.codeblock .prompt { color: var(--accent); }
|
||||
.pill-row { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 18px; }
|
||||
.pill { font-size: 12.5px; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 5px 12px; background: var(--panel); }
|
||||
|
||||
footer { border-top: 1px solid var(--border); padding: 30px 0; color: var(--muted); font-size: 13px; scroll-snap-align: end; }
|
||||
footer .wrap { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.shotrow { grid-template-columns: 1fr; }
|
||||
.nav-links { display: none; }
|
||||
.nav-links-hamburger { display: block; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.tgrid .tcard { padding: 20px; gap: 16px; }
|
||||
.tcard .av { width: 64px; height: 64px; }
|
||||
.tcard .q { font-size: 15px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<div class="wrap">
|
||||
<div class="brand">
|
||||
<svg class="boat" viewBox="0 0 32 32" width="24" height="24" aria-hidden="true"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>
|
||||
Odysseus
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#testimonials">Testimonials</a>
|
||||
<a href="#how">How it started</a>
|
||||
<a href="#start">Get started</a>
|
||||
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-links-hamburger">
|
||||
<button class="hamburger-btn" aria-label="Open menu">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3.5,7 C3.22385763,7 3,6.77614237 3,6.5 C3,6.22385763 3.22385763,6 3.5,6 L20.5,6 C20.7761424,6 21,6.22385763 21,6.5 C21,6.77614237 20.7761424,7 20.5,7 L3.5,7 Z M3.5,12 C3.22385763,12 3,11.7761424 3,11.5 C3,11.2238576 3.22385763,11 3.5,11 L20.5,11 C20.7761424,11 21,11.2238576 21,11.5 C21,11.7761424 20.7761424,12 20.5,12 L3.5,12 Z M3.5,17 C3.22385763,17 3,16.7761424 3,16.5 C3,16.2238576 3.22385763,16 3.5,16 L20.5,16 C20.7761424,16 21,16.2238576 21,16.5 C21,16.7761424 20.7761424,17 20.5,17 L3.5,17 Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="hamburger-menu">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#testimonials">Testimonials</a>
|
||||
<a href="#how">How it started</a>
|
||||
<a href="#start">Get started</a>
|
||||
|
||||
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<header class="hero">
|
||||
<div class="wrap">
|
||||
<div class="hero-logo">
|
||||
<svg viewBox="0 0 32 32" width="48" height="48" aria-hidden="true"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>
|
||||
<span class="wordmark">Odysseus</span>
|
||||
</div>
|
||||
<p class="slogan">Yours for the voyage.</p>
|
||||
<h1>Your own <span class="grad">AI workspace</span>,<br>running on your hardware.</h1>
|
||||
<p class="lede">
|
||||
Odysseus is a self-hosted interface for talking to language models — chat,
|
||||
autonomous agents, tools, model serving, email, research, and more. Local-first,
|
||||
privacy-first, and no telemetry. Just you and your models.
|
||||
</p>
|
||||
<p style="font-size:11.5px; color:var(--muted); opacity:0.7; max-width:560px; margin:-18px auto 30px;">
|
||||
(if you want to add an API that's cool too — I'm not here to tell you how to live your life…)
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<a class="btn primary" href="#start">Get started</a>
|
||||
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">View on GitHub</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- TESTIMONIALS (gag) -->
|
||||
<section id="testimonials" style="padding-top:30px;">
|
||||
<div class="wrap">
|
||||
<div class="center">
|
||||
<div class="eyebrow">Loved by enterprises</div>
|
||||
<h2 class="h">What our customers are saying</h2>
|
||||
</div>
|
||||
|
||||
<div class="tcarousel-wrap">
|
||||
<button class="tarrow prev" type="button" aria-label="Previous testimonial">‹</button>
|
||||
<div class="tgrid" id="tcarousel">
|
||||
|
||||
<!-- Coder guy -->
|
||||
<figure class="tcard">
|
||||
<span class="av"><img src="https://cdn.prod.website-files.com/66708f90d7e407423093fa76/66708f91d7e407423093fd21_john-carter-testimonial-image-dentistry-x-webflow-template.png" alt="Generic Coder Guy" loading="lazy"></span>
|
||||
<div class="tmeta">
|
||||
<p class="q">"Odysseus helped us ship more ships while shipping ships. Truly best-in-class shipping."</p>
|
||||
<div class="stars">★★★★★</div>
|
||||
<div class="nm">Generic Coder Guy</div>
|
||||
<div class="rl">Sr. Engineer, ShipShip Inc.</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<!-- Woman -->
|
||||
<figure class="tcard">
|
||||
<span class="av"><img src="https://images.pexels.com/photos/5876695/pexels-photo-5876695.jpeg?auto=compress&cs=tinysrgb&w=160&h=160&fit=crop" alt="A real woman" loading="lazy"></span>
|
||||
<div class="tmeta">
|
||||
<p class="q">"I'm a real person. This is a real testimonial. By a real woman."</p>
|
||||
<div class="stars">★★★★★</div>
|
||||
<div class="nm">Generic Corporate Woman</div>
|
||||
<div class="rl">VP of Verticals, Things LLC</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<!-- Cyclops -->
|
||||
<figure class="tcard cyclops" data-shake="1">
|
||||
<span class="av" style="border-color:rgba(255,90,90,0.6);">
|
||||
<svg viewBox="0 0 72 72" width="54" height="54" fill="none" stroke="#cbd5e1" stroke-width="2">
|
||||
<rect x="0" y="0" width="72" height="72" fill="#16241a"/>
|
||||
<circle cx="36" cy="32" r="18" fill="#7fae7f" stroke="#5a7a5a"/>
|
||||
<line x1="29" y1="22" x2="43" y2="34" stroke="#ff5a5a" stroke-width="3"/>
|
||||
<line x1="43" y1="22" x2="29" y2="34" stroke="#ff5a5a" stroke-width="3"/>
|
||||
<ellipse cx="36" cy="45" rx="7" ry="9" fill="#3a0a0a" stroke="#200"/>
|
||||
<path d="M31 51 l-1 4" stroke="#fff" stroke-width="2"/><path d="M41 51 l1 4" stroke="#fff" stroke-width="2"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="tmeta">
|
||||
<p class="q">"AHHHHHHHHHHHHHHHHHHHHHHHHHHHHH"</p>
|
||||
<div class="stars zero">☆☆☆☆☆</div>
|
||||
<div class="nm">Polyphemus</div>
|
||||
<div class="rl">Cyclops, Cave Solutions (on leave)</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<!-- Corporate -->
|
||||
<figure class="tcard">
|
||||
<span class="av">
|
||||
<svg viewBox="0 0 80 80" aria-hidden="true">
|
||||
<rect width="80" height="80" rx="18" fill="#111827"/>
|
||||
<circle cx="40" cy="29" r="14" fill="#d1d5db"/>
|
||||
<path d="M18 70c4-18 15-27 22-27s18 9 22 27" fill="#374151"/>
|
||||
<path d="M28 58h24l-5 12H33z" fill="#e06c75"/>
|
||||
<path d="M32 14h16l6 11H26z" fill="#f8fafc"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="tmeta">
|
||||
<p class="q">"Anyway, as I was saying — best-in-class."</p>
|
||||
<div class="stars">★★★★★</div>
|
||||
<div class="nm">Chad Corporate</div>
|
||||
<div class="rl">Chief Executive Officer</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
</div>
|
||||
<button class="tarrow next" type="button" aria-label="Next testimonial">›</button>
|
||||
</div>
|
||||
<div class="tnav" id="tnav"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section id="features">
|
||||
<div class="wrap">
|
||||
<div class="center">
|
||||
<div class="eyebrow">Everything, self-hosted</div>
|
||||
<h2 class="h">One app, a lot of capabilities</h2>
|
||||
<p class="sub">Started as an AI chat. Became a workspace. Each piece runs locally against
|
||||
whatever endpoints you point it at.</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
|
||||
<h3>Chat & Agents</h3>
|
||||
<p>Multi-turn chat plus autonomous agents that plan, call tools, and work through tasks.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.8-3.8a6 6 0 0 1-7.9 7.9l-6.9 6.9a2.1 2.1 0 0 1-3-3l6.9-6.9a6 6 0 0 1 7.9-7.9z"/></svg></span>
|
||||
<h3>Tools & MCP</h3>
|
||||
<p>Built-in tools (bash, files, web, memory) plus any MCP server you connect. Toggle per tool.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></span>
|
||||
<h3>Cookbook</h3>
|
||||
<p>Hardware-aware model recommendations and one-click serving across 270+ catalogued models.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg></span>
|
||||
<h3>Email Assistant</h3>
|
||||
<p>AI summaries, style-matched draft replies, auto-tagging and spam triage over IMAP/SMTP.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg></span>
|
||||
<h3>Deep Research</h3>
|
||||
<p>Multi-step research runs that gather, read, and synthesize sources into a written report.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg></span>
|
||||
<h3>Compare</h3>
|
||||
<p>Send one prompt to several models at once and compare their answers side-by-side.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/></svg></span>
|
||||
<h3>Memory</h3>
|
||||
<p>Persistent memory the assistant builds up and recalls across all your conversations.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/></svg></span>
|
||||
<h3>Skills <span style="font-size:10.5px;font-weight:700;color:var(--accent);border:1px solid var(--border);border-radius:999px;padding:1px 7px;margin-left:4px;vertical-align:middle;">self-evolving</span></h3>
|
||||
<p>The assistant writes, refines, and reuses its own skills — getting more capable over time.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
<h3>Private by default</h3>
|
||||
<p>Runs on your machine against your own endpoints. No telemetry, with optional external integrations when you choose them.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The one-shot prompt it started from (gag) -->
|
||||
<section style="padding-top:0;">
|
||||
<div class="wrap" style="text-align:center;">
|
||||
<p class="term-intro">Odysseus was created by a carefully crafted one-shot AI prompt:</p>
|
||||
<div class="term">
|
||||
<div class="term-bar">
|
||||
<span class="ttl">user@odysseus: ~</span>
|
||||
<span class="winbtns"><span data-term="min" title="Minimize">–</span><span class="x" data-term="close" title="Close">✕</span></span>
|
||||
</div>
|
||||
<pre id="term-pre"><span class="cs">></span> idk what to make can you write it for me?
|
||||
actually make an ai chat, but make it good
|
||||
and also make it better</pre>
|
||||
</div>
|
||||
<button class="term-reopen" type="button">✕ reopen terminal</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PREVIEWS — hover to expand + play -->
|
||||
<section id="previews">
|
||||
<div class="wrap">
|
||||
<div class="center">
|
||||
<div class="eyebrow">See it in action</div>
|
||||
<h2 class="h">Hover to take a closer look</h2>
|
||||
<p class="sub center">Each panel expands and plays its preview when you hover it.</p>
|
||||
</div>
|
||||
<div class="previews">
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span>[ Chat & Agents ]</span></div>
|
||||
<video muted loop playsinline preload="none"><source src="chat.webm" type="video/webm"><source src="chat.mp4" type="video/mp4"></video>
|
||||
<div class="label"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Chat & Agents</div>
|
||||
</div>
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg><span>[ Cookbook ]</span></div>
|
||||
<div class="label"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>Cookbook</div>
|
||||
</div>
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg><span>[ Email Assistant ]</span></div>
|
||||
<video muted loop playsinline preload="none"><source src="email.webm" type="video/webm"><source src="email.mp4" type="video/mp4"></video>
|
||||
<div class="label"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg>Email Assistant</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW IT STARTED -->
|
||||
<section id="how">
|
||||
<div class="wrap">
|
||||
<div class="eyebrow">How it actually started</div>
|
||||
<h2 class="h">Odysseus is everything I hate, just making it tolerable.</h2>
|
||||
<p class="sub" style="max-width:760px;">
|
||||
I started working on the Odysseus project because running local AI felt fun — a step into the future.
|
||||
But the options to actually engage with LLMs felt like taking steps back. Where were
|
||||
features like Memory, Deep Research, Agents, and just basic integrations?!
|
||||
</p>
|
||||
<p class="sub" style="max-width:760px; margin-top:14px;">
|
||||
So I started building my own, for fun — and eventually figured it might be fun to
|
||||
share what I built for myself with others. Doesn't work for you? Well… it runs
|
||||
great on my hardware.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- GET STARTED -->
|
||||
<section id="start">
|
||||
<div class="wrap">
|
||||
<div class="start">
|
||||
<div class="eyebrow">Get started</div>
|
||||
<h2 class="h" style="margin-bottom:6px;">Clone it and run</h2>
|
||||
<p class="sub center" style="margin:0 auto;">It's open source and free. No sales team, no demo request — just clone the repo.</p>
|
||||
<div class="codeblock"><span class="prompt">$</span> git clone https://github.com/pewdiepie-archdaemon/odysseus.git && cd odysseus</div>
|
||||
<div>
|
||||
<a class="btn primary" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank" style="margin-top:14px;">View on GitHub</a>
|
||||
</div>
|
||||
<div class="pill-row">
|
||||
<span class="pill">Self-hosted</span>
|
||||
<span class="pill">Bring your own models</span>
|
||||
<span class="pill">Local-first</span>
|
||||
<span class="pill">MCP-ready</span>
|
||||
<span class="pill">No telemetry</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
<div>© 2026 Odysseus · Built from one prompt that refused to stop.</div>
|
||||
<div>No cyclopes were harmed in production.<sup>*</sup></div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Typewriter for the origin terminal: type line 1, pause 2s, line 2, pause
|
||||
// 2s, line 3, hold 4s, then reset and loop. Blinking "|" cursor throughout.
|
||||
(function () {
|
||||
var pre = document.getElementById('term-pre');
|
||||
if (!pre) return;
|
||||
var lines = [
|
||||
{ p: '<span class="cs">></span> ', t: 'idk what to make can you write it for me?' },
|
||||
{ p: ' ', t: 'actually make an ai chat, but make it good' },
|
||||
{ p: ' ', t: 'and also make it better' }
|
||||
];
|
||||
var CURSOR = '<span class="term-cursor">|</span>';
|
||||
var TYPE_MS = 40;
|
||||
var done = [], li = 0, timer = null;
|
||||
|
||||
function render(partial) {
|
||||
pre.innerHTML = done.join('\n') + (done.length ? '\n' : '') + partial + CURSOR;
|
||||
}
|
||||
function typeLine() {
|
||||
var ln = lines[li], i = 0;
|
||||
(function step() {
|
||||
if (i <= ln.t.length) {
|
||||
render(ln.p + ln.t.slice(0, i));
|
||||
i++; timer = setTimeout(step, TYPE_MS);
|
||||
} else {
|
||||
done.push(ln.p + ln.t);
|
||||
li++;
|
||||
if (li >= lines.length) timer = setTimeout(reset, 4000); // hold last line 4s
|
||||
else timer = setTimeout(typeLine, 2000); // pause 2s before next
|
||||
}
|
||||
})();
|
||||
}
|
||||
function reset() { clearTimeout(timer); done = []; li = 0; typeLine(); }
|
||||
|
||||
// Start typing only when the terminal scrolls into view (and replay each
|
||||
// time you return to it).
|
||||
if ('IntersectionObserver' in window) {
|
||||
var io2 = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) { if (e.isIntersecting) reset(); });
|
||||
}, { threshold: 0.45 });
|
||||
io2.observe(pre);
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
})();
|
||||
|
||||
// Previews: hovering a panel expands it (CSS) and plays its video; the
|
||||
// video only becomes visible once it actually starts playing, so missing
|
||||
// files just leave the labeled placeholder.
|
||||
(function () {
|
||||
document.querySelectorAll('.preview-panel').forEach(function (p) {
|
||||
var v = p.querySelector('video');
|
||||
if (!v) return;
|
||||
v.addEventListener('playing', function () { p.classList.add('has-video'); });
|
||||
v.addEventListener('pause', function () { /* keep last frame */ });
|
||||
var play = function () { var pr = v.play(); if (pr && pr.catch) pr.catch(function () {}); };
|
||||
p.addEventListener('mouseenter', play);
|
||||
p.addEventListener('focus', play);
|
||||
p.addEventListener('mouseleave', function () { v.pause(); });
|
||||
p.addEventListener('blur', function () { v.pause(); });
|
||||
p.addEventListener('click', function () { if (v.paused) play(); else v.pause(); });
|
||||
});
|
||||
})();
|
||||
|
||||
// Domino reveal: fade/slide each section in as it scrolls into view.
|
||||
(function () {
|
||||
var els = document.querySelectorAll('.hero, section');
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
els.forEach(function (e) { e.classList.add('in'); });
|
||||
return;
|
||||
}
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) {
|
||||
if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); }
|
||||
});
|
||||
}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
|
||||
els.forEach(function (e) { io.observe(e); });
|
||||
})();
|
||||
|
||||
// Fake terminal window buttons — minimize, maximize, close (and reopen).
|
||||
(function () {
|
||||
var term = document.querySelector('.term');
|
||||
var reopen = document.querySelector('.term-reopen');
|
||||
if (!term) return;
|
||||
term.querySelectorAll('.winbtns [data-term]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var act = b.getAttribute('data-term');
|
||||
if (act === 'min') term.classList.toggle('term-min');
|
||||
else if (act === 'close') {
|
||||
term.classList.add('term-closed');
|
||||
if (reopen) reopen.classList.add('show');
|
||||
}
|
||||
});
|
||||
});
|
||||
if (reopen) reopen.addEventListener('click', function () {
|
||||
term.classList.remove('term-closed', 'term-min');
|
||||
reopen.classList.remove('show');
|
||||
});
|
||||
})();
|
||||
|
||||
// Mobile testimonial carousel: tap or swipe to advance; Polyphemus shakes ~1s.
|
||||
(function () {
|
||||
var carousel = document.getElementById('tcarousel');
|
||||
var nav = document.getElementById('tnav');
|
||||
if (!carousel || !nav) return;
|
||||
var cards = [].slice.call(carousel.querySelectorAll('.tcard'));
|
||||
if (!cards.length) return;
|
||||
var idx = 0;
|
||||
|
||||
var dots = cards.map(function (_, k) {
|
||||
var d = document.createElement('span');
|
||||
d.className = 'tdot';
|
||||
d.addEventListener('click', function (e) { e.stopPropagation(); show(k); });
|
||||
nav.appendChild(d);
|
||||
return d;
|
||||
});
|
||||
var hint = document.createElement('div');
|
||||
hint.className = 'thint';
|
||||
hint.textContent = 'tap or swipe for the next satisfied customer →';
|
||||
nav.appendChild(hint);
|
||||
|
||||
function show(i) {
|
||||
idx = (i + cards.length) % cards.length;
|
||||
cards.forEach(function (c, k) { c.classList.toggle('active', k === idx); c.classList.remove('shake'); });
|
||||
dots.forEach(function (d, k) { d.classList.toggle('on', k === idx); });
|
||||
var cur = cards[idx];
|
||||
if (cur.getAttribute('data-shake') === '1') {
|
||||
void cur.offsetWidth;
|
||||
cur.classList.add('shake');
|
||||
setTimeout(function () { cur.classList.remove('shake'); }, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
carousel.addEventListener('click', function () { show(idx + 1); });
|
||||
|
||||
var _prev = document.querySelector('.tarrow.prev');
|
||||
var _next = document.querySelector('.tarrow.next');
|
||||
if (_prev) _prev.addEventListener('click', function (e) { e.stopPropagation(); show(idx - 1); });
|
||||
if (_next) _next.addEventListener('click', function (e) { e.stopPropagation(); show(idx + 1); });
|
||||
|
||||
var sx = null;
|
||||
carousel.addEventListener('touchstart', function (e) { sx = e.touches[0].clientX; }, { passive: true });
|
||||
carousel.addEventListener('touchend', function (e) {
|
||||
if (sx === null) return;
|
||||
var dx = e.changedTouches[0].clientX - sx;
|
||||
if (Math.abs(dx) > 30) { show(idx + (dx < 0 ? 1 : -1)); }
|
||||
sx = null;
|
||||
});
|
||||
|
||||
show(0);
|
||||
})();
|
||||
|
||||
|
||||
// Mobile navigation: open/close hamburger menu
|
||||
(function () {
|
||||
var container = document.querySelector('.nav-links-hamburger');
|
||||
if (!container) return;
|
||||
|
||||
var button = container.querySelector('.hamburger-btn');
|
||||
|
||||
button.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
container.classList.toggle('open');
|
||||
});
|
||||
|
||||
document.addEventListener('click', function () {
|
||||
container.classList.remove('open');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -3478,6 +3478,38 @@ body.bg-pattern-sparkles {
|
||||
.continue-btn:hover {
|
||||
opacity:0.8;
|
||||
}
|
||||
|
||||
/* Round-cap "Continue" affordance — a cohesive centered pill at the chat
|
||||
bottom (not the bare red in-message stopped style). */
|
||||
.rounds-exhausted {
|
||||
justify-content:center;
|
||||
gap:12px;
|
||||
width:fit-content;
|
||||
max-width:90%;
|
||||
margin:14px auto 4px;
|
||||
padding:7px 8px 7px 16px;
|
||||
border:1px solid var(--border);
|
||||
border-radius:999px;
|
||||
background:color-mix(in srgb, var(--fg) 4%, transparent);
|
||||
opacity:1;
|
||||
}
|
||||
.rounds-exhausted .rounds-exhausted-label {
|
||||
color:color-mix(in srgb, var(--fg) 60%, transparent);
|
||||
font-size:0.95em;
|
||||
}
|
||||
.rounds-exhausted .continue-btn {
|
||||
font-size:0.9em;
|
||||
font-weight:600;
|
||||
opacity:1;
|
||||
color:var(--bg);
|
||||
background:var(--accent, var(--red));
|
||||
border-radius:999px;
|
||||
padding:4px 14px;
|
||||
line-height:1.3;
|
||||
}
|
||||
.rounds-exhausted .continue-btn:hover {
|
||||
opacity:0.88;
|
||||
}
|
||||
.ctx-indicator {
|
||||
display:inline-flex; align-items:center; gap:1px;
|
||||
font-size:0.75rem;
|
||||
@@ -8835,6 +8867,57 @@ body.hide-thinking .thinking-section { display: none !important; }
|
||||
list-style: none;
|
||||
}
|
||||
.agent-tool-output summary::-webkit-details-marker { display: none; }
|
||||
/* File-write diff — neutral chrome (not the red error tint) + colored lines */
|
||||
.agent-tool-diff {
|
||||
background: color-mix(in srgb, var(--fg) 4%, transparent);
|
||||
border-color: color-mix(in srgb, var(--fg) 18%, transparent);
|
||||
}
|
||||
.agent-tool-diff summary {
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--fg) 7%, transparent);
|
||||
border-bottom-color: color-mix(in srgb, var(--fg) 12%, transparent);
|
||||
}
|
||||
.agent-tool-diff .diff-stat {
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
font-family: var(--mono, monospace);
|
||||
}
|
||||
/* Collapsed diff summary: filename + +adds/−dels (theme green/red). */
|
||||
.agent-tool-diff summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.agent-tool-diff .diff-file {
|
||||
font-family: var(--mono, monospace);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agent-tool-diff .diff-summary-stats {
|
||||
margin-left: auto;
|
||||
font-family: var(--mono, monospace);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agent-tool-diff .diff-summary-stats .diff-stat-add { color: var(--green, #2ecc71); }
|
||||
.agent-tool-diff .diff-summary-stats .diff-stat-del { color: var(--red, #e74c3c); }
|
||||
.agent-tool-diff .diff-summary-stats .diff-stat-new { color: var(--accent, var(--red)); opacity: 0.85; }
|
||||
.diff-pre {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--mono, monospace);
|
||||
font-size: 0.82em;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.diff-pre span { display: block; white-space: pre; }
|
||||
.diff-pre .diff-add { background: color-mix(in srgb, #2ecc71 22%, transparent); }
|
||||
.diff-pre .diff-del { background: color-mix(in srgb, #e74c3c 22%, transparent); }
|
||||
.diff-pre .diff-hunk { color: var(--accent); opacity: 0.85; }
|
||||
.diff-pre .diff-meta { opacity: 0.55; }
|
||||
.diff-pre .diff-ctx { opacity: 0.8; }
|
||||
/* Suppress the global `summary::before { content: '▶' }` left arrow — this
|
||||
section uses a right-side chevron instead. */
|
||||
.agent-tool-output summary::before { content: none; }
|
||||
@@ -35736,3 +35819,109 @@ body.theme-frosted .modal {
|
||||
is already ≥16px and never zoomed — leave it so we don't shrink it. */
|
||||
.doc-email-richbody.doc-font-m { font-size: 16px !important; }
|
||||
}
|
||||
|
||||
/* GitHub Copilot device-flow connect block (model endpoints → API) */
|
||||
.adm-copilot-connect {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.adm-copilot-connect #adm-copilotStatus { flex-basis: 100%; margin-top: 0; }
|
||||
.adm-copilot-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.adm-copilot-wait {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--fg) 70%, transparent);
|
||||
}
|
||||
.adm-copilot-coderow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.adm-copilot-code-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: color-mix(in srgb, var(--fg) 45%, transparent);
|
||||
}
|
||||
.adm-copilot-code {
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 4px 10px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--fg);
|
||||
user-select: all;
|
||||
}
|
||||
.adm-copilot-copy { margin-left: auto; }
|
||||
.adm-copilot-auth {
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
padding: 7px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.adm-copilot-hint {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: color-mix(in srgb, var(--fg) 45%, transparent);
|
||||
}
|
||||
/* ── Workspace picker ───────────────────────────────────────────── */
|
||||
/* Layout (width/flex column/max-height) inherited from base .modal-content. */
|
||||
/* Editable path/address bar: reuses .styled-prompt-input for border/bg/radius/
|
||||
focus ring (set in the element's class list). Overrides only the deltas:
|
||||
mono font, and full-bleed via flex stretch with no horizontal margin (the
|
||||
modal-content's 10px padding is the gutter) instead of the base width:100%,
|
||||
which overflowed against the overflow:auto scrollbar. */
|
||||
.workspace-cur {
|
||||
align-self: stretch;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
margin: 4px 0 8px;
|
||||
font-family: var(--mono, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* flex/overflow inherited from base .modal-body; only the padding differs. */
|
||||
.workspace-body { padding: 6px 0; }
|
||||
.workspace-row {
|
||||
padding: 7px 18px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.workspace-row > span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.workspace-row-icon { flex-shrink: 0; opacity: 0.75; }
|
||||
.workspace-row:hover {
|
||||
background: color-mix(in srgb, var(--border) 20%, transparent);
|
||||
}
|
||||
.workspace-up { opacity: 0.7; }
|
||||
.workspace-empty { padding: 14px 18px; opacity: 0.5; font-size: 13px; }
|
||||
.workspace-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||
const CACHE_NAME = 'odysseus-v326';
|
||||
const CACHE_NAME = 'odysseus-v327';
|
||||
|
||||
// Core shell precached on install so repeat opens are instant without any
|
||||
// network wait. Keep this list in sync with the <script type="module"> tags
|
||||
|
||||
Reference in New Issue
Block a user