diff --git a/cortex/routers/local_llm.py b/cortex/routers/local_llm.py index acb19d1..b2d55d4 100644 --- a/cortex/routers/local_llm.py +++ b/cortex/routers/local_llm.py @@ -9,6 +9,7 @@ Routes: POST /settings/local/google-account → save/create a Google account POST /settings/local/google-account/{id}/remove → remove a Google account POST /settings/local/models/add → add a model (any provider) + POST /settings/local/models/{id}/edit → edit an existing model entry POST /settings/local/models/{id}/remove → remove a model POST /api/models/role → AJAX: set a role assignment GET /api/local-llm/fetch-models → proxy to host /api/models (JSON) @@ -157,21 +158,100 @@ def _render(username: str, success: str = "", error: str = "") -> str: else: secondary = default_secondary - ctx = f'{m.get("context_k",0)}k' if m.get("context_k") else "" - tags = " ".join(f'{t}' for t in (m.get("tags") or [])) - sec = f'{secondary}' if secondary else "" + ctx = f'{m.get("context_k",0)}k' if m.get("context_k") else "" + tags_html = " ".join(f'{t}' for t in (m.get("tags") or [])) + sec = f'{secondary}' if secondary else "" + + # ── Inline edit form fields (type-specific) ─────────────────────────── + if mtype == "local_openai": + host_opts = "".join( + f'' + for h in hosts + ) + mid = m["id"] + extra_fields = ( + f'
' + f'
' + f'
' + f'' + f'' + f'
' + f'' + ) + elif mtype == "gemini_api": + acct_opts = "".join( + f'' + for a in goog_accts + ) + extra_fields = ( + f'
' + f'
' + ) + else: + extra_fields = '' + + cur_label = m.get("label", "") + cur_model_name = m.get("model_name", "") + cur_ctx = m.get("context_k", 0) or 0 + cur_tags = ", ".join(m.get("tags") or []) model_rows += f''' -
-
-
{badge}{m.get("label") or m.get("model_name","")}{ctx}
- {m.get("model_name","")} - {sec} -
{tags}
+
+
+
+
{badge}{m.get("label") or m.get("model_name","")}{ctx}
+ {m.get("model_name","")} + {sec} +
{tags_html}
+
+
+ +
+ +
+
-
- + + +
+
+ + +
+
+ + +
+
+ {extra_fields} +
+
+ + +
+
+ + +
+
+
+ + +
''' if not model_rows: @@ -356,6 +436,42 @@ async def add_model( return HTMLResponse(_render(username, success=f'Model "{display}" added.')) +@router.post("/settings/local/models/{model_id}/edit", include_in_schema=False) +async def edit_model( + request: Request, + model_id: str, + mtype: str = Form(""), + label: str = Form(""), + model_name: str = Form(""), + context_k: int = Form(0), + tags: str = Form(""), + host_id: str = Form(""), + account_id: str = Form(""), + credential_id: str = Form("cli"), +): + username = _get_user(request) + if not username: + return RedirectResponse("/login", status_code=302) + if not model_name.strip(): + return HTMLResponse(_render(username, error="Model name is required.")) + tag_list = [t.strip() for t in tags.split(",") if t.strip()] + if mtype == "local_openai": + if not host_id.strip(): + return HTMLResponse(_render(username, error="Select a host for this model.")) + reg.save_model(username, model_id, host_id, label, model_name, context_k, tag_list) + elif mtype == "gemini_api": + reg.save_cloud_model(username, model_id, "google", model_name, label, + account_id=account_id or None, context_k=context_k, tags=tag_list) + elif mtype == "claude_cli": + reg.save_cloud_model(username, model_id, "anthropic", model_name, label, + credential_id=credential_id or "cli", context_k=context_k, tags=tag_list) + else: + return HTMLResponse(_render(username, error=f"Unknown model type: {mtype}")) + display = label.strip() or model_name.strip() + logger.info("model edited: %s / %s (%s)", username, display, mtype) + return HTMLResponse(_render(username, success=f'Model "{display}" updated.')) + + @router.post("/settings/local/models/{model_id}/remove", include_in_schema=False) async def remove_model(request: Request, model_id: str): username = _get_user(request) diff --git a/cortex/routers/settings.py b/cortex/routers/settings.py index cd71901..1b58a1d 100644 --- a/cortex/routers/settings.py +++ b/cortex/routers/settings.py @@ -60,6 +60,9 @@ def _settings_page(username: str, personas: list[str], back_persona: str = "", s google_email = auth_data.get("google_email") or "" html = html.replace("{{ google_email }}", google_email) + role = auth_data.get("role", "user") + html = html.replace("{{ user_role }}", role) + persona_items = "\n".join( f'''
  • {p} diff --git a/cortex/static/HELP.md b/cortex/static/HELP.md index bb8a6a2..86ac009 100644 --- a/cortex/static/HELP.md +++ b/cortex/static/HELP.md @@ -6,7 +6,7 @@ and are appended automatically by help.html when present. --> -*Last updated: 2026-04-28* +*Last updated: 2026-04-29* --- @@ -63,15 +63,20 @@ The ⚡ toggle is **independent of the Role selector** — you can use any role | Category | Tools | |---|---| -| Web | `web_search` | -| Files | `file_read` | -| Shell | `shell_exec`, `claude_allow_dir` | +| Web | `web_search`, `http_fetch` | +| Files | `file_read` ¹, `file_list` ¹, `file_write` ¹ ² | +| Shell | `shell_exec` ¹ ², `claude_allow_dir` ¹ | +| System | `cortex_restart` ¹ ², `cortex_logs` ¹ | | Tasks | `task_list`, `task_create`, `task_update`, `task_complete` | -| Cron | `cron_list`, `cron_add`, `cron_remove`, `cron_toggle` | -| Reminders | `reminders_add`, `reminders_list`, `reminders_clear` | +| Cron | `cron_list`, `cron_add`, `cron_remove` ², `cron_toggle` | +| Reminders | `reminders_add`, `reminders_list`, `reminders_clear` ² | | Scratchpad | `scratch_read`, `scratch_write`, `scratch_append`, `scratch_clear` | +| Notifications | `nc_talk_send` ¹ | | Aether Journals | `ae_journal_list`, `ae_journal_search`, `ae_journal_entry_create`, `ae_journal_entry_update`, `ae_journal_entry_disable`, `ae_journal_entry_append`, `ae_journal_entry_prepend` | -| Aether Tasks | `ae_task_list` | +| Aether Tasks | `ae_task_list` ¹ | + +¹ **Admin only** — requires the `admin` role. These tools are invisible to regular users. +² **Confirmation required** — the orchestrator pauses and asks you to confirm before executing. Reply to confirm and it will proceed. Tools mode is best for tasks requiring research, multi-step reasoning, or side effects (e.g. "search for X", "add a task", "what's on my list?", "append this to my journal"). Regular chat is faster for conversational turns. @@ -101,6 +106,18 @@ Notes are injected into a session without triggering an LLM response. --- +## Install as App (PWA) + +Cortex supports installation as a Progressive Web App — it runs in its own window with no browser chrome. + +- **Chrome / Edge (desktop):** Look for the install icon in the address bar, or open the browser menu → **Install Cortex…** +- **Android (Chrome):** Tap ⋮ → **Add to Home Screen** +- **iOS (Safari):** Tap the Share button → **Add to Home Screen** + +Once installed, opening Cortex from the home screen or app launcher skips the browser UI entirely. + +--- + ## Backends Three backends are available: diff --git a/cortex/static/local_llm.html b/cortex/static/local_llm.html index eb27f27..77e1323 100644 --- a/cortex/static/local_llm.html +++ b/cortex/static/local_llm.html @@ -182,10 +182,18 @@ .fetch-status.err { color: #f87171; } .model-row { + background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 8px; + margin-bottom: 0.5rem; overflow: hidden; + } + .model-row-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; padding: 0.75rem 0.9rem; - background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 8px; - margin-bottom: 0.5rem; + } + .model-btns { display: flex; gap: 0.4rem; align-items: flex-start; flex-shrink: 0; } + .model-edit-form { + padding: 0.85rem 0.9rem 0.9rem; + border-top: 1px solid var(--pg-border); + background: var(--pg-surface); } .model-info { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; } .model-label { font-size: 0.9rem; font-weight: 600; color: var(--pg-text); } @@ -670,6 +678,78 @@ // Hide fetch button initially if no hosts if (!HAS_HOSTS) fetchBtn.style.display = 'none'; + // ── Model inline edit toggle ────────────────────────────────────────── + document.querySelectorAll('.model-edit-btn').forEach(btn => { + btn.addEventListener('click', () => { + const id = btn.dataset.id; + const form = document.getElementById('edit-form-' + id); + const open = form.style.display !== 'none'; + form.style.display = open ? 'none' : 'block'; + btn.textContent = open ? 'Edit' : 'Close'; + }); + }); + document.querySelectorAll('.model-edit-cancel').forEach(btn => { + btn.addEventListener('click', () => { + const id = btn.dataset.id; + document.getElementById('edit-form-' + id).style.display = 'none'; + document.querySelector(`.model-edit-btn[data-id="${id}"]`).textContent = 'Edit'; + }); + }); + + // ── Edit form: fetch models from host ──────────────────────────────── + document.querySelectorAll('.edit-fetch-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const id = btn.dataset.id; + const hostSel = document.getElementById('edit-host-' + id); + const hostId = hostSel ? hostSel.value : ''; + const statusEl = document.getElementById('edit-fetch-status-' + id); + btn.disabled = true; + statusEl.textContent = 'Fetching…'; statusEl.className = 'fetch-status'; + const url = '/api/local-llm/fetch-models' + (hostId ? '?host_id=' + encodeURIComponent(hostId) : ''); + try { + const res = await fetch(url); + const data = await res.json(); + if (data.error) { + statusEl.textContent = '✗ ' + data.error; statusEl.className = 'fetch-status err'; + return; + } + const picker = document.getElementById('edit-model-picker-' + id); + const wrap = document.getElementById('edit-model-select-wrap-' + id); + picker.innerHTML = ''; + data.models.forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name !== m.id ? `${m.name} (${m.id})` : m.id; + opt.dataset.id = m.id; + opt.dataset.name = m.name; + picker.appendChild(opt); + }); + wrap.style.display = 'block'; + statusEl.textContent = `✓ ${data.models.length} model${data.models.length !== 1 ? 's' : ''}`; + statusEl.className = 'fetch-status ok'; + } catch (e) { + statusEl.textContent = '✗ ' + e.message; statusEl.className = 'fetch-status err'; + } finally { + btn.disabled = false; + } + }); + }); + + document.querySelectorAll('[id^="edit-model-picker-"]').forEach(picker => { + picker.addEventListener('change', () => { + const opt = picker.options[picker.selectedIndex]; + if (!opt.value) return; + const id = picker.id.replace('edit-model-picker-', ''); + const form = document.getElementById('edit-form-' + id); + form.querySelector('input[name="model_name"]').value = opt.dataset.id || opt.value; + const labelInput = form.querySelector('input[name="label"]'); + if (!labelInput.value) { + const n = opt.dataset.name; + labelInput.value = (n && n !== opt.dataset.id) ? n : ''; + } + }); + }); + // ── Claude CLI auth status ───────────────────────────────────────────── (async function() { const el = document.getElementById('claude-auth-status'); diff --git a/cortex/static/settings.html b/cortex/static/settings.html index ac6aa73..4e177b1 100644 --- a/cortex/static/settings.html +++ b/cortex/static/settings.html @@ -220,6 +220,26 @@ text-decoration: none; } .add-persona:hover { color: #a78bfa; } + + .role-badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + } + .role-badge.role-admin { + background: rgba(124, 58, 237, 0.15); + color: #a78bfa; + border: 1px solid rgba(124, 58, 237, 0.4); + } + .role-badge.role-user { + background: rgba(100, 116, 139, 0.12); + color: var(--pg-muted); + border: 1px solid var(--pg-border); + } @@ -247,6 +267,10 @@
  • +
    + + {{ user_role }} +