1030 lines
40 KiB
JavaScript
1030 lines
40 KiB
JavaScript
// ==========================================
|
||
// Chat Switchboard – Projects UI (v0.19.0)
|
||
// ==========================================
|
||
// Manages project CRUD, sidebar grouping, and
|
||
// chat-to-project association via context menu
|
||
// and drag-and-drop.
|
||
// ==========================================
|
||
|
||
async function loadProjects() {
|
||
try {
|
||
const resp = await API.listProjects();
|
||
App.projects = (resp.data || []).map(p => ({
|
||
id: p.id,
|
||
name: p.name,
|
||
description: p.description || '',
|
||
color: p.color || null,
|
||
icon: p.icon || null,
|
||
workspace_id: p.workspace_id || null,
|
||
channelCount: p.channel_count || 0,
|
||
kbCount: p.kb_count || 0,
|
||
noteCount: p.note_count || 0,
|
||
isArchived: p.is_archived || false,
|
||
}));
|
||
// Restore collapse state from localStorage
|
||
try {
|
||
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 = [];
|
||
}
|
||
}
|
||
|
||
function _saveCollapseState() {
|
||
try { localStorage.setItem('cs-collapsed-projects', JSON.stringify(App.collapsedProjects)); } catch (_) {}
|
||
}
|
||
|
||
function toggleProjectCollapse(projectId) {
|
||
App.collapsedProjects[projectId] = !App.collapsedProjects[projectId];
|
||
_saveCollapseState();
|
||
UI.renderChatList();
|
||
}
|
||
|
||
// ── Project CRUD ────────────────────────────
|
||
|
||
async function createProject() {
|
||
const name = prompt('Project name:');
|
||
if (!name || !name.trim()) return;
|
||
try {
|
||
const p = await API.createProject({ name: name.trim() });
|
||
App.projects.push({
|
||
id: p.id,
|
||
name: p.name,
|
||
description: p.description || '',
|
||
color: p.color || null,
|
||
icon: p.icon || null,
|
||
channelCount: 0, kbCount: 0, noteCount: 0,
|
||
isArchived: false,
|
||
});
|
||
UI.renderChatList();
|
||
UI.toast('Project created', 'success');
|
||
} catch (e) {
|
||
UI.toast('Failed to create project', 'error');
|
||
}
|
||
}
|
||
|
||
async function renameProject(projectId) {
|
||
const proj = App.projects.find(p => p.id === projectId);
|
||
if (!proj) return;
|
||
const name = prompt('Rename project:', proj.name);
|
||
if (!name || !name.trim() || name.trim() === proj.name) return;
|
||
try {
|
||
await API.updateProject(projectId, { name: name.trim() });
|
||
proj.name = name.trim();
|
||
UI.renderChatList();
|
||
} catch (e) {
|
||
UI.toast('Failed to rename project', 'error');
|
||
}
|
||
}
|
||
|
||
async function deleteProject(projectId) {
|
||
const proj = App.projects.find(p => p.id === projectId);
|
||
if (!proj) return;
|
||
if (!confirm(`Delete project "${proj.name}"?\nChats inside will be kept but unassigned.`)) return;
|
||
try {
|
||
await API.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) {
|
||
UI.toast('Failed to delete project', 'error');
|
||
}
|
||
}
|
||
|
||
async function setProjectColor(projectId) {
|
||
const proj = App.projects.find(p => p.id === projectId);
|
||
if (!proj) return;
|
||
// Simple color picker via an invisible input
|
||
const input = document.createElement('input');
|
||
input.type = 'color';
|
||
input.value = proj.color || '#3B82F6';
|
||
input.style.position = 'fixed';
|
||
input.style.opacity = '0';
|
||
document.body.appendChild(input);
|
||
input.addEventListener('change', async () => {
|
||
try {
|
||
await API.updateProject(projectId, { color: input.value });
|
||
proj.color = input.value;
|
||
UI.renderChatList();
|
||
} catch (e) {
|
||
UI.toast('Failed to set color', 'error');
|
||
}
|
||
input.remove();
|
||
});
|
||
input.addEventListener('blur', () => setTimeout(() => input.remove(), 200));
|
||
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) {
|
||
const chat = App.chats.find(c => c.id === chatId);
|
||
if (!chat) return;
|
||
const oldProjectId = chat.projectId;
|
||
|
||
if (projectId === null) {
|
||
// Remove from project
|
||
if (!oldProjectId) return;
|
||
try {
|
||
await API.removeChannelFromProject(oldProjectId, chatId);
|
||
chat.projectId = null;
|
||
// Remove from order tracking (v0.19.2)
|
||
const order = getProjectChannelOrder(oldProjectId);
|
||
setProjectChannelOrder(oldProjectId, order.filter(id => id !== chatId));
|
||
UI.renderChatList();
|
||
} catch (e) {
|
||
UI.toast('Failed to remove from project', 'error');
|
||
}
|
||
} else {
|
||
try {
|
||
await API.addChannelToProject(projectId, chatId, 0);
|
||
chat.projectId = projectId;
|
||
// Add to order tracking (v0.19.2)
|
||
const order = getProjectChannelOrder(projectId);
|
||
if (!order.includes(chatId)) order.push(chatId);
|
||
setProjectChannelOrder(projectId, order);
|
||
// Remove from old project order
|
||
if (oldProjectId) {
|
||
const oldOrder = getProjectChannelOrder(oldProjectId);
|
||
setProjectChannelOrder(oldProjectId, oldOrder.filter(id => id !== chatId));
|
||
}
|
||
UI.renderChatList();
|
||
} catch (e) {
|
||
UI.toast('Failed to move to project', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Context Menu ────────────────────────────
|
||
|
||
let _ctxMenu = null;
|
||
|
||
function showChatContextMenu(e, chatId) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
dismissChatContextMenu();
|
||
|
||
const chat = App.chats.find(c => c.id === chatId);
|
||
if (!chat) return;
|
||
|
||
const menu = document.createElement('div');
|
||
menu.className = 'project-ctx-menu';
|
||
|
||
// Move to project submenu
|
||
let items = '';
|
||
if (chat.projectId) {
|
||
// Reorder within project (v0.19.2)
|
||
const order = getProjectChannelOrder(chat.projectId);
|
||
const idx = order.indexOf(chatId);
|
||
if (idx > 0) {
|
||
items += `<button class="ctx-item" onclick="_moveChatInProject('${chat.projectId}','${chatId}',-1);dismissChatContextMenu()">
|
||
<span class="ctx-icon">↑</span> Move up
|
||
</button>`;
|
||
}
|
||
if (idx >= 0 && idx < order.length - 1) {
|
||
items += `<button class="ctx-item" onclick="_moveChatInProject('${chat.projectId}','${chatId}',1);dismissChatContextMenu()">
|
||
<span class="ctx-icon">↓</span> Move down
|
||
</button>`;
|
||
}
|
||
if (idx >= 0) items += '<div class="ctx-divider"></div>';
|
||
|
||
items += `<button class="ctx-item" onclick="moveChatToProject('${chatId}', null);dismissChatContextMenu()">
|
||
<span class="ctx-icon">↩</span> Remove from project
|
||
</button>`;
|
||
items += '<div class="ctx-divider"></div>';
|
||
}
|
||
|
||
App.projects.forEach(p => {
|
||
if (p.id === chat.projectId) return; // skip current
|
||
const dot = p.color ? `<span class="project-dot" style="background:${esc(p.color)}"></span>` : '';
|
||
items += `<button class="ctx-item" onclick="moveChatToProject('${chatId}','${p.id}');dismissChatContextMenu()">
|
||
${dot}<span>${esc(p.name)}</span>
|
||
</button>`;
|
||
});
|
||
|
||
if (App.projects.length === 0 && !chat.projectId) {
|
||
items += '<div class="ctx-hint">No projects yet</div>';
|
||
}
|
||
|
||
items += '<div class="ctx-divider"></div>';
|
||
items += `<button class="ctx-item" onclick="createProjectAndMove('${chatId}');dismissChatContextMenu()">
|
||
<span class="ctx-icon">+</span> New project…
|
||
</button>`;
|
||
|
||
// Workspace override (v0.21.5)
|
||
items += '<div class="ctx-divider"></div>';
|
||
items += `<button class="ctx-item" onclick="showWorkspacePicker('${chatId}');dismissChatContextMenu()">
|
||
<span class="ctx-icon">📁</span> Set workspace…
|
||
</button>`;
|
||
|
||
menu.innerHTML = items;
|
||
|
||
// Position near cursor
|
||
menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px';
|
||
menu.style.top = Math.min(e.clientY, window.innerHeight - 200) + 'px';
|
||
|
||
document.body.appendChild(menu);
|
||
_ctxMenu = menu;
|
||
|
||
// Dismiss on click outside
|
||
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
|
||
}
|
||
|
||
function dismissChatContextMenu() {
|
||
if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; }
|
||
}
|
||
|
||
async function createProjectAndMove(chatId) {
|
||
const name = prompt('New project name:');
|
||
if (!name || !name.trim()) return;
|
||
try {
|
||
const p = await API.createProject({ name: name.trim() });
|
||
App.projects.push({
|
||
id: p.id, name: p.name, description: '', color: null, icon: null,
|
||
channelCount: 0, kbCount: 0, noteCount: 0, isArchived: false,
|
||
});
|
||
await moveChatToProject(chatId, p.id);
|
||
} catch (e) {
|
||
UI.toast('Failed to create project', 'error');
|
||
}
|
||
}
|
||
|
||
// ── Drag and Drop ───────────────────────────
|
||
|
||
function onChatDragStart(e, chatId) {
|
||
e.dataTransfer.setData('text/plain', chatId);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.target.classList.add('dragging');
|
||
}
|
||
|
||
function onChatDragEnd(e) {
|
||
e.target.classList.remove('dragging');
|
||
document.querySelectorAll('.project-group.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||
}
|
||
|
||
function onProjectDragOver(e) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
e.currentTarget.classList.add('drag-over');
|
||
}
|
||
|
||
function onProjectDragLeave(e) {
|
||
e.currentTarget.classList.remove('drag-over');
|
||
}
|
||
|
||
async function onProjectDrop(e, projectId) {
|
||
e.preventDefault();
|
||
e.currentTarget.classList.remove('drag-over');
|
||
const chatId = e.dataTransfer.getData('text/plain');
|
||
if (!chatId) return;
|
||
await moveChatToProject(chatId, projectId);
|
||
}
|
||
|
||
async function onRecentDrop(e) {
|
||
e.preventDefault();
|
||
e.currentTarget.classList.remove('drag-over');
|
||
const chatId = e.dataTransfer.getData('text/plain');
|
||
if (!chatId) return;
|
||
await moveChatToProject(chatId, null);
|
||
}
|
||
|
||
// ── Project Header Menu ─────────────────────
|
||
|
||
function showProjectMenu(e, projectId) {
|
||
e.preventDefault();
|
||
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>
|
||
<button class="ctx-item" onclick="setProjectColor('${projectId}');dismissChatContextMenu()">
|
||
<span class="ctx-icon">🎨</span> Color
|
||
</button>
|
||
<div class="ctx-divider"></div>
|
||
<button class="ctx-item ctx-danger" onclick="deleteProject('${projectId}');dismissChatContextMenu()">
|
||
<span class="ctx-icon">🗑</span> Delete project
|
||
</button>`;
|
||
|
||
menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px';
|
||
menu.style.top = Math.min(e.clientY, window.innerHeight - 150) + 'px';
|
||
|
||
document.body.appendChild(menu);
|
||
_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">
|
||
<label class="project-panel-label">Default Persona</label>
|
||
<select id="projectPersonaSelect" class="project-panel-select">
|
||
<option value="">None (use model selector)</option>
|
||
</select>
|
||
<div class="project-panel-hint">New chats in this project inherit the persona's model, parameters, and system prompt.</div>
|
||
</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 class="project-panel-section">
|
||
<label class="project-panel-label">Workspace</label>
|
||
<div class="project-panel-ws-row" id="projectWsRow">
|
||
<select id="projectWsSelect" class="project-panel-select">
|
||
<option value="">None</option>
|
||
</select>
|
||
<button class="btn-small" id="projectWsCreate" title="Create workspace">+ New</button>
|
||
</div>
|
||
<div class="project-panel-hint">Workspace files are available to AI in all project chats. Enables the Editor surface.</div>
|
||
</div>
|
||
<div class="project-panel-section" id="projectGitSection" style="display:none">
|
||
<label class="project-panel-label">Git Settings</label>
|
||
<div style="display:flex;flex-direction:column;gap:6px">
|
||
<input type="text" id="projectGitRemote" class="project-panel-input" placeholder="Git remote URL (https://...)" spellcheck="false">
|
||
<input type="text" id="projectGitBranch" class="project-panel-input" placeholder="Branch (default: main)" spellcheck="false">
|
||
<select id="projectGitCredential" class="project-panel-select">
|
||
<option value="">No credential</option>
|
||
</select>
|
||
<button class="btn-small" id="projectGitSave">Save Git Config</button>
|
||
</div>
|
||
<div class="project-panel-hint">Configure git remote to sync workspace files with a repository.</div>
|
||
</div>
|
||
<div class="project-panel-section" id="projectFilesSection">
|
||
<label class="project-panel-label">Project Files</label>
|
||
<div id="projectFileList" class="project-panel-list"></div>
|
||
<div style="margin-top:6px">
|
||
<input type="file" id="projectFileInput" style="display:none" multiple>
|
||
<button class="btn-small" id="projectFileUploadBtn">Upload File</button>
|
||
</div>
|
||
</div>
|
||
<div class="project-panel-section project-panel-footer">
|
||
<label class="project-panel-checkbox">
|
||
<input type="checkbox" id="projectArchiveToggle">
|
||
<span>Archive this project</span>
|
||
</label>
|
||
</div>
|
||
</div>`;
|
||
body.appendChild(el);
|
||
|
||
// Wire save button
|
||
el.querySelector('#projectPromptSave').addEventListener('click', _saveProjectPrompt);
|
||
|
||
// Wire KB add button
|
||
el.querySelector('#projectAddKB').addEventListener('click', _showKBPicker);
|
||
|
||
// Wire persona select (v0.19.2)
|
||
el.querySelector('#projectPersonaSelect').addEventListener('change', _saveProjectPersona);
|
||
|
||
// Wire archive toggle (v0.19.2)
|
||
el.querySelector('#projectArchiveToggle').addEventListener('change', _toggleProjectArchive);
|
||
|
||
// Wire workspace select + create (v0.21.5)
|
||
el.querySelector('#projectWsSelect').addEventListener('change', _saveProjectWorkspace);
|
||
el.querySelector('#projectWsCreate').addEventListener('click', _createProjectWorkspace);
|
||
|
||
// Wire git settings (v0.22.4)
|
||
el.querySelector('#projectGitSave').addEventListener('click', _saveProjectGitConfig);
|
||
|
||
// Wire project files (v0.22.4)
|
||
el.querySelector('#projectFileUploadBtn').addEventListener('click', () => {
|
||
document.getElementById('projectFileInput').click();
|
||
});
|
||
el.querySelector('#projectFileInput').addEventListener('change', _handleProjectFileUpload);
|
||
|
||
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)
|
||
let fullSettings = {};
|
||
try {
|
||
const full = await API.getProject(projectId);
|
||
fullSettings = full.settings || {};
|
||
document.getElementById('projectSystemPrompt').value = fullSettings.system_prompt || '';
|
||
document.getElementById('projectPromptStatus').textContent = '';
|
||
// Archive toggle
|
||
document.getElementById('projectArchiveToggle').checked = !!full.is_archived;
|
||
} catch (_) {
|
||
document.getElementById('projectSystemPrompt').value = '';
|
||
}
|
||
|
||
// Populate persona picker (v0.19.2)
|
||
await _renderPersonaPicker(fullSettings.persona_id || '');
|
||
|
||
// Load KBs
|
||
await _renderProjectKBs(projectId);
|
||
|
||
// Load notes
|
||
await _renderProjectNotes(projectId);
|
||
|
||
// Load workspace picker (v0.21.5)
|
||
await _renderProjectWorkspace(projectId);
|
||
|
||
// Load git settings (v0.22.4) — depends on workspace being loaded
|
||
await _renderProjectGitSettings(projectId);
|
||
|
||
// Load project files (v0.22.4)
|
||
await _renderProjectFiles(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');
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// PERSONA PICKER (v0.19.2)
|
||
// ═══════════════════════════════════════════
|
||
|
||
async function _renderPersonaPicker(currentPersonaId) {
|
||
const select = document.getElementById('projectPersonaSelect');
|
||
if (!select) return;
|
||
select.innerHTML = '<option value="">None (use model selector)</option>';
|
||
try {
|
||
const resp = await API.listPresets();
|
||
const personas = resp.data || resp || [];
|
||
personas.forEach(p => {
|
||
const opt = document.createElement('option');
|
||
opt.value = p.id;
|
||
opt.textContent = p.name + (p.base_model_id ? ` (${p.base_model_id})` : '');
|
||
if (p.id === currentPersonaId) opt.selected = true;
|
||
select.appendChild(opt);
|
||
});
|
||
} catch (_) { /* best effort */ }
|
||
}
|
||
|
||
async function _saveProjectPersona() {
|
||
if (!_projectPanelId) return;
|
||
const personaId = document.getElementById('projectPersonaSelect').value;
|
||
try {
|
||
await API.updateProject(_projectPanelId, {
|
||
settings: { persona_id: personaId || null }
|
||
});
|
||
UI.toast(personaId ? 'Persona set' : 'Persona cleared', 'success');
|
||
} catch (e) {
|
||
UI.toast('Failed to update persona', 'error');
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// ARCHIVE TOGGLE (v0.19.2)
|
||
// ═══════════════════════════════════════════
|
||
|
||
async function _toggleProjectArchive() {
|
||
if (!_projectPanelId) return;
|
||
const checked = document.getElementById('projectArchiveToggle').checked;
|
||
try {
|
||
await API.updateProject(_projectPanelId, { is_archived: checked });
|
||
const proj = App.projects.find(p => p.id === _projectPanelId);
|
||
if (proj) proj.isArchived = checked;
|
||
UI.renderChatList();
|
||
UI.toast(checked ? 'Project archived' : 'Project unarchived', 'success');
|
||
} catch (e) {
|
||
UI.toast('Failed to update archive status', 'error');
|
||
document.getElementById('projectArchiveToggle').checked = !checked;
|
||
}
|
||
}
|
||
|
||
// ── Workspace picker (v0.21.5) ───────────────
|
||
|
||
async function _renderProjectWorkspace(projectId) {
|
||
const sel = document.getElementById('projectWsSelect');
|
||
if (!sel) return;
|
||
|
||
// Fetch current project state + available workspaces in parallel
|
||
const [proj, workspaces] = await Promise.all([
|
||
API.getProject(projectId).catch(() => null),
|
||
API.listWorkspaces().catch(() => []),
|
||
]);
|
||
|
||
const currentWsId = proj?.workspace_id || '';
|
||
const wsList = Array.isArray(workspaces) ? workspaces : (workspaces?.data || []);
|
||
|
||
sel.innerHTML = '<option value="">None</option>';
|
||
for (const ws of wsList) {
|
||
const opt = document.createElement('option');
|
||
opt.value = ws.id;
|
||
opt.textContent = ws.name || ws.id.slice(0, 8);
|
||
if (ws.id === currentWsId) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
async function _saveProjectWorkspace() {
|
||
if (!_projectPanelId) return;
|
||
const wsId = document.getElementById('projectWsSelect').value;
|
||
try {
|
||
await API.updateProject(_projectPanelId, { workspace_id: wsId || null });
|
||
UI.toast(wsId ? 'Workspace linked' : 'Workspace unlinked', 'success');
|
||
// Trigger editor surface check
|
||
if (typeof EditorMode !== 'undefined') EditorMode.check();
|
||
// Refresh git settings (v0.22.4)
|
||
await _renderProjectGitSettings(_projectPanelId);
|
||
} catch (e) {
|
||
UI.toast('Failed to update workspace', 'error');
|
||
}
|
||
}
|
||
|
||
async function _createProjectWorkspace() {
|
||
if (!_projectPanelId) return;
|
||
const proj = App.projects.find(p => p.id === _projectPanelId);
|
||
const name = prompt('Workspace name:', (proj?.name || 'Project') + ' Workspace');
|
||
if (!name || !name.trim()) return;
|
||
|
||
try {
|
||
const ws = await API.createWorkspace({
|
||
name: name.trim(),
|
||
owner_type: 'user',
|
||
owner_id: API.user?.id || '',
|
||
});
|
||
// Auto-bind to project
|
||
await API.updateProject(_projectPanelId, { workspace_id: ws.id });
|
||
UI.toast('Workspace created and linked', 'success');
|
||
// Refresh the picker
|
||
await _renderProjectWorkspace(_projectPanelId);
|
||
// Trigger editor surface check
|
||
if (typeof EditorMode !== 'undefined') EditorMode.check();
|
||
} catch (e) {
|
||
UI.toast('Failed to create workspace: ' + (e.message || e), 'error');
|
||
}
|
||
}
|
||
|
||
// ── Channel workspace picker (v0.21.5) ───────
|
||
|
||
// ── Workspace Git Config (v0.22.4) ───────────
|
||
|
||
async function _renderProjectGitSettings(projectId) {
|
||
const gitSection = document.getElementById('projectGitSection');
|
||
const wsId = document.getElementById('projectWsSelect')?.value;
|
||
if (!wsId) {
|
||
gitSection.style.display = 'none';
|
||
return;
|
||
}
|
||
gitSection.style.display = '';
|
||
|
||
try {
|
||
const ws = await API.getWorkspace(wsId);
|
||
document.getElementById('projectGitRemote').value = ws.git_remote_url || '';
|
||
document.getElementById('projectGitBranch').value = ws.git_branch || '';
|
||
|
||
// Load git credentials
|
||
const credSel = document.getElementById('projectGitCredential');
|
||
credSel.innerHTML = '<option value="">No credential</option>';
|
||
try {
|
||
const creds = await API.listGitCredentials();
|
||
const list = Array.isArray(creds) ? creds : (creds?.data || []);
|
||
for (const c of list) {
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = c.name || c.id.slice(0, 8);
|
||
if (c.id === ws.git_credential_id) opt.selected = true;
|
||
credSel.appendChild(opt);
|
||
}
|
||
} catch (_) {}
|
||
} catch (_) {
|
||
gitSection.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function _saveProjectGitConfig() {
|
||
const wsId = document.getElementById('projectWsSelect')?.value;
|
||
if (!wsId) return;
|
||
|
||
const remote = document.getElementById('projectGitRemote').value.trim();
|
||
const branch = document.getElementById('projectGitBranch').value.trim() || null;
|
||
const credId = document.getElementById('projectGitCredential').value || null;
|
||
|
||
try {
|
||
await API.updateWorkspace(wsId, {
|
||
git_remote_url: remote || null,
|
||
git_branch: branch,
|
||
git_credential_id: credId,
|
||
});
|
||
UI.toast('Git config saved', 'success');
|
||
} catch (e) {
|
||
UI.toast('Failed to save git config: ' + (e.message || e), 'error');
|
||
}
|
||
}
|
||
|
||
// ── Project Files (v0.22.4) ──────────────────
|
||
|
||
async function _renderProjectFiles(projectId) {
|
||
const list = document.getElementById('projectFileList');
|
||
if (!list) return;
|
||
|
||
try {
|
||
const resp = await API.projectListFiles(projectId);
|
||
const files = resp?.files || [];
|
||
if (!files.length) {
|
||
list.innerHTML = '<div class="project-panel-empty">No files uploaded</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = files.map(f => {
|
||
const size = f.size_bytes > 1024 * 1024
|
||
? (f.size_bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||
: (f.size_bytes / 1024).toFixed(1) + ' KB';
|
||
return `<div class="project-panel-item" style="display:flex;justify-content:space-between;align-items:center">
|
||
<span title="${esc(f.filename)}">${esc(f.filename)}</span>
|
||
<span class="text-muted" style="font-size:11px">${size}</span>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
list.innerHTML = '<div class="project-panel-empty">Failed to load files</div>';
|
||
}
|
||
}
|
||
|
||
async function _handleProjectFileUpload() {
|
||
const input = document.getElementById('projectFileInput');
|
||
if (!input.files?.length || !_projectPanelId) return;
|
||
|
||
for (const file of input.files) {
|
||
try {
|
||
await API.projectUploadFile(_projectPanelId, file);
|
||
UI.toast(`Uploaded ${file.name}`, 'success');
|
||
} catch (e) {
|
||
UI.toast(`Upload failed: ${e.message || e}`, 'error');
|
||
}
|
||
}
|
||
input.value = '';
|
||
await _renderProjectFiles(_projectPanelId);
|
||
}
|
||
|
||
|
||
|
||
async function showWorkspacePicker(chatId) {
|
||
let workspaces = [];
|
||
try {
|
||
const resp = await API.listWorkspaces();
|
||
workspaces = Array.isArray(resp) ? resp : (resp?.data || []);
|
||
} catch (_) {}
|
||
|
||
// Get current channel workspace
|
||
let currentWsId = '';
|
||
try {
|
||
const ch = await API.getChannel(chatId);
|
||
currentWsId = ch.workspace_id || '';
|
||
} catch (_) {}
|
||
|
||
// Build options
|
||
const options = [{ value: '', label: 'None (inherit from project)' }];
|
||
for (const ws of workspaces) {
|
||
options.push({ value: ws.id, label: ws.name || ws.id.slice(0, 8) });
|
||
}
|
||
options.push({ value: '__new__', label: '+ Create new workspace…' });
|
||
|
||
// Use simple select via popup
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'editor-quick-open'; // reuse overlay style
|
||
overlay.innerHTML = `
|
||
<div class="editor-qo-dialog" style="width:340px">
|
||
<div style="padding:10px 14px; font-weight:600; border-bottom:1px solid var(--border)">Set Workspace</div>
|
||
<div class="editor-qo-results" id="wsPickerList"></div>
|
||
</div>`;
|
||
|
||
const list = overlay.querySelector('#wsPickerList');
|
||
for (const opt of options) {
|
||
const row = document.createElement('div');
|
||
row.className = 'editor-qo-row' + (opt.value === currentWsId ? ' active' : '');
|
||
row.style.fontWeight = opt.value === currentWsId ? '600' : '';
|
||
row.textContent = opt.label;
|
||
row.addEventListener('click', async () => {
|
||
overlay.remove();
|
||
if (opt.value === '__new__') {
|
||
const name = prompt('Workspace name:');
|
||
if (!name || !name.trim()) return;
|
||
try {
|
||
const ws = await API.createWorkspace({
|
||
name: name.trim(),
|
||
owner_type: 'user',
|
||
owner_id: API.user?.id || '',
|
||
});
|
||
await API.updateChannel(chatId, { workspace_id: ws.id });
|
||
UI.toast('Workspace created and linked', 'success');
|
||
if (typeof EditorMode !== 'undefined') EditorMode.check();
|
||
} catch (e) {
|
||
UI.toast('Failed: ' + (e.message || e), 'error');
|
||
}
|
||
} else {
|
||
try {
|
||
await API.updateChannel(chatId, { workspace_id: opt.value || '' });
|
||
UI.toast(opt.value ? 'Workspace linked' : 'Workspace unlinked', 'success');
|
||
if (typeof EditorMode !== 'undefined') EditorMode.check();
|
||
} catch (e) {
|
||
UI.toast('Failed: ' + (e.message || e), 'error');
|
||
}
|
||
}
|
||
});
|
||
list.appendChild(row);
|
||
}
|
||
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) overlay.remove();
|
||
});
|
||
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// CHANNEL REORDER WITHIN PROJECT (v0.19.2)
|
||
// ═══════════════════════════════════════════
|
||
|
||
// Stores project channel order: projectId → [channelId, ...]
|
||
const _projectChannelOrder = {};
|
||
|
||
function getProjectChannelOrder(projectId) {
|
||
return _projectChannelOrder[projectId] || [];
|
||
}
|
||
|
||
function setProjectChannelOrder(projectId, channelIds) {
|
||
_projectChannelOrder[projectId] = channelIds;
|
||
}
|
||
|
||
// Called after loadProjects + loadChats to initialize order from server positions
|
||
async function loadProjectChannelPositions() {
|
||
for (const proj of App.projects) {
|
||
try {
|
||
const resp = await API.listProjectChannels(proj.id);
|
||
const channels = resp.data || resp || [];
|
||
// Sorted by position from server
|
||
setProjectChannelOrder(proj.id, channels.map(c => c.channel_id));
|
||
} catch (_) { /* best effort — fall back to no order */ }
|
||
}
|
||
}
|
||
|
||
function _getReorderedProjectChats(projectId, chats) {
|
||
const order = getProjectChannelOrder(projectId);
|
||
const projChats = chats.filter(c => c.projectId === projectId);
|
||
if (order.length === 0) return projChats;
|
||
|
||
// Sort by position order; unordered chats go to end
|
||
const posMap = new Map(order.map((id, i) => [id, i]));
|
||
return projChats.sort((a, b) => {
|
||
const pa = posMap.has(a.id) ? posMap.get(a.id) : 9999;
|
||
const pb = posMap.has(b.id) ? posMap.get(b.id) : 9999;
|
||
return pa - pb;
|
||
});
|
||
}
|
||
|
||
async function _reorderOnDrop(e, projectId) {
|
||
// Get all chat items in this project group from the DOM
|
||
const group = e.currentTarget;
|
||
const chatItems = group.querySelectorAll('.chat-item');
|
||
const newOrder = [];
|
||
chatItems.forEach(el => {
|
||
const onclick = el.getAttribute('onclick') || '';
|
||
const m = onclick.match(/selectChat\('([^']+)'\)/);
|
||
if (m) newOrder.push(m[1]);
|
||
});
|
||
|
||
if (newOrder.length === 0) return;
|
||
|
||
// Optimistic update
|
||
setProjectChannelOrder(projectId, newOrder);
|
||
|
||
try {
|
||
await API.reorderProjectChannels(projectId, newOrder);
|
||
} catch (e) {
|
||
UI.toast('Failed to save order', 'error');
|
||
}
|
||
}
|
||
|
||
async function _moveChatInProject(projectId, chatId, direction) {
|
||
const order = getProjectChannelOrder(projectId);
|
||
const idx = order.indexOf(chatId);
|
||
if (idx < 0) return;
|
||
const newIdx = idx + direction;
|
||
if (newIdx < 0 || newIdx >= order.length) return;
|
||
|
||
// Swap
|
||
[order[idx], order[newIdx]] = [order[newIdx], order[idx]];
|
||
setProjectChannelOrder(projectId, order);
|
||
UI.renderChatList();
|
||
|
||
try {
|
||
await API.reorderProjectChannels(projectId, order);
|
||
} catch (e) {
|
||
// Revert on failure
|
||
[order[idx], order[newIdx]] = [order[newIdx], order[idx]];
|
||
setProjectChannelOrder(projectId, order);
|
||
UI.renderChatList();
|
||
UI.toast('Failed to reorder', 'error');
|
||
}
|
||
}
|