mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 02:32:21 +01:00
Merge remote-tracking branch 'origin/dev' into fix/editor-followups-20261030
Reconcile branch with current dev (bebf53be).
Resolve conflicts in static/sw.js (bump cache version and preserve panel precache)
and src/ai_interaction.py (preserve upstream quality handling with per-user settings).
Remediate PR #6992 findings:
- A (CI & Playwright): Pin playwright==1.62.0 in requirements-dev and CI workflow; guard browser test imports against pytest runner collection failure.
- B (Per-User Image Model Settings): Support per-user image_model and image_edit_model in editor_image_models() and ai_interaction; enable non-admin preference persistence via /api/prefs fallback.
- C (CodeQL Regex Alert 1212): Replace polynomial-backtracking regex in routes/editor_agent_routes.py with linear token-based intent matcher.
- D (Undo/Redo rembg layer): Clear stale rembgLiveLayer and rembgLiveSnap references in _restoreState() to prevent edge tuner from corrupting detached layers.
This commit is contained in:
@@ -4592,7 +4592,9 @@ body.bg-pattern-ascii-fireflies {
|
||||
width:100%; background:var(--bg); color:var(--fg);
|
||||
border:1px solid var(--border); border-radius:6px;
|
||||
padding:8px; font-family:inherit; font-size:0.95rem;
|
||||
resize:vertical; outline:none;
|
||||
resize:both; outline:none;
|
||||
field-sizing:content;
|
||||
max-width:100%; box-sizing:border-box;
|
||||
min-height:80px;
|
||||
}
|
||||
.edit-textarea:focus { border-color:var(--red); }
|
||||
|
||||
@@ -2690,11 +2690,14 @@ body.theme-frosted .modal {
|
||||
max-height: 95dvh !important;
|
||||
}
|
||||
|
||||
/* Notes and Documents must respect the same viewport and safe-area
|
||||
top as other mobile full-height sheets. Keep these authoritative rules
|
||||
consistent with 05-documents.css and 14-email.css. */
|
||||
body.doc-view .doc-editor-pane,
|
||||
body.notes-view .notes-pane {
|
||||
inset: auto 0 0 !important;
|
||||
height: 95dvh !important;
|
||||
max-height: 95dvh !important;
|
||||
inset: var(--sheet-top) 0 0 !important;
|
||||
height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,6 @@
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
window._odysseusLoadTime = Date.now();
|
||||
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
|
||||
(function(){
|
||||
var ua = navigator.userAgent || '';
|
||||
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
|
||||
var vp = document.querySelector('meta[name="viewport"]');
|
||||
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
|
||||
})();
|
||||
(function(){
|
||||
try {
|
||||
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
||||
|
||||
@@ -771,7 +771,13 @@ function _getModal() {
|
||||
<button class="cal-side-nav cal-side-nav-next" id="cal-side-next" title="Next" aria-label="Next">›</button>
|
||||
</div>`;
|
||||
document.body.appendChild(_modal);
|
||||
_modal.querySelector('#cal-close').addEventListener('click', closeCalendar);
|
||||
_modal.querySelector('#cal-close').addEventListener('click', () => {
|
||||
if (_modal.querySelector('.cal-form')) {
|
||||
_render();
|
||||
return;
|
||||
}
|
||||
closeCalendar();
|
||||
});
|
||||
_modal.querySelector('#cal-side-prev')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
function streamError(payload) {
|
||||
const detail = payload?.data ?? payload;
|
||||
const candidates = [detail?.message, detail?.text, detail?.error?.message,
|
||||
detail?.error, payload?.message, payload?.text, payload?.error?.message,
|
||||
payload?.error, typeof payload === 'string' ? payload : null];
|
||||
const message = candidates.find(value => typeof value === 'string' && value.trim());
|
||||
return new Error(message || 'Model request failed.');
|
||||
}
|
||||
|
||||
/** Decode complete SSE frames, requiring the backend's terminal DONE event. */
|
||||
export async function consumeCompareEvents(response, onEvent, onActivity) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let line = '', event = 'message', data = [], skipLF = false;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (!done) onActivity?.();
|
||||
let chunk = decoder.decode(value, { stream: !done });
|
||||
if (skipLF && chunk.length) {
|
||||
if (chunk.startsWith('\n')) chunk = chunk.slice(1);
|
||||
skipLF = false;
|
||||
}
|
||||
const separators = /\r\n|\r|\n/g;
|
||||
let start = 0, separator;
|
||||
while ((separator = separators.exec(chunk))) {
|
||||
line += chunk.slice(start, separator.index);
|
||||
start = separator.index + separator[0].length;
|
||||
skipLF = separator[0] === '\r' && start === chunk.length;
|
||||
if (!line) {
|
||||
if (data.length || event === 'error') {
|
||||
const raw = data.join('\n');
|
||||
if (event !== 'error' && raw === '[DONE]') return;
|
||||
let payload;
|
||||
try { payload = JSON.parse(raw); }
|
||||
catch (_) {
|
||||
if (event === 'error') throw streamError(raw);
|
||||
throw new Error('Invalid model stream data.');
|
||||
}
|
||||
if (event === 'error' || payload?.type === 'error') throw streamError(payload);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new Error('Invalid model stream data.');
|
||||
}
|
||||
await onEvent(payload);
|
||||
}
|
||||
event = 'message';
|
||||
data = [];
|
||||
} else if (!line.startsWith(':')) {
|
||||
const colon = line.indexOf(':');
|
||||
const field = colon < 0 ? line : line.slice(0, colon);
|
||||
let value = colon < 0 ? '' : line.slice(colon + 1);
|
||||
if (value.startsWith(' ')) value = value.slice(1);
|
||||
if (field === 'data') data.push(value);
|
||||
else if (field === 'event') event = value || 'message';
|
||||
}
|
||||
line = '';
|
||||
}
|
||||
line += chunk.slice(start);
|
||||
if (done) throw new Error('Stream ended before completion.');
|
||||
}
|
||||
} finally {
|
||||
try { await reader.cancel(); } catch (_) {}
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
+12
-22
@@ -1,5 +1,6 @@
|
||||
// compare/stream.js — SSE streaming to panes
|
||||
import state from './state.js';
|
||||
import { consumeCompareEvents } from './sse.js';
|
||||
import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
|
||||
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260914metricssummary1';
|
||||
import markdownModule from '../markdown.js';
|
||||
@@ -472,25 +473,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
_resetIdleTimeout(); // any chunk = stream is alive
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') break;
|
||||
await consumeCompareEvents(response,
|
||||
(json) => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.type === 'metrics') {
|
||||
metrics = json.data;
|
||||
|
||||
@@ -672,7 +657,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
} else if (json.delta) {
|
||||
// Skip text deltas if we already rendered an image
|
||||
if (aiMsgEl._imageData) continue;
|
||||
if (aiMsgEl._imageData) return;
|
||||
// Capture TTFT on very first text delta
|
||||
if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart;
|
||||
// On first delta, destroy spinner and prepare text area
|
||||
@@ -701,8 +686,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
_scheduleLiveRender(target);
|
||||
}
|
||||
} catch (e) { console.warn('Compare stream render error:', e); }
|
||||
}
|
||||
}
|
||||
}, _resetIdleTimeout);
|
||||
|
||||
streamOk = true;
|
||||
// Destroy any remaining spinner
|
||||
@@ -900,9 +884,15 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
} else {
|
||||
console.error('Compare stream error:', error);
|
||||
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(error.message) + '</span>';
|
||||
awaitingChoice = false;
|
||||
const message = state._blindMode ? 'Model request failed. Try again.' : error.message;
|
||||
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(message) + '</span>';
|
||||
}
|
||||
} finally {
|
||||
if (aiMsgEl._spinner) {
|
||||
aiMsgEl._spinner.destroy();
|
||||
aiMsgEl._spinner = null;
|
||||
}
|
||||
clearTimeout(timeoutId);
|
||||
_timerDone = true;
|
||||
cancelAnimationFrame(_rafId);
|
||||
|
||||
@@ -1195,11 +1195,12 @@ async function _fetchDependencies() {
|
||||
// For backends with a recipe catalog (vllm / sglang / llama_cpp),
|
||||
// append a caret button that toggles a per-row recipe panel below.
|
||||
const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
|
||||
// Standalone recipe-caret button removed — the "Pick install
|
||||
// command" action lives inside the Installed ▾ dropdown menu
|
||||
// (see _showDepMenu) so each row only has ONE caret to click.
|
||||
// Kept the variable so downstream concat code stays the same.
|
||||
const recipeCaret = '';
|
||||
// The caret is the only way to open the recipe panel (the Installed ▾
|
||||
// menu has no recipe item), and the HW Fit / serve-card deep links
|
||||
// open it by clicking this caret (#6747).
|
||||
const recipeCaret = hasRecipe
|
||||
? `<button class="cookbook-dep-tag cookbook-dep-recipe-caret" data-dep-recipe-toggle="${esc(pkg.name)}" title="Pick a model to see the exact install commands" aria-expanded="false" style="background:none;border:1px solid var(--border);padding:2px 6px;display:inline-flex;align-items:center;cursor:pointer;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition:transform 0.15s"><polyline points="6 9 12 15 18 9"/></svg></button>`
|
||||
: '';
|
||||
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
|
||||
// When llama_cpp (or any future engine) reports build_deps_missing
|
||||
// from its system_prereqs probe, surface a one-tap install button
|
||||
|
||||
@@ -5232,7 +5232,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}
|
||||
|
||||
async function _handleAttachUpload(e) {
|
||||
const files = e.target.files;
|
||||
const files = Array.from(e.target.files || []);
|
||||
e.target.value = ''; // reset for next upload
|
||||
await _uploadComposeFiles(files);
|
||||
}
|
||||
@@ -5963,7 +5963,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}
|
||||
|
||||
async function _handleMarkdownImageUpload(e) {
|
||||
const files = e.target.files;
|
||||
const files = Array.from(e.target.files || []);
|
||||
e.target.value = '';
|
||||
await _uploadMarkdownImages(files);
|
||||
}
|
||||
|
||||
@@ -2650,7 +2650,14 @@ export function openEmailLibrary(opts = {}) {
|
||||
}
|
||||
|
||||
// Wire events
|
||||
document.getElementById('email-lib-close').addEventListener('click', closeEmailLibrary);
|
||||
document.getElementById('email-lib-close').addEventListener('click', () => {
|
||||
const modal = document.getElementById('email-lib-modal');
|
||||
if (modal?.classList.contains('email-reading') || modal?.querySelector('.doclib-card-expanded')) {
|
||||
_exitEmailReaderModeForList();
|
||||
return;
|
||||
}
|
||||
closeEmailLibrary();
|
||||
});
|
||||
document.getElementById('email-settings-header-back')?.addEventListener('click', _hideEmailSettingsPage);
|
||||
|
||||
// Clicking the modal header (anywhere except buttons/inputs) collapses
|
||||
|
||||
@@ -73,16 +73,24 @@ function _isDangerousSrcset(value) {
|
||||
|
||||
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
|
||||
export function _escLinkify(text) {
|
||||
const escaped = _esc(text);
|
||||
// URLs: http(s)://... or www....
|
||||
const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])/g;
|
||||
const mailRe = /\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
|
||||
return escaped
|
||||
.replace(urlRe, (m) => {
|
||||
const href = m.startsWith('www.') ? `https://${m}` : m;
|
||||
return `<a href="${_attrEsc(href)}" target="_blank" rel="noopener noreferrer">${m}</a>`;
|
||||
})
|
||||
.replace(mailRe, (m) => `<a href="${_attrEsc(`mailto:${m}`)}">${m}</a>`);
|
||||
const raw = String(text || '');
|
||||
// Match raw text once so generated anchors are never linkified again.
|
||||
const tokenRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])|\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
|
||||
const parts = [];
|
||||
let offset = 0;
|
||||
for (const match of raw.matchAll(tokenRe)) {
|
||||
parts.push(_esc(raw.slice(offset, match.index)));
|
||||
const token = match[0];
|
||||
const isUrl = !!match[1];
|
||||
const href = isUrl
|
||||
? (token.startsWith('www.') ? `https://${token}` : token)
|
||||
: `mailto:${token}`;
|
||||
const attrs = isUrl ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
parts.push(`<a href="${_attrEsc(_esc(href))}"${attrs}>${_esc(token)}</a>`);
|
||||
offset = match.index + token.length;
|
||||
}
|
||||
parts.push(_esc(raw.slice(offset)));
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
// Pull display name out of "Name <email@x>"; fallback to local-part of
|
||||
|
||||
@@ -5329,6 +5329,8 @@ function _restoreState(snap) {
|
||||
// inside _buildEditor, so we touch the DOM here).
|
||||
const sizeLabel = document.getElementById('ge-canvas-size');
|
||||
if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight} px`;
|
||||
state.rembgLiveLayer = null;
|
||||
state.rembgLiveSnap = null;
|
||||
}
|
||||
|
||||
function undo() {
|
||||
@@ -5787,7 +5789,20 @@ function _updateBrushCursor(e) {
|
||||
state.cursorEl.style.height = diameterY + 'px';
|
||||
}
|
||||
state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);
|
||||
state.cursorEl.style.transform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
|
||||
// Updating an inline style on every move invalidates document-wide :has()
|
||||
// selectors. A paused animation keeps the same transform in the rendering
|
||||
// pipeline without mutating the cursor's style attribute on every frame.
|
||||
const cursorTransform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
|
||||
if (!state.cursorEl._positionAnimation || state.cursorEl._positionAnimation.playState === 'idle') {
|
||||
state.cursorEl._positionAnimation = state.cursorEl.animate(
|
||||
[{ transform: cursorTransform }, { transform: cursorTransform }],
|
||||
{ duration: 1, fill: 'both' },
|
||||
);
|
||||
state.cursorEl._positionAnimation.pause();
|
||||
state.cursorEl._positionAnimation.currentTime = 0;
|
||||
} else {
|
||||
state.cursorEl._positionAnimation.effect.setKeyframes([{ transform: cursorTransform }, { transform: cursorTransform }]);
|
||||
}
|
||||
if (state.cursorEl.style.display !== 'block') {
|
||||
state.cursorEl.style.display = 'block';
|
||||
}
|
||||
@@ -14125,7 +14140,20 @@ export function closeEditor(options = {}) {
|
||||
const h = state.editorDocClickHandlers.pop();
|
||||
try { document.removeEventListener('click', h); } catch {}
|
||||
}
|
||||
if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
|
||||
if (_cursorRaf) { cancelAnimationFrame(_cursorRaf); _cursorRaf = null; }
|
||||
_pendingCursorEvent = null;
|
||||
_lastCursorRect = null;
|
||||
state._lastCursorDiameterX = null;
|
||||
state._lastCursorDiameterY = null;
|
||||
state._lastCursorBorderColor = null;
|
||||
if (state.cursorEl) {
|
||||
if (state.cursorEl._positionAnimation) {
|
||||
try { state.cursorEl._positionAnimation.cancel(); } catch {}
|
||||
state.cursorEl._positionAnimation = null;
|
||||
}
|
||||
state.cursorEl.remove();
|
||||
state.cursorEl = null;
|
||||
}
|
||||
try { document.getElementById('ge-cursor-layer')?.remove(); } catch {}
|
||||
// Tear down all floating popups + the dock so closing the editor
|
||||
// doesn't leave stale chips/panels behind on top of the gallery.
|
||||
|
||||
+4
-2
@@ -801,7 +801,8 @@ async function _sendRoundRobin(msg, box) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: [{
|
||||
role: 'user',
|
||||
content: `[${m._groupName || m.display}]: ${response}`
|
||||
content: `[${m._groupName || m.display}]: ${response}`,
|
||||
metadata: { group_peer: true, group_model: m._groupName || m.display, model: m.mid }
|
||||
}]}),
|
||||
});
|
||||
} catch (e) { console.warn('[group] sync failed:', e); }
|
||||
@@ -828,7 +829,8 @@ async function _syncAllResponses(holders) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: [{
|
||||
role: 'user',
|
||||
content: `[${model._groupName || model.display}]: ${response}`
|
||||
content: `[${model._groupName || model.display}]: ${response}`,
|
||||
metadata: { group_peer: true, group_model: model._groupName || model.display, model: model.mid }
|
||||
}]}),
|
||||
});
|
||||
} catch (e) { /* silent */ }
|
||||
|
||||
@@ -379,7 +379,6 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
||||
root.classList.toggle('keyboard-open', keyboardOpen);
|
||||
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
|
||||
else root.style.removeProperty('--keyboard-viewport-height');
|
||||
if (window.scrollY) window.scrollTo(0, 0);
|
||||
};
|
||||
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
|
||||
// the tapped field already had focus (e.g. the composer after a new chat)
|
||||
@@ -392,7 +391,6 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
||||
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
|
||||
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
|
||||
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
|
||||
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
|
||||
|
||||
let _lastVVHeight = window.visualViewport.height;
|
||||
window.visualViewport.addEventListener('resize', function() {
|
||||
|
||||
+5
-3
@@ -163,11 +163,13 @@ function buildCategoryChips() {
|
||||
const container = document.getElementById('memory-category-filters');
|
||||
if (!container) return;
|
||||
|
||||
const cats = new Set(memories.map(m => m.category || 'fact'));
|
||||
if (activeCategory !== 'all' && !cats.has(activeCategory)) activeCategory = 'all';
|
||||
|
||||
// Hide the chip row entirely when there are no memories — no point showing
|
||||
// an "all" chip with nothing to filter.
|
||||
if (!memories.length) { container.innerHTML = ''; return; }
|
||||
|
||||
const cats = new Set(memories.map(m => m.category || 'fact'));
|
||||
const sorted = ['all', ...Array.from(cats).sort()];
|
||||
const counts = memories.reduce((result, memory) => {
|
||||
const category = memory.category || 'fact';
|
||||
@@ -277,6 +279,7 @@ export function openMemoryModal(tabName = 'browse') {
|
||||
else if (m.default?.loadSkills) m.default.loadSkills(true);
|
||||
});
|
||||
}
|
||||
if (mode === 'memory') return loadMemories();
|
||||
}
|
||||
|
||||
function _decodeMemoryAnchorId(memoryId) {
|
||||
@@ -297,9 +300,8 @@ function _resetMemoryDeepLinkFilters() {
|
||||
// Memory search output may expose a shortened id, so prefix matching is
|
||||
// intentional here.
|
||||
export async function openMemory(memoryId, attempt = 0) {
|
||||
openMemoryModal('browse');
|
||||
_resetMemoryDeepLinkFilters();
|
||||
await loadMemories();
|
||||
await openMemoryModal('browse');
|
||||
if (renderMemoryList) renderMemoryList();
|
||||
const wanted = _decodeMemoryAnchorId(memoryId);
|
||||
if (!wanted) return;
|
||||
|
||||
+13
-2
@@ -299,7 +299,18 @@ function _renderHistoryMessage(msg, modelName) {
|
||||
} else {
|
||||
displayContent = '';
|
||||
}
|
||||
if (msg.role === 'user') {
|
||||
let displayRole = msg.role;
|
||||
// Peer turns remain user-role context for the model, but are agent replies
|
||||
// in history. Only explicit identity plus its exact prefix can opt in.
|
||||
if (displayRole === 'user' && meta?.group_peer === true
|
||||
&& typeof meta.group_model === 'string' && meta.group_model) {
|
||||
const prefix = `[${meta.group_model}]: `;
|
||||
if (displayContent.startsWith(prefix)) {
|
||||
displayRole = 'assistant';
|
||||
displayContent = displayContent.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
if (displayRole === 'user') {
|
||||
displayContent = _stripUserVisionBlocks(displayContent);
|
||||
const trimmed = displayContent.trim();
|
||||
if (
|
||||
@@ -316,7 +327,7 @@ function _renderHistoryMessage(msg, modelName) {
|
||||
displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`;
|
||||
}
|
||||
}
|
||||
return _addHistoryMessageWithFullRenderer(msg.role, displayContent, modelName, meta);
|
||||
return _addHistoryMessageWithFullRenderer(displayRole, displayContent, modelName, meta);
|
||||
}
|
||||
|
||||
function _clearHistoryPager() {
|
||||
|
||||
@@ -1,21 +1,42 @@
|
||||
import { invalidateSettings } from '../appConfig.js';
|
||||
|
||||
export async function readImagePreferences() {
|
||||
const response = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
if (!response.ok) throw new Error('Could not load image settings');
|
||||
return response.json();
|
||||
const [prefsRes, settingsRes] = await Promise.all([
|
||||
fetch('/api/prefs', { credentials: 'same-origin' }).catch(() => null),
|
||||
fetch('/api/auth/settings', { credentials: 'same-origin' }).catch(() => null),
|
||||
]);
|
||||
const prefs = (prefsRes && prefsRes.ok) ? await prefsRes.json().catch(() => ({})) : {};
|
||||
const settings = (settingsRes && settingsRes.ok) ? await settingsRes.json().catch(() => ({})) : {};
|
||||
if (!prefsRes?.ok && !settingsRes?.ok) throw new Error('Could not load image settings');
|
||||
return {
|
||||
...settings,
|
||||
image_model: prefs?.image_model || settings?.image_model || '',
|
||||
image_edit_model: prefs?.image_edit_model || settings?.image_edit_model || '',
|
||||
};
|
||||
}
|
||||
|
||||
let saveQueue = Promise.resolve();
|
||||
export function waitForImagePreferences() { return saveQueue; }
|
||||
export function saveImagePreferences(patch) {
|
||||
const save = async () => {
|
||||
const response = await fetch('/api/auth/settings', {
|
||||
let response = await fetch('/api/auth/settings', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch),
|
||||
});
|
||||
if (!response.ok) throw new Error(response.status === 403
|
||||
? 'Admin access is required to change image settings.' : 'Could not save image settings');
|
||||
if (response.status === 403) {
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
const prefRes = await fetch(`/api/prefs/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
});
|
||||
if (!prefRes.ok) {
|
||||
throw new Error('Could not save image preferences');
|
||||
}
|
||||
}
|
||||
} else if (!response.ok) {
|
||||
throw new Error('Could not save image settings');
|
||||
}
|
||||
invalidateSettings();
|
||||
window.dispatchEvent(new CustomEvent('image-preferences-changed', { detail: patch }));
|
||||
return response;
|
||||
|
||||
@@ -1260,6 +1260,9 @@ async function _expandSkillCard(card, name) {
|
||||
card._md = md || '';
|
||||
} else {
|
||||
pre.textContent = 'Loading…';
|
||||
const editBtn = [...card.querySelectorAll('.skill-card-preview button')]
|
||||
.find(button => button.textContent.trim() === 'Edit');
|
||||
if (editBtn) editBtn.disabled = true;
|
||||
try {
|
||||
const md = await _fetchSkillMarkdown(name);
|
||||
pre.textContent = md || '(empty)';
|
||||
@@ -1267,6 +1270,8 @@ async function _expandSkillCard(card, name) {
|
||||
card._md = md;
|
||||
} catch (e) {
|
||||
pre.textContent = `Failed to load SKILL.md: ${e.message || 'Unknown error'}`;
|
||||
} finally {
|
||||
if (editBtn) editBtn.disabled = !card._mdLoaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1275,6 +1280,9 @@ async function _expandSkillCard(card, name) {
|
||||
// Swap the read-only <pre> for an editable <textarea> (and back). The
|
||||
// Edit button toggles; a Save button commits via the markdown endpoint.
|
||||
function _toggleSkillEdit(card, name) {
|
||||
// A loading/error preview is not source text. Menu entry points must obey
|
||||
// the same readiness gate as the disabled footer control.
|
||||
if (!card._mdLoaded) return;
|
||||
const preview = card.querySelector('.skill-card-preview');
|
||||
if (!preview) return;
|
||||
const existing = preview.querySelector('.skill-md-editor');
|
||||
@@ -1288,6 +1296,9 @@ function _toggleSkillEdit(card, name) {
|
||||
ta.className = 'skill-md-editor';
|
||||
ta.spellcheck = false;
|
||||
ta.value = (card._md != null ? card._md : (pre ? pre.textContent : '')) || '';
|
||||
// Let the source set the intrinsic height; the card's existing flex/max
|
||||
// height keeps long files bounded and the Save footer reachable.
|
||||
ta.rows = Math.max(ta.rows, ta.value.split(/\r\n|\r|\n/).length);
|
||||
ta.addEventListener('click', (e) => e.stopPropagation());
|
||||
if (pre) pre.style.display = 'none';
|
||||
preview.insertBefore(ta, preview.querySelector('.doclib-card-expanded-actions'));
|
||||
|
||||
@@ -267,8 +267,9 @@ export function makeWindowResizable(content, options = {}) {
|
||||
content.style.maxWidth = 'none';
|
||||
content.style.maxHeight = 'none';
|
||||
const left = parseFloat(content.style.left);
|
||||
if (Number.isFinite(left) && left + w > window.innerWidth - padRight) {
|
||||
content.style.left = Math.max(padLeft, window.innerWidth - padRight - w) + 'px';
|
||||
if (Number.isFinite(left)) {
|
||||
const rightmostLeft = Math.max(padLeft, window.innerWidth - padRight - w);
|
||||
content.style.left = Math.max(padLeft, Math.min(left, rightmostLeft)) + 'px';
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
+2
-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-v1133-pr40-editor-precache';
|
||||
const CACHE_NAME = 'odysseus-v1134-pr40-editor-precache';
|
||||
|
||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||
// gives offline math fallback glyphs instead of proper typesetting.
|
||||
@@ -38,6 +38,7 @@ const KATEX_FONTS = [
|
||||
// Both are fetched at install time, in the background. Entries must match the
|
||||
// exact URL the browser requests, query string included.
|
||||
const PRECACHE = [
|
||||
'/static/js/compare/sse.js',
|
||||
'/',
|
||||
'/static/css/00-tokens.css?v=20260929csssplit',
|
||||
'/static/css/01-agent-chat.css?v=20260929csssplit',
|
||||
|
||||
Reference in New Issue
Block a user