feat: edit existing model entries in the Model Registry

- Inline edit form per model row (label, model name/ID, host/account, context, tags)
- Fetch models button in edit form for local models — same live-picker UX as Add Model
- POST /settings/local/models/{id}/edit route in local_llm.py
- Admin role badge (ADMIN/USER pill) in Account Settings page
- HELP.md updated: new tools table with admin/confirm markers, PWA install section
- TODO updated: tool expansions marked done, distill review and Unsloth resolved,
  role-based access and admin badge added to completed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-29 21:08:09 -04:00
parent 334e7f0dea
commit a5658eb3c4
6 changed files with 289 additions and 41 deletions

View File

@@ -9,6 +9,7 @@ Routes:
POST /settings/local/google-account → save/create a Google account
POST /settings/local/google-account/{id}/remove → remove a Google account
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 /api/models/role → AJAX: set a role assignment
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON)
@@ -157,21 +158,100 @@ def _render(username: str, success: str = "", error: str = "") -> str:
else:
secondary = default_secondary
ctx = f'<span class="ctx-badge">{m.get("context_k",0)}k</span>' if m.get("context_k") else ""
tags = " ".join(f'<span class="tag">{t}</span>' for t in (m.get("tags") or []))
sec = f'<span class="model-host">{secondary}</span>' if secondary else ""
ctx = f'<span class="ctx-badge">{m.get("context_k",0)}k</span>' if m.get("context_k") else ""
tags_html = " ".join(f'<span class="tag">{t}</span>' for t in (m.get("tags") or []))
sec = f'<span class="model-host">{secondary}</span>' if secondary else ""
# ── Inline edit form fields (type-specific) ───────────────────────────
if mtype == "local_openai":
host_opts = "".join(
f'<option value="{h["id"]}"'
f'{" selected" if h["id"] == m.get("host_id") else ""}>'
f'{h.get("label") or h.get("api_url","")}</option>'
for h in hosts
)
mid = m["id"]
extra_fields = (
f'<div class="field"><label>Host</label>'
f'<select name="host_id" id="edit-host-{mid}">{host_opts}</select></div>'
f'<div class="btn-row" style="margin-bottom:0.75rem">'
f'<button type="button" class="btn btn-secondary btn-sm edit-fetch-btn" data-id="{mid}">Fetch models</button>'
f'<span class="fetch-status" id="edit-fetch-status-{mid}"></span>'
f'</div>'
f'<div id="edit-model-select-wrap-{mid}" style="display:none; margin-bottom:0.75rem">'
f'<label>Pick from host</label>'
f'<select id="edit-model-picker-{mid}"><option value="">— fetch first —</option></select>'
f'</div>'
)
elif mtype == "gemini_api":
acct_opts = "".join(
f'<option value="{a["id"]}"'
f'{" selected" if a["id"] == m.get("account_id") else ""}>'
f'{a.get("label","Unnamed")}</option>'
for a in goog_accts
)
extra_fields = (
f'<div class="field"><label>Google Account</label>'
f'<select name="account_id">{acct_opts}</select></div>'
)
else:
extra_fields = '<input type="hidden" name="credential_id" value="cli">'
cur_label = m.get("label", "")
cur_model_name = m.get("model_name", "")
cur_ctx = m.get("context_k", 0) or 0
cur_tags = ", ".join(m.get("tags") or [])
model_rows += f'''
<div class="model-row">
<div class="model-info">
<div>{badge}<span class="model-label">{m.get("label") or m.get("model_name","")}</span>{ctx}</div>
<span class="model-name">{m.get("model_name","")}</span>
{sec}
<div class="tag-row">{tags}</div>
<div class="model-row" id="model-{m["id"]}">
<div class="model-row-header">
<div class="model-info">
<div>{badge}<span class="model-label">{m.get("label") or m.get("model_name","")}</span>{ctx}</div>
<span class="model-name">{m.get("model_name","")}</span>
{sec}
<div class="tag-row">{tags_html}</div>
</div>
<div class="model-btns">
<button type="button" class="row-btn model-edit-btn" data-id="{m["id"]}">Edit</button>
<form method="POST" action="/settings/local/models/{m["id"]}/remove"
onsubmit="return confirm('Remove this model?')" style="margin:0">
<button type="submit" class="row-btn danger">Remove</button>
</form>
</div>
</div>
<form method="POST" action="/settings/local/models/{m["id"]}/remove"
onsubmit="return confirm('Remove this model?')" style="display:inline">
<button type="submit" class="row-btn danger">Remove</button>
<form class="model-edit-form" id="edit-form-{m["id"]}" style="display:none"
method="POST" action="/settings/local/models/{m["id"]}/edit">
<input type="hidden" name="mtype" value="{mtype}">
<div class="field-row">
<div class="field">
<label>Display label</label>
<input type="text" name="label" value="{cur_label}"
placeholder="My Model" autocomplete="off" data-form-type="other">
</div>
<div class="field">
<label>Model name / ID</label>
<input type="text" name="model_name" value="{cur_model_name}"
placeholder="provider/model-name" autocomplete="off"
spellcheck="false" data-form-type="other" required>
</div>
</div>
{extra_fields}
<div class="field-row">
<div class="field" style="flex:0 0 auto">
<label>Context (k)</label>
<input type="number" name="context_k" value="{cur_ctx}" min="0">
</div>
<div class="field">
<label>Tags</label>
<input type="text" name="tags" value="{cur_tags}"
placeholder="fast, code, vision" autocomplete="off" data-form-type="other">
</div>
</div>
<div class="btn-row" style="margin-top:0.5rem">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<button type="button" class="model-edit-cancel btn btn-secondary btn-sm"
data-id="{m["id"]}">Cancel</button>
</div>
</form>
</div>'''
if not model_rows:
@@ -356,6 +436,42 @@ async def add_model(
return HTMLResponse(_render(username, success=f'Model "{display}" added.'))
@router.post("/settings/local/models/{model_id}/edit", include_in_schema=False)
async def edit_model(
request: Request,
model_id: str,
mtype: str = Form(""),
label: str = Form(""),
model_name: str = Form(""),
context_k: int = Form(0),
tags: str = Form(""),
host_id: str = Form(""),
account_id: str = Form(""),
credential_id: str = Form("cli"),
):
username = _get_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
if not model_name.strip():
return HTMLResponse(_render(username, error="Model name is required."))
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
if mtype == "local_openai":
if not host_id.strip():
return HTMLResponse(_render(username, error="Select a host for this model."))
reg.save_model(username, model_id, host_id, label, model_name, context_k, tag_list)
elif mtype == "gemini_api":
reg.save_cloud_model(username, model_id, "google", model_name, label,
account_id=account_id or None, context_k=context_k, tags=tag_list)
elif mtype == "claude_cli":
reg.save_cloud_model(username, model_id, "anthropic", model_name, label,
credential_id=credential_id or "cli", context_k=context_k, tags=tag_list)
else:
return HTMLResponse(_render(username, error=f"Unknown model type: {mtype}"))
display = label.strip() or model_name.strip()
logger.info("model edited: %s / %s (%s)", username, display, mtype)
return HTMLResponse(_render(username, success=f'Model "{display}" updated.'))
@router.post("/settings/local/models/{model_id}/remove", include_in_schema=False)
async def remove_model(request: Request, model_id: str):
username = _get_user(request)

View File

@@ -60,6 +60,9 @@ def _settings_page(username: str, personas: list[str], back_persona: str = "", s
google_email = auth_data.get("google_email") or ""
html = html.replace("{{ google_email }}", google_email)
role = auth_data.get("role", "user")
html = html.replace("{{ user_role }}", role)
persona_items = "\n".join(
f'''<li>
<a href="/{username}/{p}" class="persona-link">{p}</a>