feat: last-used persona cookie, emoji dropdown, theme support, auth status move

- cx_last_persona cookie set on serve_ui; root/login/help/settings
  redirects use preferred persona from cookie instead of alphabetically first
- /api/personas returns [{name, emoji}] objects; persona switcher dropdown
  renders emoji + name with flex layout and .pd-emoji span
- Help, Settings, Model Registry pages apply localStorage theme on load
  (no flash); CSS variables for dark/light replacing all hardcoded hex values
- Claude CLI auth status moved from prominent chat banner to Anthropic
  provider block in Model Registry — live dot indicator (ok/warn/err)
- Auth banner removed from main chat UI (index.html, app.js, style.css)
- Add Model collapsed into Models section as <details> to shorten page
- Light-mode overrides for provider icons, model badges, ctx-badge, tags
  (Anthropic/Google/local colors now readable in both themes)
- Help page gains table, pre/code, hr styles for HELP.md rendered content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-28 22:52:34 -04:00
parent 1222f806ce
commit 66cb197de0
9 changed files with 305 additions and 245 deletions

View File

@@ -21,6 +21,9 @@ router = APIRouter()
_STATIC = Path(__file__).parent.parent / "static" _STATIC = Path(__file__).parent.parent / "static"
_LAST_PERSONA_COOKIE = "cx_last_persona"
def _get_session_user(request: Request) -> str | None: def _get_session_user(request: Request) -> str | None:
token = request.cookies.get(COOKIE_NAME) token = request.cookies.get(COOKIE_NAME)
if not token: if not token:
@@ -31,6 +34,16 @@ def _get_session_user(request: Request) -> str | None:
return None return None
def _preferred_persona(request: Request, username: str) -> str:
names = list_user_personas(username)
if not names:
return ""
cookie_val = request.cookies.get(_LAST_PERSONA_COOKIE, "")
if cookie_val in names:
return cookie_val
return names[0]
@router.get("/help", include_in_schema=False) @router.get("/help", include_in_schema=False)
async def help_page(request: Request, persona: str = ""): async def help_page(request: Request, persona: str = ""):
username = _get_session_user(request) username = _get_session_user(request)
@@ -38,11 +51,11 @@ async def help_page(request: Request, persona: str = ""):
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username) personas = list_user_personas(username)
# Use persona from query param if valid, else fall back to first # Use persona from query param if valid, else prefer last-visited from cookie
if persona and persona in personas: if persona and persona in personas:
back_persona = persona back_persona = persona
else: else:
back_persona = personas[0] if personas else "" back_persona = _preferred_persona(request, username)
back_href = f"/{username}/{back_persona}" if back_persona else "/" back_href = f"/{username}/{back_persona}" if back_persona else "/"
html = (_STATIC / "help.html").read_text() html = (_STATIC / "help.html").read_text()

View File

