'
- # ── Local host rows ───────────────────────────────────────────────────────
- host_rows = ""
- for h in hosts:
- key_hint = f"…{h['api_key'][-4:]}" if h.get("api_key") else "not set"
- ht = h.get("host_type", "openwebui")
- ow = ' selected' if ht == "openwebui" else ''
- ai = ' selected' if ht == "openai" else ''
- host_rows += f'''
-
-
-
-
'''
- if not host_rows:
- host_rows = '
No hosts configured yet. Add one below.
'
+ # ── Host rows — split cloud (openai) vs local (openwebui) ─────────────────
+ cloud_hosts = [h for h in hosts if h.get("host_type") == "openai"]
+ local_hosts = [h for h in hosts if h.get("host_type", "openwebui") != "openai"]
+
+ cloud_host_rows = "".join(_host_row_html(h) for h in cloud_hosts)
+ local_host_rows = "".join(_host_row_html(h) for h in local_hosts)
+ if not cloud_host_rows:
+ cloud_host_rows = '
No cloud API services configured yet. Add one below.
'
+ if not local_host_rows:
+ local_host_rows = '
No local hosts configured yet. Add one below.
'
host_options = "".join(
f''
@@ -360,15 +376,35 @@ def _render(username: str, request: Request | None = None, success: str = "", er
model_opts += f' \n'
model_opts += '\n'
+ all_roles = reg.get_all_roles(username)
+
role_rows = ""
- for role in app_settings.get_defined_roles():
+ for role in all_roles:
+ is_required = role in reg.REQUIRED_ROLES
role_cfg = roles.get(role, {})
+ role_title = role.replace("_", " ").title()
+ required_badge = (
+ 'required'
+ if is_required else ''
+ )
+ rcp_danger = (
+ '' if is_required else
+ f'
'
+ f''
+ f'
'
+ )
role_rows += (
f'
'
- f'{role.title()}'
+ f'
'
+ f'{role_title}'
+ f'{required_badge}'
+ f'
'
f'
'
)
- for slot in reg.PRIORITY_KEYS[:3]:
+ for slot in reg.PRIORITY_KEYS[:2]:
slot_label = slot.replace("_", " ").title()
sel = (
f'
'
)
role_data_js = _json.dumps({
- role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:3]}
- for role in app_settings.get_defined_roles()
+ role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:2]}
+ for role in all_roles
})
role_config_data_js = _json.dumps({
@@ -421,14 +459,15 @@ def _render(username: str, request: Request | None = None, success: str = "", er
"inject_datetime": roles.get(role, {}).get("inject_datetime", True),
"inject_mode": roles.get(role, {}).get("inject_mode", True),
}
- for role in app_settings.get_defined_roles()
+ for role in all_roles
})
tool_categories_js = _json.dumps(TOOL_CATEGORIES)
# ── Catalog data + Google accounts for JS ─────────────────────────────────
- google_accounts_js = _json.dumps(reg.get_google_accounts(username))
- google_catalog_js = _json.dumps(reg.get_catalog("google"))
+ google_accounts_js = _json.dumps(reg.get_google_accounts(username))
+ google_catalog_js = _json.dumps(reg.get_catalog("google"))
anthropic_catalog_js = _json.dumps(reg.get_catalog("anthropic"))
+ cloud_catalog_js = _json.dumps(reg.get_catalog("cloud"))
has_hosts = "true" if hosts else "false"
html = (_STATIC / "local_llm.html").read_text()
@@ -436,7 +475,8 @@ def _render(username: str, request: Request | None = None, success: str = "", er
"{{ username }}": username,
"{{ google_account_rows }}": google_account_rows,
"{{ anthropic_key_rows }}": anthropic_key_rows,
- "{{ host_rows }}": host_rows,
+ "{{ cloud_host_rows }}": cloud_host_rows,
+ "{{ local_host_rows }}": local_host_rows,
"{{ model_rows }}": model_rows,
"{{ host_options }}": host_options,
"{{ role_rows }}": role_rows,
@@ -447,6 +487,7 @@ def _render(username: str, request: Request | None = None, success: str = "", er
"{{ anthropic_keys_js }}": anthropic_keys_js,
"{{ google_catalog_js }}": google_catalog_js,
"{{ anthropic_catalog_js }}": anthropic_catalog_js,
+ "{{ cloud_catalog_js }}": cloud_catalog_js,
"{{ has_hosts }}": has_hosts,
}
for key, val in replacements.items():
@@ -669,6 +710,40 @@ async def remove_model(request: Request, model_id: str):
return HTMLResponse(_render(username, request, success="Model removed."))
+@router.post("/settings/local/roles/add", include_in_schema=False)
+async def add_custom_role_route(
+ request: Request,
+ role_name: str = Form(""),
+):
+ username = _get_user(request)
+ if not username:
+ return RedirectResponse("/login", status_code=302)
+ name = role_name.strip().lower()
+ if not name or not name[0].isalpha():
+ return HTMLResponse(_render(username, request, error="Role name must start with a letter."))
+ ok = reg.add_custom_role(username, name)
+ if not ok:
+ return HTMLResponse(_render(username, request, error=f'"{name}" is a required role and cannot be re-added.'))
+ logger.info("custom role added: %s / %s", username, name)
+ return RedirectResponse("/settings/models#roles", status_code=303)
+
+
+@router.post("/settings/local/roles/remove", include_in_schema=False)
+async def remove_custom_role_route(
+ request: Request,
+ role_name: str = Form(""),
+):
+ username = _get_user(request)
+ if not username:
+ return RedirectResponse("/login", status_code=302)
+ name = role_name.strip()
+ ok = reg.remove_custom_role(username, name)
+ if not ok:
+ return HTMLResponse(_render(username, request, error=f'"{name}" is a required role and cannot be removed.'))
+ logger.info("custom role removed: %s / %s", username, name)
+ return RedirectResponse("/settings/models#roles", status_code=303)
+
+
@router.post("/api/models/role")
async def set_role(request: Request) -> JSONResponse:
"""AJAX: assign a model to a role priority slot.
diff --git a/cortex/static/HELP.md b/cortex/static/HELP.md
index c44cbe9..7b0005c 100644
--- a/cortex/static/HELP.md
+++ b/cortex/static/HELP.md
@@ -268,17 +268,24 @@ The label and context window size auto-fill from the catalog — edit them if yo
### Step 3 — Assign models to roles
-Scroll to **Role Assignments** at the bottom of the page. Each role has **Primary**, **Backup 1**, and **Backup 2** slots — Primary is tried first, then backups in order. Changes save automatically.
+Scroll to **Role Assignments** at the bottom of the page. Each role has **Primary** and **Backup** slots — Primary is tried first, then backup. Changes save automatically.
+
+**Required roles** (always present, cannot be removed):
| Role | Used for |
|---|---|
| **Chat** | Regular conversation |
| **Orchestrator** | Agent mode tool loop |
| **Distill** | Memory distillation (short / mid / long) |
-| **Coder** | Code-focused tasks |
-| **Research** | Long-context research tasks |
-Leave all slots empty to use the server default.
+**Custom roles** — Click **+ Add custom role** to create your own. Each custom role gets its own model selection, tool set, and system prompt addition. Good examples:
+
+| Example | Purpose |
+|---|---|
+| **Coder** | Code-focused tasks — larger context window, code-aware model |
+| **Research** | Long-context research — high-token model, web tools prioritized |
+
+Switch roles via the **Role** selector in the Context & Memory panel (⚙). Leave all slots empty to use the server default.
**Per-role tool sets:** Expand any role card to configure which tool categories the orchestrator can use when that role is active. Unchecked categories are hidden from the model entirely — reducing token overhead on every orchestrated call. Leaving all categories unchecked means all tools the user has access to are available (the default).
@@ -288,7 +295,7 @@ Leave all slots empty to use the server default.
## Nextcloud Talk Bot
-Inara is registered as a bot in Nextcloud Talk.
+The Cortex bot is registered in Nextcloud Talk.
- Messages sent in enabled Talk conversations are received by Cortex, processed, and replied to.
- The webhook returns `200 OK` immediately; the reply happens asynchronously.
@@ -299,12 +306,12 @@ Inara is registered as a bot in Nextcloud Talk.
## Google Chat Bot
-Inara is available as a bot in Google Chat (One Sky IT Workspace).
+The Cortex bot is available in Google Chat (One Sky IT Workspace).
-- Send Inara a direct message in Google Chat to start a conversation.
+- Send the bot a direct message in Google Chat to start a conversation.
- Each DM thread is its own session (`gc_spaces/*` prefix) — history persists across messages.
- Responses are synchronous — Google Chat displays the reply directly in the thread.
-- To add Inara to a space: open the space, add a person/app, search for **Inara**.
+- To add the bot to a space: open the space, click **Add people & apps**, and search for the Cortex bot.
- Sessions from Google Chat appear as `gc_*` prefixed IDs in the Sessions panel.
---
@@ -339,9 +346,9 @@ Cortex can send browser push notifications — even when the tab is closed.
- Open **☰ → Enable notifications** and accept the browser permission prompt.
- Once enabled, the button shows **Notifications on** (in accent colour).
- Click again to disable. Subscriptions are stored per-device.
-- The orchestrator's `web_push` tool lets Inara send you a push proactively (e.g. when a long task completes).
+- The orchestrator's `web_push` tool lets your persona send you a push proactively (e.g. when a long task completes).
-**Notification channel settings:** ☰ → **Account** → **Notification settings →** — choose Browser Push, Email, Nextcloud Talk, or Google Chat as the channel Inara uses for scheduled reminders, cron job completions, and memory digests. Use the **Send Test Notification** button to verify your setup, or **Check Reminders Now** to trigger the reminder check immediately.
+**Notification channel settings:** ☰ → **Account** → **Notification settings →** — choose Browser Push, Email, Nextcloud Talk, or Google Chat as the channel your persona uses for scheduled reminders, cron job completions, and memory digests. Use the **Send Test Notification** button to verify your setup, or **Check Reminders Now** to trigger the reminder check immediately.
---
@@ -389,7 +396,7 @@ Distillation builds up the memory layers from raw session logs. Runs automatical
## Scheduled Jobs
-Cortex can run recurring jobs on a schedule — reminders, daily briefings, automated research, and more. Manage them by asking Inara to set them up, or go directly to **☰ → Account → Schedules**.
+Cortex can run recurring jobs on a schedule — reminders, daily briefings, automated research, and more. Manage them by asking your persona to set them up, or go directly to **☰ → Account → Schedules**.
### Job Types
@@ -424,7 +431,7 @@ Schedules take effect immediately when added or edited — no restart needed. Pa
### Home Assistant Integration
-HA automations can trigger Inara via webhook. Configure in **Notifications → Home Assistant → Inbound webhook**:
+HA automations can trigger your persona via webhook. Configure in **Notifications → Home Assistant → Inbound webhook**:
- Set a **Webhook ID** (long random string — this is your secret URL component)
- Your endpoint: `https://cortex.dgrzone.com/webhook/ha/{username}/{webhook_id}`
diff --git a/cortex/static/crons.html b/cortex/static/crons.html
index d80a7e6..38d1a5f 100644
--- a/cortex/static/crons.html
+++ b/cortex/static/crons.html
@@ -7,9 +7,36 @@
+
+
@@ -79,6 +100,7 @@
← ChatHelpSettings
+ ModelsNotificationsToolsSchedules
@@ -99,11 +121,11 @@
{{ cron_list_html }}
-
+