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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user