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:
@@ -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 & time into system prompt'
|
||||
f'<span>Inject current date & 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.
|
||||
|
||||
Reference in New Issue
Block a user