diff --git a/cortex/routers/help.py b/cortex/routers/help.py index e61b2d1..f0bfcc3 100644 --- a/cortex/routers/help.py +++ b/cortex/routers/help.py @@ -21,6 +21,9 @@ router = APIRouter() _STATIC = Path(__file__).parent.parent / "static" +_LAST_PERSONA_COOKIE = "cx_last_persona" + + def _get_session_user(request: Request) -> str | None: token = request.cookies.get(COOKIE_NAME) if not token: @@ -31,6 +34,16 @@ def _get_session_user(request: Request) -> str | 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) async def help_page(request: Request, persona: str = ""): username = _get_session_user(request) @@ -38,11 +51,11 @@ async def help_page(request: Request, persona: str = ""): return RedirectResponse("/login", status_code=302) 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: back_persona = persona else: - back_persona = personas[0] if personas else "" + back_persona = _preferred_persona(request, username) back_href = f"/{username}/{back_persona}" if back_persona else "/" html = (_STATIC / "help.html").read_text() diff --git a/cortex/routers/settings.py b/cortex/routers/settings.py index f3395d3..cd71901 100644 --- a/cortex/routers/settings.py +++ b/cortex/routers/settings.py @@ -28,6 +28,9 @@ router = APIRouter() _STATIC = Path(__file__).parent.parent / "static" +_LAST_PERSONA_COOKIE = "cx_last_persona" + + def _get_session_user(request: Request) -> str | None: token = request.cookies.get(COOKIE_NAME) if not token: @@ -38,7 +41,17 @@ def _get_session_user(request: Request) -> str | 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 = html.replace("{{ username }}", username) @@ -62,7 +75,8 @@ def _settings_page(username: str, personas: list[str], success: str = "", error: ''' for p in personas ) html = html.replace("{{ persona_items }}", persona_items or "
  • No personas yet.
  • ") - 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("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help") if success: @@ -78,7 +92,8 @@ async def settings_page(request: Request): if not username: return RedirectResponse("/login", status_code=302) 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) @@ -93,19 +108,20 @@ async def change_password( return RedirectResponse("/login", status_code=302) personas = list_user_personas(username) + back_persona = _preferred_persona(request, username) 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: - 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: - 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) 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) @@ -118,11 +134,12 @@ async def rename_username( return RedirectResponse("/login", status_code=302) personas = list_user_personas(username) + back_persona = _preferred_persona(request, username) new_username = new_username.strip().lower() if not _SLUG_RE.match(new_username): return HTMLResponse(_settings_page( - username, personas, + username, personas, back_persona, error="Invalid username. Use lowercase letters, digits, _ or - only.")) if new_username == username: @@ -134,7 +151,7 @@ async def rename_username( if new_dir.exists(): return HTMLResponse(_settings_page( - username, personas, + username, personas, back_persona, error=f"Username '{new_username}' is already taken.")) old_dir.rename(new_dir) @@ -156,6 +173,7 @@ async def save_gemini_key( return RedirectResponse("/login", status_code=302) personas = list_user_personas(username) + back_persona = _preferred_persona(request, username) gemini_api_key = gemini_api_key.strip() data = _read_auth(username) @@ -167,7 +185,7 @@ async def save_gemini_key( msg = "Gemini API key removed — using server key." _write_auth(username, data) 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) @@ -181,11 +199,12 @@ async def rename_persona( return RedirectResponse("/login", status_code=302) personas = list_user_personas(username) + back_persona = _preferred_persona(request, username) new_name = new_name.strip().lower() if not _SLUG_RE.match(new_name): return HTMLResponse(_settings_page( - username, personas, + username, personas, back_persona, error="Invalid name. Use lowercase letters, digits, _ or - only.")) if new_name == old_name: @@ -196,11 +215,11 @@ async def rename_persona( new_dir = persona_root / new_name 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(): return HTMLResponse(_settings_page( - username, personas, + username, personas, back_persona, error=f"A persona named '{new_name}' already exists.")) old_dir.rename(new_dir) diff --git a/cortex/routers/ui.py b/cortex/routers/ui.py index 3db4871..614b9a2 100644 --- a/cortex/routers/ui.py +++ b/cortex/routers/ui.py @@ -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: """Return the first available persona for a user, or None.""" names = list_user_personas(username) 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 # --------------------------------------------------------------------------- @@ -85,7 +99,7 @@ async def root(request: Request): user = _get_session_user(request) if not user: return RedirectResponse("/login", status_code=302) - persona = _first_persona(user) + persona = _preferred_persona(request, user) if not persona: return HTMLResponse("

    No personas configured for your account.

    ", status_code=500) return RedirectResponse(f"/{user}/{persona}", status_code=302) @@ -100,7 +114,7 @@ async def login_page(request: Request): user = _get_session_user(request) if user: # Already logged in — redirect home - persona = _first_persona(user) + persona = _preferred_persona(request, user) if persona: return RedirectResponse(f"/{user}/{persona}", status_code=302) return HTMLResponse((_STATIC / "login.html").read_text()) @@ -254,7 +268,16 @@ async def api_personas(request: Request) -> dict: if not user: from fastapi import HTTPException 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) @@ -288,4 +311,6 @@ async def serve_ui(username: str, persona: str, request: Request): f'{{user: "{username}", persona: "{persona}", emoji: "{emoji}"}};' ) html = html.replace("", f"{config_tag}\n", 1) - return HTMLResponse(html) + resp = HTMLResponse(html) + resp.set_cookie(_LAST_PERSONA_COOKIE, persona, max_age=365 * 86400, httponly=False, samesite="lax") + return resp diff --git a/cortex/static/app.js b/cortex/static/app.js index d10f608..a701a2e 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -360,10 +360,18 @@ personaDropEl.innerHTML = ''; personas.forEach(p => { + const name = p.name || p; + const emoji = p.emoji || '✨'; const a = document.createElement('a'); - a.href = `/${CORTEX_USER}/${p}`; - a.textContent = p.charAt(0).toUpperCase() + p.slice(1); - if (p === CORTEX_PERSONA) a.classList.add('active'); + a.href = `/${CORTEX_USER}/${name}`; + if (name === 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); }); @@ -1493,19 +1501,6 @@ try { data = JSON.parse(e.data); } catch { 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 = '⚠️ Claude authentication expired — run claude in your terminal to re-authenticate, then reload.' - + ''; - document.body.prepend(banner); - } - return; - } - if (data.type !== 'nct_message' && data.type !== 'nct_response') return; if (sessionId === data.session_id) { @@ -1704,57 +1699,6 @@ syncHeight(); 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('claude'); - } - - if (d.gemini?.warning) { - warnings.push('⚠ Gemini CLI not authenticated'); - fixes.push('gemini'); - } - - if (!warnings.length) { - authBanner.classList.remove('show'); - return; - } - - authBannerMsg.innerHTML = warnings.join('
    '); - 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 ──────────────────────────────────────────── // Process all static Lucide SVGs in the header + stop button, // and seed the mode UI (which also calls render_icons internally). diff --git a/cortex/static/help.html b/cortex/static/help.html index 954c306..e608fd6 100644 --- a/cortex/static/help.html +++ b/cortex/static/help.html @@ -8,17 +8,33 @@ + diff --git a/cortex/static/index.html b/cortex/static/index.html index e3608ae..ec20dee 100644 --- a/cortex/static/index.html +++ b/cortex/static/index.html @@ -148,15 +148,6 @@ - -
    -
    - - -
    - -
    -
    diff --git a/cortex/static/local_llm.html b/cortex/static/local_llm.html index 6b131fe..eb27f27 100644 --- a/cortex/static/local_llm.html +++ b/cortex/static/local_llm.html @@ -7,12 +7,28 @@ + @@ -248,6 +287,9 @@ Claude models are accessed through the Claude CLI using your existing OAuth login. Run claude auth login to authenticate.

    +
    + Checking… +
    @@ -335,11 +377,10 @@

    Models

    {{ model_rows }} -
    - -
    -

    Add Model

    +
    + + Add model +
    @@ -427,6 +468,9 @@
    + +
    +
    @@ -625,6 +669,28 @@ // Hide fetch button initially if no hosts 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'; } + })(); diff --git a/cortex/static/settings.html b/cortex/static/settings.html index 5d2bf7b..ac6aa73 100644 --- a/cortex/static/settings.html +++ b/cortex/static/settings.html @@ -7,7 +7,23 @@ +