Changeset 0.9.3 (#53)

This commit is contained in:
2026-02-23 23:55:22 +00:00
parent ac7c7c7d59
commit 90021157e6
20 changed files with 897 additions and 414 deletions

View File

@@ -536,15 +536,35 @@ a:hover { text-decoration: underline; }
.msg-text pre code { background: none; padding: 0; font-size: inherit; }
/* Code block toolbar */
.copy-code-btn {
position: absolute; top: 6px; right: 6px; background: var(--bg-raised);
border: 1px solid var(--border); color: var(--text-3); border-radius: 4px;
padding: 2px 10px; font-size: 11px; cursor: pointer;
.code-toolbar {
position: absolute; top: 6px; right: 6px;
display: flex; gap: 4px; align-items: center;
opacity: 0; transition: opacity var(--transition);
}
.preview-code-btn { right: 56px; }
.msg-text pre:hover .copy-code-btn { opacity: 1; }
.msg-text pre:hover .code-toolbar { opacity: 1; }
.code-block.code-collapsed .code-toolbar { opacity: 1; }
.copy-code-btn {
background: var(--bg-raised); border: 1px solid var(--border);
color: var(--text-3); border-radius: 4px;
padding: 2px 10px; font-size: 11px; cursor: pointer;
transition: color var(--transition), border-color var(--transition);
}
.copy-code-btn:hover { color: var(--text); border-color: var(--border-light); }
.code-collapse-btn {
background: var(--bg-raised); border: 1px solid var(--border);
color: var(--text-3); border-radius: 4px;
padding: 2px 10px; font-size: 11px; cursor: pointer;
font-family: var(--mono); white-space: nowrap;
transition: color var(--transition), border-color var(--transition);
}
.code-collapse-btn:hover { color: var(--text); border-color: var(--border-light); }
/* Collapsed code block */
.code-block.code-collapsed code {
max-height: 3.6em; overflow: hidden; display: block;
mask-image: linear-gradient(to bottom, #000 40%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, #000 40%, transparent 100%);
}
/* Language label */
.code-lang {
@@ -553,44 +573,83 @@ a:hover { text-decoration: underline; }
text-transform: uppercase; letter-spacing: 0.5px; user-select: none;
}
/* Collapsible code blocks */
.code-collapsible {
border: 1px solid var(--border); border-radius: var(--radius);
margin: 0.75rem 0; background: var(--bg-surface);
/* HTML preview */
/* ── Side Panel (Preview + Notes) ──────── */
.side-panel {
width: 0; min-width: 0; overflow: hidden;
background: var(--bg); border-left: 1px solid var(--border);
display: flex; flex-direction: column;
transition: width 0.25s ease, min-width 0.25s ease;
}
.code-collapsible pre {
margin: 0; border: none; border-radius: 0 0 var(--radius) var(--radius);
.side-panel.open {
width: 480px; min-width: 480px;
}
.code-collapse-summary {
padding: 0.5rem 0.75rem; cursor: pointer; font-size: 12px;
color: var(--text-3); user-select: none;
transition: color var(--transition);
font-family: var(--mono);
.side-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; border-bottom: 1px solid var(--border);
background: var(--bg-raised); flex-shrink: 0;
}
.code-collapse-summary:hover { color: var(--text-2); }
.code-collapsible[open] .code-collapse-summary {
border-bottom: 1px solid var(--border);
.side-panel-tabs {
display: flex; gap: 2px; background: var(--bg-surface);
border-radius: var(--radius); padding: 2px;
}
.side-panel-tab {
padding: 4px 14px; border-radius: calc(var(--radius) - 2px);
font-size: 12px; font-family: var(--font); font-weight: 500;
background: none; border: none; color: var(--text-3);
cursor: pointer; transition: all var(--transition);
}
.side-panel-tab:hover { color: var(--text); }
.side-panel-tab.active {
background: var(--bg-raised); color: var(--text);
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.side-panel-close {
background: none; border: none; color: var(--text-3);
cursor: pointer; font-size: 16px; padding: 2px 6px;
border-radius: var(--radius); transition: all var(--transition);
}
.side-panel-close:hover { background: var(--bg-hover); color: var(--text); }
.side-panel-body {
flex: 1; overflow-y: auto; min-height: 0;
}
.side-panel-page { height: 100%; display: flex; flex-direction: column; }
.side-panel-empty {
display: flex; align-items: center; justify-content: center;
height: 100%; color: var(--text-3); font-size: 13px;
padding: 2rem; text-align: center;
}
.preview-frame {
flex: 1; width: 100%; border: none; background: #fff;
}
/* HTML preview */
.html-preview-wrap {
border: 1px solid var(--accent); border-radius: var(--radius);
margin: 0.5rem 0; overflow: hidden;
animation: fadeIn 0.15s ease;
/* Notes inside side panel */
.notes-actions-bar {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-bottom: 1px solid var(--border);
}
.html-preview-header {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 10px; background: var(--accent-dim);
font-size: 11px; color: var(--accent);
.side-panel .notes-list { padding: 4px 8px; }
.side-panel .notes-editor { padding: 8px 12px; }
.side-panel .note-read-content {
max-height: none; flex: 1; overflow-y: auto;
}
.html-preview-close {
background: none; border: none; color: var(--text-3);
cursor: pointer; padding: 0 4px; font-size: 14px;
.side-panel .notes-content-input {
min-height: 150px;
}
.html-preview-close:hover { color: var(--text); }
.html-preview-frame {
width: 100%; height: 300px; border: none;
background: #fff; display: block;
.side-panel #notesListView {
flex: 1; overflow-y: auto; min-height: 0;
}
.side-panel #notesEditorView {
flex: 1; overflow-y: auto; min-height: 0;
}
/* Mobile: full-width overlay */
@media (max-width: 768px) {
.side-panel.open {
position: fixed; top: 0; right: 0; bottom: 0;
width: 100vw; min-width: 100vw; z-index: 200;
}
}
.msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; }
@@ -662,6 +721,46 @@ a:hover { text-decoration: underline; }
font-size: 11px; color: var(--text-3); margin-left: 6px;
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tool-note-link {
background: none; border: none; color: var(--accent); cursor: pointer;
font-size: 11px; margin-left: 6px; padding: 0;
text-decoration: none;
transition: color var(--transition);
}
.tool-note-link:hover { color: var(--text); text-decoration: underline; }
/* Tool calls in message history (collapsed) */
.tool-call-block {
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-raised); font-size: 12px;
}
.tool-call-summary {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px; cursor: pointer; user-select: none;
color: var(--text-3); list-style: none;
transition: color var(--transition);
}
.tool-call-summary::-webkit-details-marker { display: none; }
.tool-call-summary::before {
content: '▸'; font-size: 10px; color: var(--text-3);
transition: transform 0.15s ease;
}
.tool-call-block[open] .tool-call-summary::before { transform: rotate(90deg); }
.tool-call-summary:hover { color: var(--text-2); }
.tool-detail {
border-top: 1px solid var(--border); padding: 6px 10px;
display: flex; flex-direction: column; gap: 6px;
}
.tool-detail-label {
font-size: 10px; color: var(--text-3); text-transform: uppercase;
letter-spacing: 0.5px; font-weight: 600;
}
.tool-detail-pre {
margin: 2px 0 0; padding: 6px 8px;
background: var(--bg-surface); border-radius: 4px;
font-size: 11px; color: var(--text-2); white-space: pre-wrap;
word-break: break-all; max-height: 200px; overflow-y: auto;
}
/* Empty state */
.empty-state {
@@ -1242,12 +1341,12 @@ button { font-family: var(--font); cursor: pointer; }
.sidebar.collapsed .sidebar-notes-btn .sb-label { display: none; }
.sidebar.collapsed .sb-notes { justify-content: center; padding: 8px; }
/* ── Notes Modal ────────────────────────── */
/* ── Notes (inside side panel) ──────────── */
.notes-header-actions { display: flex; align-items: center; gap: 8px; }
/* Notes toolbar and selection bar now live inside the side panel */
.notes-toolbar {
display: flex; gap: 8px; padding: 10px 20px;
display: flex; gap: 8px; padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-raised);
}
@@ -1269,12 +1368,12 @@ button { font-family: var(--font); cursor: pointer; }
max-width: 160px;
}
.notes-body { padding: 0 !important; }
/* Notes toolbar and selection bar now live inside the side panel */
/* Selection bar */
.notes-selection-bar {
display: flex; align-items: center; gap: 10px;
padding: 6px 20px;
padding: 6px 12px;
background: var(--accent-dim); border-bottom: 1px solid var(--border);
font-size: 12px;
}

View File

@@ -158,6 +158,99 @@
</div>
</div>
</main>
<!-- Side Panel (preview + notes) -->
<aside class="side-panel" id="sidePanel">
<div class="side-panel-header">
<div class="side-panel-tabs" id="sidePanelTabs">
<button class="side-panel-tab active" data-tab="preview" onclick="switchSidePanelTab('preview')">Preview</button>
<button class="side-panel-tab" data-tab="notes" onclick="switchSidePanelTab('notes')">Notes</button>
</div>
<button class="side-panel-close" onclick="closeSidePanel()" title="Close panel"></button>
</div>
<div class="side-panel-body">
<!-- Preview tab -->
<div class="side-panel-page" id="sidePanelPreview">
<div class="side-panel-empty" id="previewEmpty">
<p>Click <strong>Preview</strong> on any HTML code block to render it here.</p>
</div>
<iframe id="previewFrame" class="preview-frame" sandbox="allow-scripts" style="display:none"></iframe>
</div>
<!-- Notes tab -->
<div class="side-panel-page" id="sidePanelNotes" style="display:none">
<div class="notes-toolbar">
<div class="notes-search-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="notesSearchInput" placeholder="Search notes..." autocomplete="off">
</div>
<select id="notesFolderFilter" class="notes-filter-select">
<option value="">All folders</option>
</select>
<select id="notesSortSelect" class="notes-filter-select" title="Sort by">
<option value="updated_desc">Updated ↓</option>
<option value="updated_asc">Updated ↑</option>
<option value="created_desc">Created ↓</option>
<option value="created_asc">Created ↑</option>
<option value="title_asc">Title AZ</option>
<option value="title_desc">Title ZA</option>
</select>
</div>
<div class="notes-actions-bar">
<button class="btn-small" id="notesSelectModeBtn">Select</button>
<button class="btn-small btn-primary" id="notesNewBtn">+ New Note</button>
</div>
<div class="notes-selection-bar" id="notesSelectionBar" style="display:none">
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> <span id="notesSelectedCount">0</span> selected</label>
<button class="btn-small btn-danger" id="notesDeleteSelectedBtn">Delete selected</button>
<button class="btn-small" id="notesCancelSelectBtn">Cancel</button>
</div>
<!-- List view (default) -->
<div id="notesListView">
<div id="notesList" class="notes-list">
<div class="notes-empty">No notes yet. Create one or ask the AI to save a note.</div>
</div>
</div>
<!-- Editor view (hidden by default) -->
<div id="notesEditorView" style="display:none">
<div class="notes-editor">
<div class="notes-editor-toolbar">
<button class="notes-back-btn" id="notesBackBtn">← Back to list</button>
<div class="notes-mode-toggle">
<button class="btn-small" id="noteEditBtn" style="display:none">Edit</button>
<button class="btn-small" id="notePreviewBtn" style="display:none">Preview</button>
</div>
</div>
<!-- Read mode: rendered markdown -->
<div id="noteReadMode" style="display:none">
<h3 class="note-read-title" id="noteReadTitle"></h3>
<div class="note-read-meta" id="noteReadMeta"></div>
<div class="note-read-content msg-text" id="noteReadContent"></div>
<div class="notes-editor-actions">
<button class="btn-small" id="noteCopyBtn" onclick="copyNoteContent()">Copy</button>
<button class="btn-small btn-primary" id="noteEditBtn2">Edit</button>
<button class="btn-small btn-danger" id="noteDeleteBtn2" style="display:none">Delete</button>
</div>
</div>
<!-- Edit mode: form fields -->
<div id="noteEditMode">
<div class="form-group"><input type="text" id="noteEditorTitle" placeholder="Note title" class="notes-title-input"></div>
<div class="form-row">
<div class="form-group"><input type="text" id="noteEditorFolder" placeholder="Folder (e.g. /work/meetings)" class="notes-folder-input"></div>
<div class="form-group"><input type="text" id="noteEditorTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
</div>
<div class="form-group"><textarea id="noteEditorContent" rows="12" placeholder="Note content (Markdown supported)..." class="notes-content-input"></textarea></div>
<div class="notes-editor-actions">
<button class="btn-small btn-primary" id="noteSaveBtn">Save</button>
<button class="btn-small" id="noteCancelEditBtn" style="display:none">Cancel</button>
<button class="btn-small btn-danger" id="noteDeleteBtn" style="display:none">Delete</button>
</div>
</div>
</div>
</div>
</div>
</div>
</aside>
</div><!-- .app-body -->
<!-- Environment Banner (bottom) -->
@@ -270,7 +363,7 @@
</div>
<div class="form-group"><label>Temperature</label><input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2"></div>
</div>
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Show thinking blocks</label>
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Auto-expand thinking blocks</label>
</section>
</div>
<!-- Appearance Tab -->
@@ -534,6 +627,15 @@
<label class="checkbox-label"><input type="checkbox" id="adminUserPresetsToggle" checked> Allow users to create personal presets</label>
<p class="section-hint">When disabled, users can only use admin-created global presets.</p>
</section>
<section class="settings-section">
<h3>Default Model</h3>
<div class="form-group">
<select id="adminDefaultModel">
<option value="">— None (first visible) —</option>
</select>
</div>
<p class="section-hint">Model selected by default for new users or when a saved model is no longer available.</p>
</section>
<section class="settings-section">
<h3>Environment Banner</h3>
<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>
@@ -583,85 +685,6 @@
</div>
<!-- ── Notes Modal ────────────────────────── -->
<div class="modal-overlay" id="notesModal">
<div class="modal modal-wide">
<div class="modal-header">
<h2>Notes</h2>
<div class="notes-header-actions">
<button class="btn-small" id="notesSelectModeBtn">Select</button>
<button class="btn-small btn-primary" id="notesNewBtn">+ New Note</button>
<button class="modal-close" onclick="closeModal('notesModal')"></button>
</div>
</div>
<div class="notes-toolbar">
<div class="notes-search-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="notesSearchInput" placeholder="Search notes..." autocomplete="off">
</div>
<select id="notesFolderFilter" class="notes-filter-select">
<option value="">All folders</option>
</select>
<select id="notesSortSelect" class="notes-filter-select" title="Sort by">
<option value="updated_desc">Updated ↓</option>
<option value="updated_asc">Updated ↑</option>
<option value="created_desc">Created ↓</option>
<option value="created_asc">Created ↑</option>
<option value="title_asc">Title AZ</option>
<option value="title_desc">Title ZA</option>
</select>
</div>
<div class="notes-selection-bar" id="notesSelectionBar" style="display:none">
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> <span id="notesSelectedCount">0</span> selected</label>
<button class="btn-small btn-danger" id="notesDeleteSelectedBtn">Delete selected</button>
<button class="btn-small" id="notesCancelSelectBtn">Cancel</button>
</div>
<div class="modal-body notes-body">
<!-- List view (default) -->
<div id="notesListView">
<div id="notesList" class="notes-list">
<div class="notes-empty">No notes yet. Create one or ask the AI to save a note.</div>
</div>
</div>
<!-- Editor view (hidden by default) -->
<div id="notesEditorView" style="display:none">
<div class="notes-editor">
<div class="notes-editor-toolbar">
<button class="notes-back-btn" id="notesBackBtn">← Back to list</button>
<div class="notes-mode-toggle">
<button class="btn-small" id="noteEditBtn" style="display:none">Edit</button>
<button class="btn-small" id="notePreviewBtn" style="display:none">Preview</button>
</div>
</div>
<!-- Read mode: rendered markdown -->
<div id="noteReadMode" style="display:none">
<h3 class="note-read-title" id="noteReadTitle"></h3>
<div class="note-read-meta" id="noteReadMeta"></div>
<div class="note-read-content msg-text" id="noteReadContent"></div>
<div class="notes-editor-actions">
<button class="btn-small btn-primary" id="noteEditBtn2">Edit</button>
<button class="btn-small btn-danger" id="noteDeleteBtn2" style="display:none">Delete</button>
</div>
</div>
<!-- Edit mode: form fields -->
<div id="noteEditMode">
<div class="form-group"><input type="text" id="noteEditorTitle" placeholder="Note title" class="notes-title-input"></div>
<div class="form-row">
<div class="form-group"><input type="text" id="noteEditorFolder" placeholder="Folder (e.g. /work/meetings)" class="notes-folder-input"></div>
<div class="form-group"><input type="text" id="noteEditorTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
</div>
<div class="form-group"><textarea id="noteEditorContent" rows="12" placeholder="Note content (Markdown supported)..." class="notes-content-input"></textarea></div>
<div class="notes-editor-actions">
<button class="btn-small btn-primary" id="noteSaveBtn">Save</button>
<button class="btn-small" id="noteCancelEditBtn" style="display:none">Cancel</button>
<button class="btn-small btn-danger" id="noteDeleteBtn" style="display:none">Delete</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Debug Modal (Ctrl+Shift+L) -->
<div class="modal-overlay" id="debugModal">
<div class="modal debug-modal">

View File

@@ -25,7 +25,7 @@ const App = {
settings: {
model: '',
stream: true,
showThinking: true,
showThinking: false,
systemPrompt: '',
maxTokens: 0, // 0 = auto from model capabilities
temperature: 0.7,
@@ -287,6 +287,7 @@ function resolveCapabilities(backendCaps, modelId) {
async function fetchModels() {
try {
const data = await API.listEnabledModels();
App.defaultModel = data.default_model || '';
// Load user model preferences
try {
const prefData = await API.getModelPreferences();
@@ -335,6 +336,7 @@ async function fetchModels() {
});
console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPreset).length} presets, ${App.models.filter(m => m.hidden).length} hidden)`);
} catch (e) { console.warn('Model fetch failed:', e.message); }
UI.updateModelSelector();
UI.updateCapabilityBadges();
}
@@ -382,6 +384,7 @@ async function selectChat(chatId) {
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
@@ -514,6 +517,7 @@ async function reloadActivePath() {
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
@@ -535,12 +539,23 @@ async function regenerateMessage(messageId) {
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
// Truncate display to the parent of the message being regenerated.
// This makes the stream appear in the correct position — as a fresh
// response to the parent user message, not appended at the bottom.
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat) {
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
if (msgIdx > 0) {
const truncated = chat.messages.slice(0, msgIdx);
UI.renderMessages(truncated);
}
}
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const chat = App.chats.find(c => c.id === App.currentChatId);
const resp = await API.streamRegenerate(
App.currentChatId, messageId, App.abortController.signal,
model, presetId, modelInfo?.configId
@@ -969,7 +984,8 @@ function initListeners() {
});
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
await fetchModels();
UI.toast(`Loaded ${App.models.length} models`, 'success');
const visible = App.models.filter(m => !m.hidden).length;
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
});
// Settings modal
@@ -1385,12 +1401,15 @@ function initListeners() {
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Escape: stop generation → close command palette → close topmost modal
// Escape: stop generation → close command palette → close side panel → close topmost modal
if (e.key === 'Escape') {
if (App.isGenerating) { stopGeneration(); return; }
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
closeCmdPalette(); return;
}
if (document.getElementById('sidePanel')?.classList.contains('open')) {
closeSidePanel(); return;
}
const open = [...document.querySelectorAll('.modal-overlay.active')];
if (open.length) closeModal(open[open.length - 1].id);
return;
@@ -1690,6 +1709,10 @@ async function handleSaveAdminSettings() {
const userPresets = document.getElementById('adminUserPresetsToggle').checked;
await API.adminUpdateSetting('allow_user_personas', { value: userPresets ? 'true' : 'false' });
// Default model → default_model policy
const defaultModel = document.getElementById('adminDefaultModel').value;
await API.adminUpdateSetting('default_model', { value: defaultModel });
// Banner → global_settings (JSON value)
const banner = {
enabled: document.getElementById('adminBannerEnabled').checked,
@@ -2177,7 +2200,7 @@ var _selectedNoteIds = new Set();
var _notesSort = 'updated_desc';
async function openNotes() {
openModal('notesModal');
openSidePanel('notes');
_exitSelectMode();
await loadNotesList();
await loadNoteFolders();
@@ -2336,6 +2359,12 @@ function showNotesList() {
var _currentNote = null; // cached note for read mode
function copyNoteContent() {
if (!_currentNote) return;
const text = `# ${_currentNote.title || ''}\n\n${_currentNote.content || ''}`;
navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success'));
}
async function openNoteEditor(noteId) {
document.getElementById('notesListView').style.display = 'none';
document.getElementById('notesEditorView').style.display = '';

View File

@@ -325,6 +325,7 @@ const UI = {
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
</div>
</div>
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
<div class="msg-text">${formatMessage(msg.content)}</div>
</div>
</div>
@@ -408,6 +409,7 @@ const UI = {
const decoder = new TextDecoder();
let buffer = '';
let content = '';
let reasoning = '';
let currentEvent = ''; // SSE event type
while (true) {
@@ -446,11 +448,22 @@ const UI = {
continue;
}
// Reasoning content delta (thinking blocks)
const reasoningDelta = parsed.choices?.[0]?.delta?.reasoning_content || '';
if (reasoningDelta) {
reasoning += reasoningDelta;
// Render accumulated reasoning + content together
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
document.getElementById('streamContent').innerHTML = formatMessage(full);
this._scrollToBottom();
}
// Normal content delta
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
content += delta;
document.getElementById('streamContent').innerHTML = formatMessage(content);
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
document.getElementById('streamContent').innerHTML = formatMessage(full);
this._scrollToBottom();
}
currentEvent = '';
@@ -490,13 +503,23 @@ const UI = {
// Parse tool result content for a brief summary
try {
const parsed = JSON.parse(result.content);
const summary = parsed.title || parsed.id || parsed.count != null ? `${parsed.count} results` : '';
let summary = '';
if (parsed.title) summary = parsed.title;
else if (parsed.count != null) summary = `${parsed.count} results`;
if (summary) {
const hint = document.createElement('span');
hint.className = 'tool-hint';
hint.textContent = typeof summary === 'string' ? summary : JSON.stringify(summary);
el.appendChild(hint);
}
// Add "View note" link for note tools
if (result.name && result.name.startsWith('note_') && parsed.id) {
const link = document.createElement('button');
link.className = 'tool-note-link';
link.textContent = '📝 View note';
link.onclick = () => { openNotes(); setTimeout(() => openNoteEditor(parsed.id), 300); };
el.appendChild(link);
}
} catch (e) { /* not JSON or no useful summary */ }
}
this._scrollToBottom();
@@ -565,13 +588,25 @@ const UI = {
addGroup('Models', models.filter(m => m.source !== 'personal'));
addGroup('🔑 My Providers', models.filter(m => m.source === 'personal'));
// Restore selection (supports both composite and bare model IDs)
const match = App.findModel(current);
// Restore selection — resolution chain: localStorage → admin default → first visible
const visibleModels = App.models.filter(m => !m.hidden);
const match = visibleModels.find(m => m.id === current || m.baseModelId === current);
if (match) {
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
} else if (App.models.length > 0) {
const first = App.models[0];
} else if (App.defaultModel && visibleModels.length > 0) {
// Admin-configured default
const def = visibleModels.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel);
if (def) {
UI.setModelValue(def.id, def.name + (def.provider ? ` (${def.provider})` : ''));
} else {
const first = visibleModels[0];
UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : ''));
}
} else if (visibleModels.length > 0) {
const first = visibleModels[0];
UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : ''));
} else {
UI.setModelValue('', 'No visible models');
}
}
@@ -710,6 +745,28 @@ const UI = {
setGenerating(on) {
document.getElementById('stopBtn').classList.toggle('visible', on);
document.getElementById('sendBtn').disabled = on;
// Swap favicon to animated version during generation
const faviconLink = document.querySelector('link[rel="icon"][type="image/svg+xml"]');
if (faviconLink) {
if (on) {
// Pulsing favicon: dots fade in/out rapidly
faviconLink._origHref = faviconLink._origHref || faviconLink.href;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect x="4" y="4" width="56" height="56" rx="10" fill="%23252220" stroke="%234a4540" stroke-width="1.5"/>
<circle cx="22" cy="23" r="8" fill="%23111"/><circle cx="42" cy="23" r="8" fill="%23111"/>
<circle cx="42" cy="43" r="8" fill="%23111"/><circle cx="22" cy="43" r="8" fill="%23111"/>
<circle cx="22" cy="23" r="5.5" fill="%232D7DD2"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0s"/></circle>
<circle cx="42" cy="23" r="5.5" fill="%23E8852E"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.3s"/></circle>
<circle cx="42" cy="43" r="5.5" fill="%239B59B6"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.6s"/></circle>
<circle cx="22" cy="43" r="5.5" fill="%232EAA4E"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.9s"/></circle>
</svg>`;
faviconLink.href = 'data:image/svg+xml,' + svg;
} else if (faviconLink._origHref) {
faviconLink.href = faviconLink._origHref;
}
}
if (on) {
const container = document.getElementById('chatMessages');
const currentModel = App.findModel(App.settings.model);
@@ -1583,6 +1640,19 @@ const UI = {
// User presets / personas (policy: allow_user_personas)
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
// Default model (policy: default_model) — populate from current model list
const defModelSel = document.getElementById('adminDefaultModel');
if (defModelSel) {
defModelSel.innerHTML = '<option value="">— None (first visible) —</option>';
App.models.filter(m => !m.hidden).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
defModelSel.appendChild(opt);
});
defModelSel.value = policies.default_model || '';
}
// Banner (global_settings)
const banner = getSetting('banner', {}) || {};
document.getElementById('adminBannerEnabled').checked = !!banner.enabled;
@@ -1765,9 +1835,8 @@ function formatMessage(content) {
for (const b of thinkingBlocks) {
const inner = esc(b.content).replace(/\n/g, '<br>');
const thinkHTML = App.settings.showThinking
? `<details class="thinking-block"><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`
: '';
const openAttr = App.settings.showThinking ? ' open' : '';
const thinkHTML = `<details class="thinking-block"${openAttr}><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`;
html = html.replace(new RegExp(`<p>\\s*THINK_PLACEHOLDER_${b.id}\\s*</p>`, 'g'), thinkHTML);
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
}
@@ -1782,7 +1851,7 @@ function _formatMarked(text) {
ADD_ATTR: ['id', 'class', 'onclick', 'sandbox', 'srcdoc']
});
// Process code blocks: add copy button, collapsible wrapper, HTML preview
// Process code blocks: add copy button, collapse toggle, HTML preview
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
const langMatch = attrs.match(/class="language-(\w+)"/);
@@ -1791,9 +1860,15 @@ function _formatMarked(text) {
// Count lines for collapse decision
const lineCount = (code.match(/\n/g) || []).length + 1;
const COLLAPSE_THRESHOLD = 15;
const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
// Build toolbar buttons
let toolbar = `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
const collapseBtn = lineCount > 5
? `<button class="code-collapse-btn" onclick="toggleCodeCollapse('${codeId}')" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
: '';
let toolbar = collapseBtn;
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
// HTML preview button (only for HTML-like content)
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
@@ -1801,13 +1876,8 @@ function _formatMarked(text) {
}
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
const block = `<pre class="code-block">${langLabel}<code id="${codeId}"${attrs}>${code}</code>${toolbar}</pre>`;
// Wrap in collapsible details if long
if (lineCount > COLLAPSE_THRESHOLD) {
return `<details class="code-collapsible"><summary class="code-collapse-summary">Code${lang ? ' (' + lang + ')' : ''} · ${lineCount} lines</summary>${block}</details>`;
}
return block;
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
return `<pre class="code-block${collapsedClass}" id="block-${codeId}">${langLabel}<code id="${codeId}"${attrs}>${code}</code><div class="code-toolbar">${toolbar}</div></pre>`;
});
return html;
@@ -1819,20 +1889,22 @@ function _formatBasic(text) {
const id = 'code-' + Math.random().toString(36).slice(2, 9);
const lineCount = (code.match(/\n/g) || []).length + 1;
const COLLAPSE_THRESHOLD = 15;
const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
let toolbar = `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
const collapseBtn = lineCount > 5
? `<button class="code-collapse-btn" onclick="toggleCodeCollapse('${id}')" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
: '';
let toolbar = collapseBtn;
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
toolbar += `<button class="copy-code-btn preview-code-btn" onclick="toggleHTMLPreview('${id}')">Preview</button>`;
}
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
const block = `<pre class="code-block">${langLabel}<code id="${id}" class="language-${lang}">${code.trim()}</code>${toolbar}</pre>`;
if (lineCount > COLLAPSE_THRESHOLD) {
return `<details class="code-collapsible"><summary class="code-collapse-summary">Code${lang ? ' (' + lang + ')' : ''} · ${lineCount} lines</summary>${block}</details>`;
}
return block;
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
return `<pre class="code-block${collapsedClass}" id="block-${id}">${langLabel}<code id="${id}" class="language-${lang}">${code.trim()}</code><div class="code-toolbar">${toolbar}</div></pre>`;
});
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
@@ -1854,46 +1926,107 @@ function _looksLikeHTML(code) {
return /<!DOCTYPE|<html|<head|<body|<div|<span|<style|<script/i.test(decoded);
}
// Toggle code block collapse/expand
function toggleCodeCollapse(codeId) {
const pre = document.getElementById('block-' + codeId);
if (!pre) return;
const isCollapsed = pre.classList.toggle('code-collapsed');
const btn = pre.querySelector('.code-collapse-btn');
if (btn) {
const lines = btn.textContent.replace(/[▸▾]\s*/, '');
btn.textContent = (isCollapsed ? '▸ ' : '▾ ') + lines;
btn.title = isCollapsed ? 'Expand' : 'Collapse';
}
}
// Toggle sandboxed HTML preview below a code block
function toggleHTMLPreview(codeId) {
const existing = document.getElementById('preview-' + codeId);
if (existing) {
existing.remove();
return;
}
const codeEl = document.getElementById(codeId);
if (!codeEl) return;
const pre = codeEl.closest('pre');
if (!pre) return;
// Decode HTML entities back to real HTML for the preview
const rawHTML = codeEl.textContent;
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
const wrapper = document.createElement('div');
wrapper.id = 'preview-' + codeId;
wrapper.className = 'html-preview-wrap';
frame.srcdoc = rawHTML;
frame.style.display = '';
if (empty) empty.style.display = 'none';
const header = document.createElement('div');
header.className = 'html-preview-header';
header.innerHTML = `<span>HTML Preview</span><button class="html-preview-close" onclick="document.getElementById('preview-${codeId}').remove()">✕</button>`;
openSidePanel('preview');
}
const iframe = document.createElement('iframe');
iframe.className = 'html-preview-frame';
iframe.sandbox = 'allow-scripts'; // no allow-same-origin: fully isolated
iframe.srcdoc = rawHTML;
function openSidePanel(tab) {
const panel = document.getElementById('sidePanel');
panel.classList.add('open');
if (tab) switchSidePanelTab(tab);
}
wrapper.appendChild(header);
wrapper.appendChild(iframe);
function closeSidePanel() {
document.getElementById('sidePanel').classList.remove('open');
}
// Insert after the <pre> (or after <details> if collapsible)
const parent = pre.closest('.code-collapsible') || pre;
parent.after(wrapper);
function switchSidePanelTab(tab) {
// Update tab buttons
document.querySelectorAll('.side-panel-tab').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
// Show/hide pages
document.getElementById('sidePanelPreview').style.display = tab === 'preview' ? '' : 'none';
document.getElementById('sidePanelNotes').style.display = tab === 'notes' ? '' : 'none';
}
// ── Helpers ──────────────────────────────────
// Render tool calls from stored message data (history view)
function _renderToolCallsHTML(toolCalls) {
if (!toolCalls || !Array.isArray(toolCalls) || toolCalls.length === 0) return '';
const items = toolCalls.map(tc => {
const name = tc.function?.name || tc.name || 'unknown';
const args = tc.function?.arguments || tc.arguments || tc.input || '';
const result = tc.result || '';
const isError = tc.is_error || false;
const tcId = tc.id || '';
const statusCls = isError ? 'tool-error' : 'tool-done';
const statusText = isError ? 'error' : 'done';
// Check for note tool — add view link
let noteLink = '';
if (name.startsWith('note_') && result) {
try {
const parsed = typeof result === 'string' ? JSON.parse(result) : result;
if (parsed.id) {
noteLink = `<button class="tool-note-link" onclick="openNotes(); setTimeout(() => openNoteEditor('${esc(parsed.id)}'), 300)">📝 View</button>`;
}
} catch (e) { /* not JSON */ }
}
// Build collapsible detail
let detailHTML = '';
if (args || result) {
const argsStr = typeof args === 'string' ? args : JSON.stringify(args, null, 2);
const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
detailHTML = `<div class="tool-detail">`;
if (argsStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Input</span><pre class="tool-detail-pre">${esc(argsStr)}</pre></div>`;
if (resultStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Output</span><pre class="tool-detail-pre">${esc(resultStr)}</pre></div>`;
detailHTML += `</div>`;
}
return `<details class="tool-call-block">
<summary class="tool-call-summary">
<span class="tool-icon">🔧</span>
<span class="tool-name">${esc(name)}</span>
<span class="tool-status ${statusCls}">${statusText}</span>
${noteLink}
</summary>
${detailHTML}
</details>`;
});
return `<div class="msg-tools">${items.join('')}</div>`;
}
function _relativeTime(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);