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},
]
# 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 ────────────────────────────────────────────────
@@ -148,6 +160,8 @@ _ROLE_LAST_RESORT: dict[str, str] = {
PRIORITY_KEYS = ["primary", "backup_1", "backup_2", "backup_3", "backup_4"]
REQUIRED_ROLES: list[str] = ["chat", "orchestrator", "distill"]
# ── Storage ───────────────────────────────────────────────────────────────────
@@ -565,6 +579,8 @@ def get_catalog(provider: str, username: str | None = None) -> list[dict]:
return list(ANTHROPIC_CATALOG)
if provider == "google":
return list(GOOGLE_CATALOG)
if provider == "cloud":
return list(CLOUD_API_CATALOG)
return []
@@ -851,6 +867,52 @@ def remove_model(username: str, model_id: str) -> bool:
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:
"""
Assign a model to a role priority slot.