@@ -28,6 +28,9 @@ router = APIRouter()
_STATIC = Path(__file__).parent.parent / "static" _STATIC = Path(__file__).parent.parent / "static"
_LAST_PERSONA_COOKIE = "cx_last_persona"
def _get_session_user(request: Request) -> str | None: def _get_session_user(request: Request) -> str | None:
token = request.cookies.get(COOKIE_NAME) token = request.cookies.get(COOKIE_NAME)
if not token: if not token:
@@ -38,7 +41,17 @@ def _get_session_user(request: Request) -> str | None:
return None return None
def _settings_page(username: str, personas: list[str], success: str = "", error: str = "") -> str: def _preferred_persona(request: Request, username: str) -> str:
names = list_user_personas(username)
if not names:
return ""
cookie_val = request.cookies.get(_LAST_PERSONA_COOKIE, "")
if cookie_val in names:
return cookie_val
return names[0]
def _settings_page(username: str, personas: list[str], back_persona: str = "", success: str = "", error: str = "") -> str:
html = (_STATIC / "settings.html").read_text() html = (_STATIC / "settings.html").read_text()
html = html.replace("{{ username }}", username) html = html.replace("{{ username }}", username)
@@ -62,7 +75,8 @@ def _settings_page(username: str, personas: list[str], success: str = "", error:
</li>''' for p in personas </li>''' for p in personas
) )
html = html.replace("{{ persona_items }}", persona_items or "<li><em>No personas yet.</em></li>") html = html.replace("{{ persona_items }}", persona_items or "<li><em>No personas yet.</em></li>")
back_persona = personas[0] if personas else "" if not back_persona:
back_persona = personas[0] if personas else ""
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/") html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help") html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
if success: if success:
@@ -78,7 +92,8 @@ async def settings_page(request: Request):
if not username: if not username:
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username) personas = list_user_personas(username)
return HTMLResponse(_settings_page(username, personas)) back_persona = _preferred_persona(request, username)
return HTMLResponse(_settings_page(username, personas, back_persona=back_persona))
@router.post("/settings/password", include_in_schema=False) @router.post("/settings/password", include_in_schema=False)
@@ -93,19 +108,20 @@ async def change_password(
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username) personas = list_user_personas(username)
back_persona = _preferred_persona(request, username)
if not check_credentials(username, current_password): if not check_credentials(username, current_password):
return HTMLResponse(_settings_page(username, personas, error="Current password is incorrect.")) return HTMLResponse(_settings_page(username, personas, back_persona, error="Current password is incorrect."))
if len(new_password) < 8: if len(new_password) < 8:
return HTMLResponse(_settings_page(username, personas, error="New password must be at least 8 characters.")) return HTMLResponse(_settings_page(username, personas, back_persona, error="New password must be at least 8 characters."))
if new_password != confirm_password: if new_password != confirm_password:
return HTMLResponse(_settings_page(username, personas, error="New passwords do not match.")) return HTMLResponse(_settings_page(username, personas, back_persona, error="New passwords do not match."))
set_password(username, new_password) set_password(username, new_password)
logger.info("password changed: %s", username) logger.info("password changed: %s", username)
return HTMLResponse(_settings_page(username, personas, success="Password updated successfully.")) return HTMLResponse(_settings_page(username, personas, back_persona, success="Password updated successfully."))
@router.post("/settings/username", include_in_schema=False) @router.post("/settings/username", include_in_schema=False)
@@ -118,11 +134,12 @@ async def rename_username(
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username) personas = list_user_personas(username)
back_persona = _preferred_persona(request, username)
new_username = new_username.strip().lower() new_username = new_username.strip().lower()
if not _SLUG_RE.match(new_username): if not _SLUG_RE.match(new_username):
return HTMLResponse(_settings_page( return HTMLResponse(_settings_page(
username, personas, username, personas, back_persona,
error="Invalid username. Use lowercase letters, digits, _ or - only.")) error="Invalid username. Use lowercase letters, digits, _ or - only."))
if new_username == username: if new_username == username:
@@ -134,7 +151,7 @@ async def rename_username(
if new_dir.exists(): if new_dir.exists():
return HTMLResponse(_settings_page( return HTMLResponse(_settings_page(
username, personas, username, personas, back_persona,
error=f"Username '{new_username}' is already taken.")) error=f"Username '{new_username}' is already taken."))
old_dir.rename(new_dir) old_dir.rename(new_dir)
@@ -156,6 +173,7 @@ async def save_gemini_key(
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username) personas = list_user_personas(username)
back_persona = _preferred_persona(request, username)
gemini_api_key = gemini_api_key.strip() gemini_api_key = gemini_api_key.strip()
data = _read_auth(username) data = _read_auth(username)
@@ -167,7 +185,7 @@ async def save_gemini_key(
msg = "Gemini API key removed — using server key." msg = "Gemini API key removed — using server key."
_write_auth(username, data) _write_auth(username, data)
logger.info("gemini key updated: %s", username) logger.info("gemini key updated: %s", username)
return HTMLResponse(_settings_page(username, personas, success=msg)) return HTMLResponse(_settings_page(username, personas, back_persona, success=msg))
@router.post("/settings/persona/rename", include_in_schema=False) @router.post("/settings/persona/rename", include_in_schema=False)
@@ -181,11 +199,12 @@ async def rename_persona(
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username) personas = list_user_personas(username)
back_persona = _preferred_persona(request, username)
new_name = new_name.strip().lower() new_name = new_name.strip().lower()
if not _SLUG_RE.match(new_name): if not _SLUG_RE.match(new_name):
return HTMLResponse(_settings_page( return HTMLResponse(_settings_page(
username, personas, username, personas, back_persona,
error="Invalid name. Use lowercase letters, digits, _ or - only.")) error="Invalid name. Use lowercase letters, digits, _ or - only."))
if new_name == old_name: if new_name == old_name:
@@ -196,11 +215,11 @@ async def rename_persona(
new_dir = persona_root / new_name new_dir = persona_root / new_name
if not old_dir.exists(): if not old_dir.exists():
return HTMLResponse(_settings_page(username, personas, error=f"Persona '{old_name}' not found.")) return HTMLResponse(_settings_page(username, personas, back_persona, error=f"Persona '{old_name}' not found."))
if new_dir.exists(): if new_dir.exists():
return HTMLResponse(_settings_page( return HTMLResponse(_settings_page(
username, personas, username, personas, back_persona,
error=f"A persona named '{new_name}' already exists.")) error=f"A persona named '{new_name}' already exists."))
old_dir.rename(new_dir) old_dir.rename(new_dir)

View File

@@ -56,12 +56,26 @@ def _set_cookie(response: Response, username: str) -> None:
) )
_LAST_PERSONA_COOKIE = "cx_last_persona"
def _first_persona(username: str) -> str | None: def _first_persona(username: str) -> str | None:
"""Return the first available persona for a user, or None.""" """Return the first available persona for a user, or None."""
names = list_user_personas(username) names = list_user_personas(username)
return names[0] if names else None return names[0] if names else None
def _preferred_persona(request: Request, username: str) -> str | None:
"""Return the last-visited persona from cookie if valid, else the first available."""
names = list_user_personas(username)
if not names:
return None
cookie_val = request.cookies.get(_LAST_PERSONA_COOKIE, "")
if cookie_val in names:
return cookie_val
return names[0]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Favicon — default sparkle; persona pages override via JS # Favicon — default sparkle; persona pages override via JS
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -85,7 +99,7 @@ async def root(request: Request):
user = _get_session_user(request) user = _get_session_user(request)
if not user: if not user:
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
persona = _first_persona(user) persona = _preferred_persona(request, user)
if not persona: if not persona:
return HTMLResponse("<h1>No personas configured for your account.</h1>", status_code=500) return HTMLResponse("<h1>No personas configured for your account.</h1>", status_code=500)
return RedirectResponse(f"/{user}/{persona}", status_code=302) return RedirectResponse(f"/{user}/{persona}", status_code=302)
@@ -100,7 +114,7 @@ async def login_page(request: Request):
user = _get_session_user(request) user = _get_session_user(request)
if user: if user:
# Already logged in — redirect home # Already logged in — redirect home
persona = _first_persona(user) persona = _preferred_persona(request, user)
if persona: if persona:
return RedirectResponse(f"/{user}/{persona}", status_code=302) return RedirectResponse(f"/{user}/{persona}", status_code=302)
return HTMLResponse((_STATIC / "login.html").read_text()) return HTMLResponse((_STATIC / "login.html").read_text())
@@ -254,7 +268,16 @@ async def api_personas(request: Request) -> dict:
if not user: if not user:
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
return {"user": user, "personas": list_user_personas(user)} personas_with_emoji = []
for p in list_user_personas(user):
emoji = ""
identity_path = persona_path(user, p) / "IDENTITY.md"
if identity_path.exists():
m = re.search(r"\|\s*Emoji\s*\|\s*(.+?)\s*\|", identity_path.read_text())
if m:
emoji = m.group(1).strip()
personas_with_emoji.append({"name": p, "emoji": emoji})
return {"user": user, "personas": personas_with_emoji}
@router.get("/{username}/{persona}", include_in_schema=False) @router.get("/{username}/{persona}", include_in_schema=False)
@@ -288,4 +311,6 @@ async def serve_ui(username: str, persona: str, request: Request):
f'{{user: "{username}", persona: "{persona}", emoji: "{emoji}"}};</script>' f'{{user: "{username}", persona: "{persona}", emoji: "{emoji}"}};</script>'
) )
html = html.replace("</head>", f"{config_tag}\n</head>", 1) html = html.replace("</head>", f"{config_tag}\n</head>", 1)
return HTMLResponse(html) resp = HTMLResponse(html)
resp.set_cookie(_LAST_PERSONA_COOKIE, persona, max_age=365 * 86400, httponly=False, samesite="lax")
return resp

View File

