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>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
and are appended automatically by help.html when present.
|
||||
-->
|
||||
|
||||
*Last updated: 2026-04-28*
|
||||
*Last updated: 2026-04-29*
|
||||
|
||||
---
|
||||
|
||||
@@ -63,15 +63,20 @@ The ⚡ toggle is **independent of the Role selector** — you can use any role
|
||||
|
||||
| Category | Tools |
|
||||
|---|---|
|
||||
| Web | `web_search` |
|
||||
| Files | `file_read` |
|
||||
| Shell | `shell_exec`, `claude_allow_dir` |
|
||||
| Web | `web_search`, `http_fetch` |
|
||||
| Files | `file_read` ¹, `file_list` ¹, `file_write` ¹ ² |
|
||||
| Shell | `shell_exec` ¹ ², `claude_allow_dir` ¹ |
|
||||
| System | `cortex_restart` ¹ ², `cortex_logs` ¹ |
|
||||
| Tasks | `task_list`, `task_create`, `task_update`, `task_complete` |
|
||||
| Cron | `cron_list`, `cron_add`, `cron_remove`, `cron_toggle` |
|
||||
| Reminders | `reminders_add`, `reminders_list`, `reminders_clear` |
|
||||
| Cron | `cron_list`, `cron_add`, `cron_remove` ², `cron_toggle` |
|
||||
| Reminders | `reminders_add`, `reminders_list`, `reminders_clear` ² |
|
||||
| Scratchpad | `scratch_read`, `scratch_write`, `scratch_append`, `scratch_clear` |
|
||||
| Notifications | `nc_talk_send` ¹ |
|
||||
| Aether Journals | `ae_journal_list`, `ae_journal_search`, `ae_journal_entry_create`, `ae_journal_entry_update`, `ae_journal_entry_disable`, `ae_journal_entry_append`, `ae_journal_entry_prepend` |
|
||||
| Aether Tasks | `ae_task_list` |
|
||||
| Aether Tasks | `ae_task_list` ¹ |
|
||||
|
||||
¹ **Admin only** — requires the `admin` role. These tools are invisible to regular users.
|
||||
² **Confirmation required** — the orchestrator pauses and asks you to confirm before executing. Reply to confirm and it will proceed.
|
||||
|
||||
Tools mode is best for tasks requiring research, multi-step reasoning, or side effects (e.g. "search for X", "add a task", "what's on my list?", "append this to my journal"). Regular chat is faster for conversational turns.
|
||||
|
||||
@@ -101,6 +106,18 @@ Notes are injected into a session without triggering an LLM response.
|
||||
|
||||
---
|
||||
|
||||
## Install as App (PWA)
|
||||
|
||||
Cortex supports installation as a Progressive Web App — it runs in its own window with no browser chrome.
|
||||
|
||||
- **Chrome / Edge (desktop):** Look for the install icon in the address bar, or open the browser menu → **Install Cortex…**
|
||||
- **Android (Chrome):** Tap ⋮ → **Add to Home Screen**
|
||||
- **iOS (Safari):** Tap the Share button → **Add to Home Screen**
|
||||
|
||||
Once installed, opening Cortex from the home screen or app launcher skips the browser UI entirely.
|
||||
|
||||
---
|
||||
|
||||
## Backends
|
||||
|
||||
Three backends are available:
|
||||
|
||||
@@ -182,10 +182,18 @@
|
||||
.fetch-status.err { color: #f87171; }
|
||||
|
||||
.model-row {
|
||||
background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 8px;
|
||||
margin-bottom: 0.5rem; overflow: hidden;
|
||||
}
|
||||
.model-row-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 0.75rem; padding: 0.75rem 0.9rem;
|
||||
background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.model-btns { display: flex; gap: 0.4rem; align-items: flex-start; flex-shrink: 0; }
|
||||
.model-edit-form {
|
||||
padding: 0.85rem 0.9rem 0.9rem;
|
||||
border-top: 1px solid var(--pg-border);
|
||||
background: var(--pg-surface);
|
||||
}
|
||||
.model-info { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
|
||||
.model-label { font-size: 0.9rem; font-weight: 600; color: var(--pg-text); }
|
||||
@@ -670,6 +678,78 @@
|
||||
// Hide fetch button initially if no hosts
|
||||
if (!HAS_HOSTS) fetchBtn.style.display = 'none';
|
||||
|
||||
// ── Model inline edit toggle ──────────────────────────────────────────
|
||||
document.querySelectorAll('.model-edit-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = btn.dataset.id;
|
||||
const form = document.getElementById('edit-form-' + id);
|
||||
const open = form.style.display !== 'none';
|
||||
form.style.display = open ? 'none' : 'block';
|
||||
btn.textContent = open ? 'Edit' : 'Close';
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.model-edit-cancel').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = btn.dataset.id;
|
||||
document.getElementById('edit-form-' + id).style.display = 'none';
|
||||
document.querySelector(`.model-edit-btn[data-id="${id}"]`).textContent = 'Edit';
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit form: fetch models from host ────────────────────────────────
|
||||
document.querySelectorAll('.edit-fetch-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id;
|
||||
const hostSel = document.getElementById('edit-host-' + id);
|
||||
const hostId = hostSel ? hostSel.value : '';
|
||||
const statusEl = document.getElementById('edit-fetch-status-' + id);
|
||||
btn.disabled = true;
|
||||
statusEl.textContent = 'Fetching…'; statusEl.className = 'fetch-status';
|
||||
const url = '/api/local-llm/fetch-models' + (hostId ? '?host_id=' + encodeURIComponent(hostId) : '');
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
statusEl.textContent = '✗ ' + data.error; statusEl.className = 'fetch-status err';
|
||||
return;
|
||||
}
|
||||
const picker = document.getElementById('edit-model-picker-' + id);
|
||||
const wrap = document.getElementById('edit-model-select-wrap-' + id);
|
||||
picker.innerHTML = '<option value="">— select to fill —</option>';
|
||||
data.models.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name !== m.id ? `${m.name} (${m.id})` : m.id;
|
||||
opt.dataset.id = m.id;
|
||||
opt.dataset.name = m.name;
|
||||
picker.appendChild(opt);
|
||||
});
|
||||
wrap.style.display = 'block';
|
||||
statusEl.textContent = `✓ ${data.models.length} model${data.models.length !== 1 ? 's' : ''}`;
|
||||
statusEl.className = 'fetch-status ok';
|
||||
} catch (e) {
|
||||
statusEl.textContent = '✗ ' + e.message; statusEl.className = 'fetch-status err';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[id^="edit-model-picker-"]').forEach(picker => {
|
||||
picker.addEventListener('change', () => {
|
||||
const opt = picker.options[picker.selectedIndex];
|
||||
if (!opt.value) return;
|
||||
const id = picker.id.replace('edit-model-picker-', '');
|
||||
const form = document.getElementById('edit-form-' + id);
|
||||
form.querySelector('input[name="model_name"]').value = opt.dataset.id || opt.value;
|
||||
const labelInput = form.querySelector('input[name="label"]');
|
||||
if (!labelInput.value) {
|
||||
const n = opt.dataset.name;
|
||||
labelInput.value = (n && n !== opt.dataset.id) ? n : '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Claude CLI auth status ─────────────────────────────────────────────
|
||||
(async function() {
|
||||
const el = document.getElementById('claude-auth-status');
|
||||
|
||||
@@ -220,6 +220,26 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
.add-persona:hover { color: #a78bfa; }
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.role-badge.role-admin {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
color: #a78bfa;
|
||||
border: 1px solid rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
.role-badge.role-user {
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
color: var(--pg-muted);
|
||||
border: 1px solid var(--pg-border);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -247,6 +267,10 @@
|
||||
<label>Username</label>
|
||||
<input type="text" value="{{ username }}" readonly>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Role</label>
|
||||
<span class="role-badge role-{{ user_role }}">{{ user_role }}</span>
|
||||
</div>
|
||||
<button type="button" id="show-rename-user" class="persona-rename-toggle"
|
||||
style="opacity:0.7; font-size:0.8rem; padding:0.3rem 0.6rem; border:1px solid var(--pg-border); border-radius:6px; margin-top:0.25rem;">
|
||||
✏ Change username
|
||||
|
||||
@@ -32,15 +32,12 @@ automatically. Remaining work is quality/reliability parity, not ground-up desig
|
||||
|
||||
### [Tools] Orchestrator tool expansions
|
||||
New tools for `cortex/tools/` — higher-value additions that fill obvious gaps.
|
||||
- [ ] **`cortex_restart`** — `systemctl --user restart cortex`; lets Inara apply her own
|
||||
config changes without human intervention. Return last 10 log lines after restart.
|
||||
- [ ] **`cortex_logs`** — `journalctl --user -u cortex -n N` — tail service logs for debugging
|
||||
- [ ] **`http_fetch`** — fetch a URL and return content; for health checks, API probing,
|
||||
webhook testing. Different from `web_search` — direct URL, returns raw response.
|
||||
- [ ] **`file_list`** — list files and directories at a path; currently only `file_read` exists
|
||||
- [ ] **`file_write`** — write content to a file (with path allow-list for safety)
|
||||
- [ ] **`nc_talk_send`** — proactively send a message to the user via Nextcloud Talk
|
||||
(outbound; complements the proactive notifications channel work)
|
||||
- [x] **`cortex_restart`** — detached subprocess, 5s delay, admin-only, confirm-required — 2026-04-29
|
||||
- [x] **`cortex_logs`** — `journalctl --user -u cortex -n N`, admin-only — 2026-04-29
|
||||
- [x] **`http_fetch`** — direct URL fetch via httpx, 8192 char cap — 2026-04-29
|
||||
- [x] **`file_list`** — directory listing with size, dirs first, 200 entry cap, admin-only — 2026-04-29
|
||||
- [x] **`file_write`** — overwrite/append to home_root paths, admin-only, confirm-required — 2026-04-29
|
||||
- [x] **`nc_talk_send`** — outbound NC Talk message via notification.py, admin-only — 2026-04-29
|
||||
- [ ] **`email_send`** — send email via existing `email_utils.py` SMTP helper
|
||||
- [ ] **`web_push`** — send a browser push notification (requires push subscription stored
|
||||
per-user; pairs well with the PWA service worker already in place)
|
||||
@@ -109,17 +106,6 @@ See `ARCH__Intelligence_Layer.md` for full design.
|
||||
- Manually review `inara/MEMORY_LONG.md` — confirm quality before fully trusting
|
||||
- Adjust distill prompts in `cortex/memory_distiller.py` if needed
|
||||
|
||||
### [Distill] Distill quality review
|
||||
- Short/mid/long distill prompts live in `cortex/memory_distiller.py`
|
||||
- After first few automatic runs, review quality and tune
|
||||
|
||||
### [Local] Unsloth Gemma 4 variants
|
||||
- Unsloth Dynamic 2.0 Q4_K_M GGUFs fail with `500: unable to load model` on Ollama v0.20.0
|
||||
- Root cause: Ollama's bundled llama.cpp doesn't recognize Gemma 4 GGUF architecture metadata from raw files
|
||||
- Waiting on Ollama point release (v0.20.1+) — then switch Open WebUI to Unsloth variants
|
||||
- Expected speedup: ~10–20% smaller context footprint vs baseline, same quality
|
||||
- `agent-support-gemma-small` → Unsloth E4B Q4_K_M; `agent-support-gemma-medium` → Unsloth 26B A4B Q4_K_M
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Lower Priority / Future
|
||||
@@ -216,6 +202,28 @@ other based on resources and specialisation. No central coordinator required.
|
||||
|
||||
## ✅ Completed
|
||||
|
||||
### [Tools] Role-based access control + confirmation gate — 2026-04-29
|
||||
- `TOOL_ROLES` dict maps tool names to minimum required role (`admin`/`user`)
|
||||
- `CONFIRM_REQUIRED` set blocks destructive tools; orchestrator injects confirmation prompt instead
|
||||
- `get_tools_for_role(role)` filters both Gemini declarations and callables
|
||||
- `get_user_role(username)` added to `auth_utils.py`; passed through both orchestrators
|
||||
- `manage_passwords.py role <username> admin|user` — shell-only admin promotion
|
||||
- Admin-only tools: `shell_exec`, `claude_allow_dir`, `cortex_restart`, `cortex_logs`,
|
||||
`file_read`, `file_list`, `file_write`, `ae_task_list`, `nc_talk_send`
|
||||
- Confirm-required tools: `cortex_restart`, `file_write`, `shell_exec`, `cron_remove`, `reminders_clear`
|
||||
|
||||
### [UI] Admin role badge in Account settings — 2026-04-29
|
||||
- `GET /settings` now injects `user_role` from `auth.json` into settings page
|
||||
- Role shown as a styled pill badge (purple ADMIN, muted USER) below username field
|
||||
|
||||
### [Local] Unsloth Gemma 4 variants — resolved 2026-04-29
|
||||
- Ollama update resolved the `500: unable to load model` issue
|
||||
- Unsloth Dynamic 2.0 Q4_K_M GGUFs loading correctly
|
||||
|
||||
### [Distill] Distill quality review — resolved 2026-04-29
|
||||
- Short/mid/long output reviewed and quality confirmed acceptable
|
||||
- No prompt tuning needed at this time
|
||||
|
||||
### [UI] Progressive Web App (PWA) — 2026-04-29
|
||||
- `manifest.json`, `sw.js`, PNG icons (192/512) generated via rsvg-convert
|
||||
- `/manifest.json` and `/sw.js` served at root via ui.py; exempted in auth_middleware
|
||||
|
||||
Reference in New Issue
Block a user