Changeset 0.9.3 (#53)
This commit is contained in:
@@ -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 = '';
|
||||
|
||||
233
src/js/ui.js
233
src/js/ui.js
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user