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

@@ -13,6 +13,8 @@ Routes:
POST /settings/local/models/add → add a model (any provider)
POST /settings/local/models/{id}/edit → edit an existing model entry
POST /settings/local/models/{id}/remove → remove a model
POST /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
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON)
"""
@@ -56,6 +58,70 @@ router = APIRouter()
_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 ───────────────────────────────────────────────────────────────
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:
google_account_rows = '<p class="empty-note">No accounts configured yet.</p>'
# ── 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'''
<div class="host-row">
<form method="POST" action="/settings/local/host" class="host-form">
<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 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 = '<p class="empty-note">No cloud API services configured yet. Add one below.</p>'
if not local_host_rows:
local_host_rows = '<p class="empty-note">No local hosts configured yet. Add one below.</p>'
host_options = "".join(
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 += '</optgroup>\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 = (
'<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 += (
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">'
)
for slot in reg.PRIORITY_KEYS[:3]:
for slot in reg.PRIORITY_KEYS[:2]:
slot_label = slot.replace("_", " ").title()
sel = (
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>'
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 class="role-config-panel" id="rcp-{role}">'
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'placeholder="Extra instructions injected into the system prompt when this role is active…"></textarea>'
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'<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 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' 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'<span class="rcp-hint" style="display:block;margin-top:0.2rem">'
f'Disable both for pure processing roles (summarizer, classifier, translator)</span>'
f'</div>'
f'<p class="rcp-hint" style="margin-top:0.4rem">Disable both for pure processing roles (summarizer, classifier, translator).</p>'
f'</div>'
f'<div class="rcp-field">'
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-secondary btn-sm rcp-cancel" data-role="{role}">Cancel</button>'
f'</div>'
f'{rcp_danger}'
f'</div>'
)
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.