feat: custom roles, Tailwind settings pages, pg.css fixes, doc cleanup

Model Registry:
- Add per-user custom roles (add/remove via UI); required roles chat/orchestrator/distill
  are always present and cannot be removed
- Auto-migrate legacy .env-defined roles to custom_roles on first access
- Role config panel (gear): Remove role button moved inside panel; required badge below name
- Role select: Primary + Backup slots only (was three)

Settings pages — Tailwind CSS migration (CDN, preflight: false):
- local_llm.html, settings.html, help.html, notifications.html, tools_settings.html,
  crons.html, integrations.html all migrated; pg-* color tokens; dark/light via data-theme

pg.css fixes:
- input[type=checkbox/radio]: width: auto — prevents pg.css width:100% from stretching checkboxes
- btn-submit: responsive sizing via Tailwind w-full md:w-96 on each button (no longer full-width on desktop)

Documentation:
- MASTER.md, TODO__Agents.md: remove "/ Inara" from titles; description updated to "per-user AI personas"
- HELP.md: persona-agnostic language throughout (NC Talk, Google Chat, push, schedules, HA sections);
  roles section restructured to show required vs. custom roles with examples
- notifications.html: subtitle and HA description use "your persona" not "Inara"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-15 21:03:11 -04:00
parent 070f1ce156
commit 7a27190ffe
13 changed files with 1224 additions and 953 deletions

View File

