<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Navi — Admin</title>
<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.0.3/src/regular/style.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0e0e0e; --bg2: #161616; --bg3: #1e1e1e; --bg4: #222;
--border: #252525; --border2: #2e2e2e;
--text: #d4d4d4; --text2: #888; --text3: #555;
--accent: #4ec9b0;
--danger: #f48771;
}
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
header {
flex-shrink: 0;
background: var(--bg2);
border-bottom: 1px solid var(--border);
padding: 0 14px;
display: flex;
align-items: center;
gap: 10px;
}
.logo {
font-size: 11px; color: var(--text3); letter-spacing: .1em;
text-transform: uppercase; white-space: nowrap;
align-self: center; padding: 10px 0;
}
.logo b { color: var(--accent); }
.tabs {
display: flex; align-items: stretch; gap: 2px; margin-left: 10px;
}
.tab {
padding: 0 14px; font-family: inherit; font-size: 12px;
color: var(--text3); background: none; border: none;
border-bottom: 2px solid transparent; cursor: pointer;
transition: color .15s, border-color .15s; line-height: 40px;
}
.tab:hover { color: var(--text2); }
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.header-right {
margin-left: auto;
display: flex; align-items: center; gap: 12px;
}
.header-right a {
color: var(--text2); text-decoration: none; font-size: 12px;
}
.header-right a:hover { color: var(--text); }
main { flex: 1; overflow-y: auto; padding: 14px 16px; }
.pane { display: none; }
.pane.active { display: block; }
/* Tables */
table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
th, td {
padding: 8px 12px; border-bottom: 1px solid var(--border);
text-align: left; color: var(--text);
}
th {
background: var(--bg2); font-weight: 600; color: #fff;
position: sticky; top: 0; z-index: 1;
}
tr:hover td { background: var(--bg2); }
.mono { font-family: monospace; font-size: 11px; color: var(--text2); }
.dim { color: var(--text3); }
.right { text-align: right; }
/* Controls */
input, select, button {
font-family: inherit; font-size: 12px;
background: var(--bg3); border: 1px solid var(--border2);
color: var(--text); border-radius: 4px; outline: none;
}
input, select { padding: 6px 10px; }
button { padding: 6px 12px; cursor: pointer; }
button:hover { background: var(--bg4); border-color: #555; }
button.danger { background: #3a1e1e; border-color: #522; }
button.danger:hover { background: #522; }
.toolbar {
display: flex; align-items: center; gap: 12px;
margin-bottom: 12px; flex-wrap: wrap;
}
.search-input {
flex: 1; max-width: 320px;
background: var(--bg3); color: var(--text);
border: 1px solid var(--border2); padding: 6px 10px;
}
/* Switch */
.switch {
position: relative; display: inline-block;
width: 40px; height: 22px;
}
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background: #444; transition: .2s; border-radius: 22px;
}
.slider:before {
position: absolute; content: "";
height: 16px; width: 16px; left: 3px; bottom: 3px;
background: white; transition: .2s; border-radius: 50%;
}
input:checked + .slider { background: var(--accent); }
input:checked + .slider:before { transform: translateX(18px); }
/* Drawer */
.drawer-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5); z-index: 200;
display: flex; justify-content: flex-end;
}
.drawer {
width: 500px; background: var(--bg2);
padding: 1rem; overflow: auto;
border-left: 1px solid var(--border);
}
.drawer pre {
font-size: 12px; color: var(--text);
white-space: pre-wrap; word-break: break-word;
}
.drawer h3 { margin-bottom: 10px; color: #fff; }
/* Modal */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6); z-index: 300;
display: flex; align-items: center; justify-content: center;
padding: 20px;
}
.modal {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 8px;
width: 100%; max-width: 900px;
max-height: 90vh;
display: flex; flex-direction: column;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.modal-header h3 { color: #fff; margin: 0; font-size: 16px; }
.modal-body {
padding: 14px 18px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
display: flex; gap: 8px; justify-content: flex-end;
padding: 12px 18px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.form-row {
display: flex; gap: 12px; flex-wrap: wrap;
}
.form-row > div { flex: 1; min-width: 120px; }
/* Group */
.group-title {
margin: 12px 0 8px; color: #fff; font-size: 14px;
text-transform: capitalize;
}
/* Form fields inside modal */
#profile-form label {
display: block;
color: var(--text2);
font-size: 12px;
margin: 8px 0 4px;
}
#profile-form input[type="text"],
#profile-form input[type="number"],
#profile-form select,
#profile-form textarea {
width: 100%;
background: var(--bg3);
border: 1px solid var(--border2);
color: var(--text);
padding: 6px 10px;
font-size: 13px;
border-radius: 4px;
font-family: inherit;
}
#profile-form input[type="checkbox"] {
margin-right: 6px;
accent-color: var(--accent);
}
#profile-form textarea {
resize: vertical;
}
#profile-form section {
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.value-cell {
max-width: 400px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.placeholder {
text-align: center; color: var(--text3);
padding: 80px 0; font-size: 13px;
}
.error-msg { color: var(--danger); padding: 12px; }
/* Sortable headers */
th.sortable { cursor: pointer; user-select: none; }
th.sortable::after { content: ' ⇅'; color: var(--text3); font-size: 10px; }
th.sortable.sort-asc::after { content: ' ▲'; color: var(--accent); }
th.sortable.sort-desc::after { content: ' ▼'; color: var(--accent); }
/* Pagination */
.pagination-btn {
padding: 4px 10px; font-size: 12px; min-width: 32px;
background: var(--bg3); border: 1px solid var(--border2);
color: var(--text); border-radius: 4px; cursor: pointer;
}
.pagination-btn:hover:not(:disabled) { background: var(--bg4); }
.pagination-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.pagination-info { color: var(--text2); font-size: 12px; margin: 0 8px; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #444; }
</style>
</head>
<body>
<header>
<div class="logo"><b>NAVI</b> ADMIN</div>
<div class="tabs">
<button class="tab active" data-tab="users">Users</button>
<button class="tab" data-tab="sessions">Sessions</button>
<button class="tab" data-tab="memory">Memory</button>
<button class="tab" data-tab="profiles">Profiles</button>
</div>
<div class="header-right">
<a href="/debug">Debug</a>
<a href="/debug/eval">Eval</a>
<a href="/">App</a>
</div>
</header>
<main>
<!-- Users -->
<div id="pane-users" class="pane active">
<div id="users-content"><div class="placeholder">Loading users…</div></div>
</div>
<!-- Sessions -->
<div id="pane-sessions" class="pane">
<div class="toolbar">
<input id="sess-search" class="search-input" placeholder="Search session ID, name, user or profile…">
<span id="sess-count" class="dim"></span>
</div>
<div id="sessions-content"><div class="placeholder">Loading sessions…</div></div>
<div id="sess-pagination" class="toolbar" style="justify-content:center; margin-top:8px;"></div>
</div>
<!-- Memory -->
<div id="pane-memory" class="pane">
<div class="toolbar">
<input id="mem-search" class="search-input" placeholder="Search key, value or category…">
<span id="mem-count" class="dim"></span>
</div>
<div id="memory-content"><div class="placeholder">Loading memory…</div></div>
<div id="mem-pagination" class="toolbar" style="justify-content:center; margin-top:8px;"></div>
</div>
<!-- Profiles -->
<div id="pane-profiles" class="pane">
<div id="profiles-content"><div class="placeholder">Loading profiles…</div></div>
</div>
</main>
<script>
'use strict';
// ── tiny utils ──────────────────────────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
const fmtDate = (d) => d ? new Date(d).toLocaleString() : '—';
const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
const api = {
async request(method, path, body) {
const opts = { method, credentials: 'include', headers: {} };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
try {
const r = await fetch(path, opts);
if (r.status === 401) {
window.location.href = '/';
return;
}
if (!r.ok) {
const txt = await r.text().catch(() => '');
throw new Error(`${method} ${path} → ${r.status}: ${txt}`);
}
if (r.status === 204) return null;
return r.json();
} catch (err) {
if (err.name === 'TypeError') {
throw new Error('Network error — check your connection or server availability');
}
throw err;
}
},
get(path) { return this.request('GET', path); },
patch(path, body) { return this.request('PATCH', path, body); },
del(path) { return this.request('DELETE', path); },
users() { return this.get('/admin/users'); },
sessions(qs='') { return this.get('/admin/sessions' + (qs ? '?' + qs : '')); },
memory(qs='') { return this.get('/admin/memory' + (qs ? '?' + qs : '')); },
profiles() { return this.get('/admin/profiles'); },
profile(id) { return this.get(`/admin/profiles/${id}`); },
saveProfile(id, body) { return this.request('PUT', `/admin/profiles/${id}`, body); },
session(id){ return this.get(`/admin/sessions/${id}`); },
delSession(id){ return this.del(`/admin/sessions/${id}`); },
userSessions(id){ return this.get(`/admin/users/${id}/sessions`); },
updateRole(id, role){ return this.patch(`/admin/users/${id}/role`, { role }); },
updateProfile(id, isAdminOnly){ return this.patch(`/admin/profiles/${id}/availability`, { is_admin_only: isAdminOnly }); },
};
// ── tabs ─────────────────────────────────────────────────────────────────
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.pane').forEach(p => p.classList.toggle('active', p.id === `pane-${tab}`));
history.replaceState(null, '', `#${tab}`);
if (tab === 'users' && !state.usersLoaded) loadUsers();
if (tab === 'sessions' && !state.sessionsLoaded) loadSessions();
if (tab === 'memory' && !state.memoryLoaded) loadMemory();
if (tab === 'profiles' && !state.profilesLoaded) loadProfiles();
}
document.querySelectorAll('.tab').forEach(btn =>
btn.addEventListener('click', () => {
if (btn.dataset.tab === 'sessions') { sessState.search = ''; $('#sess-search').value = ''; }
if (btn.dataset.tab === 'memory') { memState.userId = null; }
switchTab(btn.dataset.tab);
})
);
const state = { usersLoaded: false, sessionsLoaded: false, memoryLoaded: false, profilesLoaded: false };
// ── Users ────────────────────────────────────────────────────────────────
async function loadUsers() {
const el = $('#users-content');
try {
const users = await api.users();
state.usersLoaded = true;
el.innerHTML = renderUsers(users);
} catch (err) {
el.innerHTML = `<div class="error-msg">${esc(err.message)}</div>`;
}
}
function renderUsers(users) {
if (!users.length) return '<div class="placeholder">No users.</div>';
let html = '<table><thead><tr>';
['ID','Email','Display Name','Role','Permissions','Created','Actions'].forEach(h => html += `<th>${h}</th>`);
html += '</tr></thead><tbody>';
for (const u of users) {
const perms = (() => { try { const a = JSON.parse(u.permissions); return Array.isArray(a) && a.length ? a.join(', ') : '—'; } catch { return '—'; } })();
html += `<tr>
<td class="mono">${esc(u.id)}</td>
<td>${esc(u.email)}</td>
<td>${esc(u.display_name || '—')}</td>
<td><select onchange="onRoleChange('${esc(u.id)}', this.value)">
<option value="user" ${u.role==='user'?'selected':''}>user</option>
<option value="admin" ${u.role==='admin'?'selected':''}>admin</option>
</select></td>
<td>${esc(perms)}</td>
<td>${fmtDate(u.created_at)}</td>
<td>
<button onclick="showUserSessions('${esc(u.id)}')">Sessions</button>
<button onclick="showUserMemory('${esc(u.id)}')">Memory</button>
<button onclick="showUserDetail('${esc(u.id)}')">Detail</button>
</td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
window.onRoleChange = async (id, role) => {
try { await api.updateRole(id, role); } catch (err) { alert(err.message); }
};
window.showUserDetail = async (id) => {
try {
const u = (await api.users()).find(x => x.id === id);
showDrawer('User Detail', JSON.stringify(u, null, 2));
} catch (err) { alert(err.message); }
};
window.showUserSessions = (id) => {
sessState.search = id;
$('#sess-search').value = id;
switchTab('sessions');
};
window.showUserMemory = (id) => {
memState.userId = id;
switchTab('memory');
};
// ── Sessions ───────────────────────────────────────────────────────────────
const sessState = { limit: 50, offset: 0, search: '', sortBy: 'last_active', sortOrder: 'desc', total: 0, items: [] };
async function loadSessions() {
const el = $('#sessions-content');
const countEl = $('#sess-count');
try {
const params = new URLSearchParams();
params.set('limit', String(sessState.limit));
params.set('offset', String(sessState.offset));
if (sessState.search) params.set('search', sessState.search);
params.set('sort_by', sessState.sortBy);
params.set('sort_order', sessState.sortOrder);
const data = await api.sessions(params.toString());
state.sessionsLoaded = true;
sessState.total = data.total || 0;
sessState.items = data.items || [];
el.innerHTML = renderSessionsTable(sessState.items);
countEl.textContent = `${sessState.total} total`;
renderSessionPagination();
} catch (err) {
el.innerHTML = `<div class="error-msg">${esc(err.message)}</div>`;
countEl.textContent = '';
}
}
const SESSION_COLUMNS = [
{ key: 'session_id', label: 'Session ID' },
{ key: 'user_id', label: 'User' },
{ key: 'profile_id', label: 'Profile' },
{ key: 'name', label: 'Name' },
{ key: 'message_count', label: 'Messages' },
{ key: 'pinned', label: 'Pinned' },
{ key: 'last_active', label: 'Last Active' },
{ key: 'actions', label: 'Actions', sortable: false },
];
function renderSessionsTable(items) {
if (!items.length) return '<div class="placeholder">No sessions.</div>';
let html = '<table><thead><tr>';
for (const col of SESSION_COLUMNS) {
const isSorted = sessState.sortBy === col.key;
const cls = col.sortable !== false ? `sortable ${isSorted ? 'sort-' + sessState.sortOrder : ''}` : '';
html += `<th class="${cls}" onclick="${col.sortable !== false ? `onSessionSort('${esc(col.key)}')` : ''}">${esc(col.label)}</th>`;
}
html += '</tr></thead><tbody>';
for (const s of items) {
html += `<tr>
<td class="mono">${esc(s.session_id)}</td>
<td class="mono">${esc(s.user_id || '—')}</td>
<td>${esc(s.profile_id)}</td>
<td>${esc(s.name || '—')}</td>
<td class="right">${s.message_count}</td>
<td>${s.pinned ? 'Yes' : 'No'}</td>
<td>${fmtDate(s.last_active)}</td>
<td>
<button onclick="showSessionDetail('${esc(s.session_id)}')">Detail</button>
<button class="danger" onclick="deleteSession('${esc(s.session_id)}')">Delete</button>
</td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
function renderSessionPagination() {
const el = $('#sess-pagination');
const start = sessState.total ? sessState.offset + 1 : 0;
const end = Math.min(sessState.offset + sessState.limit, sessState.total);
const hasPrev = sessState.offset > 0;
const hasNext = sessState.offset + sessState.limit < sessState.total;
el.innerHTML = `
<button class="pagination-btn" onclick="changeSessionPage(-1)" ${hasPrev ? '' : 'disabled'}>Prev</button>
<span class="pagination-info">${start}–${end} of ${sessState.total}</span>
<button class="pagination-btn" onclick="changeSessionPage(1)" ${hasNext ? '' : 'disabled'}>Next</button>
<select class="pagination-btn" onchange="setSessionLimit(this.value)" style="padding:4px 6px; min-width:60px;">
<option value="10" ${sessState.limit===10?'selected':''}>10</option>
<option value="25" ${sessState.limit===25?'selected':''}>25</option>
<option value="50" ${sessState.limit===50?'selected':''}>50</option>
<option value="100" ${sessState.limit===100?'selected':''}>100</option>
</select>
`;
}
window.changeSessionPage = (delta) => {
sessState.offset = Math.max(0, sessState.offset + delta * sessState.limit);
loadSessions();
};
window.setSessionLimit = (val) => {
sessState.limit = parseInt(val, 10);
sessState.offset = 0;
loadSessions();
};
window.onSessionSort = (col) => {
if (sessState.sortBy === col) {
sessState.sortOrder = sessState.sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sessState.sortBy = col;
sessState.sortOrder = 'desc';
}
sessState.offset = 0;
loadSessions();
};
$('#sess-search').addEventListener('input', debounce(() => {
sessState.search = $('#sess-search').value.trim();
sessState.offset = 0;
loadSessions();
}, 300));
function debounce(fn, ms) {
let t;
return () => { clearTimeout(t); t = setTimeout(fn, ms); };
}
window.showSessionDetail = async (id) => {
try {
const s = await api.session(id);
showDrawer('Session Detail', JSON.stringify(s, null, 2));
} catch (err) { alert(err.message); }
};
window.deleteSession = async (id) => {
if (!confirm(`Delete session ${id}?`)) return;
try { await api.delSession(id); loadSessions(); } catch (err) { alert(err.message); }
};
// ── Memory ─────────────────────────────────────────────────────────────────
const memState = { limit: 50, offset: 0, search: '', userId: null, sortBy: 'updated_at', sortOrder: 'desc', total: 0, items: [] };
async function loadMemory() {
const el = $('#memory-content');
const countEl = $('#mem-count');
try {
const params = new URLSearchParams();
params.set('limit', String(memState.limit));
params.set('offset', String(memState.offset));
if (memState.search) params.set('search', memState.search);
if (memState.userId) params.set('user_id', memState.userId);
params.set('sort_by', memState.sortBy);
params.set('sort_order', memState.sortOrder);
const data = await api.memory(params.toString());
state.memoryLoaded = true;
memState.total = data.total || 0;
memState.items = data.items || [];
el.innerHTML = renderMemoryTable(memState.items);
countEl.textContent = `${memState.total} total`;
renderMemoryPagination();
} catch (err) {
el.innerHTML = `<div class="error-msg">${esc(err.message)}</div>`;
countEl.textContent = '';
}
}
const MEMORY_COLUMNS = [
{ key: 'category', label: 'Category' },
{ key: 'key', label: 'Key' },
{ key: 'value', label: 'Value', sortable: false },
{ key: 'source', label: 'Source' },
{ key: 'confidence', label: 'Confidence' },
{ key: 'updated_at', label: 'Updated' },
];
function renderMemoryTable(items) {
if (!items.length) return '<div class="placeholder">No facts.</div>';
let html = '<table><thead><tr>';
for (const col of MEMORY_COLUMNS) {
const isSorted = memState.sortBy === col.key;
const cls = col.sortable !== false ? `sortable ${isSorted ? 'sort-' + memState.sortOrder : ''}` : '';
html += `<th class="${cls}" onclick="${col.sortable !== false ? `onMemorySort('${esc(col.key)}')` : ''}">${esc(col.label)}</th>`;
}
html += '</tr></thead><tbody>';
for (const f of items) {
html += `<tr>
<td>${esc(f.category)}</td>
<td>${esc(f.key)}</td>
<td class="value-cell">${esc(f.value)}</td>
<td>${esc(f.source)}</td>
<td>${f.confidence}</td>
<td>${fmtDate(f.updated_at)}</td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
function renderMemoryPagination() {
const el = $('#mem-pagination');
const start = memState.total ? memState.offset + 1 : 0;
const end = Math.min(memState.offset + memState.limit, memState.total);
const hasPrev = memState.offset > 0;
const hasNext = memState.offset + memState.limit < memState.total;
el.innerHTML = `
<button class="pagination-btn" onclick="changeMemoryPage(-1)" ${hasPrev ? '' : 'disabled'}>Prev</button>
<span class="pagination-info">${start}–${end} of ${memState.total}</span>
<button class="pagination-btn" onclick="changeMemoryPage(1)" ${hasNext ? '' : 'disabled'}>Next</button>
<select class="pagination-btn" onchange="setMemoryLimit(this.value)" style="padding:4px 6px; min-width:60px;">
<option value="10" ${memState.limit===10?'selected':''}>10</option>
<option value="25" ${memState.limit===25?'selected':''}>25</option>
<option value="50" ${memState.limit===50?'selected':''}>50</option>
<option value="100" ${memState.limit===100?'selected':''}>100</option>
</select>
`;
}
window.changeMemoryPage = (delta) => {
memState.offset = Math.max(0, memState.offset + delta * memState.limit);
loadMemory();
};
window.setMemoryLimit = (val) => {
memState.limit = parseInt(val, 10);
memState.offset = 0;
loadMemory();
};
window.onMemorySort = (col) => {
if (memState.sortBy === col) {
memState.sortOrder = memState.sortOrder === 'asc' ? 'desc' : 'asc';
} else {
memState.sortBy = col;
memState.sortOrder = 'desc';
}
memState.offset = 0;
loadMemory();
};
$('#mem-search').addEventListener('input', debounce(() => {
memState.search = $('#mem-search').value.trim();
memState.offset = 0;
loadMemory();
}, 300));
// ── Profiles ──────────────────────────────────────────────────────────────
async function loadProfiles() {
const el = $('#profiles-content');
try {
const profiles = await api.profiles();
state.profilesLoaded = true;
el.innerHTML = renderProfiles(profiles);
} catch (err) {
el.innerHTML = `<div class="error-msg">${esc(err.message)}</div>`;
}
}
function renderProfiles(profiles) {
if (!profiles.length) return '<div class="placeholder">No profiles.</div>';
let html = '<table><thead><tr>';
['ID','Name','Description','Admin Only','Actions'].forEach(h => html += `<th>${h}</th>`);
html += '</tr></thead><tbody>';
for (const p of profiles) {
html += `<tr>
<td class="mono">${esc(p.id)}</td>
<td>${esc(p.name)}</td>
<td>${esc(p.description)}</td>
<td>
<label class="switch">
<input type="checkbox" ${p.is_admin_only ? 'checked' : ''} onchange="toggleProfile('${esc(p.id)}', this.checked)">
<span class="slider"></span>
</label>
</td>
<td><button onclick="editProfile('${esc(p.id)}')">Edit</button></td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
window.toggleProfile = async (id, value) => {
try { await api.updateProfile(id, value); loadProfiles(); } catch (err) { alert(err.message); }
};
window.editProfile = async (id) => {
try {
const p = await api.profile(id);
showProfileModal(id, p);
} catch (err) {
alert(err.message);
}
};
function showProfileModal(id, p) {
const existing = document.querySelector('.modal-overlay');
if (existing) existing.remove();
const modelVal = Array.isArray(p.model) ? p.model.join('\n') : String(p.model || '');
const toolsVal = Array.isArray(p.enabled_tools) ? p.enabled_tools.join('\n') : String(p.enabled_tools || '');
const subagentToolsVal = Array.isArray(p.subagent_tools) ? p.subagent_tools.join('\n') : String(p.subagent_tools || '');
const ctxVal = Array.isArray(p.context_providers) ? p.context_providers.join('\n') : String(p.context_providers || '');
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<h3>Edit Profile: ${esc(p.id)}</h3>
<button class="btn-icon" onclick="this.closest('.modal-overlay').remove()" title="Close"><i class="ph ph-x"></i></button>
</div>
<div class="modal-body" id="profile-form">
<section><h4 class="group-title">Basic</h4>
<label>Name</label><input id="pf-name" value="${esc(p.name)}">
<label>Description</label><input id="pf-description" value="${esc(p.description)}">
<label>Short Description</label><input id="pf-short_desc" value="${esc(p.short_description||'')}" placeholder="One-line summary...">
<label>LLM Backend</label><input id="pf-backend" value="${esc(p.llm_backend)}">
</section>
<section><h4 class="group-title">Model & Generation</h4>
<label>Model (one per line, priority order)</label>
<textarea id="pf-model" rows="4" style="font-family:monospace;">${esc(modelVal)}</textarea>
<div class="form-row">
<div><label>Temperature</label><input id="pf-temp" type="number" step="0.01" value="${p.temperature}"></div>
<div><label>Top-K</label><input id="pf-topk" type="number" value="${p.top_k??''}"></div>
<div><label>Top-P</label><input id="pf-topp" type="number" step="0.01" value="${p.top_p??''}"></div>
<div><label>Max Iterations</label><input id="pf-max_iter" type="number" value="${p.max_iterations}"></div>
<div><label>Num Threads</label><input id="pf-num_thread" type="number" value="${p.num_thread??''}"></div>
</div>
</section>
<section><h4 class="group-title">Thinking</h4>
<label><input type="checkbox" id="pf-think" ${p.think_enabled?'checked':''}> Think Enabled</label>
<label><input type="checkbox" id="pf-budget" ${p.iteration_budget_enabled?'checked':''}> Iteration Budget</label>
<label><input type="checkbox" id="pf-goal" ${p.goal_anchoring_enabled?'checked':''}> Goal Anchoring (interval: <input id="pf-goal_int" type="number" value="${p.goal_anchoring_interval}" style="width:60px;">)</label>
<label><input type="checkbox" id="pf-stall" ${p.anti_stall_enabled?'checked':''}> Anti-Stall (threshold: <input id="pf-stall_thr" type="number" value="${p.anti_stall_threshold}" style="width:60px;">)</label>
<label><input type="checkbox" id="pf-validate" ${p.step_validation_enabled?'checked':''}> Step Validation</label>
<label><input type="checkbox" id="pf-replan" ${p.adaptive_replan_enabled?'checked':''}> Adaptive Replan</label>
</section>
<section><h4 class="group-title">Planning</h4>
<label><input type="checkbox" id="pf-plan" ${p.planning_enabled?'checked':''}> Planning Enabled</label>
<label><input type="checkbox" id="pf-plan_mand" ${p.planning_mandatory?'checked':''}> Planning Mandatory</label>
<label><input type="checkbox" id="pf-phase1" ${p.planning_phase1_enabled?'checked':''}> Phase 1 (Analysis)</label>
<label><input type="checkbox" id="pf-phase2" ${p.planning_phase2_enabled?'checked':''}> Phase 2 (Review)</label>
<label><input type="checkbox" id="pf-phase3" ${p.planning_phase3_enabled?'checked':''}> Phase 3 (Plan)</label>
</section>
<section><h4 class="group-title">Sub-agent</h4>
<label><input type="checkbox" id="pf-sub_plan" ${p.subagent_planning_enabled?'checked':''}> Sub-agent Planning</label>
<label>Sub-agent Think Enabled</label>
<select id="pf-sub_think">
<option value="null" ${p.subagent_think_enabled===null?'selected':''}>Inherit (null)</option>
<option value="true" ${p.subagent_think_enabled===true?'selected':''}>True</option>
<option value="false" ${p.subagent_think_enabled===false?'selected':''}>False</option>
</select>
<label>Sub-agent Tools (one per line)</label>
<textarea id="pf-sub_tools" rows="3" style="font-family:monospace;">${esc(subagentToolsVal)}</textarea>
</section>
<section><h4 class="group-title">Tools</h4>
<label>Enabled Tools (one per line)</label>
<textarea id="pf-tools" rows="6" style="font-family:monospace;">${esc(toolsVal)}</textarea>
<label>Context Providers (one per line)</label>
<textarea id="pf-ctx" rows="3" style="font-family:monospace;">${esc(ctxVal)}</textarea>
</section>
<section><h4 class="group-title">System Prompt</h4>
<textarea id="pf-system" rows="10" style="font-family:monospace;width:100%;">${esc(p.system_prompt||'')}</textarea>
<label>Sub-agent System Prompt</label>
<textarea id="pf-sub_system" rows="4" style="font-family:monospace;width:100%;">${esc(p.subagent_system_prompt||'')}</textarea>
</section>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="saveProfileEdit('${esc(id)}')">Save</button>
<button onclick="this.closest('.modal-overlay').remove()">Cancel</button>
</div>
</div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
}
window.saveProfileEdit = async (id) => {
const body = {
name: document.getElementById('pf-name').value,
description: document.getElementById('pf-description').value,
short_description: document.getElementById('pf-short_desc').value,
llm_backend: document.getElementById('pf-backend').value,
model: document.getElementById('pf-model').value.split('\n').map(s => s.trim()).filter(Boolean),
temperature: parseFloat(document.getElementById('pf-temp').value),
top_k: document.getElementById('pf-topk').value ? parseInt(document.getElementById('pf-topk').value, 10) : null,
top_p: document.getElementById('pf-topp').value ? parseFloat(document.getElementById('pf-topp').value) : null,
max_iterations: parseInt(document.getElementById('pf-max_iter').value, 10),
num_thread: document.getElementById('pf-num_thread').value ? parseInt(document.getElementById('pf-num_thread').value, 10) : null,
think_enabled: document.getElementById('pf-think').checked,
iteration_budget_enabled: document.getElementById('pf-budget').checked,
goal_anchoring_enabled: document.getElementById('pf-goal').checked,
goal_anchoring_interval: parseInt(document.getElementById('pf-goal_int').value, 10),
anti_stall_enabled: document.getElementById('pf-stall').checked,
anti_stall_threshold: parseInt(document.getElementById('pf-stall_thr').value, 10),
step_validation_enabled: document.getElementById('pf-validate').checked,
adaptive_replan_enabled: document.getElementById('pf-replan').checked,
planning_enabled: document.getElementById('pf-plan').checked,
planning_mandatory: document.getElementById('pf-plan_mand').checked,
planning_phase1_enabled: document.getElementById('pf-phase1').checked,
planning_phase2_enabled: document.getElementById('pf-phase2').checked,
planning_phase3_enabled: document.getElementById('pf-phase3').checked,
subagent_planning_enabled: document.getElementById('pf-sub_plan').checked,
subagent_think_enabled: (() => {
const v = document.getElementById('pf-sub_think').value;
return v === 'null' ? null : v === 'true';
})(),
subagent_tools: document.getElementById('pf-sub_tools').value.split('\n').map(s => s.trim()).filter(Boolean),
enabled_tools: document.getElementById('pf-tools').value.split('\n').map(s => s.trim()).filter(Boolean),
context_providers: document.getElementById('pf-ctx').value.split('\n').map(s => s.trim()).filter(Boolean),
system_prompt: document.getElementById('pf-system').value,
subagent_system_prompt: document.getElementById('pf-sub_system').value,
};
try {
await api.saveProfile(id, body);
document.querySelector('.modal-overlay')?.remove();
loadProfiles();
} catch (err) {
alert(err.message);
}
};
// ── Drawer ─────────────────────────────────────────────────────────────────
function showDrawer(title, content) {
const existing = document.querySelector('.drawer-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'drawer-overlay';
overlay.innerHTML = `
<div class="drawer">
<h3>${esc(title)}</h3>
<pre>${esc(content)}</pre>
<button onclick="this.closest('.drawer-overlay').remove()">Close</button>
</div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
}
// ── boot ───────────────────────────────────────────────────────────────────
const hash = location.hash.slice(1);
const initialTab = ['users','sessions','memory','profiles'].includes(hash) ? hash : 'users';
if (initialTab !== 'users') switchTab(initialTab);
else loadUsers();
</script>
</body>
</html>