Model Registry: - Add per-user custom roles (add/remove via UI); required roles chat/orchestrator/distill are always present and cannot be removed - Auto-migrate legacy .env-defined roles to custom_roles on first access - Role config panel (gear): Remove role button moved inside panel; required badge below name - Role select: Primary + Backup slots only (was three) Settings pages — Tailwind CSS migration (CDN, preflight: false): - local_llm.html, settings.html, help.html, notifications.html, tools_settings.html, crons.html, integrations.html all migrated; pg-* color tokens; dark/light via data-theme pg.css fixes: - input[type=checkbox/radio]: width: auto — prevents pg.css width:100% from stretching checkboxes - btn-submit: responsive sizing via Tailwind w-full md:w-96 on each button (no longer full-width on desktop) Documentation: - MASTER.md, TODO__Agents.md: remove "/ Inara" from titles; description updated to "per-user AI personas" - HELP.md: persona-agnostic language throughout (NC Talk, Google Chat, push, schedules, HA sections); roles section restructured to show required vs. custom roles with examples - notifications.html: subtitle and HA description use "your persona" not "Inara" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
398 lines
16 KiB
HTML
398 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Cortex — Account Settings</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
corePlugins: { preflight: false },
|
|
darkMode: ['selector', '[data-theme="dark"]'],
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
pg: {
|
|
bg: 'var(--pg-bg)',
|
|
surface: 'var(--pg-surface)',
|
|
border: 'var(--pg-border)',
|
|
text: 'var(--pg-text)',
|
|
muted: 'var(--pg-muted)',
|
|
dim: 'var(--pg-dim)',
|
|
dimmer: 'var(--pg-dimmer)',
|
|
bright: 'var(--pg-bright)',
|
|
accent: 'var(--pg-accent)',
|
|
action: 'var(--pg-action)',
|
|
}
|
|
},
|
|
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<link rel="stylesheet" href="/static/pg.css">
|
|
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
|
|
<style>
|
|
/* ── Server-generated persona list ── */
|
|
.persona-list {
|
|
list-style: none; display: flex; flex-direction: column;
|
|
gap: 0.5rem; margin-top: 0.5rem;
|
|
}
|
|
.persona-list li { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
|
.persona-link {
|
|
display: inline-block; padding: 0.3rem 0.75rem;
|
|
background: var(--pg-bg); border: 1px solid var(--pg-border);
|
|
border-radius: 20px; color: var(--pg-accent); font-size: 0.85rem;
|
|
text-decoration: none; transition: border-color 0.15s;
|
|
}
|
|
.persona-link:hover { border-color: var(--pg-action); }
|
|
.persona-list li em { color: var(--pg-muted); font-size: 0.85rem; }
|
|
.persona-rename-toggle {
|
|
background: none; border: 1px solid var(--pg-border);
|
|
border-radius: 6px; color: var(--pg-muted); font-size: 0.8rem;
|
|
padding: 0.3rem 0.6rem; margin-top: 0.25rem;
|
|
cursor: pointer; opacity: 0.7; transition: opacity 0.15s, color 0.15s;
|
|
}
|
|
.persona-rename-toggle:hover { opacity: 1; color: var(--pg-accent); }
|
|
.persona-rename-form { display: flex; align-items: center; gap: 0.4rem; }
|
|
.persona-rename-form input[type="text"] {
|
|
width: 12rem; padding: 0.3rem 0.6rem;
|
|
border-color: var(--pg-action); font-size: 0.9rem;
|
|
}
|
|
.persona-rename-form .btn-save { padding: 0.3rem 0.75rem; font-size: 0.85rem; }
|
|
|
|
/* ── Server-generated role badge ── */
|
|
.role-badge {
|
|
display: inline-block; padding: 0.25rem 0.75rem;
|
|
border-radius: 20px; font-size: 0.78rem; font-weight: 600;
|
|
text-transform: uppercase; letter-spacing: 0.06em;
|
|
}
|
|
.role-badge.role-admin {
|
|
background: rgba(124,58,237,0.15); color: var(--pg-accent);
|
|
border: 1px solid rgba(124,58,237,0.4);
|
|
}
|
|
.role-badge.role-user {
|
|
background: rgba(100,116,139,0.12); color: var(--pg-muted);
|
|
border: 1px solid var(--pg-border);
|
|
}
|
|
|
|
/* ── JS-toggled states ── */
|
|
#clear-ls-ok { display: none; margin-left: 0.75rem; font-size: 0.8rem; color: #4ade80; }
|
|
.usage-wrap { overflow-x: auto; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="page-nav">
|
|
<a href="{{ back_href }}" class="nav-link">← Chat</a>
|
|
<a href="{{ help_href }}" class="nav-link">Help</a>
|
|
<a href="/settings" class="nav-link active">Settings</a>
|
|
<a href="/settings/models" class="nav-link">Models</a>
|
|
<a href="/settings/notifications" class="nav-link">Notifications</a>
|
|
<a href="/settings/tools" class="nav-link">Tools</a>
|
|
<a href="/settings/crons" class="nav-link">Schedules</a>
|
|
{{ integrations_nav }}
|
|
<span class="nav-spacer"></span>
|
|
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
|
</nav>
|
|
<div class="page-wrap">
|
|
<h1 class="page-title">Account Settings</h1>
|
|
<p class="page-subtitle">Manage your account and personas.</p>
|
|
|
|
<!-- SUCCESS -->
|
|
<!-- ERROR -->
|
|
|
|
<!-- OpenRouter quickstart (shown by JS when no model is configured) -->
|
|
<div id="openrouter-quickstart"
|
|
class="hidden rounded-xl border border-amber-800 bg-amber-950 p-4 mb-5">
|
|
<p class="text-xs font-semibold text-amber-400 mb-1">⚡ You're on the server default model</p>
|
|
<p class="text-xs text-amber-600 mb-3 leading-relaxed">
|
|
You can chat now, but adding your own model gives you more choices, lets you pick
|
|
role-specific models, and tracks your usage separately.
|
|
OpenRouter is the easiest way to get started — one key, many models.
|
|
</p>
|
|
<a href="/setup/model"
|
|
class="inline-block px-3 py-2 rounded-md bg-amber-900 text-amber-100 text-sm font-medium hover:bg-amber-800 transition-colors">
|
|
Set up OpenRouter →
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Account info -->
|
|
<div class="section">
|
|
<h2>Account</h2>
|
|
<div class="field">
|
|
<label>Username</label>
|
|
<input type="text" value="{{ username }}" readonly>
|
|
</div>
|
|
<div class="field">
|
|
<label>Role</label>
|
|
<span class="role-badge role-{{ user_role }}">{{ user_role }}</span>
|
|
</div>
|
|
<button type="button" id="show-rename-user" class="persona-rename-toggle">
|
|
✏ Change username
|
|
</button>
|
|
<form id="rename-user-form" method="POST" action="/settings/username" style="display:none; margin-top:0.75rem;">
|
|
<div class="field">
|
|
<label for="new_username">New username</label>
|
|
<input type="text" id="new_username" name="new_username"
|
|
value="{{ username }}"
|
|
pattern="[a-z_][a-z0-9_\-]{0,31}" required autofocus
|
|
autocomplete="off" data-form-type="other">
|
|
<p class="hint">Lowercase letters, digits, _ or - only. You will be logged out after renaming.</p>
|
|
</div>
|
|
<div class="btn-row">
|
|
<button type="submit" class="btn-save">Save</button>
|
|
<button type="button" id="cancel-rename-user" class="btn-cancel">Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Connected accounts -->
|
|
<div class="section">
|
|
<h2>Connected Accounts</h2>
|
|
<div class="field">
|
|
<label>Google Account</label>
|
|
<input type="text" value="{{ google_email }}" readonly
|
|
placeholder="No Google account linked"
|
|
style="{{ google_email == '' and 'color:var(--pg-dimmer)' or '' }}">
|
|
</div>
|
|
<p class="hint" style="margin-top:-0.5rem;">To link or change your Google account, contact Scott.</p>
|
|
</div>
|
|
|
|
<!-- Email Allowlist -->
|
|
<div class="section">
|
|
<h2>Email Allowlist</h2>
|
|
<p class="section-note">
|
|
One regex pattern per line. The <code>email_send</code> tool will only send to addresses
|
|
that match at least one pattern. Leave blank to block all outbound email.
|
|
</p>
|
|
<form method="POST" action="/settings/email-allowlist">
|
|
<div class="field">
|
|
<label for="email_allowlist_ta">Allowed patterns</label>
|
|
<textarea id="email_allowlist_ta" name="patterns" rows="6"
|
|
placeholder=".*@example\.com alice@example\.com"
|
|
spellcheck="false">{{ email_allowlist }}</textarea>
|
|
</div>
|
|
<button type="submit" class="btn-submit w-full md:w-96">Save allowlist</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- HTTP POST Allowlist -->
|
|
<div class="section">
|
|
<h2>HTTP POST Allowlist</h2>
|
|
<p class="section-note">
|
|
One URL prefix per line. The <code>http_post</code> tool will only POST to URLs that
|
|
start with a listed prefix. Leave blank to block all outbound POST requests.
|
|
</p>
|
|
<form method="POST" action="/settings/http-allowlist">
|
|
<div class="field">
|
|
<label for="http_allowlist_ta">Allowed URL prefixes</label>
|
|
<textarea id="http_allowlist_ta" name="prefixes" rows="5"
|
|
placeholder="https://ha.dgrzone.com/api/webhook/ https://n8n.dgrzone.com/webhook/"
|
|
spellcheck="false">{{ http_allowlist }}</textarea>
|
|
</div>
|
|
<button type="submit" class="btn-submit w-full md:w-96">Save allowlist</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Usage summary -->
|
|
<div class="section" id="usage-section">
|
|
<h2>Usage</h2>
|
|
<p class="section-note">
|
|
Token consumption tracked for API-backed models (Gemini API, local OpenAI-compatible).
|
|
Claude CLI calls are not metered.
|
|
</p>
|
|
<div id="usage-table-wrap" class="usage-wrap">
|
|
<p class="section-note">Loading…</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Browser Cache -->
|
|
<div class="section">
|
|
<h2>Browser Cache</h2>
|
|
<p class="section-note">
|
|
Clears UI preferences stored in this browser: active mode, session ID, memory toggles,
|
|
theme, font size, and context tier. Does not sign you out.
|
|
</p>
|
|
<button type="button" id="clear-ls-btn" class="btn-secondary">Clear browser cache</button>
|
|
<span id="clear-ls-ok">Cleared.</span>
|
|
</div>
|
|
|
|
<!-- Change Password -->
|
|
<div class="section">
|
|
<h2>Change Password</h2>
|
|
<form method="POST" action="/settings/password" id="password-form">
|
|
<div class="field">
|
|
<label for="current_password">Current password</label>
|
|
<input type="password" id="current_password" name="current_password"
|
|
autocomplete="current-password" required>
|
|
</div>
|
|
<div class="field">
|
|
<label for="new_password">New password</label>
|
|
<input type="password" id="new_password" name="new_password"
|
|
autocomplete="new-password" required minlength="8">
|
|
</div>
|
|
<div class="field">
|
|
<label for="confirm_password">Confirm new password</label>
|
|
<input type="password" id="confirm_password" name="confirm_password"
|
|
autocomplete="new-password" required>
|
|
</div>
|
|
<button type="submit" class="btn-submit w-full md:w-96">Update password</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Sessions -->
|
|
<div class="section">
|
|
<h2>Sessions</h2>
|
|
<p class="section-note">
|
|
Auto-name any sessions that still show a random ID, using their first message as the name.
|
|
Only unnamed sessions are affected — existing names are left alone.
|
|
</p>
|
|
<button type="button" id="backfill-names-btn" class="btn-secondary">Auto-name old sessions</button>
|
|
<span id="backfill-names-ok"
|
|
class="ml-3 text-xs hidden"
|
|
style="color:#4ade80"></span>
|
|
</div>
|
|
|
|
<!-- Personas -->
|
|
<div class="section">
|
|
<h2>Personas</h2>
|
|
<ul class="persona-list">
|
|
{{ persona_items }}
|
|
</ul>
|
|
<a href="/setup/persona"
|
|
class="inline-block mt-3 text-xs text-pg-muted hover:text-pg-accent transition-colors">
|
|
+ Add new persona
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Password confirmation check
|
|
document.getElementById('password-form').addEventListener('submit', e => {
|
|
const np = document.getElementById('new_password').value;
|
|
const cfm = document.getElementById('confirm_password').value;
|
|
if (np !== cfm) {
|
|
e.preventDefault();
|
|
alert('New passwords do not match.');
|
|
}
|
|
});
|
|
|
|
// Username rename toggle
|
|
document.getElementById('show-rename-user').addEventListener('click', () => {
|
|
document.getElementById('show-rename-user').style.display = 'none';
|
|
document.getElementById('rename-user-form').style.display = 'block';
|
|
document.getElementById('new_username').focus();
|
|
});
|
|
document.getElementById('cancel-rename-user').addEventListener('click', () => {
|
|
document.getElementById('rename-user-form').style.display = 'none';
|
|
document.getElementById('show-rename-user').style.display = '';
|
|
});
|
|
|
|
// Clear localStorage (keeps JWT cookie — no sign-out)
|
|
document.getElementById('clear-ls-btn').addEventListener('click', () => {
|
|
localStorage.clear();
|
|
document.getElementById('clear-ls-ok').style.display = 'inline';
|
|
});
|
|
|
|
// Show OpenRouter quick-start card if no model is configured
|
|
(async () => {
|
|
try {
|
|
const d = await fetch('/backend').then(r => r.json());
|
|
if ((d.available_roles || []).length === 0) {
|
|
const el = document.getElementById('openrouter-quickstart');
|
|
el.classList.remove('hidden');
|
|
el.style.display = 'block';
|
|
}
|
|
} catch (_) {}
|
|
})();
|
|
|
|
// Usage summary table
|
|
(async () => {
|
|
const wrap = document.getElementById('usage-table-wrap');
|
|
try {
|
|
const resp = await fetch('/api/usage/summary');
|
|
if (!resp.ok) throw new Error(resp.statusText);
|
|
const rows_data = await resp.json();
|
|
if (!rows_data.length) {
|
|
wrap.innerHTML = '<p class="section-note">No usage recorded yet.</p>';
|
|
return;
|
|
}
|
|
const fmt = n => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
|
|
const rows = rows_data.map(d => {
|
|
const labelCell = d.label !== d.key
|
|
? `<span title="${d.key}">${d.label}</span>`
|
|
: `<span>${d.key}</span>`;
|
|
return `<tr>
|
|
<td>${labelCell}</td>
|
|
<td>${d.calls}</td>
|
|
<td>${fmt(d.prompt_tokens)}</td>
|
|
<td>${fmt(d.completion_tokens)}</td>
|
|
<td>${fmt(d.total_tokens)}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
wrap.innerHTML = `<table class="usage-table">
|
|
<thead><tr>
|
|
<th style="text-align:left">Model</th>
|
|
<th>Calls</th><th>Prompt</th><th>Output</th><th>Total</th>
|
|
</tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>`;
|
|
} catch (e) {
|
|
wrap.innerHTML = '<p class="section-note">Could not load usage data.</p>';
|
|
}
|
|
})();
|
|
|
|
// Auto-name old sessions backfill
|
|
document.getElementById('backfill-names-btn').addEventListener('click', async () => {
|
|
const btn = document.getElementById('backfill-names-btn');
|
|
const ok = document.getElementById('backfill-names-ok');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Working…';
|
|
try {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const user = params.get('user') || document.querySelector('input[value]')?.value || '';
|
|
const persona = params.get('persona') || '';
|
|
const qs = user ? `?user=${encodeURIComponent(user)}&persona=${encodeURIComponent(persona)}` : '';
|
|
const res = await fetch(`/api/sessions/backfill-names${qs}`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.detail || res.statusText);
|
|
const n = data.named ?? 0;
|
|
ok.textContent = `Named ${n} session${n !== 1 ? 's' : ''}.`;
|
|
ok.style.display = 'inline';
|
|
ok.classList.remove('hidden');
|
|
} catch (e) {
|
|
ok.textContent = 'Error — check console.';
|
|
ok.style.color = '#f87171';
|
|
ok.style.display = 'inline';
|
|
ok.classList.remove('hidden');
|
|
}
|
|
btn.textContent = 'Auto-name old sessions';
|
|
btn.disabled = false;
|
|
});
|
|
|
|
// Persona rename toggle
|
|
document.querySelectorAll('.persona-rename-toggle').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const p = btn.dataset.persona;
|
|
const form = document.querySelector(`.persona-rename-form[data-persona="${p}"]`);
|
|
btn.style.display = 'none';
|
|
form.style.display = 'flex';
|
|
form.querySelector('input[type="text"]').focus();
|
|
});
|
|
});
|
|
document.querySelectorAll('.persona-rename-cancel').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const form = btn.closest('.persona-rename-form');
|
|
const p = form.dataset.persona;
|
|
const toggle = document.querySelector(`.persona-rename-toggle[data-persona="${p}"]`);
|
|
form.style.display = 'none';
|
|
toggle.style.display = '';
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|