@@ -106,6 +106,18 @@ GOOGLE_CATALOG: list[dict] = [
{"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash-Lite (preview)", "context_k": 1000}, {"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash-Lite (preview)", "context_k": 1000},
] ]
# Known OpenAI-compatible cloud inference services.
# All use host_type "openai" (/chat/completions + /models paths).
CLOUD_API_CATALOG: list[dict] = [
{"id": "openrouter", "label": "OpenRouter", "api_url": "https://openrouter.ai/api/v1"},
{"id": "openai", "label": "OpenAI", "api_url": "https://api.openai.com/v1"},
{"id": "groq", "label": "Groq", "api_url": "https://api.groq.com/openai/v1"},
{"id": "xai", "label": "X.ai / Grok", "api_url": "https://api.x.ai/v1"},
{"id": "together", "label": "Together.ai", "api_url": "https://api.together.xyz/v1"},
{"id": "fireworks", "label": "Fireworks.ai", "api_url": "https://api.fireworks.ai/inference/v1"},
{"id": "custom", "label": "Custom", "api_url": ""},
]
# ── Built-in model definitions ──────────────────────────────────────────────── # ── Built-in model definitions ────────────────────────────────────────────────
@@ -148,6 +160,8 @@ _ROLE_LAST_RESORT: dict[str, str] = {
PRIORITY_KEYS = ["primary", "backup_1", "backup_2", "backup_3", "backup_4"] PRIORITY_KEYS = ["primary", "backup_1", "backup_2", "backup_3", "backup_4"]
REQUIRED_ROLES: list[str] = ["chat", "orchestrator", "distill"]
# ── Storage ─────────────────────────────────────────────────────────────────── # ── Storage ───────────────────────────────────────────────────────────────────
@@ -565,6 +579,8 @@ def get_catalog(provider: str, username: str | None = None) -> list[dict]:
return list(ANTHROPIC_CATALOG) return list(ANTHROPIC_CATALOG)
if provider == "google": if provider == "google":
return list(GOOGLE_CATALOG) return list(GOOGLE_CATALOG)
if provider == "cloud":
return list(CLOUD_API_CATALOG)
return [] return []
@@ -851,6 +867,52 @@ def remove_model(username: str, model_id: str) -> bool:
return len(data["models"]) < before return len(data["models"]) < before
def get_custom_roles(username: str) -> list[str]:
"""
Return the user's custom (non-required) roles.
Falls back to config-defined roles minus required ones for migration.
"""
registry = _load(username)
if "custom_roles" in registry:
return [r for r in registry["custom_roles"] if r and r not in REQUIRED_ROLES]
from config import settings as _cfg
return [r for r in _cfg.get_defined_roles() if r not in REQUIRED_ROLES]
def get_all_roles(username: str) -> list[str]:
"""Return required roles followed by the user's custom roles."""
return list(REQUIRED_ROLES) + get_custom_roles(username)
def add_custom_role(username: str, role_name: str) -> bool:
"""Add a custom role. Returns False if the name is invalid or already a required role."""
role_name = role_name.strip().lower()
if not role_name or role_name in REQUIRED_ROLES:
return False
data = _load(username)
if "custom_roles" not in data:
from config import settings as _cfg
data["custom_roles"] = [r for r in _cfg.get_defined_roles() if r not in REQUIRED_ROLES]
if role_name not in data["custom_roles"]:
data["custom_roles"].append(role_name)
_save(username, data)
return True
def remove_custom_role(username: str, role_name: str) -> bool:
"""Remove a custom role. Required roles cannot be removed."""
if role_name in REQUIRED_ROLES:
return False
data = _load(username)
if "custom_roles" not in data:
from config import settings as _cfg
data["custom_roles"] = [r for r in _cfg.get_defined_roles() if r not in REQUIRED_ROLES]
if role_name in data["custom_roles"]:
data["custom_roles"].remove(role_name)
_save(username, data)
return True
def set_role(username: str, role: str, priority: str, model_id: str | None) -> bool: def set_role(username: str, role: str, priority: str, model_id: str | None) -> bool:
""" """
Assign a model to a role priority slot. Assign a model to a role priority slot.

View File

@@ -13,6 +13,8 @@ Routes:
POST /settings/local/models/add → add a model (any provider) 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}/edit → edit an existing model entry
POST /settings/local/models/{id}/remove → remove a model POST /settings/local/models/{id}/remove → remove a model
POST /settings/local/roles/add → add a custom role (redirects to #roles)
POST /settings/local/roles/remove → remove a custom role (redirects to #roles)
POST /api/models/role → AJAX: set a role assignment POST /api/models/role → AJAX: set a role assignment
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON) GET /api/local-llm/fetch-models → proxy to host /api/models (JSON)
""" """
@@ -56,6 +58,70 @@ router = APIRouter()
_STATIC = Path(__file__).parent.parent / "static" _STATIC = Path(__file__).parent.parent / "static"
def _host_row_html(h: dict) -> str:
"""Return the HTML for one host config row (edit form + remove link)."""
api_key = h.get("api_key", "")
key_hint = f"{api_key[-4:]}" if api_key else "not set"
ht = h.get("host_type", "openwebui")
ow = ' selected' if ht == "openwebui" else ''
ai = ' selected' if ht == "openai" else ''
hid = h["id"]
hlbl = h.get("label", "")
hurl = h.get("api_url", "")
maxc = h.get("max_concurrent", 3)
return f'''
<div class="host-row">
<form method="POST" action="/settings/local/host" class="host-form">
<input type="hidden" name="host_id" value="{hid}">
<div class="field-row">
<div class="field">
<label>Label</label>
<input type="text" name="label" value="{hlbl}"
placeholder="Gaming Laptop" autocomplete="off" data-form-type="other">
</div>
<div class="field" style="flex:2">
<label>API URL</label>
<input type="text" name="api_url" value="{hurl}"
placeholder="http://192.168.x.x:3000"
autocomplete="off" spellcheck="false" data-form-type="other">
</div>
</div>
<div class="field-row">
<div class="field">
<label>API Key</label>
<input type="password" name="api_key" placeholder="Leave blank to keep existing"
autocomplete="new-password" data-1p-ignore data-lpignore="true"
data-form-type="other">
<p class="key-status">Current: {key_hint}</p>
</div>
<div class="field" style="flex:0 0 auto">
<label>Type</label>
<select name="host_type">
<option value="openwebui"{ow}>Open WebUI / Ollama</option>
<option value="openai"{ai}>OpenAI-compatible API</option>
</select>
</div>
<div class="field" style="flex:0 0 auto; width:6rem">
<label>Max parallel</label>
<input type="number" name="max_concurrent" min="1" max="20"
value="{maxc}" style="width:100%">
</div>
</div>
<div class="btn-row">
<button type="submit" class="btn btn-secondary btn-sm">Save</button>
<button type="button" class="btn btn-secondary btn-sm fetch-btn"
data-host-id="{hid}">Fetch models</button>
<span class="fetch-status" id="fetch-{hid}"></span>
</div>
</form>
<form method="POST" action="/settings/local/host/{hid}/remove"
onsubmit="return confirm('Remove host and all its models?')"
style="margin-top:0.5rem">
<button type="submit" class="btn-link danger">Remove host</button>
</form>
</div>'''
# ── Auth helper ─────────────────────────────────────────────────────────────── # ── Auth helper ───────────────────────────────────────────────────────────────
def _get_user(request: Request) -> str | None: def _get_user(request: Request) -> str | None:
@@ -97,66 +163,16 @@ def _render(username: str, request: Request | None = None, success: str = "", er
if not google_account_rows: if not google_account_rows:
google_account_rows = '<p class="empty-note">No accounts configured yet.</p>' google_account_rows = '<p class="empty-note">No accounts configured yet.</p>'
# ── Local host rows ─────────────────────────────────────────────────────── # ── Host rows — split cloud (openai) vs local (openwebui) ─────────────────
host_rows = "" cloud_hosts = [h for h in hosts if h.get("host_type") == "openai"]
for h in hosts: local_hosts = [h for h in hosts if h.get("host_type", "openwebui") != "openai"]
key_hint = f"{h['api_key'][-4:]}" if h.get("api_key") else "not set"
ht = h.get("host_type", "openwebui") cloud_host_rows = "".join(_host_row_html(h) for h in cloud_hosts)
ow = ' selected' if ht == "openwebui" else '' local_host_rows = "".join(_host_row_html(h) for h in local_hosts)
ai = ' selected' if ht == "openai" else '' if not cloud_host_rows:
host_rows += f''' cloud_host_rows = '<p class="empty-note">No cloud API services configured yet. Add one below.</p>'
<div class="host-row"> if not local_host_rows:
<form method="POST" action="/settings/local/host" class="host-form"> local_host_rows = '<p class="empty-note">No local hosts configured yet. Add one below.</p>'
<input type="hidden" name="host_id" value="{h["id"]}">
<div class="field-row">
<div class="field">
<label>Label</label>
<input type="text" name="label" value="{h.get("label","")}"
placeholder="Gaming Laptop" autocomplete="off" data-form-type="other">
</div>
<div class="field" style="flex:2">
<label>API URL</label>
<input type="text" name="api_url" value="{h.get("api_url","")}"
placeholder="http://192.168.x.x:3000"
autocomplete="off" spellcheck="false" data-form-type="other">
</div>
</div>
<div class="field-row">
<div class="field">
<label>API Key</label>
<input type="password" name="api_key" placeholder="Leave blank to keep existing"
autocomplete="new-password" data-1p-ignore data-lpignore="true"
data-form-type="other">
<p class="key-status">Current: {key_hint}</p>
</div>
<div class="field" style="flex:0 0 auto">
<label>Type</label>
<select name="host_type">
<option value="openwebui"{ow}>Open WebUI / Ollama</option>
<option value="openai"{ai}>OpenAI-compatible (OpenRouter, etc.)</option>
</select>
</div>
<div class="field" style="flex:0 0 auto; width:6rem">
<label>Max parallel</label>
<input type="number" name="max_concurrent" min="1" max="20"
value="{h.get('max_concurrent', 3)}" style="width:100%">
</div>
</div>
<div class="btn-row">
<button type="submit" class="btn btn-secondary btn-sm">Save</button>
<button type="button" class="btn btn-secondary btn-sm fetch-btn"
data-host-id="{h["id"]}">Fetch models</button>
<span class="fetch-status" id="fetch-{h["id"]}"></span>
</div>
</form>
<form method="POST" action="/settings/local/host/{h["id"]}/remove"
onsubmit="return confirm('Remove host and all its models?')"
style="margin-top:0.5rem">
<button type="submit" class="btn-link danger">Remove host</button>
</form>
</div>'''
if not host_rows:
host_rows = '<p class="empty-note">No hosts configured yet. Add one below.</p>'
host_options = "".join( host_options = "".join(
f'<option value="{h["id"]}">{h.get("label") or h["api_url"]}</option>' f'<option value="{h["id"]}">{h.get("label") or h["api_url"]}</option>'
@@ -360,15 +376,35 @@ def _render(username: str, request: Request | None = None, success: str = "", er
model_opts += f' <option value="{m["id"]}">{lbl}</option>\n' model_opts += f' <option value="{m["id"]}">{lbl}</option>\n'
model_opts += '</optgroup>\n' model_opts += '</optgroup>\n'
all_roles = reg.get_all_roles(username)
role_rows = "" 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_cfg = roles.get(role, {})
role_title = role.replace("_", " ").title()
required_badge = (
'<span class="required-badge">required</span>'
if is_required else ''
)
rcp_danger = (
'' if is_required else
f'<div class="rcp-danger">'
f'<form method="POST" action="/settings/local/roles/remove" class="remove-role-form">'
f'<input type="hidden" name="role_name" value="{role}">'
f'<button type="submit" class="btn-link danger" data-role="{role}">Remove this role…</button>'
f'</form>'
f'</div>'
)
role_rows += ( role_rows += (
f'<div class="role-row" data-role="{role}">' f'<div class="role-row" data-role="{role}">'
f'<span class="role-name">{role.title()}</span>' f'<div class="role-name-col">'
f'<span class="role-name">{role_title}</span>'
f'{required_badge}'
f'</div>'
f'<div class="role-slots">' f'<div class="role-slots">'
) )
for slot in reg.PRIORITY_KEYS[:3]: for slot in reg.PRIORITY_KEYS[:2]:
slot_label = slot.replace("_", " ").title() slot_label = slot.replace("_", " ").title()
sel = ( sel = (
f'<select class="role-select" data-role="{role}" ' f'<select class="role-select" data-role="{role}" '
@@ -377,7 +413,7 @@ def _render(username: str, request: Request | None = None, success: str = "", er
role_rows += f'<div class="role-slot"><span class="slot-label">{slot_label}</span>{sel}</div>' role_rows += f'<div class="role-slot"><span class="slot-label">{slot_label}</span>{sel}</div>'
role_rows += ( role_rows += (
f'</div>' f'</div>'
f'<button class="role-cfg-btn" data-role="{role}" title="Configure persona and tools">⚙</button>' f'<button class="role-cfg-btn" data-role="{role}" title="Configure">⚙</button>'
f'</div>' f'</div>'
f'<div class="role-config-panel" id="rcp-{role}">' f'<div class="role-config-panel" id="rcp-{role}">'
f'<div class="rcp-field">' f'<div class="rcp-field">'
@@ -385,17 +421,18 @@ def _render(username: str, request: Request | None = None, success: str = "", er
f'<textarea class="rcp-textarea" data-role="{role}" rows="3" ' f'<textarea class="rcp-textarea" data-role="{role}" rows="3" '
f'placeholder="Extra instructions injected into the system prompt when this role is active…"></textarea>' f'placeholder="Extra instructions injected into the system prompt when this role is active…"></textarea>'
f'</div>' f'</div>'
f'<div class="rcp-field rcp-field-inline">' f'<div class="rcp-field">'
f'<div style="display:flex;flex-direction:column;gap:0.3rem">'
f'<label class="rcp-check">' f'<label class="rcp-check">'
f'<input type="checkbox" class="rcp-datetime-cb" data-role="{role}" checked>' f'<input type="checkbox" class="rcp-datetime-cb" data-role="{role}" checked>'
f' Inject current date &amp; time into system prompt' f'<span>Inject current date &amp; time into system prompt</span>'
f'</label>' f'</label>'
f'<label class="rcp-check" style="margin-top:0.4rem">' f'<label class="rcp-check">'
f'<input type="checkbox" class="rcp-mode-cb" data-role="{role}" checked>' f'<input type="checkbox" class="rcp-mode-cb" data-role="{role}" checked>'
f' Inject session mode (Chat / Off The Record) into system prompt' f'<span>Inject session mode (Chat / Off The Record) into system prompt</span>'
f'</label>' f'</label>'
f'<span class="rcp-hint" style="display:block;margin-top:0.2rem">' f'</div>'
f'Disable both for pure processing roles (summarizer, classifier, translator)</span>' f'<p class="rcp-hint" style="margin-top:0.4rem">Disable both for pure processing roles (summarizer, classifier, translator).</p>'
f'</div>' f'</div>'
f'<div class="rcp-field">' f'<div class="rcp-field">'
f'<label class="rcp-label">Tool allow-list ' f'<label class="rcp-label">Tool allow-list '
@@ -406,12 +443,13 @@ def _render(username: str, request: Request | None = None, success: str = "", er
f'<button class="btn btn-primary btn-sm rcp-save" data-role="{role}">Save</button>' f'<button class="btn btn-primary btn-sm rcp-save" data-role="{role}">Save</button>'
f'<button class="btn btn-secondary btn-sm rcp-cancel" data-role="{role}">Cancel</button>' f'<button class="btn btn-secondary btn-sm rcp-cancel" data-role="{role}">Cancel</button>'
f'</div>' f'</div>'
f'{rcp_danger}'
f'</div>' f'</div>'
) )
role_data_js = _json.dumps({ role_data_js = _json.dumps({
role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:3]} role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:2]}
for role in app_settings.get_defined_roles() for role in all_roles
}) })
role_config_data_js = _json.dumps({ 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_datetime": roles.get(role, {}).get("inject_datetime", True),
"inject_mode": roles.get(role, {}).get("inject_mode", 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) tool_categories_js = _json.dumps(TOOL_CATEGORIES)
# ── Catalog data + Google accounts for JS ───────────────────────────────── # ── Catalog data + Google accounts for JS ─────────────────────────────────
google_accounts_js = _json.dumps(reg.get_google_accounts(username)) google_accounts_js = _json.dumps(reg.get_google_accounts(username))
google_catalog_js = _json.dumps(reg.get_catalog("google")) google_catalog_js = _json.dumps(reg.get_catalog("google"))
anthropic_catalog_js = _json.dumps(reg.get_catalog("anthropic")) 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" has_hosts = "true" if hosts else "false"
html = (_STATIC / "local_llm.html").read_text() html = (_STATIC / "local_llm.html").read_text()
@@ -436,7 +475,8 @@ def _render(username: str, request: Request | None = None, success: str = "", er
"{{ username }}": username, "{{ username }}": username,
"{{ google_account_rows }}": google_account_rows, "{{ google_account_rows }}": google_account_rows,
"{{ anthropic_key_rows }}": anthropic_key_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, "{{ model_rows }}": model_rows,
"{{ host_options }}": host_options, "{{ host_options }}": host_options,
"{{ role_rows }}": role_rows, "{{ 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, "{{ anthropic_keys_js }}": anthropic_keys_js,
"{{ google_catalog_js }}": google_catalog_js, "{{ google_catalog_js }}": google_catalog_js,
"{{ anthropic_catalog_js }}": anthropic_catalog_js, "{{ anthropic_catalog_js }}": anthropic_catalog_js,
"{{ cloud_catalog_js }}": cloud_catalog_js,
"{{ has_hosts }}": has_hosts, "{{ has_hosts }}": has_hosts,
} }
for key, val in replacements.items(): 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.")) 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") @router.post("/api/models/role")
async def set_role(request: Request) -> JSONResponse: async def set_role(request: Request) -> JSONResponse:
"""AJAX: assign a model to a role priority slot. """AJAX: assign a model to a role priority slot.

View File

@@ -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 ### 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 | | Role | Used for |
|---|---| |---|---|
| **Chat** | Regular conversation | | **Chat** | Regular conversation |
| **Orchestrator** | Agent mode tool loop | | **Orchestrator** | Agent mode tool loop |
| **Distill** | Memory distillation (short / mid / long) | | **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). **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 ## 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. - Messages sent in enabled Talk conversations are received by Cortex, processed, and replied to.
- The webhook returns `200 OK` immediately; the reply happens asynchronously. - 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 ## 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. - 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. - 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. - 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. - Open **☰ → Enable notifications** and accept the browser permission prompt.
- Once enabled, the button shows **Notifications on** (in accent colour). - Once enabled, the button shows **Notifications on** (in accent colour).
- Click again to disable. Subscriptions are stored per-device. - 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 ## 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 ### Job Types
@@ -424,7 +431,7 @@ Schedules take effect immediately when added or edited — no restart needed. Pa
### Home Assistant Integration ### 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) - Set a **Webhook ID** (long random string — this is your secret URL component)
- Your endpoint: `https://cortex.dgrzone.com/webhook/ha/{username}/{webhook_id}` - Your endpoint: `https://cortex.dgrzone.com/webhook/ha/{username}/{webhook_id}`

View File

@@ -7,9 +7,36 @@
<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 src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false },
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
pg: {
bg: 'var(--pg-bg)',
surface: 'var(--pg-surface)',
border: 'var(--pg-border)',
text: 'var(--pg-text)',
muted: 'var(--pg-muted)',
dim: 'var(--pg-dim)',
dimmer: 'var(--pg-dimmer)',
bright: 'var(--pg-bright)',
accent: 'var(--pg-accent)',
action: 'var(--pg-action)',
}
},
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
}
}
}
</script>
<link rel="stylesheet" href="/static/pg.css"> <link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</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>
/* ── Server-generated table + badges ── */
.cron-table { .cron-table {
width: 100%; border-collapse: collapse; font-size: 0.82rem; width: 100%; border-collapse: collapse; font-size: 0.82rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
@@ -47,15 +74,14 @@
font-family: inherit; font-family: inherit;
} }
.btn-cron:hover { border-color: var(--pg-accent); color: var(--pg-accent); } .btn-cron:hover { border-color: var(--pg-accent); color: var(--pg-accent); }
.btn-cron-del:hover { border-color: var(--pg-danger, #ef4444); color: var(--pg-danger, #ef4444); }
.btn-cron-del { color: var(--pg-dimmer); } .btn-cron-del { color: var(--pg-dimmer); }
.btn-cron-del:hover { border-color: #ef4444; color: #ef4444; }
.payload-cell { .payload-cell {
max-width: 240px; overflow: hidden; text-overflow: ellipsis; max-width: 240px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; color: var(--pg-dimmer); white-space: nowrap; color: var(--pg-dimmer);
} }
.persona-group { margin-bottom: 0.25rem; }
.persona-group-label { .persona-group-label {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase; font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--pg-dimmer); margin: 1.25rem 0 0.5rem; letter-spacing: 0.06em; color: var(--pg-dimmer); margin: 1.25rem 0 0.5rem;
@@ -67,11 +93,6 @@
border: 1px dashed var(--pg-border); border-radius: 8px; border: 1px dashed var(--pg-border); border-radius: 8px;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.add-form-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0 0.75rem;
}
.add-form-grid .field-full { grid-column: 1 / -1; }
</style> </style>
</head> </head>
<body> <body>
@@ -79,6 +100,7 @@
<a href="{{ back_href }}" class="nav-link">← Chat</a> <a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a> <a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a> <a href="/settings" class="nav-link">Settings</a>
<a href="/settings/models" class="nav-link">Models</a>
<a href="/settings/notifications" class="nav-link">Notifications</a> <a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a> <a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link active">Schedules</a> <a href="/settings/crons" class="nav-link active">Schedules</a>
@@ -99,11 +121,11 @@
<!-- Cron list --> <!-- Cron list -->
{{ cron_list_html }} {{ cron_list_html }}
<!-- Add new cron --> <!-- Add new schedule -->
<div class="section"> <div class="section">
<h2>Add schedule</h2> <h2>Add schedule</h2>
<form method="POST" action="/settings/crons/add"> <form method="POST" action="/settings/crons/add">
<div class="add-form-grid"> <div class="grid grid-cols-2 gap-x-3">
<div class="field"> <div class="field">
<label for="add_persona">Persona</label> <label for="add_persona">Persona</label>
<select id="add_persona" name="persona"> <select id="add_persona" name="persona">
@@ -136,13 +158,13 @@
monthly · monthly:DD · monthly:DD:HH:MM · yearly:MM:DD · yearly:MM:DD:HH:MM monthly · monthly:DD · monthly:DD:HH:MM · yearly:MM:DD · yearly:MM:DD:HH:MM
</p> </p>
</div> </div>
<div class="field field-full"> <div class="field col-span-2">
<label for="add_payload">Payload / prompt</label> <label for="add_payload">Payload / prompt</label>
<textarea id="add_payload" name="payload" rows="3" <textarea id="add_payload" name="payload" rows="3"
placeholder="Check my open tasks and send a summary." required></textarea> placeholder="Check my open tasks and send a summary." required></textarea>
</div> </div>
</div> </div>
<button type="submit" class="btn-submit">Add schedule</button> <button type="submit" class="btn-submit w-full md:w-96">Add schedule</button>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -8,38 +8,40 @@
<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 src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false },
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
pg: {
bg: 'var(--pg-bg)',
surface: 'var(--pg-surface)',
border: 'var(--pg-border)',
text: 'var(--pg-text)',
muted: 'var(--pg-muted)',
dim: 'var(--pg-dim)',
dimmer: 'var(--pg-dimmer)',
bright: 'var(--pg-bright)',
accent: 'var(--pg-accent)',
action: 'var(--pg-action)',
}
},
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
}
}
}
</script>
<link rel="stylesheet" href="/static/pg.css"> <link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</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>
.page { max-width: 860px; margin: 0 auto; padding: 2rem 1.5rem 4rem; } /* ── Tab panels (JS-toggled display) ── */
/* ── Header ── */
header { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--pg-border); }
header h1 { font-size: 1.5rem; font-weight: 700; color: var(--pg-accent); }
header p { font-size: 0.85rem; color: var(--pg-muted); margin-top: 0.25rem; }
/* ── Tabs ── */
.tab-bar {
display: flex; gap: 0.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--pg-border);
padding-bottom: 0;
}
.tab-btn {
padding: 0.45rem 1rem;
font-size: 0.85rem; font-weight: 500;
color: var(--pg-dim);
background: none; border: none; border-bottom: 2px solid transparent;
cursor: pointer; transition: color 0.15s, border-color 0.15s;
margin-bottom: -1px;
}
.tab-btn:hover { color: var(--pg-bright); }
.tab-btn.active { color: var(--pg-accent); border-bottom-color: var(--pg-accent); }
.tab-panel { display: none; } .tab-panel { display: none; }
.tab-panel.active { display: block; } .tab-panel.active { display: block; }
/* ── Content ── */ /* ── Dynamically-rendered markdown content ── */
.help-body { line-height: 1.7; } .help-body { line-height: 1.7; }
details { details {
@@ -83,8 +85,6 @@
.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 { 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 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; } .help-body hr { border: none; border-top: 1px solid var(--pg-border); margin: 0.5rem 0; }
.empty-state { color: var(--pg-dim); font-size: 0.9rem; padding: 2rem 0; text-align: center; }
</style> </style>
</head> </head>
<body> <body>
@@ -92,6 +92,7 @@
<a id="nav-chat" href="/" class="nav-link">← Chat</a> <a id="nav-chat" href="/" class="nav-link">← Chat</a>
<a href="/help" class="nav-link active">Help</a> <a href="/help" class="nav-link active">Help</a>
<a href="/settings" class="nav-link" id="nav-settings">Settings</a> <a href="/settings" class="nav-link" id="nav-settings">Settings</a>
<a href="/settings/models" class="nav-link">Models</a>
<a href="/settings/notifications" class="nav-link">Notifications</a> <a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a> <a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a> <a href="/settings/crons" class="nav-link">Schedules</a>
@@ -99,23 +100,33 @@
<span class="nav-spacer"></span> <span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a> <a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav> </nav>
<div class="page">
<header>
<h1>Help &amp; Reference</h1>
<p id="persona-label"></p>
</header>
<div class="tab-bar"> <div class="max-w-3xl mx-auto px-6 py-8 pb-16">
<button class="tab-btn active" data-tab="ui">UI Guide</button> <div class="mb-6 pb-4 border-b border-pg-border">
<button class="tab-btn" data-tab="tools">Tools</button> <h1 class="text-xl font-bold text-pg-accent">Help &amp; Reference</h1>
<button class="tab-btn" data-tab="persona" id="tab-btn-persona">Persona</button> <p id="persona-label" class="text-xs text-pg-muted mt-1"></p>
</div> </div>
<div id="tab-ui" class="tab-panel active"><div class="help-body"><p class="empty-state">Loading…</p></div></div> <!-- Tab bar -->
<div id="tab-tools" class="tab-panel"> <div class="help-body"><p class="empty-state">Loading…</p></div></div> <div class="flex gap-1 mb-5 border-b border-pg-border -mb-px">
<div id="tab-persona" class="tab-panel"> <div class="help-body"><p class="empty-state">Loading…</p></div></div> <button class="tab-btn px-4 py-2 text-sm font-medium text-pg-dim border-b-2 border-transparent -mb-px cursor-pointer transition-colors hover:text-pg-bright active"
data-tab="ui">UI Guide</button>
<button class="tab-btn px-4 py-2 text-sm font-medium text-pg-dim border-b-2 border-transparent -mb-px cursor-pointer transition-colors hover:text-pg-bright"
data-tab="tools">Tools</button>
<button class="tab-btn px-4 py-2 text-sm font-medium text-pg-dim border-b-2 border-transparent -mb-px cursor-pointer transition-colors hover:text-pg-bright"
data-tab="persona" id="tab-btn-persona">Persona</button>
</div>
<div id="tab-ui" class="tab-panel active"><div class="help-body"><p class="text-pg-dimmer text-sm text-center py-8">Loading…</p></div></div>
<div id="tab-tools" class="tab-panel"> <div class="help-body"><p class="text-pg-dimmer text-sm text-center py-8">Loading…</p></div></div>
<div id="tab-persona" class="tab-panel"> <div class="help-body"><p class="text-pg-dimmer text-sm text-center py-8">Loading…</p></div></div>
</div> </div>
<style>
/* Active tab indicator — must be CSS since Tailwind can't match .active sibling state */
.tab-btn.active { color: var(--pg-accent); border-bottom-color: var(--pg-accent); }
</style>
<script> <script>
const cfg = window.HELP_CONFIG || {}; const cfg = window.HELP_CONFIG || {};
const user = cfg.user || 'scott'; const user = cfg.user || 'scott';
@@ -186,13 +197,13 @@
fetch('/static/HELP.md') fetch('/static/HELP.md')
.then(r => r.ok ? r.text() : Promise.reject(r.status)) .then(r => r.ok ? r.text() : Promise.reject(r.status))
.then(md => render('tab-ui', md, false, UI_OPEN)) .then(md => render('tab-ui', md, false, UI_OPEN))
.catch(e => { document.querySelector('#tab-ui .help-body').innerHTML = `<p class="empty-state">Failed to load: ${e}</p>`; }); .catch(e => { document.querySelector('#tab-ui .help-body').innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">Failed to load: ${e}</p>`; });
// Tools // Tools
fetch('/static/TOOLS.md') fetch('/static/TOOLS.md')
.then(r => r.ok ? r.text() : Promise.reject(r.status)) .then(r => r.ok ? r.text() : Promise.reject(r.status))
.then(md => render('tab-tools', md, true, null)) .then(md => render('tab-tools', md, true, null))
.catch(e => { document.querySelector('#tab-tools .help-body').innerHTML = `<p class="empty-state">Failed to load: ${e}</p>`; }); .catch(e => { document.querySelector('#tab-tools .help-body').innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">Failed to load: ${e}</p>`; });
// Persona-specific HELP.md // Persona-specific HELP.md
const personaPanel = document.querySelector('#tab-persona .help-body'); const personaPanel = document.querySelector('#tab-persona .help-body');
@@ -204,13 +215,13 @@
if (content) { if (content) {
render('tab-persona', content, true, null); render('tab-persona', content, true, null);
} else { } else {
personaPanel.innerHTML = `<p class="empty-state">No ${persona}-specific notes yet. Edit <code>HELP.md</code> in the Files panel to add them.</p>`; personaPanel.innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">No ${persona}-specific notes yet. Edit <code>HELP.md</code> in the Files panel to add them.</p>`;
} }
} else { } else {
personaPanel.innerHTML = `<p class="empty-state">No ${persona}-specific notes yet.</p>`; personaPanel.innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">No ${persona}-specific notes yet.</p>`;
} }
} catch (_) { } catch (_) {
personaPanel.innerHTML = `<p class="empty-state">No ${persona}-specific notes yet.</p>`; personaPanel.innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">No ${persona}-specific notes yet.</p>`;
} }
} }

