Changeset 0.7.2 (#40)

This commit is contained in:
2026-02-21 19:03:19 +00:00
parent 494b1aa981
commit 416e5439ea
28 changed files with 3813 additions and 138 deletions

View File

@@ -570,6 +570,36 @@ a:hover { text-decoration: underline; }
max-height: 300px; overflow-y: auto; line-height: 1.6;
}
/* Tool activity indicators */
.msg-tools {
display: flex; flex-direction: column; gap: 4px;
margin-bottom: 0.5rem;
}
.tool-activity {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--text-3);
background: var(--bg-raised); border: 1px solid var(--border);
padding: 4px 10px; border-radius: var(--radius);
animation: tool-fade-in 0.2s ease;
}
@keyframes tool-fade-in { from { opacity: 0; transform: translateY(-4px); } }
.tool-icon { font-size: 13px; }
.tool-name { font-family: var(--mono); font-size: 11px; color: var(--text-2); }
.tool-status { font-size: 11px; margin-left: auto; }
.tool-running { color: var(--warning); }
.tool-running::after {
content: ''; display: inline-block; width: 4px; height: 4px;
border-radius: 50%; background: var(--warning); margin-left: 4px;
animation: tool-pulse 1s infinite;
}
@keyframes tool-pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
.tool-done { color: var(--success); }
.tool-error { color: var(--danger); }
.tool-hint {
font-size: 11px; color: var(--text-3); margin-left: 6px;
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Empty state */
.empty-state {
display: flex; flex-direction: column; align-items: center;
@@ -1064,6 +1094,175 @@ button { font-family: var(--font); cursor: pointer; }
.debug-pre { white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow: auto; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; margin: 4px 0; }
.debug-footer { display: flex; align-items: center; }
/* ── Sidebar Notes Button ───────────────── */
.sidebar-notes-btn {
padding: 4px 8px; border-top: 1px solid var(--border);
flex-shrink: 0;
}
.sb-notes {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 8px 12px; background: none; border: none;
border-radius: var(--radius); color: var(--text-2);
cursor: pointer; font-size: 13px; font-family: var(--font);
transition: background var(--transition), color var(--transition);
}
.sb-notes:hover { background: var(--bg-hover); color: var(--text); }
.sidebar.collapsed .sidebar-notes-btn .sb-label { display: none; }
.sidebar.collapsed .sb-notes { justify-content: center; padding: 8px; }
/* ── Notes Modal ────────────────────────── */
.notes-header-actions { display: flex; align-items: center; gap: 8px; }
.notes-toolbar {
display: flex; gap: 8px; padding: 10px 20px;
border-bottom: 1px solid var(--border);
background: var(--bg-raised);
}
.notes-search-wrap {
display: flex; align-items: center; gap: 6px; flex: 1;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0 10px;
}
.notes-search-wrap svg { color: var(--text-3); flex-shrink: 0; }
.notes-search-wrap input {
flex: 1; background: none; border: none; color: var(--text);
font-size: 13px; font-family: var(--font); padding: 6px 0;
outline: none;
}
.notes-filter-select {
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text); font-size: 12px;
padding: 4px 8px; font-family: var(--font); cursor: pointer;
max-width: 160px;
}
.notes-body { padding: 0 !important; }
/* Selection bar */
.notes-selection-bar {
display: flex; align-items: center; gap: 10px;
padding: 6px 20px;
background: var(--accent-dim); border-bottom: 1px solid var(--border);
font-size: 12px;
}
.notes-select-all {
display: flex; align-items: center; gap: 6px;
color: var(--text-2); cursor: pointer; font-size: 12px;
}
.notes-select-all input { cursor: pointer; }
.notes-list { padding: 8px; }
.notes-empty, .notes-loading {
text-align: center; color: var(--text-3); padding: 2rem;
font-size: 13px;
}
.note-item {
display: flex; align-items: flex-start; gap: 8px;
padding: 10px 12px; border-radius: var(--radius);
cursor: pointer; border: 1px solid transparent;
transition: background var(--transition), border-color var(--transition);
}
.note-item:hover {
background: var(--bg-hover); border-color: var(--border);
}
.note-item.selected {
background: var(--accent-dim); border-color: var(--accent);
}
.note-select-col { flex-shrink: 0; padding-top: 2px; }
.note-checkbox { cursor: pointer; accent-color: var(--accent); }
.note-content-col { flex: 1; min-width: 0; }
.note-item-header {
display: flex; align-items: baseline; justify-content: space-between;
gap: 8px; margin-bottom: 2px;
}
.note-item-title {
font-size: 13px; font-weight: 600; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.note-item-time { font-size: 11px; color: var(--text-3); flex-shrink: 0; }
.note-preview {
font-size: 12px; color: var(--text-3); line-height: 1.4;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-height: 1.4em;
}
.note-headline {
font-size: 12px; color: var(--text-3); line-height: 1.4;
}
.note-headline mark {
background: var(--accent-dim); color: var(--accent);
padding: 0 2px; border-radius: 2px;
}
.note-item-meta {
display: flex; align-items: center; gap: 4px;
margin-top: 4px; flex-wrap: wrap;
}
.note-folder {
font-size: 11px; color: var(--text-3); font-family: var(--mono);
background: var(--bg-raised); padding: 1px 6px; border-radius: 3px;
}
.note-tag {
font-size: 10px; color: var(--purple); background: var(--purple-dim);
padding: 1px 6px; border-radius: 3px;
}
/* Notes editor */
.notes-editor { padding: 12px; }
.notes-editor-toolbar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
/* Note read mode */
.note-read-title {
font-size: 18px; font-weight: 600; color: var(--text);
margin: 0 0 6px; line-height: 1.3;
}
.note-read-meta {
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
margin-bottom: 12px;
}
.note-read-content {
font-size: var(--msg-font, 14px); line-height: 1.7;
max-height: 50vh; overflow-y: auto;
padding: 12px; background: var(--bg-surface);
border: 1px solid var(--border); border-radius: var(--radius);
}
.notes-back-btn {
background: none; border: none; color: var(--text-3);
cursor: pointer; font-size: 12px; font-family: var(--font);
padding: 4px 0; margin-bottom: 8px;
transition: color var(--transition);
}
.notes-back-btn:hover { color: var(--text); }
.notes-title-input {
font-size: 16px !important; font-weight: 600;
border: 1px solid var(--border) !important;
padding: 8px 10px !important;
}
.notes-folder-input, .notes-tags-input {
font-size: 12px !important; font-family: var(--mono);
}
.notes-content-input {
font-size: 13px !important; line-height: 1.6 !important;
min-height: 200px; resize: vertical;
font-family: var(--mono); border: 1px solid var(--border) !important;
}
.notes-editor input, .notes-editor textarea {
width: 100%; background: var(--bg-surface); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 6px 10px; font-family: var(--font); outline: none;
transition: border-color var(--transition);
}
.notes-editor input:focus, .notes-editor textarea:focus {
border-color: var(--accent);
}
.notes-editor-actions {
display: flex; gap: 8px; margin-top: 12px;
}
/* ── Responsive ──────────────────────────── */
.mobile-menu-btn {

View File

@@ -56,6 +56,14 @@
<div class="sidebar-chats" id="chatHistory"></div>
<!-- Notes shortcut -->
<div class="sidebar-notes-btn">
<button class="sb-notes" id="notesBtn" title="Notes">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span class="sb-label">Notes</span>
</button>
</div>
<!-- User area -->
<div class="sidebar-bottom">
<button class="user-btn" id="userMenuBtn">
@@ -401,6 +409,86 @@
</div>
</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

@@ -278,6 +278,27 @@ const API = {
updatePreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
deletePreset(id) { return this._del(`/api/v1/presets/${id}`); },
// ── Notes ────────────────────────────────
listNotes(limit = 50, offset = 0, folder, tag, sort) {
let url = `/api/v1/notes?limit=${limit}&offset=${offset}`;
if (folder) url += `&folder=${encodeURIComponent(folder)}`;
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
return this._get(url);
},
createNote(title, content, folderPath, tags) {
const body = { title, content };
if (folderPath) body.folder_path = folderPath;
if (tags?.length) body.tags = tags;
return this._post('/api/v1/notes', body);
},
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
updateNote(id, updates) { return this._put(`/api/v1/notes/${id}`, updates); },
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
// ── HTTP Internals ───────────────────────
_authHeaders() {

View File

@@ -753,6 +753,56 @@ function initListeners() {
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
// Notes panel
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => {
if (_notesSelectMode) _exitSelectMode();
else _enterSelectMode();
});
document.getElementById('notesBackBtn')?.addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
document.getElementById('noteSaveBtn')?.addEventListener('click', saveNote);
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
document.getElementById('noteEditBtn2')?.addEventListener('click', _showNoteEditMode);
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
else showNotesList();
});
document.getElementById('notesFolderFilter')?.addEventListener('change', (e) => {
document.getElementById('notesSearchInput').value = '';
_exitSelectMode();
loadNotesList(e.target.value);
});
document.getElementById('notesSortSelect')?.addEventListener('change', (e) => {
_notesSort = e.target.value;
_exitSelectMode();
loadNotesList();
});
document.getElementById('notesSelectAll')?.addEventListener('change', (e) => {
_toggleSelectAll(e.target.checked);
});
document.getElementById('notesDeleteSelectedBtn')?.addEventListener('click', _bulkDeleteSelected);
document.getElementById('notesCancelSelectBtn')?.addEventListener('click', _exitSelectMode);
// Notes search with debounce
let _notesSearchTimer;
document.getElementById('notesSearchInput')?.addEventListener('input', (e) => {
clearTimeout(_notesSearchTimer);
const q = e.target.value.trim();
_notesSearchTimer = setTimeout(() => {
_exitSelectMode();
if (q.length >= 2) {
document.getElementById('notesFolderFilter').value = '';
loadNotesList(null, q);
} else if (q.length === 0) {
loadNotesList();
}
}, 300);
});
// Chat actions
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
@@ -1212,6 +1262,307 @@ async function deleteAdminPreset(id, name) {
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Notes Panel ─────────────────────────────
var _editingNoteId = null;
var _notesSelectMode = false;
var _selectedNoteIds = new Set();
var _notesSort = 'updated_desc';
async function openNotes() {
openModal('notesModal');
_exitSelectMode();
await loadNotesList();
await loadNoteFolders();
}
async function loadNotesList(folder, searchQuery) {
const list = document.getElementById('notesList');
list.innerHTML = '<div class="notes-loading">Loading…</div>';
try {
let data;
if (searchQuery) {
data = await API.searchNotes(searchQuery);
const results = data.data || [];
if (results.length === 0) {
list.innerHTML = '<div class="notes-empty">No results found</div>';
return;
}
list.innerHTML = results.map(n => _noteListItem(n, true)).join('');
} else {
const folderVal = folder || document.getElementById('notesFolderFilter')?.value || '';
data = await API.listNotes(100, 0, folderVal, '', _notesSort);
const notes = data.data || [];
if (notes.length === 0) {
list.innerHTML = `<div class="notes-empty">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
return;
}
list.innerHTML = notes.map(n => _noteListItem(n, false)).join('');
}
} catch (e) {
list.innerHTML = `<div class="notes-empty">Failed to load: ${esc(e.message)}</div>`;
}
}
function _noteListItem(note, isSearch) {
const tags = (note.tags || []).map(t => `<span class="note-tag">${esc(t)}</span>`).join('');
const folder = note.folder_path && note.folder_path !== '/' ? `<span class="note-folder">${esc(note.folder_path)}</span>` : '';
const preview = isSearch && note.headline
? `<div class="note-headline">${_highlightHeadline(note.headline)}</div>`
: `<div class="note-preview">${esc((note.preview || note.content || '').slice(0, 120))}</div>`;
const time = _relativeTime(note.updated_at);
const checked = _selectedNoteIds.has(note.id) ? 'checked' : '';
return `
<div class="note-item ${_selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
<div class="note-select-col" style="display:${_notesSelectMode ? '' : 'none'}">
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
onclick="event.stopPropagation(); _toggleNoteSelect('${note.id}', this.checked)">
</div>
<div class="note-content-col" onclick="${_notesSelectMode ? `_toggleNoteSelect('${note.id}')` : `openNoteEditor('${note.id}')`}">
<div class="note-item-header">
<span class="note-item-title">${esc(note.title)}</span>
<span class="note-item-time">${time}</span>
</div>
${preview}
<div class="note-item-meta">${folder}${tags}</div>
</div>
</div>`;
}
function _highlightHeadline(headline) {
const escaped = esc(headline);
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
}
// ── Multi-select ────────────────
function _enterSelectMode() {
_notesSelectMode = true;
_selectedNoteIds.clear();
document.getElementById('notesSelectionBar').style.display = '';
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = '');
_updateSelectedCount();
}
function _exitSelectMode() {
_notesSelectMode = false;
_selectedNoteIds.clear();
document.getElementById('notesSelectionBar').style.display = 'none';
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = 'none');
document.querySelectorAll('.note-item.selected').forEach(el => el.classList.remove('selected'));
document.querySelectorAll('.note-checkbox').forEach(cb => cb.checked = false);
const sa = document.getElementById('notesSelectAll');
if (sa) sa.checked = false;
}
function _toggleNoteSelect(id, forceState) {
if (!_notesSelectMode) {
_enterSelectMode();
}
const has = _selectedNoteIds.has(id);
const newState = forceState !== undefined ? forceState : !has;
if (newState) _selectedNoteIds.add(id);
else _selectedNoteIds.delete(id);
const item = document.querySelector(`.note-item[data-note-id="${id}"]`);
if (item) {
item.classList.toggle('selected', newState);
const cb = item.querySelector('.note-checkbox');
if (cb) cb.checked = newState;
}
_updateSelectedCount();
}
function _toggleSelectAll(checked) {
document.querySelectorAll('.note-checkbox').forEach(cb => {
const id = cb.dataset.id;
if (checked) _selectedNoteIds.add(id);
else _selectedNoteIds.delete(id);
cb.checked = checked;
cb.closest('.note-item')?.classList.toggle('selected', checked);
});
_updateSelectedCount();
}
function _updateSelectedCount() {
const el = document.getElementById('notesSelectedCount');
if (el) el.textContent = _selectedNoteIds.size;
}
async function _bulkDeleteSelected() {
if (_selectedNoteIds.size === 0) return;
const count = _selectedNoteIds.size;
if (!confirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
try {
const resp = await API.bulkDeleteNotes([..._selectedNoteIds]);
UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success');
_exitSelectMode();
await loadNotesList();
await loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Folders / Editor / CRUD ─────
async function loadNoteFolders() {
const sel = document.getElementById('notesFolderFilter');
if (!sel) return;
try {
const data = await API.listNoteFolders();
const folders = data.folders || [];
sel.innerHTML = '<option value="">All folders</option>';
folders.forEach(f => {
sel.innerHTML += `<option value="${esc(f.path)}">${esc(f.path)} (${f.count})</option>`;
});
} catch (e) { /* non-critical */ }
}
function showNotesList() {
document.getElementById('notesListView').style.display = '';
document.getElementById('notesEditorView').style.display = 'none';
_editingNoteId = null;
}
var _currentNote = null; // cached note for read mode
async function openNoteEditor(noteId) {
document.getElementById('notesListView').style.display = 'none';
document.getElementById('notesEditorView').style.display = '';
if (noteId) {
_editingNoteId = noteId;
try {
_currentNote = await API.getNote(noteId);
_populateEditFields(_currentNote);
_showNoteReadMode();
} catch (e) {
UI.toast('Failed to load note: ' + e.message, 'error');
showNotesList();
}
} else {
// New note — straight to edit mode
_editingNoteId = null;
_currentNote = null;
_clearEditFields();
_showNoteEditMode();
document.getElementById('noteEditorTitle').focus();
}
}
function _populateEditFields(note) {
document.getElementById('noteEditorTitle').value = note.title || '';
document.getElementById('noteEditorFolder').value = note.folder_path || '';
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
document.getElementById('noteEditorContent').value = note.content || '';
}
function _clearEditFields() {
document.getElementById('noteEditorTitle').value = '';
document.getElementById('noteEditorFolder').value = '';
document.getElementById('noteEditorTags').value = '';
document.getElementById('noteEditorContent').value = '';
}
function _showNoteReadMode() {
if (!_currentNote) return;
const n = _currentNote;
// Title
document.getElementById('noteReadTitle').textContent = n.title || '';
// Meta: folder + tags
const parts = [];
if (n.folder_path && n.folder_path !== '/') parts.push(`<span class="note-folder">${esc(n.folder_path)}</span>`);
(n.tags || []).forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
// Rendered markdown content — reuse the chat formatter
document.getElementById('noteReadContent').innerHTML = formatMessage(n.content || '');
// Show/hide delete
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
// Toggle visibility
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';
document.getElementById('noteEditBtn').style.display = '';
document.getElementById('notePreviewBtn').style.display = 'none';
}
function _showNoteEditMode() {
document.getElementById('noteReadMode').style.display = 'none';
document.getElementById('noteEditMode').style.display = '';
document.getElementById('noteDeleteBtn').style.display = _editingNoteId ? '' : 'none';
document.getElementById('noteCancelEditBtn').style.display = _editingNoteId ? '' : 'none';
document.getElementById('noteEditBtn').style.display = 'none';
document.getElementById('notePreviewBtn').style.display = '';
}
function _showNotePreview() {
// Live preview from textarea without saving
const title = document.getElementById('noteEditorTitle').value.trim();
const content = document.getElementById('noteEditorContent').value;
const folderVal = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
document.getElementById('noteReadTitle').textContent = title || 'Untitled';
const parts = [];
if (folderVal) parts.push(`<span class="note-folder">${esc(folderVal)}</span>`);
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';
document.getElementById('noteEditBtn').style.display = '';
document.getElementById('notePreviewBtn').style.display = 'none';
// Keep delete hidden in preview-from-edit
document.getElementById('noteDeleteBtn2').style.display = 'none';
}
async function saveNote() {
const title = document.getElementById('noteEditorTitle').value.trim();
const content = document.getElementById('noteEditorContent').value;
const folder = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
if (!title) { UI.toast('Title is required', 'warning'); return; }
if (!content) { UI.toast('Content is required', 'warning'); return; }
try {
if (_editingNoteId) {
const updated = await API.updateNote(_editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' });
_currentNote = updated;
UI.toast('Note updated', 'success');
_showNoteReadMode();
} else {
const created = await API.createNote(title, content, folder, tags);
_editingNoteId = created.id;
_currentNote = created;
UI.toast('Note created', 'success');
_showNoteReadMode();
}
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteNote() {
if (!_editingNoteId) return;
if (!confirm('Delete this note?')) return;
try {
await API.deleteNote(_editingNoteId);
UI.toast('Note deleted', 'success');
showNotesList();
await loadNotesList();
await loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Banners ──────────────────────────────────
async function initBanners() {

View File

@@ -215,6 +215,7 @@ const UI = {
<div class="msg-avatar">🤖</div>
<div class="msg-body">
<div class="msg-head"><span class="msg-role">${esc(label)}</span></div>
<div class="msg-tools" id="streamTools" style="display:none"></div>
<div class="msg-text" id="streamContent"></div>
</div>
</div>`;
@@ -224,6 +225,7 @@ const UI = {
const decoder = new TextDecoder();
let buffer = '';
let content = '';
let currentEvent = ''; // SSE event type
while (true) {
const { done, value } = await reader.read();
@@ -234,18 +236,41 @@ const UI = {
buffer = lines.pop() || '';
for (const line of lines) {
// SSE event type line
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim();
continue;
}
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') continue;
if (data === '[DONE]') { currentEvent = ''; continue; }
try {
const parsed = JSON.parse(data);
if (currentEvent === 'tool_use') {
// parsed = array of tool calls
this._renderToolUse(parsed);
currentEvent = '';
continue;
}
if (currentEvent === 'tool_result') {
// parsed = { tool_call_id, name, content, is_error }
this._renderToolResult(parsed);
currentEvent = '';
continue;
}
// Normal content delta
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
content += delta;
document.getElementById('streamContent').innerHTML = formatMessage(content);
this._scrollToBottom();
}
currentEvent = '';
} catch (e) { /* partial JSON */ }
}
}
@@ -253,6 +278,47 @@ const UI = {
return content;
},
_renderToolUse(toolCalls) {
const el = document.getElementById('streamTools');
if (!el) return;
el.style.display = '';
for (const tc of toolCalls) {
const name = tc.function?.name || tc.name || 'unknown';
const toolDiv = document.createElement('div');
toolDiv.className = 'tool-activity';
toolDiv.id = `tool_${tc.id}`;
toolDiv.innerHTML = `
<span class="tool-icon">🔧</span>
<span class="tool-name">${esc(name)}</span>
<span class="tool-status tool-running">running…</span>`;
el.appendChild(toolDiv);
}
this._scrollToBottom();
},
_renderToolResult(result) {
const el = document.getElementById(`tool_${result.tool_call_id}`);
if (el) {
const statusEl = el.querySelector('.tool-status');
if (statusEl) {
statusEl.textContent = result.is_error ? 'error' : 'done';
statusEl.className = `tool-status ${result.is_error ? 'tool-error' : 'tool-done'}`;
}
// 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` : '';
if (summary) {
const hint = document.createElement('span');
hint.className = 'tool-hint';
hint.textContent = typeof summary === 'string' ? summary : JSON.stringify(summary);
el.appendChild(hint);
}
} catch (e) { /* not JSON or no useful summary */ }
}
this._scrollToBottom();
},
// ── Model Selector (Custom Dropdown) ────
_modelValue: '',