Changeset 0.22.5 (#147)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-03 09:58:23 +00:00
committed by xcaliber
parent 3953dcf364
commit 45fe965c32
32 changed files with 3021 additions and 11 deletions

View File

@@ -64,6 +64,7 @@
}
.editor-tree-add:hover { background: var(--hover); color: var(--accent); }
.editor-tree-content { flex: 1; overflow-y: auto; padding: 4px 0; }
.editor-file-tree.drag-over { outline: 2px dashed var(--accent, #5865f2); outline-offset: -2px; background: rgba(88,101,242,0.05); }
.editor-tree-row {
display: flex; align-items: center; gap: 4px;
padding: 3px 8px; cursor: pointer;

View File

@@ -39,6 +39,12 @@ const API = {
refreshToken: this.refreshToken,
user: this.user
}));
// Sync to cookie for Go template page auth (v0.22.5)
if (this.accessToken) {
document.cookie = `sb_token=${this.accessToken}; path=/; max-age=900; SameSite=Strict`;
} else {
document.cookie = 'sb_token=; path=/; max-age=0';
}
},
clearTokens() {
@@ -46,6 +52,7 @@ const API = {
this.refreshToken = null;
this.user = null;
localStorage.removeItem(_storageKey);
document.cookie = 'sb_token=; path=/; max-age=0'; // clear page auth cookie
if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
},
@@ -255,6 +262,32 @@ const API = {
mkdirWorkspace(wsId, path) {
return this._post(`/api/v1/workspaces/${wsId}/files/mkdir`, { path });
},
// Upload a File object to a workspace (binary-safe, v0.23.0)
async uploadWorkspaceFile(wsId, file, destPath) {
const path = destPath || file.name;
const url = BASE + `/api/v1/workspaces/${wsId}/files/write?path=${encodeURIComponent(path)}`;
const doFetch = () => fetch(url, {
method: 'PUT',
headers: {
'Content-Type': file.type || 'application/octet-stream',
'Content-Length': file.size.toString(),
'Authorization': `Bearer ${this.accessToken}`,
},
body: file,
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
await this._refreshAccessToken();
resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${resp.status}`);
}
return resp.json();
},
getWorkspaceGitStatus(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/status`); },
getWorkspaceGitBranches(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/branches`); },
listWorkspaces() { return this._get('/api/v1/workspaces'); },

View File

@@ -175,6 +175,43 @@ const EditorMode = {
}).catch(() => {});
},
/**
* Mount into server-rendered editor surface template containers.
* Called by the <script> in editor.html when __SURFACE__ === 'editor'.
* Bypasses Surfaces registry — the Go template owns the layout.
*/
mountServerRendered(wsId, wsName) {
this._wsId = wsId;
this._wsName = wsName || 'Workspace';
if (!this._built) this._build();
const headerMount = document.getElementById('editorHeaderMount');
const mainMount = document.getElementById('editorMainMount');
if (headerMount && this._els.header) {
headerMount.appendChild(this._els.header);
}
if (mainMount && this._els.main) {
mainMount.appendChild(this._els.main);
}
this._active = true;
this._registered = true;
// Populate file tree and git info
this._refreshFileTree();
this._refreshGitBranch();
// Focus active editor if any
if (this._activeFile) {
const f = this._openFiles.get(this._activeFile);
if (f?.editor?.focus) setTimeout(() => f.editor.focus(), 50);
}
console.log(`[EditorMode] Mounted server-rendered for workspace ${wsId}`);
},
_unregister() {
if (!this._registered) return;
if (this._active) {
@@ -325,6 +362,11 @@ const EditorMode = {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
<span class="editor-hdr-label">New</span>
</button>
<button class="editor-hdr-btn" id="editorUploadBtn" title="Upload files">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<span class="editor-hdr-label">Upload</span>
</button>
<input type="file" id="editorFileInput" multiple style="display:none">
<div class="editor-export-wrap">
<button class="editor-hdr-btn" id="editorExportBtn" title="Export active file">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
@@ -350,6 +392,15 @@ const EditorMode = {
// New file
el.querySelector('#editorNewFileBtn').addEventListener('click', () => this._createNewFile());
// Upload files (v0.23.0 — fixes bug #3)
const uploadBtn = el.querySelector('#editorUploadBtn');
const fileInput = el.querySelector('#editorFileInput');
uploadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
if (fileInput.files.length) this._uploadFiles(fileInput.files);
fileInput.value = ''; // reset so same file can be re-selected
});
// Export dropdown
const exportBtn = el.querySelector('#editorExportBtn');
const exportMenu = el.querySelector('#editorExportMenu');
@@ -395,6 +446,25 @@ const EditorMode = {
treeContent.innerHTML = '<div class="editor-tree-loading">Loading files…</div>';
el.appendChild(treeContent);
// Drag-and-drop upload (v0.23.0 — fixes bug #3)
el.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
el.classList.add('drag-over');
});
el.addEventListener('dragleave', (e) => {
e.preventDefault();
el.classList.remove('drag-over');
});
el.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
el.classList.remove('drag-over');
if (e.dataTransfer?.files?.length) {
this._uploadFiles(e.dataTransfer.files);
}
});
return el;
},
@@ -982,6 +1052,46 @@ const EditorMode = {
}
},
// ── File Upload (v0.23.0 — fixes bug #3) ─────
async _uploadFiles(fileList) {
if (!this._wsId) return;
const files = Array.from(fileList);
const toast = typeof UI !== 'undefined' ? UI.toast.bind(UI) : console.log;
let ok = 0, fail = 0;
for (const file of files) {
try {
await API.uploadWorkspaceFile(this._wsId, file);
ok++;
} catch (e) {
fail++;
console.error(`[EditorMode] Upload failed for ${file.name}:`, e);
}
}
if (ok > 0) {
toast(`Uploaded ${ok} file${ok > 1 ? 's' : ''}${fail ? ` (${fail} failed)` : ''}`, fail ? 'warning' : 'success');
await this._refreshFileTree();
// Open the last uploaded file if it's a text file
if (files.length === 1) {
const f = files[0];
const textTypes = ['text/', 'application/json', 'application/javascript', 'application/xml',
'application/yaml', 'application/toml', 'application/x-sh'];
const textExts = ['.md', '.txt', '.js', '.ts', '.go', '.py', '.rs', '.c', '.h', '.css',
'.html', '.json', '.yaml', '.yml', '.toml', '.sh', '.sql', '.csv', '.xml',
'.jsx', '.tsx', '.vue', '.svelte', '.rb', '.java', '.kt', '.swift', '.lua',
'.pl', '.php', '.r', '.m', '.env', '.gitignore', '.dockerfile', '.tf', '.hcl'];
const isText = textTypes.some(t => f.type.startsWith(t)) ||
textExts.some(ext => f.name.toLowerCase().endsWith(ext)) ||
f.type === '';
if (isText) this._openFile(f.name);
}
} else if (fail > 0) {
toast(`Upload failed for ${fail} file${fail > 1 ? 's' : ''}`, 'error');
}
},
// ── Export ───────────────────────────────
async _exportActiveFile(format) {

303
src/js/pages.js Normal file
View File

@@ -0,0 +1,303 @@
// ==========================================
// Chat Switchboard Pages (v0.22.5)
// ==========================================
// Client-side handlers for Go template-rendered pages.
// Works with server-rendered HTML — no DOM construction,
// just event handling and API calls.
//
// The server pre-populates all dropdowns. This JS handles:
// - Cascading model select (provider change → filter models)
// - Admin save operations (roles, routing, providers, teams, users, settings)
// - Table filtering
// ==========================================
const Pages = {
// ── Model Select Cascading ───────────────
roleProviderChanged(selectEl) {
const providerID = selectEl.value;
const filterType = selectEl.dataset.filterType || '';
const row = selectEl.closest('.form-row') || selectEl.parentElement;
const modelSelect = row.querySelector('.model-model-select');
if (!modelSelect) return;
const models = _pageData('Models') || [];
const filtered = models.filter(m =>
m.provider_config_id === providerID &&
(!filterType || m.model_type === filterType)
);
modelSelect.innerHTML = '<option value="">— select model —</option>' +
filtered.map(m =>
`<option value="${m.model_id}">${_esc(m.display_name || m.model_id)}</option>`
).join('');
},
// ── Role Save ────────────────────────────
async saveRole(roleName) {
const container = document.querySelector(`.role-config[data-role="${roleName}"]`);
if (!container) return;
const getValue = (slot, cls) => {
const sel = container.querySelector(`[data-slot="${slot}"].${cls}`);
return sel ? sel.value : '';
};
const body = {
role: roleName,
primary: getValue('primary','model-provider-select') && getValue('primary','model-model-select')
? { provider_config_id: getValue('primary','model-provider-select'), model_id: getValue('primary','model-model-select') }
: null,
fallback: getValue('fallback','model-provider-select') && getValue('fallback','model-model-select')
? { provider_config_id: getValue('fallback','model-provider-select'), model_id: getValue('fallback','model-model-select') }
: null,
};
const resp = await _api('PUT', '/api/v1/admin/roles/' + roleName, body);
resp ? _toast('Role saved', 'success') : null;
},
// ── Routing Policy ───────────────────────
showRoutingForm() { _show('routingPolicyForm'); },
hideRoutingForm() { _hide('routingPolicyForm'); },
routingScopeChanged(sel) {
const row = document.getElementById('routingTeamRow');
if (row) row.style.display = sel.value === 'team' ? '' : 'none';
},
async saveRoutingPolicy() {
const id = _val('routingPolicyId');
const body = {
name: _val('routingPolicyName'),
priority: parseInt(_val('routingPolicyPriority') || '100'),
type: _val('routingPolicyType'),
scope: _val('routingPolicyScope'),
team_id: _val('routingPolicyScope') === 'team' ? _val('routingPolicyTeamId') : '',
config: _parseJSON(_val('routingPolicyConfig')),
active: document.getElementById('routingPolicyActive')?.checked ?? true,
};
if (body.config === null) return; // parse error
const ok = await _api(id ? 'PUT' : 'POST',
id ? '/api/v1/admin/routing/policies/' + id : '/api/v1/admin/routing/policies', body);
if (ok) { this.hideRoutingForm(); window.location.reload(); }
},
// ── Providers ────────────────────────────
showProviderForm() { _show('providerFormWrap'); _val('providerFormId', ''); _val('providerFormTitle', 'Add Provider'); },
hideProviderForm() { _hide('providerFormWrap'); },
editProvider(id) {
const details = (_pageData('ProviderDetails') || []).find(p => p.id === id);
if (!details) return;
_val('providerFormId', id);
_val('providerFormName', details.name);
_val('providerFormEndpoint', details.endpoint);
document.getElementById('providerFormTitle').textContent = 'Edit Provider';
_show('providerFormWrap');
},
async saveProvider() {
const id = _val('providerFormId');
const body = {
name: _val('providerFormName'),
provider: _val('providerFormType'),
endpoint: _val('providerFormEndpoint'),
scope: _val('providerFormScope'),
};
const key = _val('providerFormKey');
if (key) body.api_key = key;
const teamId = _val('providerFormTeamId');
if (body.scope === 'team' && teamId) body.team_id = teamId;
const ok = await _api(id ? 'PUT' : 'POST',
id ? '/api/v1/admin/providers/' + id : '/api/v1/admin/providers', body);
if (ok) { this.hideProviderForm(); window.location.reload(); }
},
async syncProvider(id) {
_toast('Syncing…', 'info');
const ok = await _api('POST', '/api/v1/admin/providers/' + id + '/sync', {});
if (ok) { _toast('Sync complete', 'success'); window.location.reload(); }
},
// ── Model Filtering ──────────────────────
filterModels(query) {
const q = (query || _val('modelSearch') || '').toLowerCase();
const typeF = _val('modelTypeFilter') || '';
const provF = _val('modelProviderFilter') || '';
document.querySelectorAll('#modelTable tbody tr').forEach(tr => {
const name = (tr.dataset.name || '').toLowerCase();
const type = tr.dataset.type || '';
const prov = tr.dataset.provider || '';
const show = (!q || name.includes(q)) && (!typeF || type === typeF) && (!provF || prov === provF);
tr.style.display = show ? '' : 'none';
});
},
// ── Teams ────────────────────────────────
showTeamForm() { _show('teamFormWrap'); _val('teamFormId', ''); _val('teamFormName', ''); _val('teamFormDescription', ''); },
hideTeamForm() { _hide('teamFormWrap'); },
editTeam(id, name, desc) {
_val('teamFormId', id);
_val('teamFormName', name);
_val('teamFormDescription', desc);
document.getElementById('teamFormTitle').textContent = 'Edit Team';
_show('teamFormWrap');
},
async saveTeam() {
const id = _val('teamFormId');
const body = { name: _val('teamFormName'), description: _val('teamFormDescription') };
const ok = await _api(id ? 'PUT' : 'POST',
id ? '/api/v1/admin/teams/' + id : '/api/v1/admin/teams', body);
if (ok) { this.hideTeamForm(); window.location.reload(); }
},
// ── Users ────────────────────────────────
filterUsers(query) {
const q = (query || '').toLowerCase();
document.querySelectorAll('#userTable tbody tr').forEach(tr => {
tr.style.display = (!q || (tr.dataset.name || '').toLowerCase().includes(q)) ? '' : 'none';
});
},
async editUserRole(id, username, currentRole) {
const role = prompt(`Role for ${username}:`, currentRole);
if (!role || role === currentRole) return;
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { role });
if (ok) window.location.reload();
},
async toggleUser(id, active) {
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { is_active: active });
if (ok) window.location.reload();
},
// ── Settings ─────────────────────────────
async saveSettings() {
// Policies
const policies = {
allow_registration: document.getElementById('settRegEnabled')?.checked ? 'true' : 'false',
default_user_active: _val('settRegDefaultState') === 'active' ? 'true' : 'false',
allow_user_byok: document.getElementById('settUserBYOK')?.checked ? 'true' : 'false',
allow_user_personas: document.getElementById('settUserPersonas')?.checked ? 'true' : 'false',
kb_direct_access: document.getElementById('settKBDirect')?.checked ? 'true' : 'false',
};
// Banner
const banner = {
enabled: document.getElementById('settBannerEnabled')?.checked || false,
text: _val('settBannerText'),
bg: _val('settBannerBg'),
fg: _val('settBannerFg'),
position: 'both',
};
// System prompt
const system_prompt = { content: _val('settSystemPrompt') };
const ok = await _api('PUT', '/api/v1/admin/settings', { policies, settings: { banner, system_prompt } });
if (ok) _toast('Settings saved', 'success');
},
// ── User Settings (settings surface) ─────
async saveProfile() {
const name = _val('settingsDisplayName');
if (!name) { _toast('Display name is required', 'error'); return; }
const ok = await _api('PUT', '/api/v1/users/me', { display_name: name });
if (ok) _toast('Profile saved', 'success');
},
async changePassword() {
const current = _val('settingsCurrentPw');
const newPw = _val('settingsNewPw');
const confirm = _val('settingsConfirmPw');
if (!current || !newPw) { _toast('All password fields are required', 'error'); return; }
if (newPw !== confirm) { _toast('Passwords do not match', 'error'); return; }
if (newPw.length < 8) { _toast('Password must be at least 8 characters', 'error'); return; }
const ok = await _api('PUT', '/api/v1/auth/password', { current_password: current, new_password: newPw });
if (ok) {
_toast('Password changed', 'success');
_val('settingsCurrentPw', '');
_val('settingsNewPw', '');
_val('settingsConfirmPw', '');
}
},
};
// ── Init: banner toggle ──────────────────────
document.addEventListener('DOMContentLoaded', () => {
const cb = document.getElementById('settBannerEnabled');
const fields = document.getElementById('bannerFields');
if (cb && fields) {
cb.addEventListener('change', () => { fields.style.display = cb.checked ? '' : 'none'; });
fields.style.display = cb.checked ? '' : 'none';
}
});
// ── Helpers ──────────────────────────────────
function _pageData(key) {
const d = window.__PAGE_DATA__;
return d ? (d[key] || d[key.toLowerCase()]) : null;
}
function _val(id, setVal) {
const el = document.getElementById(id);
if (!el) return '';
if (setVal !== undefined) { el.value = setVal; return; }
return el.value;
}
function _show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
function _hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
function _esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function _toast(msg, type) {
if (typeof UI !== 'undefined' && UI.toast) {
UI.toast(msg, type);
} else {
console[type === 'error' ? 'error' : 'log']('[Pages]', msg);
}
}
function _parseJSON(text) {
if (!text || !text.trim()) return {};
try { return JSON.parse(text); }
catch (e) { _toast('Invalid JSON', 'error'); return null; }
}
async function _api(method, path, body) {
const base = window.__BASE__ || '';
const storageKey = base ? `sb_auth_${base.replace(/\//g, '')}` : 'sb_auth';
let token = '';
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
token = saved.accessToken || '';
} catch (e) { /* ignore */ }
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
try {
const resp = await fetch(base + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
if (resp.ok) return true;
const err = await resp.json().catch(() => ({}));
_toast(err.error || `Error ${resp.status}`, 'error');
return false;
} catch (e) {
_toast('Connection error', 'error');
return false;
}
}