View File

@@ -7,19 +7,35 @@
<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 src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false },
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
pg: {
bg: 'var(--pg-bg)',
surface: 'var(--pg-surface)',
border: 'var(--pg-border)',
text: 'var(--pg-text)',
muted: 'var(--pg-muted)',
dim: 'var(--pg-dim)',
dimmer: 'var(--pg-dimmer)',
bright: 'var(--pg-bright)',
accent: 'var(--pg-accent)',
action: 'var(--pg-action)',
}
},
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
}
}
}
</script>
<link rel="stylesheet" href="/static/pg.css"> <link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</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>
details.channel-block {
border: 1px solid var(--pg-border); border-radius: 8px;
margin-bottom: 0.75rem; overflow: hidden;
}
details.channel-block summary {
padding: 0.75rem 1rem; font-size: 0.85rem; font-weight: 600;
color: var(--pg-muted); cursor: pointer; list-style: none;
display: flex; align-items: center; gap: 0.5rem;
user-select: none; background: var(--pg-bg);
}
details.channel-block summary::-webkit-details-marker { display: none; } details.channel-block summary::-webkit-details-marker { display: none; }
details.channel-block summary::before { details.channel-block summary::before {
content: '▶'; font-size: 0.65rem; color: var(--pg-dimmer); content: '▶'; font-size: 0.65rem; color: var(--pg-dimmer);
@@ -27,16 +43,6 @@
} }
details.channel-block[open] summary::before { transform: rotate(90deg); } details.channel-block[open] summary::before { transform: rotate(90deg); }
details.channel-block[open] summary { border-bottom: 1px solid var(--pg-border); } details.channel-block[open] summary { border-bottom: 1px solid var(--pg-border); }
.channel-block-body { padding: 1rem 1rem 0.25rem; }
.channel-hint {
font-size: 0.75rem; color: var(--pg-dimmer);
margin-top: -0.6rem; margin-bottom: 1rem; line-height: 1.5;
}
.field-row {
display: grid; grid-template-columns: 1fr auto; gap: 0.75rem; align-items: end;
}
.field-row .field { margin-bottom: 0; }
.field-narrow input { max-width: 120px; }
</style> </style>
</head> </head>
<body> <body>
@@ -44,6 +50,7 @@
<a href="{{ back_href }}" class="nav-link">← Chat</a> <a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a> <a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a> <a href="/settings" class="nav-link">Settings</a>
<a href="/settings/models" class="nav-link">Models</a>
<a href="/settings/notifications" class="nav-link">Notifications</a> <a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a> <a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a> <a href="/settings/crons" class="nav-link">Schedules</a>
@@ -60,7 +67,6 @@
<form method="POST" action="/settings/integrations"> <form method="POST" action="/settings/integrations">
<!-- Aether Platform Database -->
<div class="section"> <div class="section">
<h2>Aether Platform Database</h2> <h2>Aether Platform Database</h2>
<p class="section-note"> <p class="section-note">
@@ -69,14 +75,18 @@
Only SELECT, SHOW, DESCRIBE, and EXPLAIN are permitted — no writes possible. Only SELECT, SHOW, DESCRIBE, and EXPLAIN are permitted — no writes possible.
</p> </p>
<details class="channel-block" {{ ae_db_host and 'open' or '' }}> <details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
<summary>Connection</summary> {{ ae_db_host and 'open' or '' }}>
<div class="channel-block-body"> <summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
<p class="channel-hint"> Connection
Use the same credentials as <code>agents_sync/mcp/scripts/sql_inspector.py</code>. </summary>
The password field is left blank in the form — leave it blank to keep the stored value. <div class="px-4 pt-4 pb-2">
<p class="text-xs text-pg-dimmer mb-4 -mt-1 leading-relaxed">
Use the same credentials as
<code class="font-mono text-pg-accent bg-pg-bg border border-pg-border rounded px-1 text-xs">agents_sync/mcp/scripts/sql_inspector.py</code>.
Leave the password blank to keep the stored value.
</p> </p>
<div class="field-row"> <div class="grid grid-cols-[1fr_7rem] gap-3 items-start">
<div class="field"> <div class="field">
<label for="ae_db_host">Host</label> <label for="ae_db_host">Host</label>
<input type="text" id="ae_db_host" name="ae_db_host" <input type="text" id="ae_db_host" name="ae_db_host"
@@ -84,7 +94,7 @@
placeholder="192.168.64.5" placeholder="192.168.64.5"
autocomplete="off" spellcheck="false"> autocomplete="off" spellcheck="false">
</div> </div>
<div class="field field-narrow"> <div class="field">
<label for="ae_db_port">Port</label> <label for="ae_db_port">Port</label>
<input type="number" id="ae_db_port" name="ae_db_port" <input type="number" id="ae_db_port" name="ae_db_port"
value="{{ ae_db_port }}" value="{{ ae_db_port }}"
@@ -117,7 +127,7 @@
</details> </details>
</div> </div>
<button type="submit" class="btn-submit">Save integrations</button> <button type="submit" class="btn-submit w-full md:w-96">Save integrations</button>
</form> </form>
</div> </div>
</body> </body>