@@ -360,10 +360,18 @@
personaDropEl.innerHTML = ''; personaDropEl.innerHTML = '';
personas.forEach(p => { personas.forEach(p => {
const name = p.name || p;
const emoji = p.emoji || '✨';
const a = document.createElement('a'); const a = document.createElement('a');
a.href = `/${CORTEX_USER}/${p}`; a.href = `/${CORTEX_USER}/${name}`;
a.textContent = p.charAt(0).toUpperCase() + p.slice(1); if (name === CORTEX_PERSONA) a.classList.add('active');
if (p === CORTEX_PERSONA) a.classList.add('active'); const emojiEl = document.createElement('span');
emojiEl.className = 'pd-emoji';
emojiEl.textContent = emoji;
a.appendChild(emojiEl);
const nameEl = document.createElement('span');
nameEl.textContent = name.charAt(0).toUpperCase() + name.slice(1);
a.appendChild(nameEl);
personaDropEl.appendChild(a); personaDropEl.appendChild(a);
}); });
@@ -1493,19 +1501,6 @@
try { data = JSON.parse(e.data); } catch { return; } try { data = JSON.parse(e.data); } catch { return; }
if (data.type === 'keepalive') return; if (data.type === 'keepalive') return;
if (data.type === 'claude_auth_expired') {
let banner = document.getElementById('claude-auth-banner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'claude-auth-banner';
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#7c2d12;color:#fef2f2;padding:0.6rem 1rem;font-size:0.85rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;';
banner.innerHTML = '<span>⚠️ Claude authentication expired — run <code style="background:#991b1b;padding:0.1rem 0.3rem;border-radius:3px;">claude</code> in your terminal to re-authenticate, then reload.</span>'
+ '<button onclick="this.parentElement.remove()" style="background:none;border:none;color:#fef2f2;font-size:1.1rem;cursor:pointer;padding:0 0.3rem;">✕</button>';
document.body.prepend(banner);
}
return;
}
if (data.type !== 'nct_message' && data.type !== 'nct_response') return; if (data.type !== 'nct_message' && data.type !== 'nct_response') return;
if (sessionId === data.session_id) { if (sessionId === data.session_id) {
@@ -1704,57 +1699,6 @@
syncHeight(); syncHeight();
addMessage('system', 'Session started'); addMessage('system', 'Session started');
// ── Auth token warning banner ─────────────────────────────
const authBanner = document.getElementById('auth-banner');
const authBannerMsg = document.getElementById('auth-banner-msg');
const authBannerHint = document.getElementById('auth-banner-hint');
const authBannerClose = document.getElementById('auth-banner-close');
async function checkAuthStatus() {
try {
const res = await fetch('/auth/status');
if (!res.ok) return;
const d = await res.json();
const warnings = [];
const fixes = [];
let anyExpired = false;
if (d.claude?.warning) {
if (d.claude.expired) {
warnings.push('✕ Claude CLI token has expired');
anyExpired = true;
} else {
warnings.push(`⚠ Claude CLI token expires in ${d.claude.access_token_hours_remaining}h`);
}
fixes.push('<code>claude</code>');
}
if (d.gemini?.warning) {
warnings.push('⚠ Gemini CLI not authenticated');
fixes.push('<code>gemini</code>');
}
if (!warnings.length) {
authBanner.classList.remove('show');
return;
}
authBannerMsg.innerHTML = warnings.join('<br>');
authBannerHint.innerHTML =
`To fix: SSH into the Cortex host and run ${fixes.join(' and/or ')}`
+ 'follow the login prompt, then restart Cortex.';
authBanner.classList.toggle('expired', anyExpired);
authBanner.classList.add('show');
} catch { /* silently ignore — don't break the UI */ }
}
authBannerClose.addEventListener('click', () => authBanner.classList.remove('show'));
checkAuthStatus();
// Re-check every 30 minutes
setInterval(checkAuthStatus, 30 * 60 * 1000);
// ── Initial render ──────────────────────────────────────────── // ── Initial render ────────────────────────────────────────────
// Process all static Lucide SVGs in the header + stop button, // Process all static Lucide SVGs in the header + stop button,
// and seed the mode UI (which also calls render_icons internally). // and seed the mode UI (which also calls render_icons internally).

View File

@@ -8,17 +8,33 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
<style> <style>
:root {
--pg-bg: #0f1117; --pg-surface: #1a1d27;
--pg-border: #2d3148;
--pg-text: #e2e8f0; --pg-muted: #94a3b8;
--pg-dim: #64748b; --pg-dimmer: #475569;
--pg-bright: #cbd5e1; --pg-nav-hover: rgba(255,255,255,0.05);
}
[data-theme="light"] {
--pg-bg: #f4f2fa; --pg-surface: #ffffff;
--pg-border: #d0c8e8;
--pg-text: #1a1228; --pg-muted: #5a5478;
--pg-dim: #7a7290; --pg-dimmer: #9e98b0;
--pg-bright: #1a1228; --pg-nav-hover: rgba(0,0,0,0.05);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
min-height: 100vh; min-height: 100vh;
background: #0f1117; background: var(--pg-bg);
font-family: 'Inter', system-ui, -apple-system, sans-serif; font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-weight: 450; font-weight: 450;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
color: #e2e8f0; color: var(--pg-text);
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
} }
@@ -41,32 +57,32 @@
border-radius: 6px; border-radius: 6px;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: #64748b; color: var(--pg-dim);
text-decoration: none; text-decoration: none;
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
white-space: nowrap; white-space: nowrap;
} }
.nav-link:hover { color: #cbd5e1; background: rgba(255,255,255,0.05); } .nav-link:hover { color: var(--pg-bright); background: var(--pg-nav-hover); }
.nav-link.active { color: #a78bfa; } .nav-link.active { color: #a78bfa; }
.nav-spacer { flex: 1; min-width: 0.5rem; } .nav-spacer { flex: 1; min-width: 0.5rem; }
.nav-link.nav-logout { color: #475569; } .nav-link.nav-logout { color: var(--pg-dimmer); }
.nav-link.nav-logout:hover { color: #94a3b8; background: none; } .nav-link.nav-logout:hover { color: var(--pg-muted); background: none; }
header { header {
margin-bottom: 2rem; margin-bottom: 2rem;
padding-bottom: 1rem; padding-bottom: 1rem;
border-bottom: 1px solid #2d3148; border-bottom: 1px solid var(--pg-border);
} }
header h1 { font-size: 1.5rem; font-weight: 700; color: #a78bfa; } header h1 { font-size: 1.5rem; font-weight: 700; color: #a78bfa; }
header p { font-size: 0.85rem; color: #94a3b8; margin-top: 0.25rem; } header p { font-size: 0.85rem; color: var(--pg-muted); margin-top: 0.25rem; }
#help-body { line-height: 1.7; } #help-body { line-height: 1.7; }
/* Collapsible sections */ /* Collapsible sections */
details { details {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
background: #1a1d27; background: var(--pg-surface);
border: 1px solid #2d3148; border: 1px solid var(--pg-border);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
} }
@@ -74,7 +90,7 @@
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.95rem;
color: #cbd5e1; color: var(--pg-bright);
cursor: pointer; cursor: pointer;
list-style: none; list-style: none;
display: flex; display: flex;
@@ -84,7 +100,7 @@
summary::before { summary::before {
content: '▶'; content: '▶';
font-size: 0.65rem; font-size: 0.65rem;
color: #94a3b8; color: var(--pg-muted);
transition: transform 0.15s; transition: transform 0.15s;
} }
details[open] summary::before { transform: rotate(90deg); } details[open] summary::before { transform: rotate(90deg); }
@@ -94,13 +110,13 @@
padding: 0 1rem 1rem; padding: 0 1rem 1rem;
} }
#help-body p { margin: 0.5rem 0; font-size: 0.9rem; color: #cbd5e1; } #help-body p { margin: 0.5rem 0; font-size: 0.9rem; color: var(--pg-bright); }
#help-body ul { margin: 0.5rem 0 0.5rem 1.25rem; } #help-body ul { margin: 0.5rem 0 0.5rem 1.25rem; }
#help-body li { font-size: 0.9rem; color: #cbd5e1; margin-bottom: 0.25rem; } #help-body li { font-size: 0.9rem; color: var(--pg-bright); margin-bottom: 0.25rem; }
#help-body strong { color: #e2e8f0; } #help-body strong { color: var(--pg-text); }
#help-body code { #help-body code {
background: #0f1117; background: var(--pg-bg);
border: 1px solid #2d3148; border: 1px solid var(--pg-border);
border-radius: 4px; border-radius: 4px;
padding: 0.1em 0.4em; padding: 0.1em 0.4em;
font-size: 0.85em; font-size: 0.85em;
@@ -111,13 +127,21 @@
#help-body h3 { #help-body h3 {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
color: #94a3b8; color: var(--pg-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
margin: 0.75rem 0 0.25rem; margin: 0.75rem 0 0.25rem;
} }
#loading { color: #94a3b8; font-size: 0.9rem; padding: 1rem 0; } #loading { color: var(--pg-muted); font-size: 0.9rem; padding: 1rem 0; }
#help-body table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin: 0.5rem 0 0.75rem; }
#help-body th, #help-body td { padding: 0.45rem 0.7rem; text-align: left; border-bottom: 1px solid var(--pg-border); }
#help-body th { color: var(--pg-muted); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
#help-body td { color: var(--pg-bright); }
#help-body pre { background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 6px; padding: 0.75rem 1rem; overflow-x: auto; margin: 0.5rem 0; }
#help-body pre code { background: none; border: none; padding: 0; font-size: 0.85em; color: var(--pg-muted); }
#help-body hr { border: none; border-top: 1px solid var(--pg-border); margin: 0.5rem 0; }
</style> </style>
</head> </head>
<body> <body>

View File

@@ -148,15 +148,6 @@
</div> </div>
</div> </div>
<!-- Auth warning banner — shown when Claude CLI token is near expiry -->
<div id="auth-banner">
<div id="auth-banner-text">
<span id="auth-banner-msg"></span>
<span id="auth-banner-hint"></span>
</div>
<button id="auth-banner-close" title="Dismiss"></button>
</div>
<div id="messages"></div> <div id="messages"></div>
<div id="session-id"></div> <div id="session-id"></div>

View File

@@ -7,12 +7,28 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
<style> <style>
:root {
--pg-bg: #0f1117; --pg-surface: #1a1d27;
--pg-border: #2d3148; --pg-border-deep: #1e2030;
--pg-text: #e2e8f0; --pg-muted: #94a3b8;
--pg-dim: #64748b; --pg-dimmer: #475569;
--pg-bright: #cbd5e1; --pg-nav-hover: rgba(255,255,255,0.05);
}
[data-theme="light"] {
--pg-bg: #f4f2fa; --pg-surface: #ffffff;
--pg-border: #d0c8e8; --pg-border-deep: #d0c8e8;
--pg-text: #1a1228; --pg-muted: #5a5478;
--pg-dim: #7a7290; --pg-dimmer: #9e98b0;
--pg-bright: #1a1228; --pg-nav-hover: rgba(0,0,0,0.05);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
min-height: 100vh; background: #0f1117; min-height: 100vh; background: var(--pg-bg);
font-family: 'Inter', system-ui, sans-serif; font-weight: 450; font-family: 'Inter', system-ui, sans-serif; font-weight: 450;
-webkit-font-smoothing: antialiased; color: #e2e8f0; -webkit-font-smoothing: antialiased; color: var(--pg-text);
padding: 2rem 1.5rem 4rem; padding: 2rem 1.5rem 4rem;
} }
.page { max-width: 700px; margin: 0 auto; } .page { max-width: 700px; margin: 0 auto; }
@@ -22,32 +38,32 @@
.nav-link { .nav-link {
display: inline-flex; align-items: center; display: inline-flex; align-items: center;
padding: 0.3rem 0.6rem; border-radius: 6px; padding: 0.3rem 0.6rem; border-radius: 6px;
font-size: 0.8rem; font-weight: 500; color: #64748b; font-size: 0.8rem; font-weight: 500; color: var(--pg-dim);
text-decoration: none; transition: color 0.15s, background 0.15s; white-space: nowrap; text-decoration: none; transition: color 0.15s, background 0.15s; white-space: nowrap;
} }
.nav-link:hover { color: #cbd5e1; background: rgba(255,255,255,0.05); } .nav-link:hover { color: var(--pg-bright); background: var(--pg-nav-hover); }
.nav-link.active { color: #a78bfa; } .nav-link.active { color: #a78bfa; }
.nav-spacer { flex: 1; min-width: 0.5rem; } .nav-spacer { flex: 1; min-width: 0.5rem; }
.nav-link.nav-logout { color: #475569; } .nav-link.nav-logout { color: var(--pg-dimmer); }
.nav-link.nav-logout:hover { color: #94a3b8; background: none; } .nav-link.nav-logout:hover { color: var(--pg-muted); background: none; }
/* Page header */ /* Page header */
.page-header { margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #2d3148; } .page-header { margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid var(--pg-border); }
.page-header h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; } .page-header h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
.page-header p { font-size: 0.82rem; color: #94a3b8; margin-top: 0.25rem; } .page-header p { font-size: 0.82rem; color: var(--pg-muted); margin-top: 0.25rem; }
/* Section cards */ /* Section cards */
.section { .section {
background: #1a1d27; border: 1px solid #2d3148; background: var(--pg-surface); border: 1px solid var(--pg-border);
border-radius: 10px; padding: 1.5rem; margin-bottom: 1.25rem; border-radius: 10px; padding: 1.5rem; margin-bottom: 1.25rem;
} }
.section h2 { .section h2 {
font-size: 0.85rem; font-weight: 600; color: #94a3b8; font-size: 0.85rem; font-weight: 600; color: var(--pg-muted);
text-transform: uppercase; letter-spacing: 0.05em; text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 1.1rem; padding-bottom: 0.5rem; margin-bottom: 1.1rem; padding-bottom: 0.5rem;
border-bottom: 1px solid #2d3148; border-bottom: 1px solid var(--pg-border);
} }
.section-note { font-size: 0.8rem; color: #64748b; margin-bottom: 1rem; line-height: 1.5; } .section-note { font-size: 0.8rem; color: var(--pg-dim); margin-bottom: 1rem; line-height: 1.5; }
/* Provider sub-sections */ /* Provider sub-sections */
.provider-block { margin-bottom: 1.25rem; } .provider-block { margin-bottom: 1.25rem; }
@@ -63,26 +79,44 @@
} }
.pi-anthropic { background: #1e1b4b; color: #818cf8; } .pi-anthropic { background: #1e1b4b; color: #818cf8; }
.pi-google { background: #042f2e; color: #34d399; } .pi-google { background: #042f2e; color: #34d399; }
.provider-title { font-size: 0.9rem; font-weight: 600; color: #e2e8f0; } [data-theme="light"] .pi-anthropic { background: #ede9fe; color: #5b21b6; }
.provider-subtitle { font-size: 0.78rem; color: #64748b; } [data-theme="light"] .pi-google { background: #d1fae5; color: #065f46; }
.provider-title { font-size: 0.9rem; font-weight: 600; color: var(--pg-text); }
.provider-subtitle { font-size: 0.78rem; color: var(--pg-dim); }
/* Auth status row */
.auth-status {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.8rem; margin-top: 0.6rem; color: var(--pg-muted);
}
.auth-status .dot {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
background: var(--pg-dim);
}
.auth-status.ok .dot { background: #4ade80; }
.auth-status.ok { color: #4ade80; }
.auth-status.warn { color: #fbbf24; }
.auth-status.warn .dot { background: #fbbf24; }
.auth-status.err .dot { background: #f87171; }
.auth-status.err { color: #f87171; }
/* Account rows */ /* Account rows */
.account-row { .account-row {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 0.6rem 0.9rem; background: #0f1117; padding: 0.6rem 0.9rem; background: var(--pg-bg);
border: 1px solid #2d3148; border-radius: 8px; margin-bottom: 0.5rem; border: 1px solid var(--pg-border); border-radius: 8px; margin-bottom: 0.5rem;
} }
.account-label { font-size: 0.88rem; font-weight: 600; color: #e2e8f0; } .account-label { font-size: 0.88rem; font-weight: 600; color: var(--pg-text); }
.account-hint { font-size: 0.73rem; color: #475569; margin-left: 0.5rem; font-family: monospace; } .account-hint { font-size: 0.73rem; color: var(--pg-dimmer); margin-left: 0.5rem; font-family: monospace; }
/* Form elements */ /* Form elements */
.field { margin-bottom: 0.9rem; } .field { margin-bottom: 0.9rem; }
label { display: block; font-size: 0.78rem; font-weight: 500; color: #94a3b8; margin-bottom: 0.35rem; } label { display: block; font-size: 0.78rem; font-weight: 500; color: var(--pg-muted); margin-bottom: 0.35rem; }
input[type="text"], input[type="password"], input[type="url"], input[type="text"], input[type="password"], input[type="url"],
input[type="number"], select { input[type="number"], select {
width: 100%; padding: 0.6rem 0.8rem; width: 100%; padding: 0.6rem 0.8rem;
background: #0f1117; border: 1px solid #2d3148; border-radius: 6px; background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 6px;
color: #e2e8f0; font-size: 0.9rem; font-family: inherit; color: var(--pg-text); font-size: 0.9rem; font-family: inherit;
outline: none; transition: border-color 0.15s; outline: none; transition: border-color 0.15s;
} }
input:focus, select:focus { border-color: #7c3aed; } input:focus, select:focus { border-color: #7c3aed; }
@@ -90,7 +124,7 @@
input[type="number"] { width: 6rem; } input[type="number"] { width: 6rem; }
.field-row { display: flex; gap: 0.75rem; } .field-row { display: flex; gap: 0.75rem; }
.field-row .field { flex: 1; margin-bottom: 0; } .field-row .field { flex: 1; margin-bottom: 0; }
.key-status { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; } .key-status { font-size: 0.75rem; color: var(--pg-muted); margin-top: 0.35rem; }
/* Buttons */ /* Buttons */
.btn { .btn {
@@ -100,29 +134,29 @@
} }
.btn-primary { background: #7c3aed; color: #fff; } .btn-primary { background: #7c3aed; color: #fff; }
.btn-primary:hover { background: #6d28d9; } .btn-primary:hover { background: #6d28d9; }
.btn-secondary { background: #1a1d27; color: #94a3b8; border: 1px solid #2d3148; } .btn-secondary { background: var(--pg-surface); color: var(--pg-muted); border: 1px solid var(--pg-border); }
.btn-secondary:hover { border-color: #94a3b8; color: #e2e8f0; } .btn-secondary:hover { border-color: var(--pg-muted); color: var(--pg-text); }
.btn-sm { padding: 0.35rem 0.7rem; font-size: 0.8rem; font-weight: 500; } .btn-sm { padding: 0.35rem 0.7rem; font-size: 0.8rem; font-weight: 500; }
.btn-row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.75rem; flex-wrap: wrap; } .btn-row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.75rem; flex-wrap: wrap; }
.btn-link { .btn-link {
background: none; border: none; cursor: pointer; font-family: inherit; background: none; border: none; cursor: pointer; font-family: inherit;
font-size: 0.78rem; color: #64748b; padding: 0; font-size: 0.78rem; color: var(--pg-dim); padding: 0;
text-decoration: underline; text-underline-offset: 2px; text-decoration: underline; text-underline-offset: 2px;
} }
.btn-link:hover { color: #94a3b8; } .btn-link:hover { color: var(--pg-muted); }
.btn-link.danger { color: #7f1d1d; } .btn-link.danger { color: #7f1d1d; }
.btn-link.danger:hover { color: #f87171; } .btn-link.danger:hover { color: #f87171; }
/* Provider tabs */ /* Provider tabs */
.ptabs { display: flex; gap: 0; margin-bottom: 1.1rem; border-bottom: 1px solid #2d3148; } .ptabs { display: flex; gap: 0; margin-bottom: 1.1rem; border-bottom: 1px solid var(--pg-border); }
.ptab { .ptab {
padding: 0.45rem 0.9rem; font-size: 0.82rem; font-weight: 500; padding: 0.45rem 0.9rem; font-size: 0.82rem; font-weight: 500;
background: none; border: none; cursor: pointer; color: #64748b; background: none; border: none; cursor: pointer; color: var(--pg-dim);
border-bottom: 2px solid transparent; margin-bottom: -1px; border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s; font-family: inherit; transition: color 0.15s, border-color 0.15s; font-family: inherit;
} }
.ptab.active { color: #a78bfa; border-bottom-color: #a78bfa; } .ptab.active { color: #a78bfa; border-bottom-color: #a78bfa; }
.ptab:hover:not(.active) { color: #cbd5e1; } .ptab:hover:not(.active) { color: var(--pg-bright); }
/* Provider badges on model rows */ /* Provider badges on model rows */
.pbadge { .pbadge {
@@ -133,39 +167,44 @@
.pb-anthropic { background: #1e1b4b; color: #818cf8; } .pb-anthropic { background: #1e1b4b; color: #818cf8; }
.pb-google { background: #042f2e; color: #34d399; } .pb-google { background: #042f2e; color: #34d399; }
.pb-local { background: #1e293b; color: #64748b; } .pb-local { background: #1e293b; color: #64748b; }
[data-theme="light"] .pb-anthropic { background: #ede9fe; color: #5b21b6; }
[data-theme="light"] .pb-google { background: #d1fae5; color: #065f46; }
[data-theme="light"] .pb-local { background: #e2e8f0; color: #475569; }
/* Host & model rows */ /* Host & model rows */
.host-row { .host-row {
background: #0f1117; border: 1px solid #2d3148; border-radius: 8px; background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 8px;
padding: 1rem; margin-bottom: 0.75rem; padding: 1rem; margin-bottom: 0.75rem;
} }
.host-form .field-row { margin-bottom: 0.6rem; } .host-form .field-row { margin-bottom: 0.6rem; }
.fetch-status { font-size: 0.78rem; color: #94a3b8; } .fetch-status { font-size: 0.78rem; color: var(--pg-muted); }
.fetch-status.ok { color: #4ade80; } .fetch-status.ok { color: #4ade80; }
.fetch-status.err { color: #f87171; } .fetch-status.err { color: #f87171; }
.model-row { .model-row {
display: flex; align-items: flex-start; justify-content: space-between; display: flex; align-items: flex-start; justify-content: space-between;
gap: 0.75rem; padding: 0.75rem 0.9rem; gap: 0.75rem; padding: 0.75rem 0.9rem;
background: #0f1117; border: 1px solid #2d3148; border-radius: 8px; background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 8px;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.model-info { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; } .model-info { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
.model-label { font-size: 0.9rem; font-weight: 600; color: #e2e8f0; } .model-label { font-size: 0.9rem; font-weight: 600; color: var(--pg-text); }
.model-name { font-size: 0.75rem; color: #64748b; font-family: monospace; word-break: break-all; } .model-name { font-size: 0.75rem; color: var(--pg-dim); font-family: monospace; word-break: break-all; }
.model-host { font-size: 0.72rem; color: #475569; } .model-host { font-size: 0.72rem; color: var(--pg-dimmer); }
.ctx-badge { .ctx-badge {
display: inline-block; margin-left: 0.35rem; display: inline-block; margin-left: 0.35rem;
padding: 0.1rem 0.3rem; border-radius: 3px; padding: 0.1rem 0.3rem; border-radius: 3px;
background: #1e293b; color: #64748b; font-size: 0.65rem; font-weight: 600; background: #1e293b; color: #64748b; font-size: 0.65rem; font-weight: 600;
vertical-align: middle; vertical-align: middle;
} }
[data-theme="light"] .ctx-badge { background: #e2e8f0; color: #475569; }
.tag-row { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.2rem; } .tag-row { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.2rem; }
.tag { padding: 0.1rem 0.4rem; border-radius: 3px; background: #1e1b4b; color: #818cf8; font-size: 0.68rem; font-weight: 500; } .tag { padding: 0.1rem 0.4rem; border-radius: 3px; background: #1e1b4b; color: #818cf8; font-size: 0.68rem; font-weight: 500; }
[data-theme="light"] .tag { background: #ede9fe; color: #5b21b6; }
.row-btn { .row-btn {
padding: 0.3rem 0.65rem; border-radius: 5px; font-size: 0.78rem; padding: 0.3rem 0.65rem; border-radius: 5px; font-size: 0.78rem;
font-weight: 500; cursor: pointer; font-family: inherit; font-weight: 500; cursor: pointer; font-family: inherit;
border: 1px solid #2d3148; background: #1a1d27; color: #94a3b8; border: 1px solid var(--pg-border); background: var(--pg-surface); color: var(--pg-muted);
transition: border-color 0.15s, color 0.15s; flex-shrink: 0; transition: border-color 0.15s, color 0.15s; flex-shrink: 0;
} }
.row-btn.danger { color: #f87171; } .row-btn.danger { color: #f87171; }
@@ -174,17 +213,17 @@
/* Role assignments */ /* Role assignments */
.role-row { .role-row {
display: flex; align-items: flex-start; gap: 1rem; display: flex; align-items: flex-start; gap: 1rem;
padding: 0.6rem 0; border-bottom: 1px solid #1e2030; padding: 0.6rem 0; border-bottom: 1px solid var(--pg-border-deep);
} }
.role-row:last-child { border-bottom: none; } .role-row:last-child { border-bottom: none; }
.role-name { font-size: 0.82rem; font-weight: 600; color: #a78bfa; min-width: 6rem; padding-top: 0.45rem; } .role-name { font-size: 0.82rem; font-weight: 600; color: #a78bfa; min-width: 6rem; padding-top: 0.45rem; }
.role-slots { display: flex; flex-wrap: wrap; gap: 0.5rem; flex: 1; } .role-slots { display: flex; flex-wrap: wrap; gap: 0.5rem; flex: 1; }
.role-slot { display: flex; flex-direction: column; gap: 0.2rem; flex: 1; min-width: 8rem; } .role-slot { display: flex; flex-direction: column; gap: 0.2rem; flex: 1; min-width: 8rem; }
.slot-label { font-size: 0.68rem; color: #475569; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; } .slot-label { font-size: 0.68rem; color: var(--pg-dimmer); font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; }
.role-select { .role-select {
padding: 0.4rem 0.6rem; font-size: 0.8rem; padding: 0.4rem 0.6rem; font-size: 0.8rem;
background: #0f1117; border: 1px solid #2d3148; border-radius: 6px; background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 6px;
color: #e2e8f0; font-family: inherit; cursor: pointer; outline: none; color: var(--pg-text); font-family: inherit; cursor: pointer; outline: none;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.role-select:focus { border-color: #7c3aed; } .role-select:focus { border-color: #7c3aed; }
@@ -194,7 +233,7 @@
/* Model select picker */ /* Model select picker */
#model-select-wrap { display: none; margin-bottom: 0.75rem; } #model-select-wrap { display: none; margin-bottom: 0.75rem; }
.tags-hint { font-size: 0.72rem; color: #475569; margin-top: 0.3rem; } .tags-hint { font-size: 0.72rem; color: var(--pg-dimmer); margin-top: 0.3rem; }
/* Messages & Toast */ /* Messages & Toast */
.msg { font-size: 0.85rem; text-align: center; padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem; } .msg { font-size: 0.85rem; text-align: center; padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
@@ -202,15 +241,15 @@
.msg.error { color: #f87171; background: #2d0a0a; border: 1px solid #7f1d1d; } .msg.error { color: #f87171; background: #2d0a0a; border: 1px solid #7f1d1d; }
#toast { #toast {
position: fixed; bottom: 1.5rem; right: 1.5rem; position: fixed; bottom: 1.5rem; right: 1.5rem;
background: #1a1d27; border: 1px solid #166534; color: #4ade80; background: var(--pg-surface); border: 1px solid #166534; color: #4ade80;
padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.82rem; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.82rem;
opacity: 0; transition: opacity 0.2s; pointer-events: none; z-index: 100; opacity: 0; transition: opacity 0.2s; pointer-events: none; z-index: 100;
} }
#toast.show { opacity: 1; } #toast.show { opacity: 1; }
#toast.err { border-color: #7f1d1d; color: #f87171; } #toast.err { border-color: #7f1d1d; color: #f87171; }
.empty-note { font-size: 0.85rem; color: #475569; padding: 0.3rem 0; } .empty-note { font-size: 0.85rem; color: var(--pg-dimmer); padding: 0.3rem 0; }
details summary { font-size: 0.82rem; color: #64748b; cursor: pointer; user-select: none; margin-top: 0.75rem; } details summary { font-size: 0.82rem; color: var(--pg-dim); cursor: pointer; user-select: none; margin-top: 0.75rem; }
details > div { margin-top: 0.75rem; } details > div { margin-top: 0.75rem; }
</style> </style>
</head> </head>
@@ -248,6 +287,9 @@
Claude models are accessed through the Claude CLI using your existing OAuth login. Claude models are accessed through the Claude CLI using your existing OAuth login.
Run <code style="font-family:monospace;color:#94a3b8">claude auth login</code> to authenticate. Run <code style="font-family:monospace;color:#94a3b8">claude auth login</code> to authenticate.
</p> </p>
<div id="claude-auth-status" class="auth-status" style="margin-top:0.6rem">
<span class="dot"></span><span id="claude-auth-msg">Checking…</span>
</div>
</div> </div>
<div class="provider-block" style="border-top:1px solid #2d3148; padding-top:1.25rem"> <div class="provider-block" style="border-top:1px solid #2d3148; padding-top:1.25rem">
@@ -335,11 +377,10 @@
<div class="section"> <div class="section">
<h2>Models</h2> <h2>Models</h2>
{{ model_rows }} {{ model_rows }}
</div>
<!-- ── Add Model ── --> <details>
<div class="section"> <summary>+ Add model</summary>
<h2>Add Model</h2> <div>
<div class="ptabs" id="provider-tabs"> <div class="ptabs" id="provider-tabs">
<button type="button" class="ptab active" data-p="local">Local</button> <button type="button" class="ptab active" data-p="local">Local</button>
@@ -427,6 +468,9 @@
<span id="fetch-status" class="fetch-status"></span> <span id="fetch-status" class="fetch-status"></span>
</div> </div>
</form> </form>
</div>
</details>
</div> </div>
<!-- ── Role Assignments ── --> <!-- ── Role Assignments ── -->
@@ -625,6 +669,28 @@
// Hide fetch button initially if no hosts // Hide fetch button initially if no hosts
if (!HAS_HOSTS) fetchBtn.style.display = 'none'; if (!HAS_HOSTS) fetchBtn.style.display = 'none';
// ── Claude CLI auth status ─────────────────────────────────────────────
(async function() {
const el = document.getElementById('claude-auth-status');
const msg = document.getElementById('claude-auth-msg');
if (!el || !msg) return;
try {
const d = await fetch('/auth/status').then(r => r.json());
const c = d.claude;
if (!c) return;
if (c.expired) {
el.className = 'auth-status err';
msg.textContent = 'Token expired — run claude auth login on the Cortex host, then restart Cortex.';
} else if (c.warning) {
el.className = 'auth-status warn';
msg.textContent = `Token expires in ${c.access_token_hours_remaining}h — run claude auth login to refresh.`;
} else {
el.className = 'auth-status ok';
msg.textContent = `Authenticated — token valid for ${c.access_token_hours_remaining}h`;
}
} catch { msg.textContent = 'Status unavailable'; }
})();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -7,7 +7,23 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
<style> <style>
:root {
--pg-bg: #0f1117; --pg-surface: #1a1d27;
--pg-border: #2d3148;
--pg-text: #e2e8f0; --pg-muted: #94a3b8;
--pg-dim: #64748b; --pg-dimmer: #475569;
--pg-bright: #cbd5e1; --pg-nav-hover: rgba(255,255,255,0.05);
}
[data-theme="light"] {
--pg-bg: #f4f2fa; --pg-surface: #ffffff;
--pg-border: #d0c8e8;
--pg-text: #1a1228; --pg-muted: #5a5478;
--pg-dim: #7a7290; --pg-dimmer: #9e98b0;
--pg-bright: #1a1228; --pg-nav-hover: rgba(0,0,0,0.05);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
@@ -15,18 +31,18 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #0f1117; background: var(--pg-bg);
font-family: 'Inter', system-ui, -apple-system, sans-serif; font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-weight: 450; font-weight: 450;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
color: #e2e8f0; color: var(--pg-text);
padding: 1.5rem; padding: 1.5rem;
} }
.card { .card {
background: #1a1d27; background: var(--pg-surface);
border: 1px solid #2d3148; border: 1px solid var(--pg-border);
border-radius: 12px; border-radius: 12px;
padding: 2.5rem 2rem; padding: 2.5rem 2rem;
width: 100%; width: 100%;
@@ -47,30 +63,30 @@
border-radius: 6px; border-radius: 6px;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: #64748b; color: var(--pg-dim);
text-decoration: none; text-decoration: none;
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
white-space: nowrap; white-space: nowrap;
} }
.nav-link:hover { color: #cbd5e1; background: rgba(255,255,255,0.05); } .nav-link:hover { color: var(--pg-bright); background: var(--pg-nav-hover); }
.nav-link.active { color: #a78bfa; } .nav-link.active { color: #a78bfa; }
.nav-spacer { flex: 1; min-width: 0.5rem; } .nav-spacer { flex: 1; min-width: 0.5rem; }
.nav-link.nav-logout { color: #475569; } .nav-link.nav-logout { color: var(--pg-dimmer); }
.nav-link.nav-logout:hover { color: #94a3b8; background: none; } .nav-link.nav-logout:hover { color: var(--pg-muted); background: none; }
.logo { .logo {
margin-bottom: 1.75rem; margin-bottom: 1.75rem;
} }
.logo h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; } .logo h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
.logo p { font-size: 0.8rem; color: #94a3b8; margin-top: 0.2rem; } .logo p { font-size: 0.8rem; color: var(--pg-muted); margin-top: 0.2rem; }
h2 { h2 {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
color: #94a3b8; color: var(--pg-muted);
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 0.4rem; padding-bottom: 0.4rem;
border-bottom: 1px solid #2d3148; border-bottom: 1px solid var(--pg-border);
} }
.section { margin-bottom: 2rem; } .section { margin-bottom: 2rem; }
@@ -79,23 +95,23 @@
display: block; display: block;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: #94a3b8; color: var(--pg-muted);
margin-bottom: 0.4rem; margin-bottom: 0.4rem;
} }
input { input {
width: 100%; width: 100%;
padding: 0.65rem 0.85rem; padding: 0.65rem 0.85rem;
background: #0f1117; background: var(--pg-bg);
border: 1px solid #2d3148; border: 1px solid var(--pg-border);
border-radius: 6px; border-radius: 6px;
color: #e2e8f0; color: var(--pg-text);
font-size: 0.95rem; font-size: 0.95rem;
outline: none; outline: none;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
input:focus { border-color: #7c3aed; } input:focus { border-color: #7c3aed; }
input[readonly] { color: #94a3b8; cursor: default; } input[readonly] { color: var(--pg-muted); cursor: default; }
.field { margin-bottom: 1rem; } .field { margin-bottom: 1rem; }
@@ -144,8 +160,8 @@
.persona-link { .persona-link {
display: inline-block; display: inline-block;
padding: 0.3rem 0.75rem; padding: 0.3rem 0.75rem;
background: #0f1117; background: var(--pg-bg);
border: 1px solid #2d3148; border: 1px solid var(--pg-border);
border-radius: 20px; border-radius: 20px;
color: #a78bfa; color: #a78bfa;
font-size: 0.85rem; font-size: 0.85rem;
@@ -153,12 +169,12 @@
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.persona-link:hover { border-color: #7c3aed; } .persona-link:hover { border-color: #7c3aed; }
.persona-list li em { color: #94a3b8; font-size: 0.85rem; } .persona-list li em { color: var(--pg-muted); font-size: 0.85rem; }
.persona-rename-toggle { .persona-rename-toggle {
background: none; background: none;
border: none; border: none;
color: #94a3b8; color: var(--pg-muted);
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
@@ -172,10 +188,10 @@
.persona-rename-form input[type="text"] { .persona-rename-form input[type="text"] {
width: 12rem; width: 12rem;
padding: 0.3rem 0.6rem; padding: 0.3rem 0.6rem;
background: #0f1117; background: var(--pg-bg);
border: 1px solid #7c3aed; border: 1px solid #7c3aed;
border-radius: 6px; border-radius: 6px;
color: #e2e8f0; color: var(--pg-text);
font-size: 0.9rem; font-size: 0.9rem;
outline: none; outline: none;
} }
@@ -187,20 +203,20 @@
} }
.persona-rename-cancel { .persona-rename-cancel {
background: none; background: none;
border: 1px solid #2d3148; border: 1px solid var(--pg-border);
border-radius: 6px; border-radius: 6px;
color: #94a3b8; color: var(--pg-muted);
font-size: 0.85rem; font-size: 0.85rem;
padding: 0.3rem 0.6rem; padding: 0.3rem 0.6rem;
cursor: pointer; cursor: pointer;
} }
.persona-rename-cancel:hover { border-color: #94a3b8; color: #e2e8f0; } .persona-rename-cancel:hover { border-color: var(--pg-muted); color: var(--pg-text); }
.add-persona { .add-persona {
display: inline-block; display: inline-block;
margin-top: 0.75rem; margin-top: 0.75rem;
font-size: 0.8rem; font-size: 0.8rem;
color: #94a3b8; color: var(--pg-muted);
text-decoration: none; text-decoration: none;
} }
.add-persona:hover { color: #a78bfa; } .add-persona:hover { color: #a78bfa; }
@@ -232,7 +248,7 @@
<input type="text" value="{{ username }}" readonly> <input type="text" value="{{ username }}" readonly>
</div> </div>
<button type="button" id="show-rename-user" class="persona-rename-toggle" <button type="button" id="show-rename-user" class="persona-rename-toggle"
style="opacity:0.7; font-size:0.8rem; padding:0.3rem 0.6rem; border:1px solid #2d3148; border-radius:6px; margin-top:0.25rem;"> style="opacity:0.7; font-size:0.8rem; padding:0.3rem 0.6rem; border:1px solid var(--pg-border); border-radius:6px; margin-top:0.25rem;">
✏ Change username ✏ Change username
</button> </button>
<form id="rename-user-form" method="POST" action="/settings/username" <form id="rename-user-form" method="POST" action="/settings/username"
@@ -243,14 +259,14 @@
value="{{ username }}" value="{{ username }}"
pattern="[a-z_][a-z0-9_\-]{0,31}" required autofocus pattern="[a-z_][a-z0-9_\-]{0,31}" required autofocus
autocomplete="off" data-form-type="other"> autocomplete="off" data-form-type="other">
<p style="font-size:0.75rem; color:#94a3b8; margin-top:0.3rem;"> <p style="font-size:0.75rem; color:var(--pg-muted); margin-top:0.3rem;">
Lowercase letters, digits, _ or - only. You will be logged out after renaming. Lowercase letters, digits, _ or - only. You will be logged out after renaming.
</p> </p>
</div> </div>
<div style="display:flex; gap:0.5rem;"> <div style="display:flex; gap:0.5rem;">
<button type="submit" style="flex:1; padding:0.5rem; background:#7c3aed; border:none; border-radius:6px; color:#fff; font-size:0.9rem; font-weight:600; cursor:pointer;">Save</button> <button type="submit" style="flex:1; padding:0.5rem; background:#7c3aed; border:none; border-radius:6px; color:#fff; font-size:0.9rem; font-weight:600; cursor:pointer;">Save</button>
<button type="button" id="cancel-rename-user" <button type="button" id="cancel-rename-user"
style="padding:0.5rem 0.9rem; background:none; border:1px solid #2d3148; border-radius:6px; color:#94a3b8; font-size:0.9rem; cursor:pointer;">Cancel</button> style="padding:0.5rem 0.9rem; background:none; border:1px solid var(--pg-border); border-radius:6px; color:var(--pg-muted); font-size:0.9rem; cursor:pointer;">Cancel</button>
</div> </div>
</form> </form>
</div> </div>
@@ -262,9 +278,9 @@
<label>Google Account</label> <label>Google Account</label>
<input type="text" value="{{ google_email }}" readonly <input type="text" value="{{ google_email }}" readonly
placeholder="No Google account linked" placeholder="No Google account linked"
style="{{ google_email == '' and 'color:#475569' or '' }}"> style="{{ google_email == '' and 'color:var(--pg-dimmer)' or '' }}">
</div> </div>
<p style="font-size:0.75rem; color:#94a3b8; margin-top:-0.5rem;"> <p style="font-size:0.75rem; color:var(--pg-muted); margin-top:-0.5rem;">
To link or change your Google account, contact Scott. To link or change your Google account, contact Scott.
</p> </p>
</div> </div>
@@ -272,13 +288,13 @@
<!-- Browser cache --> <!-- Browser cache -->
<div class="section"> <div class="section">
<h2>Browser Cache</h2> <h2>Browser Cache</h2>
<p style="font-size:0.8rem; color:#94a3b8; margin-bottom:0.85rem; line-height:1.55;"> <p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.85rem; line-height:1.55;">
Clears UI preferences stored in this browser: active mode, session ID, memory toggles, Clears UI preferences stored in this browser: active mode, session ID, memory toggles,
theme, font size, and context tier. Does not sign you out. theme, font size, and context tier. Does not sign you out.
</p> </p>
<button type="button" id="clear-ls-btn" <button type="button" id="clear-ls-btn"
style="padding:0.5rem 1rem; background:none; border:1px solid #2d3148; border-radius:6px; style="padding:0.5rem 1rem; background:none; border:1px solid var(--pg-border); border-radius:6px;
color:#94a3b8; font-size:0.88rem; font-weight:500; cursor:pointer; color:var(--pg-muted); font-size:0.88rem; font-weight:500; cursor:pointer;
transition:border-color 0.15s, color 0.15s;"> transition:border-color 0.15s, color 0.15s;">
Clear browser cache Clear browser cache
</button> </button>
@@ -290,7 +306,7 @@
<!-- Model Registry link --> <!-- Model Registry link -->
<div class="section"> <div class="section">
<h2>Model Registry</h2> <h2>Model Registry</h2>
<p style="font-size:0.8rem; color:#94a3b8; margin-bottom:0.85rem; line-height:1.55;"> <p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.85rem; line-height:1.55;">
Configure AI providers (Anthropic, Google), local hosts (Open WebUI, Ollama, OpenRouter, etc.), Configure AI providers (Anthropic, Google), local hosts (Open WebUI, Ollama, OpenRouter, etc.),
and assign models to roles — chat, orchestrator, distill, and more. and assign models to roles — chat, orchestrator, distill, and more.
</p> </p>

View File

@@ -163,7 +163,7 @@
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4); box-shadow: 0 8px 24px var(--shadow);
z-index: 200; z-index: 200;
overflow: hidden; overflow: hidden;
} }
@@ -171,7 +171,9 @@
.persona-dropdown.open { display: block; } .persona-dropdown.open { display: block; }
.persona-dropdown a { .persona-dropdown a {
display: block; display: flex;
align-items: center;
gap: 8px;
padding: 0.55rem 0.85rem; padding: 0.55rem 0.85rem;
color: var(--text); color: var(--text);
text-decoration: none; text-decoration: none;
@@ -183,6 +185,12 @@
.persona-dropdown a.active { color: var(--accent); font-weight: 600; } .persona-dropdown a.active { color: var(--accent); font-weight: 600; }
.persona-dropdown .pd-emoji {
font-size: 1.1rem;
line-height: 1;
flex-shrink: 0;
}
.persona-dropdown .pd-divider { .persona-dropdown .pd-divider {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
margin: 0.25rem 0; margin: 0.25rem 0;
@@ -1412,52 +1420,6 @@
padding-left: 12px; padding-left: 12px;
} }
/* ── Auth warning banner ─────────────────────────────────── */
#auth-banner {
display: none;
align-items: center;
gap: 10px;
padding: 8px 20px;
background: rgba(160, 100, 0, 0.18);
border-bottom: 1px solid rgba(200, 140, 20, 0.45);
font-size: 0.82rem;
color: #c9a84c;
}
#auth-banner.show { display: flex; }
#auth-banner.expired {
background: rgba(120, 20, 20, 0.25);
border-color: rgba(200, 60, 60, 0.45);
color: var(--error-text);
}
#auth-banner-text { flex: 1; display: flex; flex-direction: column; gap: 2px; }
#auth-banner-msg { font-weight: 500; }
#auth-banner-hint {
font-size: 0.76rem;
opacity: 0.8;
}
#auth-banner-hint code {
font-family: 'Courier New', monospace;
background: rgba(0,0,0,0.2);
border-radius: 3px;
padding: 0 4px;
}
#auth-banner-close {
background: none;
border: 1px solid currentColor;
border-radius: 4px;
color: inherit;
font-size: 0.7rem;
padding: 2px 7px;
cursor: pointer;
opacity: 0.7;
flex-shrink: 0;
}
#auth-banner-close:hover { opacity: 1; }
/* ── Toasts ──────────────────────────────────────────────── */ /* ── Toasts ──────────────────────────────────────────────── */
#toast-container { #toast-container {
position: fixed; position: fixed;