Changeset 0.7.2 (#40)
This commit is contained in:
@@ -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() {
|
||||
|
||||
351
src/js/app.js
351
src/js/app.js
@@ -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() {
|
||||
|
||||
68
src/js/ui.js
68
src/js/ui.js
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user