File diff suppressed because it is too large Load Diff

View File

@@ -7,38 +7,36 @@
<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 src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false },
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
pg: {
bg: 'var(--pg-bg)',
surface: 'var(--pg-surface)',
border: 'var(--pg-border)',
text: 'var(--pg-text)',
muted: 'var(--pg-muted)',
dim: 'var(--pg-dim)',
dimmer: 'var(--pg-dimmer)',
bright: 'var(--pg-bright)',
accent: 'var(--pg-accent)',
action: 'var(--pg-action)',
}
},
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
}
}
}
</script>
<link rel="stylesheet" href="/static/pg.css"> <link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</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>
/* ── Test action buttons ── */ /* ── Channel collapsible arrow ── */
.test-btn-row { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
.test-btn {
flex: 1; padding: 0.6rem 0.75rem;
border: 1px solid var(--pg-border); border-radius: 6px;
background: var(--pg-bg); color: var(--pg-text);
font-size: 0.85rem; font-weight: 500; cursor: pointer;
transition: border-color 0.15s, color 0.15s; text-align: center;
}
.test-btn:hover { border-color: var(--pg-action); color: var(--pg-accent); }
.test-btn:disabled { opacity: 0.5; cursor: default; }
.test-result {
margin-top: 0.75rem; padding: 0.6rem 0.8rem; border-radius: 6px;
font-size: 0.82rem; line-height: 1.5; display: none;
}
.test-result.ok { background: rgba(74,222,128,0.1); color: #4ade80; border: 1px solid rgba(74,222,128,0.25); }
.test-result.err { background: rgba(248,113,113,0.1); color: #f87171; border: 1px solid rgba(248,113,113,0.25); }
/* ── Channel config collapsible blocks ── */
details.channel-block {
border: 1px solid var(--pg-border); border-radius: 8px;
margin-bottom: 0.75rem; overflow: hidden;
}
details.channel-block summary {
padding: 0.75rem 1rem; font-size: 0.85rem; font-weight: 600;
color: var(--pg-muted); cursor: pointer; list-style: none;
display: flex; align-items: center; gap: 0.5rem;
user-select: none; background: var(--pg-bg);
}
details.channel-block summary::-webkit-details-marker { display: none; } details.channel-block summary::-webkit-details-marker { display: none; }
details.channel-block summary::before { details.channel-block summary::before {
content: '▶'; font-size: 0.65rem; color: var(--pg-dimmer); content: '▶'; font-size: 0.65rem; color: var(--pg-dimmer);
@@ -46,11 +44,9 @@
} }
details.channel-block[open] summary::before { transform: rotate(90deg); } details.channel-block[open] summary::before { transform: rotate(90deg); }
details.channel-block[open] summary { border-bottom: 1px solid var(--pg-border); } details.channel-block[open] summary { border-bottom: 1px solid var(--pg-border); }
.channel-block-body { padding: 1rem 1rem 0.25rem; }
.channel-hint { /* ── Test result feedback (JS-toggled display) ── */
font-size: 0.75rem; color: var(--pg-dimmer); #test-result { display: none; }
margin-top: -0.6rem; margin-bottom: 1rem; line-height: 1.5;
}
</style> </style>
</head> </head>
<body> <body>
@@ -58,6 +54,7 @@
<a href="{{ back_href }}" class="nav-link">← Chat</a> <a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a> <a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a> <a href="/settings" class="nav-link">Settings</a>
<a href="/settings/models" class="nav-link">Models</a>
<a href="/settings/notifications" class="nav-link active">Notifications</a> <a href="/settings/notifications" class="nav-link active">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a> <a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a> <a href="/settings/crons" class="nav-link">Schedules</a>
@@ -67,7 +64,7 @@
</nav> </nav>
<div class="page-wrap"> <div class="page-wrap">
<h1 class="page-title">Notifications</h1> <h1 class="page-title">Notifications</h1>
<p class="page-subtitle">How Inara reaches out proactively — reminders, cron jobs, and memory digests.</p> <p class="page-subtitle">How your persona reaches out proactively — reminders, cron jobs, and memory digests.</p>
<!-- SUCCESS --> <!-- SUCCESS -->
<!-- ERROR --> <!-- ERROR -->
@@ -90,8 +87,9 @@
<p class="hint">Used for reminder alerts, distillation summaries, and cron job notifications.</p> <p class="hint">Used for reminder alerts, distillation summaries, and cron job notifications.</p>
</div> </div>
<div class="field"> <div class="field">
<label for="notification_email">Email address override <label for="notification_email">
<span style="color:var(--pg-dim); font-weight:400;">(optional)</span> Email address override
<span class="font-normal text-pg-dim">(optional)</span>
</label> </label>
<input type="email" id="notification_email" name="notification_email" <input type="email" id="notification_email" name="notification_email"
value="{{ notify_email_override }}" value="{{ notify_email_override }}"
@@ -110,12 +108,15 @@
requires a Nextcloud username and app password. requires a Nextcloud username and app password.
</p> </p>
<details class="channel-block" {{ nc_url and 'open' or '' }}> <details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
<summary>Bot credentials (sending)</summary> {{ nc_url and 'open' or '' }}>
<div class="channel-block-body"> <summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
<p class="channel-hint"> Bot credentials (sending)
</summary>
<div class="px-4 pt-4 pb-2">
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
Set these up in your Nextcloud Talk room → Bot settings. Set these up in your Nextcloud Talk room → Bot settings.
See the <a href="/help" style="color:var(--pg-accent);">setup guide</a> for step-by-step instructions. See the <a href="/help" class="text-pg-accent">setup guide</a> for step-by-step instructions.
</p> </p>
<div class="field"> <div class="field">
<label for="nc_url">Nextcloud URL</label> <label for="nc_url">Nextcloud URL</label>
@@ -143,10 +144,13 @@
</div> </div>
</details> </details>
<details class="channel-block" {{ nc_username and 'open' or '' }}> <details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
<summary>API credentials (reading history)</summary> {{ nc_username and 'open' or '' }}>
<div class="channel-block-body"> <summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
<p class="channel-hint"> API credentials (reading history)
</summary>
<div class="px-4 pt-4 pb-2">
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
Required for the <code>nc_talk_history</code> orchestrator tool. Required for the <code>nc_talk_history</code> orchestrator tool.
Generate an app password in Nextcloud → Settings → Security → App passwords. Generate an app password in Nextcloud → Settings → Security → App passwords.
</p> </p>
@@ -172,15 +176,18 @@
<div class="section"> <div class="section">
<h2>Home Assistant</h2> <h2>Home Assistant</h2>
<p class="section-note"> <p class="section-note">
Receive events from HA automations and let Inara call the HA REST API Receive events from HA automations and let your persona call the HA REST API
(read states, control devices). Webhook ID is the shared secret used in your (read states, control devices). Webhook ID is the shared secret used in your
HA <code>rest_command</code> URL. HA <code>rest_command</code> URL.
</p> </p>
<details class="channel-block" {{ ha_url and 'open' or '' }}> <details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
<summary>Connection</summary> {{ ha_url and 'open' or '' }}>
<div class="channel-block-body"> <summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
<p class="channel-hint"> Connection
</summary>
<div class="px-4 pt-4 pb-2">
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
HA URL and a Long-Lived Access Token (Profile → scroll to bottom → HA URL and a Long-Lived Access Token (Profile → scroll to bottom →
Long-Lived Access Tokens → Create Token). Long-Lived Access Tokens → Create Token).
</p> </p>
@@ -201,10 +208,13 @@
</div> </div>
</details> </details>
<details class="channel-block" {{ ha_webhook_id and 'open' or '' }}> <details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
<summary>Inbound webhook (HA → Cortex)</summary> {{ ha_webhook_id and 'open' or '' }}>
<div class="channel-block-body"> <summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
<p class="channel-hint"> Inbound webhook (HA → Cortex)
</summary>
<div class="px-4 pt-4 pb-2">
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
The webhook ID is the shared secret in your HA <code>rest_command</code> URL. The webhook ID is the shared secret in your HA <code>rest_command</code> URL.
Your endpoint: <code>https://cortex.dgrzone.com/webhook/ha/{{ ha_username }}/&lt;webhook_id&gt;</code> Your endpoint: <code>https://cortex.dgrzone.com/webhook/ha/{{ ha_username }}/&lt;webhook_id&gt;</code>
</p> </p>
@@ -235,10 +245,13 @@
Incoming messages are handled separately via the Google Chat Add-on. Incoming messages are handled separately via the Google Chat Add-on.
</p> </p>
<details class="channel-block" {{ gc_webhook and 'open' or '' }}> <details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
<summary>Outbound webhook</summary> {{ gc_webhook and 'open' or '' }}>
<div class="channel-block-body"> <summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
<p class="channel-hint"> Outbound webhook
</summary>
<div class="px-4 pt-4 pb-2">
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
Create a webhook in your Google Chat space → Manage webhooks. Paste the full URL here. Create a webhook in your Google Chat space → Manage webhooks. Paste the full URL here.
</p> </p>
<div class="field"> <div class="field">
@@ -252,7 +265,7 @@
</details> </details>
</div> </div>
<button type="submit" class="btn-submit">Save notification settings</button> <button type="submit" class="btn-submit w-full md:w-96">Save notification settings</button>
</form> </form>
<!-- Test --> <!-- Test -->
@@ -262,11 +275,14 @@
Fire a notification via your configured channel or run the reminder check Fire a notification via your configured channel or run the reminder check
immediately — no need to wait for the daily 09:00 scheduler job. immediately — no need to wait for the daily 09:00 scheduler job.
</p> </p>
<div class="test-btn-row"> <div class="flex gap-3 mt-2">
<button class="test-btn" id="btn-test-notify">Send Test Notification</button> <button class="flex-1 px-3 py-2.5 text-sm font-medium border border-pg-border rounded-md bg-pg-bg text-pg-text hover:border-pg-action hover:text-pg-accent transition-colors cursor-pointer disabled:opacity-50"
<button class="test-btn" id="btn-check-reminders">Check Reminders Now</button> id="btn-test-notify">Send Test Notification</button>
<button class="flex-1 px-3 py-2.5 text-sm font-medium border border-pg-border rounded-md bg-pg-bg text-pg-text hover:border-pg-action hover:text-pg-accent transition-colors cursor-pointer disabled:opacity-50"
id="btn-check-reminders">Check Reminders Now</button>
</div> </div>
<div class="test-result" id="test-result"></div> <div id="test-result"
class="mt-3 px-3 py-2.5 rounded-md text-sm leading-relaxed"></div>
</div> </div>
</div> </div>
@@ -287,7 +303,9 @@
function showResult(ok, msg) { function showResult(ok, msg) {
resultEl.textContent = msg; resultEl.textContent = msg;
resultEl.className = 'test-result ' + (ok ? 'ok' : 'err'); resultEl.className = ok
? 'mt-3 px-3 py-2.5 rounded-md text-sm leading-relaxed bg-green-950 text-green-400 border border-green-800'
: 'mt-3 px-3 py-2.5 rounded-md text-sm leading-relaxed bg-red-950 text-red-400 border border-red-800';
resultEl.style.display = 'block'; resultEl.style.display = 'block';
} }

View File

@@ -91,6 +91,7 @@ input, select, textarea {
input:focus, select:focus, textarea:focus { border-color: var(--pg-action); } input:focus, select:focus, textarea:focus { border-color: var(--pg-action); }
input[readonly] { color: var(--pg-muted); cursor: default; } input[readonly] { color: var(--pg-muted); cursor: default; }
input[type="password"] { font-family: monospace; letter-spacing: 0.05em; } input[type="password"] { font-family: monospace; letter-spacing: 0.05em; }
input[type="checkbox"], input[type="radio"] { width: auto; padding: 0; }
textarea { textarea {
font-family: 'SF Mono', 'Fira Mono', 'Menlo', monospace; font-family: 'SF Mono', 'Fira Mono', 'Menlo', monospace;
@@ -99,12 +100,12 @@ textarea {
/* ── Buttons ── */ /* ── Buttons ── */
/* Full-width primary form submit */ /* Primary form submit */
.btn-submit { .btn-submit {
width: 100%; padding: 0.7rem; margin-top: 0.25rem; padding: 0.6rem 1.5rem; margin-top: 0.25rem;
background: var(--pg-action); border: none; border-radius: 6px; background: var(--pg-action); border: none; border-radius: 6px;
color: #fff; font-size: 1rem; font-weight: 600; color: #fff; font-size: 0.9rem; font-weight: 600;
cursor: pointer; transition: background 0.15s; cursor: pointer; transition: opacity 0.15s;
} }
.btn-submit:hover { opacity: 0.88; } .btn-submit:hover { opacity: 0.88; }

View File

@@ -7,10 +7,36 @@
<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 src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false },
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
pg: {
bg: 'var(--pg-bg)',
surface: 'var(--pg-surface)',
border: 'var(--pg-border)',
text: 'var(--pg-text)',
muted: 'var(--pg-muted)',
dim: 'var(--pg-dim)',
dimmer: 'var(--pg-dimmer)',
bright: 'var(--pg-bright)',
accent: 'var(--pg-accent)',
action: 'var(--pg-action)',
}
},
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
}
}
}
</script>
<link rel="stylesheet" href="/static/pg.css"> <link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</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>
/* ── Persona list ── */ /* ── Server-generated persona list ── */
.persona-list { .persona-list {
list-style: none; display: flex; flex-direction: column; list-style: none; display: flex; flex-direction: column;
gap: 0.5rem; margin-top: 0.5rem; gap: 0.5rem; margin-top: 0.5rem;
@@ -37,13 +63,8 @@
border-color: var(--pg-action); font-size: 0.9rem; border-color: var(--pg-action); font-size: 0.9rem;
} }
.persona-rename-form .btn-save { padding: 0.3rem 0.75rem; font-size: 0.85rem; } .persona-rename-form .btn-save { padding: 0.3rem 0.75rem; font-size: 0.85rem; }
.add-persona {
display: inline-block; margin-top: 0.75rem;
font-size: 0.8rem; color: var(--pg-muted); text-decoration: none;
}
.add-persona:hover { color: var(--pg-accent); }
/* ── Role badge ── */ /* ── Server-generated role badge ── */
.role-badge { .role-badge {
display: inline-block; padding: 0.25rem 0.75rem; display: inline-block; padding: 0.25rem 0.75rem;
border-radius: 20px; font-size: 0.78rem; font-weight: 600; border-radius: 20px; font-size: 0.78rem; font-weight: 600;
@@ -58,26 +79,8 @@
border: 1px solid var(--pg-border); border: 1px solid var(--pg-border);
} }
/* ── OpenRouter quickstart warning card ── */ /* ── JS-toggled states ── */
#openrouter-quickstart { #clear-ls-ok { display: none; margin-left: 0.75rem; font-size: 0.8rem; color: #4ade80; }
display: none; background: #1c1a0a; border: 1px solid #78350f;
border-radius: 8px; padding: 1rem; margin-bottom: 1rem;
}
#openrouter-quickstart .qs-title {
font-size: 0.82rem; color: #fbbf24; font-weight: 600; margin-bottom: 0.4rem;
}
#openrouter-quickstart .qs-body {
font-size: 0.8rem; color: #d97706; margin-bottom: 0.75rem; line-height: 1.5;
}
.action-link.action-link-amber {
background: #92400e; color: #fef3c7; font-size: 0.85rem; padding: 0.5rem 0.9rem;
}
.action-link.action-link-amber:hover { opacity: 0.9; background: #78350f; }
/* ── Inline result feedback spans ── */
.result-ok { display: none; margin-left: 0.75rem; font-size: 0.8rem; color: #4ade80; }
/* ── Usage table wrapper ── */
.usage-wrap { overflow-x: auto; } .usage-wrap { overflow-x: auto; }
</style> </style>
</head> </head>
@@ -86,6 +89,7 @@
<a href="{{ back_href }}" class="nav-link">← Chat</a> <a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a> <a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link active">Settings</a> <a href="/settings" class="nav-link active">Settings</a>
<a href="/settings/models" class="nav-link">Models</a>
<a href="/settings/notifications" class="nav-link">Notifications</a> <a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a> <a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a> <a href="/settings/crons" class="nav-link">Schedules</a>
@@ -100,6 +104,21 @@
<!-- SUCCESS --> <!-- SUCCESS -->
<!-- ERROR --> <!-- ERROR -->
<!-- OpenRouter quickstart (shown by JS when no model is configured) -->
<div id="openrouter-quickstart"
class="hidden rounded-xl border border-amber-800 bg-amber-950 p-4 mb-5">
<p class="text-xs font-semibold text-amber-400 mb-1">⚡ You're on the server default model</p>
<p class="text-xs text-amber-600 mb-3 leading-relaxed">
You can chat now, but adding your own model gives you more choices, lets you pick
role-specific models, and tracks your usage separately.
OpenRouter is the easiest way to get started — one key, many models.
</p>
<a href="/setup/model"
class="inline-block px-3 py-2 rounded-md bg-amber-900 text-amber-100 text-sm font-medium hover:bg-amber-800 transition-colors">
Set up OpenRouter →
</a>
</div>
<!-- Account info --> <!-- Account info -->
<div class="section"> <div class="section">
<h2>Account</h2> <h2>Account</h2>
@@ -156,7 +175,7 @@
placeholder=".*@example\.com&#10;alice@example\.com" placeholder=".*@example\.com&#10;alice@example\.com"
spellcheck="false">{{ email_allowlist }}</textarea> spellcheck="false">{{ email_allowlist }}</textarea>
</div> </div>
<button type="submit" class="btn-submit">Save allowlist</button> <button type="submit" class="btn-submit w-full md:w-96">Save allowlist</button>
</form> </form>
</div> </div>
@@ -174,28 +193,10 @@
placeholder="https://ha.dgrzone.com/api/webhook/&#10;https://n8n.dgrzone.com/webhook/" placeholder="https://ha.dgrzone.com/api/webhook/&#10;https://n8n.dgrzone.com/webhook/"
spellcheck="false">{{ http_allowlist }}</textarea> spellcheck="false">{{ http_allowlist }}</textarea>
</div> </div>
<button type="submit" class="btn-submit">Save allowlist</button> <button type="submit" class="btn-submit w-full md:w-96">Save allowlist</button>
</form> </form>
</div> </div>
<!-- Notifications -->
<div class="section">
<h2>Notifications</h2>
<p class="section-note">
Configure how Inara reaches out proactively — reminders, cron jobs, and memory digests.
</p>
<a href="/settings/notifications" class="action-link">Notification settings →</a>
</div>
<!-- Tool Permissions → /settings/tools -->
<div class="section">
<h2>Tool Permissions</h2>
<p class="section-note">
Configure tool access, risk policy, and confirmation gate overrides on the Tools page.
</p>
<a href="/settings/tools" class="action-link">Tool settings →</a>
</div>
<!-- Usage summary --> <!-- Usage summary -->
<div class="section" id="usage-section"> <div class="section" id="usage-section">
<h2>Usage</h2> <h2>Usage</h2>
@@ -216,28 +217,7 @@
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" class="btn-secondary">Clear browser cache</button> <button type="button" id="clear-ls-btn" class="btn-secondary">Clear browser cache</button>
<span id="clear-ls-ok" class="result-ok">Cleared.</span> <span id="clear-ls-ok">Cleared.</span>
</div>
<!-- Model Registry -->
<div class="section">
<h2>Model Registry</h2>
<div id="openrouter-quickstart">
<p class="qs-title">⚡ You're on the server default model</p>
<p class="qs-body">
You can chat now, but adding your own model gives you more choices, lets you pick
role-specific models, and tracks your usage separately.
OpenRouter is the easiest way to get started — one key, many models.
</p>
<a href="/setup/model" class="action-link action-link-amber">Set up OpenRouter →</a>
</div>
<p class="section-note">
Configure AI providers (Anthropic, Google), local hosts (Open WebUI, Ollama, OpenRouter, etc.),
and assign models to roles — chat, orchestrator, distill, and more.
</p>
<a href="/settings/models" class="action-link">Manage models →</a>
</div> </div>
<!-- Change Password --> <!-- Change Password -->
@@ -259,7 +239,7 @@
<input type="password" id="confirm_password" name="confirm_password" <input type="password" id="confirm_password" name="confirm_password"
autocomplete="new-password" required> autocomplete="new-password" required>
</div> </div>
<button type="submit" class="btn-submit">Update password</button> <button type="submit" class="btn-submit w-full md:w-96">Update password</button>
</form> </form>
</div> </div>
@@ -271,7 +251,9 @@
Only unnamed sessions are affected — existing names are left alone. Only unnamed sessions are affected — existing names are left alone.
</p> </p>
<button type="button" id="backfill-names-btn" class="btn-secondary">Auto-name old sessions</button> <button type="button" id="backfill-names-btn" class="btn-secondary">Auto-name old sessions</button>
<span id="backfill-names-ok" class="result-ok"></span> <span id="backfill-names-ok"
class="ml-3 text-xs hidden"
style="color:#4ade80"></span>
</div> </div>
<!-- Personas --> <!-- Personas -->
@@ -280,7 +262,10 @@
<ul class="persona-list"> <ul class="persona-list">
{{ persona_items }} {{ persona_items }}
</ul> </ul>
<a href="/setup/persona" class="add-persona">+ Add new persona</a> <a href="/setup/persona"
class="inline-block mt-3 text-xs text-pg-muted hover:text-pg-accent transition-colors">
+ Add new persona
</a>
</div> </div>
</div> </div>
@@ -317,7 +302,9 @@
try { try {
const d = await fetch('/backend').then(r => r.json()); const d = await fetch('/backend').then(r => r.json());
if ((d.available_roles || []).length === 0) { if ((d.available_roles || []).length === 0) {
document.getElementById('openrouter-quickstart').style.display = 'block'; const el = document.getElementById('openrouter-quickstart');
el.classList.remove('hidden');
el.style.display = 'block';
} }
} catch (_) {} } catch (_) {}
})(); })();
@@ -375,10 +362,12 @@
const n = data.named ?? 0; const n = data.named ?? 0;
ok.textContent = `Named ${n} session${n !== 1 ? 's' : ''}.`; ok.textContent = `Named ${n} session${n !== 1 ? 's' : ''}.`;
ok.style.display = 'inline'; ok.style.display = 'inline';
ok.classList.remove('hidden');
} catch (e) { } catch (e) {
ok.textContent = 'Error — check console.'; ok.textContent = 'Error — check console.';
ok.style.color = '#f87171'; ok.style.color = '#f87171';
ok.style.display = 'inline'; ok.style.display = 'inline';
ok.classList.remove('hidden');
} }
btn.textContent = 'Auto-name old sessions'; btn.textContent = 'Auto-name old sessions';
btn.disabled = false; btn.disabled = false;

