Compare commits
7 Commits
ce1561572a
...
23f8659aaa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23f8659aaa | ||
|
|
fe6561bf6a | ||
|
|
1127610752 | ||
|
|
2144d7c2c0 | ||
|
|
2ca02006dd | ||
|
|
48a6734ec3 | ||
|
|
7b51e7cc44 |
@@ -8,7 +8,7 @@ import uvicorn
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
|
||||
|
||||
from config import settings
|
||||
from routers import chat, google_chat, nextcloud_talk, files, distill
|
||||
from routers import chat, google_chat, nextcloud_talk, files, distill, auth
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -28,6 +28,7 @@ app.include_router(google_chat.router)
|
||||
app.include_router(nextcloud_talk.router)
|
||||
app.include_router(files.router)
|
||||
app.include_router(distill.router)
|
||||
app.include_router(auth.router)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
|
||||
69
cortex/routers/auth.py
Normal file
69
cortex/routers/auth.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
CLI auth status for both Claude and Gemini backends.
|
||||
|
||||
GET /auth/status — returns per-backend auth info and warning flags
|
||||
|
||||
Claude: warns when OAuth token is < WARN_HOURS from expiry (requires
|
||||
user to re-run `claude` to refresh via browser flow).
|
||||
Gemini: warns only when oauth_creds.json is missing or has no
|
||||
refresh_token (access token rotates automatically every ~1h).
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/auth")
|
||||
|
||||
CLAUDE_CREDS = Path.home() / ".claude" / ".credentials.json"
|
||||
GEMINI_CREDS = Path.home() / ".gemini" / "oauth_creds.json"
|
||||
GEMINI_ACCTS = Path.home() / ".gemini" / "google_accounts.json"
|
||||
WARN_HOURS = 24
|
||||
|
||||
|
||||
def _claude_status() -> dict:
|
||||
try:
|
||||
data = json.loads(CLAUDE_CREDS.read_text())
|
||||
oauth = data["claudeAiOauth"]
|
||||
expires_dt = datetime.fromtimestamp(oauth["expiresAt"] / 1000, tz=timezone.utc)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
hours_remaining = (expires_dt - now).total_seconds() / 3600
|
||||
return {
|
||||
"ok": True,
|
||||
"expires_at": expires_dt.isoformat(),
|
||||
"hours_remaining": round(hours_remaining, 1),
|
||||
"warning": hours_remaining < WARN_HOURS,
|
||||
"expired": hours_remaining <= 0,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("claude auth check failed: %s", e)
|
||||
return {"ok": False, "error": str(e), "warning": True, "expired": False}
|
||||
|
||||
|
||||
def _gemini_status() -> dict:
|
||||
try:
|
||||
creds = json.loads(GEMINI_CREDS.read_text())
|
||||
if not creds.get("refresh_token"):
|
||||
return {"ok": True, "authenticated": False, "warning": True, "account": None}
|
||||
account = None
|
||||
try:
|
||||
accts = json.loads(GEMINI_ACCTS.read_text())
|
||||
account = accts.get("active")
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "authenticated": True, "warning": False, "account": account}
|
||||
except FileNotFoundError:
|
||||
return {"ok": True, "authenticated": False, "warning": True, "account": None}
|
||||
except Exception as e:
|
||||
logger.warning("gemini auth check failed: %s", e)
|
||||
return {"ok": False, "error": str(e), "warning": True, "authenticated": False}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def auth_status() -> dict:
|
||||
return {
|
||||
"claude": _claude_status(),
|
||||
"gemini": _gemini_status(),
|
||||
}
|
||||
@@ -118,7 +118,7 @@
|
||||
function setBackendUI(backend) {
|
||||
primaryBackend = backend;
|
||||
backendToggle.textContent = backend;
|
||||
backendToggle.className = 'hdr-btn' + (backend === 'gemini' ? ' gemini' : '');
|
||||
backendToggle.className = 'ctx-btn' + (backend === 'gemini' ? ' mem-on' : '');
|
||||
}
|
||||
|
||||
backendToggle.addEventListener('click', async () => {
|
||||
@@ -813,9 +813,39 @@
|
||||
document.getElementById('mem-short-btn').classList.toggle('mem-on', memShort);
|
||||
}
|
||||
|
||||
function formatNextRun(iso) {
|
||||
if (!iso) return 'n/a';
|
||||
const dt = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = dt - now;
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
const time = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (diffDays === 0) return `today ${time}`;
|
||||
if (diffDays === 1) return `tomorrow ${time}`;
|
||||
return dt.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
|
||||
}
|
||||
|
||||
async function loadSchedule() {
|
||||
const schedEl = document.getElementById('ctx-schedule');
|
||||
try {
|
||||
const res = await fetch('/distill/status');
|
||||
const d = await res.json();
|
||||
if (!d.enabled || !d.jobs.length) {
|
||||
schedEl.textContent = 'auto-distill disabled';
|
||||
return;
|
||||
}
|
||||
schedEl.innerHTML = d.jobs
|
||||
.map(j => `${j.id.replace('distill_', '').padEnd(6)} → ${formatNextRun(j.next_run)}`)
|
||||
.join('<br>');
|
||||
} catch {
|
||||
schedEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
ctxOpenBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
ctxPanel.classList.toggle('open');
|
||||
if (ctxPanel.classList.contains('open')) loadSchedule();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
@@ -884,6 +914,33 @@
|
||||
const helpBody = document.getElementById('help-modal-body');
|
||||
const helpClose = document.getElementById('help-close-btn');
|
||||
|
||||
// Sections open by default — everything after "Notes" starts collapsed
|
||||
const HELP_OPEN_SECTIONS = new Set(['Header Controls', 'Chat', 'Sessions', 'Notes']);
|
||||
|
||||
function makeCollapsible(container) {
|
||||
const h2s = Array.from(container.querySelectorAll('h2'));
|
||||
for (const h2 of h2s) {
|
||||
const title = h2.textContent.trim();
|
||||
const details = document.createElement('details');
|
||||
if (HELP_OPEN_SECTIONS.has(title)) details.open = true;
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = title;
|
||||
details.appendChild(summary);
|
||||
|
||||
// Collect all following siblings until the next h2
|
||||
const siblings = [];
|
||||
let node = h2.nextSibling;
|
||||
while (node && node.nodeName !== 'H2') {
|
||||
siblings.push(node);
|
||||
node = node.nextSibling;
|
||||
}
|
||||
for (const sib of siblings) details.appendChild(sib);
|
||||
|
||||
h2.parentNode.replaceChild(details, h2);
|
||||
}
|
||||
}
|
||||
|
||||
async function openHelp() {
|
||||
helpBody.textContent = 'Loading…';
|
||||
helpModal.classList.add('open');
|
||||
@@ -895,6 +952,7 @@
|
||||
helpBody.querySelectorAll('a').forEach(a => {
|
||||
a.target = '_blank'; a.rel = 'noopener noreferrer';
|
||||
});
|
||||
makeCollapsible(helpBody);
|
||||
} catch (err) {
|
||||
helpBody.textContent = `Failed to load help: ${err.message}`;
|
||||
}
|
||||
@@ -905,3 +963,54 @@
|
||||
helpModal.addEventListener('click', (e) => {
|
||||
if (e.target === helpModal) helpModal.classList.remove('open');
|
||||
});
|
||||
|
||||
// ── 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.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);
|
||||
|
||||
@@ -28,10 +28,7 @@
|
||||
</div>
|
||||
<button id="sessions-btn" class="hdr-btn">Sessions</button>
|
||||
<button id="files-btn" class="hdr-btn">Files</button>
|
||||
<button id="ctx-open-btn" class="hdr-btn" title="Context & memory settings">⚙<span class="tier-badge">2</span></button>
|
||||
<button id="backend-toggle" class="hdr-btn" title="Click to switch primary backend">claude</button>
|
||||
<button id="font-size-btn" class="hdr-btn" title="Cycle font size">Aa</button>
|
||||
<button id="theme-btn" class="hdr-btn" title="Toggle light/dark mode">☾</button>
|
||||
<button id="ctx-open-btn" class="hdr-btn" title="Settings">⚙<span class="tier-badge">2</span></button>
|
||||
<button id="help-btn" class="hdr-btn" title="Help & reference">?</button>
|
||||
|
||||
<div id="sessions-panel"></div>
|
||||
@@ -64,6 +61,20 @@
|
||||
<button class="ctx-btn" id="distill-all-btn" title="Run all three steps in sequence">all</button>
|
||||
</div>
|
||||
<div id="ctx-distill-status"></div>
|
||||
<div id="ctx-schedule"></div>
|
||||
</div>
|
||||
<div class="ctx-section">
|
||||
<div class="ctx-section-title">Backend</div>
|
||||
<div class="ctx-row">
|
||||
<button id="backend-toggle" class="ctx-btn" title="Click to switch primary backend">claude</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctx-section">
|
||||
<div class="ctx-section-title">Display</div>
|
||||
<div class="ctx-row">
|
||||
<button id="font-size-btn" class="ctx-btn" title="Cycle font size: normal → large → small">Aa</button>
|
||||
<button id="theme-btn" class="ctx-btn" title="Toggle light/dark mode">☾</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -98,6 +109,15 @@
|
||||
</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="session-id"></div>
|
||||
|
||||
|
||||
@@ -142,7 +142,6 @@
|
||||
|
||||
.hdr-btn:hover { border-color: var(--muted); color: var(--text); }
|
||||
|
||||
#backend-toggle.gemini { border-color: var(--success-dim); color: var(--success); }
|
||||
#sessions-btn { margin-left: auto; }
|
||||
|
||||
/* Sessions panel */
|
||||
@@ -150,8 +149,8 @@
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 20px;
|
||||
width: 300px;
|
||||
right: 12px;
|
||||
width: min(300px, calc(100vw - 24px));
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
background: var(--surface);
|
||||
@@ -710,8 +709,8 @@
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 20px;
|
||||
width: 280px;
|
||||
right: 12px;
|
||||
width: min(280px, calc(100vw - 24px));
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
@@ -767,9 +766,13 @@
|
||||
#ctx-distill-status.show { opacity: 1; }
|
||||
#ctx-distill-status.err { color: var(--error-text); }
|
||||
|
||||
/* Theme toggle + font size */
|
||||
#theme-btn { font-size: 0.85rem; padding: 5px 8px; }
|
||||
#font-size-btn { min-width: 32px; text-align: center; }
|
||||
#ctx-schedule {
|
||||
margin-top: 6px;
|
||||
font-size: 0.66rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Ctx header button — shows current tier as a dim superscript */
|
||||
#ctx-open-btn .tier-badge {
|
||||
@@ -902,3 +905,132 @@
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Help modal collapsible sections ────────────────────── */
|
||||
#help-modal-body details {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
#help-modal-body details:last-of-type { border-bottom: none; }
|
||||
|
||||
#help-modal-body summary {
|
||||
cursor: pointer;
|
||||
padding: 10px 4px;
|
||||
font-size: 1.0em;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
}
|
||||
#help-modal-body summary::-webkit-details-marker { display: none; }
|
||||
|
||||
#help-modal-body summary::before {
|
||||
content: '▶';
|
||||
font-size: 0.6em;
|
||||
opacity: 0.55;
|
||||
transition: transform 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#help-modal-body details[open] > summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
#help-modal-body summary:hover { color: var(--text); }
|
||||
|
||||
/* Content inside a details block gets a little left indent */
|
||||
#help-modal-body details > *:not(summary) {
|
||||
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; }
|
||||
|
||||
/* ── Mobile responsive ───────────────────────────────────── */
|
||||
@media (max-width: 520px) {
|
||||
header { padding: 8px 12px; gap: 8px; }
|
||||
header .subtitle { display: none; }
|
||||
#messages { padding: 12px; }
|
||||
|
||||
/* dvh adjusts as soft keyboard opens/closes */
|
||||
body { height: 100dvh; }
|
||||
|
||||
/* Hide session ID — saves vertical space */
|
||||
#session-id { display: none; }
|
||||
|
||||
/* Input area: stack textarea above button row */
|
||||
#input-area {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 8px 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 16px minimum prevents iOS Safari auto-zoom on focus */
|
||||
#input { font-size: 16px; }
|
||||
|
||||
/* Right col goes horizontal, full width */
|
||||
#right-col {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Desktop-only controls — hide on mobile */
|
||||
#height-row,
|
||||
#enter-toggle { display: none !important; }
|
||||
|
||||
/* Larger touch targets */
|
||||
#send, #stop { padding: 12px 0; flex: 1; font-size: 1rem; }
|
||||
#note-btn { padding: 12px 14px; }
|
||||
#note-type-btn { padding: 6px 10px; }
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
header .name { font-size: 1rem; }
|
||||
.header-emoji { font-size: 1.3rem; }
|
||||
.hdr-btn { padding: 5px 8px; }
|
||||
}
|
||||
|
||||
@@ -12,12 +12,19 @@
|
||||
|---|---|
|
||||
| **Sessions** | Open the sessions panel — list, resume, or start sessions |
|
||||
| **Files** | Open the identity file editor (SOUL, MEMORY, etc.) |
|
||||
| **⚙ N** | Open the Context & Memory panel (N = current tier) |
|
||||
| **claude / gemini** | Active backend — click to toggle primary |
|
||||
| **Aa / A+ / A−** | Cycle font size: normal (16px) → large (18px) → small (14px) |
|
||||
| **☾ / ☀** | Toggle dark / light theme |
|
||||
| **⚙ N** | Open the Settings panel (N = current context tier) |
|
||||
| **?** | Open this help panel |
|
||||
|
||||
The **⚙ Settings** panel contains all configuration options:
|
||||
|
||||
| Section | Controls |
|
||||
|---|---|
|
||||
| **Context Tier** | T1 – T4 context depth |
|
||||
| **Memory Layers** | Toggle Long / Mid / Short memory on/off |
|
||||
| **Distill Memory** | Manually trigger short / mid / long / all distillation |
|
||||
| **Backend** | Active LLM backend — click to toggle claude ↔ gemini |
|
||||
| **Display** | Aa/A+/A− font size cycle · ☾/☀ theme toggle |
|
||||
|
||||
All header settings (theme, font size, tier, memory layers) persist in `localStorage` across page refreshes.
|
||||
|
||||
---
|
||||
@@ -58,7 +65,7 @@ Notes are injected into a session without triggering an LLM response.
|
||||
## Backends
|
||||
|
||||
- **Claude CLI** and **Gemini CLI** are both available. One is primary, the other is fallback.
|
||||
- Click the backend button (`claude` or `gemini`) to switch which is primary.
|
||||
- Click **⚙** → **Backend** to toggle between `claude` and `gemini` as the primary.
|
||||
- If the primary fails or times out, the fallback is used automatically. A **⚡** notice appears in the chat when this happens.
|
||||
- Timeouts: Claude 60s, Gemini 120s.
|
||||
|
||||
@@ -199,7 +206,7 @@ Chat request body (`POST /chat`):
|
||||
|
||||
- **Auto memory distillation (long)** — short and mid run automatically; long-term integration is off by default (set `AUTO_DISTILL_LONG=true` in `.env` to enable)
|
||||
- **Ollama local model backend** — direct Ollama API support (no CLI wrapper)
|
||||
- **OAuth token auto-refresh notifications** — alert when Claude CLI token is near expiry
|
||||
- **OAuth token auto-refresh notifications** — ✓ implemented: amber banner shown when Claude CLI token is within 24h of expiry (check `GET /auth/status`)
|
||||
- **Multi-user support** — per-user identity/memory files; currently single-user (Scott)
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user