|
|
|
|
@@ -25,6 +25,14 @@ async function loadProjects() {
|
|
|
|
|
const saved = JSON.parse(localStorage.getItem('cs-collapsed-projects') || '{}');
|
|
|
|
|
App.collapsedProjects = saved;
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
// Restore active project (v0.19.1)
|
|
|
|
|
const savedActive = localStorage.getItem('cs-active-project');
|
|
|
|
|
if (savedActive && App.projects.some(p => p.id === savedActive)) {
|
|
|
|
|
App.activeProjectId = savedActive;
|
|
|
|
|
} else {
|
|
|
|
|
App.activeProjectId = null;
|
|
|
|
|
localStorage.removeItem('cs-active-project');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Failed to load projects:', e.message);
|
|
|
|
|
App.projects = [];
|
|
|
|
|
@@ -87,6 +95,11 @@ async function deleteProject(projectId) {
|
|
|
|
|
App.projects = App.projects.filter(p => p.id !== projectId);
|
|
|
|
|
// Unassign chats client-side
|
|
|
|
|
App.chats.forEach(c => { if (c.projectId === projectId) c.projectId = null; });
|
|
|
|
|
// Clear active if this was the active project (v0.19.1)
|
|
|
|
|
if (App.activeProjectId === projectId) {
|
|
|
|
|
App.activeProjectId = null;
|
|
|
|
|
localStorage.removeItem('cs-active-project');
|
|
|
|
|
}
|
|
|
|
|
UI.renderChatList();
|
|
|
|
|
UI.toast('Project deleted', 'success');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
@@ -118,6 +131,19 @@ async function setProjectColor(projectId) {
|
|
|
|
|
input.click();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Active Project (v0.19.1) ────────────────
|
|
|
|
|
|
|
|
|
|
function toggleActiveProject(projectId) {
|
|
|
|
|
if (App.activeProjectId === projectId) {
|
|
|
|
|
App.activeProjectId = null;
|
|
|
|
|
localStorage.removeItem('cs-active-project');
|
|
|
|
|
} else {
|
|
|
|
|
App.activeProjectId = projectId;
|
|
|
|
|
localStorage.setItem('cs-active-project', projectId);
|
|
|
|
|
}
|
|
|
|
|
UI.renderChatList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Move Chat to Project ────────────────────
|
|
|
|
|
|
|
|
|
|
async function moveChatToProject(chatId, projectId) {
|
|
|
|
|
@@ -265,9 +291,19 @@ function showProjectMenu(e, projectId) {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
dismissChatContextMenu();
|
|
|
|
|
|
|
|
|
|
const isActive = App.activeProjectId === projectId;
|
|
|
|
|
const activeLabel = isActive ? 'Unpin project' : 'Pin as active';
|
|
|
|
|
|
|
|
|
|
const menu = document.createElement('div');
|
|
|
|
|
menu.className = 'project-ctx-menu';
|
|
|
|
|
menu.innerHTML = `
|
|
|
|
|
<button class="ctx-item" onclick="toggleActiveProject('${projectId}');dismissChatContextMenu()">
|
|
|
|
|
<span class="ctx-icon">${isActive ? '📌' : '📌'}</span> ${activeLabel}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="ctx-item" onclick="openProjectPanel('${projectId}');dismissChatContextMenu()">
|
|
|
|
|
<span class="ctx-icon">⚙</span> Project settings
|
|
|
|
|
</button>
|
|
|
|
|
<div class="ctx-divider"></div>
|
|
|
|
|
<button class="ctx-item" onclick="renameProject('${projectId}');dismissChatContextMenu()">
|
|
|
|
|
<span class="ctx-icon">✏</span> Rename
|
|
|
|
|
</button>
|
|
|
|
|
@@ -286,3 +322,218 @@ function showProjectMenu(e, projectId) {
|
|
|
|
|
_ctxMenu = menu;
|
|
|
|
|
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════
|
|
|
|
|
// PROJECT DETAIL PANEL (v0.19.1)
|
|
|
|
|
// ═══════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
let _projectPanelId = null; // currently-displayed project ID
|
|
|
|
|
|
|
|
|
|
function _registerProjectPanel() {
|
|
|
|
|
const body = document.getElementById('sidePanelBody');
|
|
|
|
|
if (!body || typeof PanelRegistry === 'undefined') return;
|
|
|
|
|
|
|
|
|
|
// Create the panel element dynamically
|
|
|
|
|
const el = document.createElement('div');
|
|
|
|
|
el.className = 'side-panel-page';
|
|
|
|
|
el.id = 'sidePanelProject';
|
|
|
|
|
el.style.display = 'none';
|
|
|
|
|
el.innerHTML = `
|
|
|
|
|
<div class="project-panel" id="projectPanelInner">
|
|
|
|
|
<div class="project-panel-section">
|
|
|
|
|
<label class="project-panel-label">System Prompt</label>
|
|
|
|
|
<textarea id="projectSystemPrompt" class="project-panel-textarea"
|
|
|
|
|
placeholder="Instructions for all chats in this project..." rows="6"></textarea>
|
|
|
|
|
<button class="btn-small btn-primary" id="projectPromptSave" style="margin-top:6px">Save</button>
|
|
|
|
|
<span id="projectPromptStatus" class="project-panel-status"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="project-panel-section">
|
|
|
|
|
<div class="project-panel-section-header">
|
|
|
|
|
<label class="project-panel-label">Knowledge Bases</label>
|
|
|
|
|
<button class="btn-small" id="projectAddKB" title="Add KB">+ Add</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="projectKBList" class="project-panel-list"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="project-panel-section">
|
|
|
|
|
<label class="project-panel-label">Notes</label>
|
|
|
|
|
<div id="projectNoteList" class="project-panel-list"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
body.appendChild(el);
|
|
|
|
|
|
|
|
|
|
// Wire save button
|
|
|
|
|
el.querySelector('#projectPromptSave').addEventListener('click', _saveProjectPrompt);
|
|
|
|
|
|
|
|
|
|
// Wire KB add button
|
|
|
|
|
el.querySelector('#projectAddKB').addEventListener('click', _showKBPicker);
|
|
|
|
|
|
|
|
|
|
PanelRegistry.register('project', {
|
|
|
|
|
element: el,
|
|
|
|
|
label: 'Project',
|
|
|
|
|
onOpen() { if (_projectPanelId) _loadProjectPanel(_projectPanelId); },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openProjectPanel(projectId) {
|
|
|
|
|
_projectPanelId = projectId;
|
|
|
|
|
const proj = App.projects.find(p => p.id === projectId);
|
|
|
|
|
if (typeof PanelRegistry !== 'undefined') {
|
|
|
|
|
PanelRegistry.open('project');
|
|
|
|
|
// Update label to show project name
|
|
|
|
|
const label = document.getElementById('sidePanelLabel');
|
|
|
|
|
if (label && proj) label.textContent = proj.name;
|
|
|
|
|
}
|
|
|
|
|
await _loadProjectPanel(projectId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function _loadProjectPanel(projectId) {
|
|
|
|
|
const proj = App.projects.find(p => p.id === projectId);
|
|
|
|
|
if (!proj) return;
|
|
|
|
|
|
|
|
|
|
// Load full project data from server (includes settings)
|
|
|
|
|
try {
|
|
|
|
|
const full = await API.getProject(projectId);
|
|
|
|
|
const sp = (full.settings && full.settings.system_prompt) || '';
|
|
|
|
|
document.getElementById('projectSystemPrompt').value = sp;
|
|
|
|
|
document.getElementById('projectPromptStatus').textContent = '';
|
|
|
|
|
} catch (_) {
|
|
|
|
|
document.getElementById('projectSystemPrompt').value = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load KBs
|
|
|
|
|
await _renderProjectKBs(projectId);
|
|
|
|
|
|
|
|
|
|
// Load notes
|
|
|
|
|
await _renderProjectNotes(projectId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function _saveProjectPrompt() {
|
|
|
|
|
if (!_projectPanelId) return;
|
|
|
|
|
const sp = document.getElementById('projectSystemPrompt').value;
|
|
|
|
|
const status = document.getElementById('projectPromptStatus');
|
|
|
|
|
try {
|
|
|
|
|
await API.updateProject(_projectPanelId, {
|
|
|
|
|
settings: { system_prompt: sp }
|
|
|
|
|
});
|
|
|
|
|
status.textContent = '✓ Saved';
|
|
|
|
|
status.className = 'project-panel-status success';
|
|
|
|
|
setTimeout(() => { status.textContent = ''; }, 2000);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
status.textContent = '✗ Failed';
|
|
|
|
|
status.className = 'project-panel-status error';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function _renderProjectKBs(projectId) {
|
|
|
|
|
const list = document.getElementById('projectKBList');
|
|
|
|
|
if (!list) return;
|
|
|
|
|
try {
|
|
|
|
|
const resp = await API.listProjectKBs(projectId);
|
|
|
|
|
const kbs = resp.data || resp || [];
|
|
|
|
|
if (kbs.length === 0) {
|
|
|
|
|
list.innerHTML = '<div class="project-panel-empty">No KBs bound to this project</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
list.innerHTML = kbs.map(kb => `
|
|
|
|
|
<div class="project-panel-item">
|
|
|
|
|
<span class="project-panel-item-name">${esc(kb.name || kb.kb_id)}</span>
|
|
|
|
|
<button class="project-panel-item-remove" onclick="_removeProjectKB('${projectId}','${kb.kb_id}')" title="Remove">✕</button>
|
|
|
|
|
</div>`).join('');
|
|
|
|
|
} catch (_) {
|
|
|
|
|
list.innerHTML = '<div class="project-panel-empty">Failed to load KBs</div>';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function _removeProjectKB(projectId, kbId) {
|
|
|
|
|
try {
|
|
|
|
|
await API.removeKBFromProject(projectId, kbId);
|
|
|
|
|
await _renderProjectKBs(projectId);
|
|
|
|
|
UI.toast('KB removed from project', 'success');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
UI.toast('Failed to remove KB', 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function _showKBPicker() {
|
|
|
|
|
if (!_projectPanelId) return;
|
|
|
|
|
try {
|
|
|
|
|
// Get all KBs user can see
|
|
|
|
|
const allKBs = await API.listKnowledgeBases();
|
|
|
|
|
const kbList = allKBs.data || allKBs || [];
|
|
|
|
|
if (kbList.length === 0) {
|
|
|
|
|
UI.toast('No knowledge bases available', 'info');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get already-bound KBs
|
|
|
|
|
const boundResp = await API.listProjectKBs(_projectPanelId);
|
|
|
|
|
const boundIds = new Set((boundResp.data || boundResp || []).map(k => k.kb_id));
|
|
|
|
|
|
|
|
|
|
// Filter to unbound only
|
|
|
|
|
const available = kbList.filter(kb => !boundIds.has(kb.id));
|
|
|
|
|
if (available.length === 0) {
|
|
|
|
|
UI.toast('All KBs already bound to this project', 'info');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Simple picker: use a dropdown menu
|
|
|
|
|
const btn = document.getElementById('projectAddKB');
|
|
|
|
|
const rect = btn.getBoundingClientRect();
|
|
|
|
|
dismissChatContextMenu();
|
|
|
|
|
|
|
|
|
|
const menu = document.createElement('div');
|
|
|
|
|
menu.className = 'project-ctx-menu';
|
|
|
|
|
menu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px';
|
|
|
|
|
menu.style.top = (rect.bottom + 4) + 'px';
|
|
|
|
|
menu.innerHTML = available.map(kb => `
|
|
|
|
|
<button class="ctx-item" onclick="_addProjectKB('${_projectPanelId}','${kb.id}');dismissChatContextMenu()">
|
|
|
|
|
${esc(kb.name)}
|
|
|
|
|
</button>`).join('');
|
|
|
|
|
|
|
|
|
|
document.body.appendChild(menu);
|
|
|
|
|
_ctxMenu = menu;
|
|
|
|
|
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
UI.toast('Failed to load KBs', 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function _addProjectKB(projectId, kbId) {
|
|
|
|
|
try {
|
|
|
|
|
await API.addKBToProject(projectId, kbId, true);
|
|
|
|
|
await _renderProjectKBs(projectId);
|
|
|
|
|
UI.toast('KB added to project', 'success');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
UI.toast('Failed to add KB', 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function _renderProjectNotes(projectId) {
|
|
|
|
|
const list = document.getElementById('projectNoteList');
|
|
|
|
|
if (!list) return;
|
|
|
|
|
try {
|
|
|
|
|
const resp = await API.listProjectNotes(projectId);
|
|
|
|
|
const notes = resp.data || resp || [];
|
|
|
|
|
if (notes.length === 0) {
|
|
|
|
|
list.innerHTML = '<div class="project-panel-empty">No notes in this project</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
list.innerHTML = notes.map(n => `
|
|
|
|
|
<div class="project-panel-item">
|
|
|
|
|
<span class="project-panel-item-name">${esc(n.title || n.note_id)}</span>
|
|
|
|
|
<button class="project-panel-item-remove" onclick="_removeProjectNote('${projectId}','${n.note_id}')" title="Remove">✕</button>
|
|
|
|
|
</div>`).join('');
|
|
|
|
|
} catch (_) {
|
|
|
|
|
list.innerHTML = '<div class="project-panel-empty">Failed to load notes</div>';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function _removeProjectNote(projectId, noteId) {
|
|
|
|
|
try {
|
|
|
|
|
await API.removeNoteFromProject(projectId, noteId);
|
|
|
|
|
await _renderProjectNotes(projectId);
|
|
|
|
|
UI.toast('Note removed from project', 'success');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
UI.toast('Failed to remove note', 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|