View File

@@ -7,42 +7,36 @@
<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 src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false },
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
pg: {
bg: 'var(--pg-bg)',
surface: 'var(--pg-surface)',
border: 'var(--pg-border)',
text: 'var(--pg-text)',
muted: 'var(--pg-muted)',
dim: 'var(--pg-dim)',
dimmer: 'var(--pg-dimmer)',
bright: 'var(--pg-bright)',
accent: 'var(--pg-accent)',
action: 'var(--pg-action)',
}
},
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
}
}
}
</script>
<link rel="stylesheet" href="/static/pg.css"> <link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</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>
/* ── Policy cards (bordered sections on tools page) ── */ /* ── Server-generated tool table ── */
.policy-card {
background: var(--pg-surface); border: 1px solid var(--pg-border);
border-radius: 0.75rem; padding: 1.25rem 1.5rem; margin-bottom: 1.75rem;
}
.policy-card h2 { font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; }
.policy-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
.policy-label { font-size: 0.875rem; font-weight: 500; min-width: 6rem; }
.policy-note { font-size: 0.8rem; color: var(--pg-muted); margin-top: 0.35rem; line-height: 1.5; }
/* Compact selects and inputs inside policy cards */
.policy-card select, .policy-card input[type="text"] {
padding: 0.4rem 0.65rem; font-size: 0.875rem;
}
/* Two-column layout for allow/deny textareas */
.col-split { display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: flex-start; }
.col-half { flex: 1; min-width: 200px; }
.col-half label { font-size: 0.8rem; font-weight: 600; margin-bottom: 0.35rem; }
.col-half textarea {
font-size: 0.82rem; border-radius: 0.375rem; padding: 0.45rem 0.65rem;
}
/* Save button (compact, not full-width) */
.save-btn {
background: var(--pg-action); color: #fff; border: none;
border-radius: 0.5rem; padding: 0.5rem 1.4rem;
font-size: 0.875rem; font-weight: 600; cursor: pointer;
margin-top: 0.5rem; transition: opacity 0.15s;
}
.save-btn:hover { opacity: 0.88; }
/* ── Tool table ── */
.table-section-label { .table-section-label {
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--pg-dimmer); text-transform: uppercase; color: var(--pg-dimmer);
@@ -65,7 +59,7 @@
.tool-table tr:hover td { background: rgba(124,58,237,0.04); } .tool-table tr:hover td { background: rgba(124,58,237,0.04); }
.tool-name { font-family: monospace; font-size: 0.82rem; } .tool-name { font-family: monospace; font-size: 0.82rem; }
/* Risk badges */ /* Risk badges (server-generated) */
.risk { display: inline-block; font-size: 0.7rem; font-weight: 700; .risk { display: inline-block; font-size: 0.7rem; font-weight: 700;
padding: 0.15rem 0.45rem; border-radius: 9999px; letter-spacing: 0.04em; } padding: 0.15rem 0.45rem; border-radius: 9999px; letter-spacing: 0.04em; }
.risk-low { background: rgba(34,197,94,0.12); color: #4ade80; } .risk-low { background: rgba(34,197,94,0.12); color: #4ade80; }
@@ -75,7 +69,7 @@
[data-theme="light"] .risk-medium { background: rgba(234,179,8,0.15); color: #ca8a04; } [data-theme="light"] .risk-medium { background: rgba(234,179,8,0.15); color: #ca8a04; }
[data-theme="light"] .risk-high { background: rgba(239,68,68,0.15); color: #dc2626; } [data-theme="light"] .risk-high { background: rgba(239,68,68,0.15); color: #dc2626; }
/* Auto status pill */ /* Auto-status pill (server-generated, updated by JS) */
.auto-pill { .auto-pill {
display: inline-block; font-size: 0.68rem; font-weight: 600; display: inline-block; font-size: 0.68rem; font-weight: 600;
padding: 0.12rem 0.4rem; border-radius: 9999px; padding: 0.12rem 0.4rem; border-radius: 9999px;
@@ -84,19 +78,13 @@
.auto-off { background: rgba(148,163,184,0.12); color: var(--pg-dimmer); } .auto-off { background: rgba(148,163,184,0.12); color: var(--pg-dimmer); }
[data-theme="light"] .auto-on { color: #7c3aed; } [data-theme="light"] .auto-on { color: #7c3aed; }
/* Override select */ /* Override select (server-generated) */
.override-sel { .override-sel {
font-size: 0.78rem; padding: 0.25rem 0.5rem; font-size: 0.78rem; padding: 0.25rem 0.5rem;
border-radius: 0.3rem; min-width: 7rem; width: auto; border-radius: 0.3rem; min-width: 7rem; width: auto;
} }
.override-sel.forced-on { border-color: #7c3aed; color: #7c3aed; } .override-sel.forced-on { border-color: #7c3aed; color: #7c3aed; }
.override-sel.forced-off { border-color: #dc2626; color: #dc2626; } .override-sel.forced-off { border-color: #dc2626; color: #dc2626; }
/* Legend */
.legend { display: flex; gap: 1.25rem; flex-wrap: wrap; margin-bottom: 1.25rem; font-size: 0.8rem; color: var(--pg-muted); }
.legend-dot { display: inline-block; width: 0.55rem; height: 0.55rem; border-radius: 50%; margin-right: 0.3rem; }
.legend-dot.on { background: #a78bfa; }
.legend-dot.off { background: var(--pg-dimmer); }
</style> </style>
</head> </head>
<body> <body>
@@ -105,6 +93,7 @@
<a href="{{ back_href }}" class="nav-link">← Chat</a> <a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a> <a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a> <a href="/settings" class="nav-link">Settings</a>
<a href="/settings/models" class="nav-link">Models</a>
<a href="/settings/notifications" class="nav-link">Notifications</a> <a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link active">Tools</a> <a href="/settings/tools" class="nav-link active">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a> <a href="/settings/crons" class="nav-link">Schedules</a>
@@ -125,55 +114,55 @@
<form method="POST" action="/settings/tools" id="tools-form"> <form method="POST" action="/settings/tools" id="tools-form">
<!-- Risk policy --> <!-- Risk policy card -->
<div class="policy-card"> <div class="rounded-xl border border-pg-border bg-pg-surface p-5 mb-5">
<h2>Risk Policy</h2> <h2 class="text-sm font-semibold text-pg-bright mb-4">Risk Policy</h2>
<div class="policy-row"> <div class="flex items-center gap-4 flex-wrap mb-3">
<span class="policy-label">Max risk level</span> <span class="text-sm font-medium text-pg-text min-w-[6rem]">Max risk level</span>
<select name="max_risk" id="max-risk-sel"> <select name="max_risk" id="max-risk-sel" class="w-auto">
<option value="" {{ sel_none }}>No filter — use all role-permitted tools</option> <option value="" {{ sel_none }}>No filter — use all role-permitted tools</option>
<option value="low" {{ sel_low }}>Low — read-only and sandboxed tools only</option> <option value="low" {{ sel_low }}>Low — read-only and sandboxed tools only</option>
<option value="medium" {{ sel_medium }}>Medium — low + medium risk (recommended)</option> <option value="medium" {{ sel_medium }}>Medium — low + medium risk (recommended)</option>
<option value="high" {{ sel_high }}>High — all tools including destructive ones</option> <option value="high" {{ sel_high }}>High — all tools including destructive ones</option>
</select> </select>
</div> </div>
<p class="policy-note"> <p class="text-xs text-pg-muted leading-relaxed mb-2">
<strong>Low</strong> tools are read-only and sandboxed (web search, project file reads, HA status checks).<br> <strong class="text-pg-text">Low</strong> tools are read-only and sandboxed (web search, project file reads, HA status checks).<br>
<strong>Medium</strong> tools write to local data or send notifications to you (cron jobs, scratch, task management).<br> <strong class="text-pg-text">Medium</strong> tools write to local data or send notifications to you (cron jobs, scratch, task management).<br>
<strong>High</strong> tools affect external systems or the host (shell exec, email, device control, service restart). <strong class="text-pg-text">High</strong> tools affect external systems or the host (shell exec, email, device control, service restart).
</p> </p>
<p class="policy-note" style="margin-top:0.75rem;"> <p class="text-xs text-pg-muted leading-relaxed">
The <em>Auto</em> column below shows each tool's status at your current max risk level. The <em>Auto</em> column below shows each tool's status at your current max risk level.
Use the override column to force-include or force-exclude individual tools. Use the override column to force-include or force-exclude individual tools.
</p> </p>
</div> </div>
<!-- Legend --> <!-- Legend -->
<div class="legend"> <div class="flex gap-5 flex-wrap mb-4 text-xs text-pg-muted">
<span><span class="legend-dot on"></span>Auto-included by risk level</span> <span><span class="inline-block w-2 h-2 rounded-full bg-[#a78bfa] mr-1.5"></span>Auto-included by risk level</span>
<span><span class="legend-dot off"></span>Auto-excluded by risk level</span> <span><span class="inline-block w-2 h-2 rounded-full bg-pg-dimmer mr-1.5"></span>Auto-excluded by risk level</span>
</div> </div>
<!-- Tool table --> <!-- Tool table (server-generated) -->
{{ tool_table_html }} {{ tool_table_html }}
<!-- Confirmation gate --> <!-- Confirmation gate card -->
<div class="policy-card" style="margin-top:1.75rem;"> <div class="rounded-xl border border-pg-border bg-pg-surface p-5 mt-5 mb-5">
<h2>Confirmation Gate</h2> <h2 class="text-sm font-semibold text-pg-bright mb-2">Confirmation Gate</h2>
<p class="policy-note"> <p class="text-xs text-pg-muted leading-relaxed mb-4">
Some tools require explicit confirmation before executing. Override the defaults here.<br> Some tools require explicit confirmation before executing. Override the defaults here.<br>
Tools requiring confirmation by default: <code>{{ confirm_required_tools }}</code> Tools requiring confirmation by default: <code class="font-mono text-pg-accent bg-pg-bg border border-pg-border rounded px-1">{{ confirm_required_tools }}</code>
</p> </p>
<div class="col-split" style="margin-top:0.85rem;"> <div class="flex gap-6 flex-wrap items-start">
<div class="col-half"> <div class="flex-1 min-w-[200px]">
<label>Allow list — bypass confirmation</label> <label class="block text-xs font-semibold text-pg-muted mb-1">Allow list — bypass confirmation</label>
<textarea name="allow_list" rows="4" <textarea name="allow_list" rows="4"
placeholder="reminders_clear&#10;cron_remove" placeholder="reminders_clear&#10;cron_remove"
autocomplete="off" spellcheck="false">{{ tool_allow }}</textarea> autocomplete="off" spellcheck="false">{{ tool_allow }}</textarea>
<p class="hint">One tool name per line. These tools skip the confirmation prompt.</p> <p class="hint">One tool name per line. These tools skip the confirmation prompt.</p>
</div> </div>
<div class="col-half"> <div class="flex-1 min-w-[200px]">
<label>Deny list — always block</label> <label class="block text-xs font-semibold text-pg-muted mb-1">Deny list — always block</label>
<textarea name="deny_list" rows="4" <textarea name="deny_list" rows="4"
placeholder="shell_exec&#10;file_write" placeholder="shell_exec&#10;file_write"
autocomplete="off" spellcheck="false">{{ tool_deny }}</textarea> autocomplete="off" spellcheck="false">{{ tool_deny }}</textarea>
@@ -182,8 +171,8 @@
</div> </div>
</div> </div>
<div style="margin-top:1.5rem;"> <div class="mt-4">
<button type="submit" class="save-btn">Save tool settings</button> <button type="submit" class="btn-submit w-full md:w-96">Save tool settings</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,4 +1,4 @@
# Cortex / Inara — Master Index # Cortex — Master Index
> Start here. This document is a map, not a manual. > Start here. This document is a map, not a manual.
> Last updated: 2026-05-13 > Last updated: 2026-05-13
@@ -10,7 +10,7 @@
## What It Is ## What It Is
Cortex is a self-hosted personal AI platform. It routes messages from any input channel to AI backends, manages a resident agent (Inara) with persistent memory, and coordinates across a fleet of machines. It is infrastructure, not a product. Cortex is a self-hosted personal AI platform. It routes messages from any input channel to AI backends, manages per-user AI personas with persistent memory, and coordinates across a fleet of machines. It is infrastructure, not a product.
**Running at:** `https://cortex.dgrzone.com` | `systemctl --user restart cortex` **Running at:** `https://cortex.dgrzone.com` | `systemctl --user restart cortex`
@@ -43,7 +43,7 @@ Cortex is a self-hosted personal AI platform. It routes messages from any input
| Distill safety | ✅ Live | Per-persona asyncio lock, per-endpoint cooldowns, Rebuild option | | Distill safety | ✅ Live | Per-persona asyncio lock, per-endpoint cooldowns, Rebuild option |
| Guided onboarding | ✅ Live | Setup Step 3 for OpenRouter; existing-user banner; settings quick-link | | Guided onboarding | ✅ Live | Setup Step 3 for OpenRouter; existing-user banner; settings quick-link |
**65 orchestrator tools** across 17 domain modules — added 2026-05-12: `file_diff`, `git_status` / `git_log` / `git_diff` (read-only git inspection), `ae_db_query` / `ae_db_describe` / `ae_db_show_view` (SELECT-only Aether MariaDB access, admin, per-user credentials). `/settings/integrations` page added (admin-only). File attachments in chat (images for vision-capable local models; text/code files for all backends). Settings pages unified under `pg.css`. Added 2026-05-13: `task` cron type (full orchestrator loop on a schedule); monthly/yearly schedule formats (`monthly`, `monthly:DD:HH:MM`, `yearly:MM:DD:HH:MM`); Schedules web UI at `/settings/crons` (list, add, edit, pause, delete); HA inbound webhook tools toggle (orchestrator vs. direct LLM); Anthropic API key backend (`anthropic_api` model type via Anthropic SDK — alternative to CLI OAuth). **65 orchestrator tools** across 17 domain modules — added 2026-05-12: `file_diff`, `git_status` / `git_log` / `git_diff` (read-only git inspection), `ae_db_query` / `ae_db_describe` / `ae_db_show_view` (SELECT-only Aether MariaDB access, admin, per-user credentials). `/settings/integrations` page added (admin-only). File attachments in chat (images for vision-capable local models; text/code files for all backends). Settings pages unified under `pg.css`. Added 2026-05-13: `task` cron type (full orchestrator loop on a schedule); monthly/yearly schedule formats (`monthly`, `monthly:DD:HH:MM`, `yearly:MM:DD:HH:MM`); Schedules web UI at `/settings/crons` (list, add, edit, pause, delete); HA inbound webhook tools toggle (orchestrator vs. direct LLM); Anthropic API key backend (`anthropic_api` model type via Anthropic SDK — alternative to CLI OAuth); Cloud APIs catalog in Model Registry — named provider picker (OpenRouter, OpenAI, Groq, X.ai/Grok, Together.ai, Fireworks.ai, Custom) with auto-filled URLs; hosts split into Cloud APIs / Local Hosts sections. Added 2026-05-15: Per-user custom roles — three required roles (`chat`, `orchestrator`, `distill`) are always present; users can add/remove custom roles (e.g. `coder`, `research`) via the Model Registry UI; existing `.env`-defined roles auto-migrated. Settings pages (`local_llm.html` + all settings pages) migrated to Tailwind CSS CDN (no build step); `preflight: false` preserves `pg.css` base styles; `input[type=checkbox/radio]` global width fix in `pg.css`; `btn-submit` now responsive (`w-full md:w-96`).
**Active users / personas:** scott/inara, holly/tina, brian/wintermute **Active users / personas:** scott/inara, holly/tina, brian/wintermute

View File

@@ -1,4 +1,4 @@
# Cortex / Inara — Agent Task List # Cortex — Agent Task List
> Read this file before starting any work on this project. > Read this file before starting any work on this project.
> **Status:** Active development — ongoing. > **Status:** Active development — ongoing.