Changeset 0.10.5 (#61)

This commit is contained in:
2026-02-25 00:18:03 +00:00
parent e5ee78c498
commit d2ec55b16d
16 changed files with 1643 additions and 801 deletions

View File

@@ -2,6 +2,53 @@
All notable changes to Chat Switchboard.
## [0.10.5] — 2026-02-24
### Added
- **`ui-primitives.js` — shared rendering primitives and registries.**
Single source of truth for provider types, role definitions, and reusable
UI components. Extension-ready via registry pattern (`Providers.add()`,
`Roles.add()`). Primitives follow the `renderPresetForm()` pattern:
`(container, options) → control object` with `getValues/setValues/clear`.
- `Providers` registry — types, labels, default endpoints (was 5 places → 1)
- `Roles` registry — names, type filters, hints (was hardcoded in 2 places → 1)
- `renderCapBadges(caps, opts)` — consolidates 3 badge builders (compact + detailed)
- `renderProviderForm(container, opts)` — replaces 3 form implementations
- `renderProviderList(container, opts)` — replaces 3 list renderers
- `renderRoleConfig(container, opts)` — replaces 2 role UIs + 4 handlers
- `renderUsageDashboard(container, opts)` — replaces 3 usage renderers
### Fixed
- **Admin provider form now auto-fills endpoint on type change.** Was missing
from admin (worked in user BYOK and team forms). Now all three scopes use
the same primitive with identical behavior.
- **Team provider form consolidated.** Was two separate HTML forms (create +
edit) with separate listeners. Now a single dual-mode form matching the
pattern used by admin and user BYOK scopes.
### Changed
- Provider type definitions removed from `index.html` (2 static `<select>`s)
and `settings-handlers.js` (1 dynamic build + 2 endpoint maps). All now
sourced from `Providers` registry in `ui-primitives.js`.
- Provider list rendering uses event delegation instead of inline `onclick`
handlers. Each list returns `{ refresh, getCache }` control handles.
- Role configuration uses `data-role-*` attributes for event delegation
instead of `id`-based selectors and global `onchange` handlers.
- Usage dashboards accept options for compact/full mode, custom API calls,
and extension-provided extra columns.
- All 16 `confirm()` calls replaced with `showConfirm()` — styled modal
dialog matching the app design instead of browser-native dialog. Supports
`danger` styling, Escape/Enter keys, click-outside dismiss.
- Sidebar collapse icon now always visible (dimmed) next to the logo,
brightens on hover. Previously the icon replaced the logo on hover only.
When sidebar is collapsed, only the collapse icon shows (logo hidden).
- New `.popup-menu` + `.popup-menu-item` shared CSS base for all dropdown
and flyout menus. `createPopupMenu(anchor, opts)` primitive available for
future menu creation with consistent behavior.
- Removed ~360 lines of duplicated code across `admin-handlers.js`,
`settings-handlers.js`, `ui-admin.js`, and `ui-settings.js`.
- Removed ~40 lines of static HTML form markup from `index.html`.
## [0.10.4] — 2026-02-24
### Added

View File

@@ -29,6 +29,8 @@ v0.10.3 Frontend Refactor (no features, code health)
v0.10.4 Model Type Pipeline + Role Fixes
v0.10.5 UI Primitives + Extension Surfaces
┌───────┴──────────────┐
│ │
v0.11.0 Extension v0.12.0 File Handling
@@ -501,6 +503,28 @@ filter models by type instead of showing everything.
---
## v0.10.5 — UI Primitives + Extension Surfaces ✅
Consolidate duplicated UI patterns into shared primitives with extension-ready
interfaces. Pre-extension infrastructure: the primitives we build now become
the `ctx.*` extension API surface in v0.11.0 with no rewrite needed.
- [x] `Providers` registry — single source of truth for types, labels, endpoints
- [x] `Roles` registry — names, type filters, hints
- [x] `renderCapBadges()` — consolidates 3 badge builders (compact + detailed modes)
- [x] `renderProviderForm()` — replaces 3 form implementations, dual-mode create/edit
- [x] `renderProviderList()` — replaces 3 list renderers, event delegation
- [x] `renderRoleConfig()` — replaces 2 role UIs + 4 handlers
- [x] `renderUsageDashboard()` — replaces 3 usage renderers (compact + full)
- [x] Admin provider form gets endpoint auto-fill (was missing)
- [x] Team provider form consolidated from 2 separate forms to 1 dual-mode
- [x] Removed ~360 lines of duplicated code + ~40 lines of static HTML
- [x] `showConfirm()` — styled confirm dialog replacing all 16 native `confirm()` calls
- [x] `.popup-menu` shared CSS + `createPopupMenu()` primitive for consistent menus
- [x] Sidebar collapse icon always visible (dimmed), brightens on hover
---
## v0.11.0 — Extension Foundation (Browser Tier)
The platform play. See [EXTENSIONS.md](EXTENSIONS.md) for full spec.

358
UI-AUDIT-0.10.5.md Normal file
View File

@@ -0,0 +1,358 @@
# UI Duplication Audit — v0.10.5 ✅ IMPLEMENTED
Systematic catalog of every repeated pattern in the frontend.
Each section: what's duplicated, where, and what's inconsistent.
---
## 1. Provider Form (CREATE + EDIT) — 3 copies
The same "configure a provider" concept exists in **3 places** with
different field IDs, different features, and different behaviors.
### Where
| Context | Create handler | Edit handler | HTML form IDs |
|---------|---------------|-------------|---------------|
| **User BYOK** | `handleCreateProvider()` settings-handlers.js:88 | same fn (dual-mode via `UI._editingProviderId`) | `providerName`, `providerType`, `providerEndpoint`, `providerApiKey`, `providerDefaultModel` |
| **Admin Global** | `createGlobalProvider()` admin-handlers.js:122 | same fn (dual-mode via `_editingProviderId`) | `adminProvName`, `adminProvType`, `adminProvEndpoint`, `adminProvKey`, `adminProvModel`, `adminProvPrivate` |
| **Team** | inline anon listener settings-handlers.js:571 | `settingsTeamEditProviderSubmit` listener :597 | Create: `teamProviderName`, `teamProviderType`, `teamProviderEndpoint`, `teamProviderKey`, `teamProviderPrivate` / Edit: `teamProviderEditName`, `teamProviderEditEndpoint`, `teamProviderEditKey`, `teamProviderEditDefault`, `teamProviderEditPrivate` |
### Inconsistencies
| Feature | User BYOK | Admin | Team Create | Team Edit |
|---------|-----------|-------|-------------|-----------|
| **Endpoint auto-fill on type change** | ✅ | ❌ MISSING | ✅ | ❌ N/A (no type field) |
| **"Private" checkbox** | ❌ | ✅ | ✅ | ✅ |
| **"Default Model" field** | ✅ | ✅ | ❌ MISSING | ✅ |
| **Provider type dropdown** | Static HTML | Static HTML | Dynamic JS (same labels) | N/A (no type change) |
| **Provider types defined in** | index.html:408 | index.html:626 | settings-handlers.js:553 | — |
| **Endpoint defaults defined in** | settings-handlers.js:480 | NOWHERE | settings-handlers.js:542 | — |
| **API key placeholder** | "sk-..." / "(unchanged)" | "sk-..." / "(unchanged)" | "sk-..." | "(unchanged)" |
| **Post-save refresh** | `loadProviderList()` + `fetchModels()` | `loadAdminProviders()` (no fetchModels) | `loadTeamManageProviders()` + `fetchModels()` | `loadTeamManageProviders()` (no fetchModels) |
| **Create/Edit dual-mode** | Single fn, flag | Single fn, flag | Separate forms + separate listeners | Separate forms + separate listeners |
| **Form show/hide** | `UI.showProviderForm()`/`hideProviderForm()` | inline `.style.display` | inline `.style.display` | inline `.style.display` |
### Constants duplicated
```
Provider types: 3× — index.html (2 static selects) + settings-handlers.js (1 dynamic build)
Provider labels: 3× — { openai: 'OpenAI-compatible', anthropic: 'Anthropic', venice: 'Venice.ai', openrouter: 'OpenRouter' }
Endpoint map: 2× — settings-handlers.js:480 and :542 (admin has ZERO)
```
---
## 2. Provider List (READ) — 3 copies
| Context | Function | File:Line | API call |
|---------|----------|-----------|----------|
| **User BYOK** | `UI.loadProviderList()` | ui-settings.js:534 | `API.listConfigs()` |
| **Admin Global** | `UI.loadAdminProviders()` | ui-admin.js:343 | `API.adminListGlobalConfigs()` |
| **Team** | `UI.loadTeamManageProviders(teamId)` | ui-settings.js:191 | `API.teamListProviders(teamId)` |
### Inconsistencies
| Feature | User BYOK | Admin | Team |
|---------|-----------|-------|------|
| Shows endpoint | ❌ | ✅ | ❌ |
| Shows provider type | ✅ (in meta) | ✅ (in meta) | ✅ (in meta) |
| Shows has_key indicator | ✅ 🔑/⚠️ | ❌ | ✅ 🔑 |
| Shows active/inactive toggle | ❌ | ❌ | ✅ |
| Shows private badge | ❌ | ✅ | ✅ |
| Edit button | ✅ | ✅ | ✅ |
| Delete button | ✅ | ✅ | ✅ |
| Refresh models button | ✅ | ❌ (separate bulk) | ❌ |
| Row CSS class | `provider-row` | `admin-provider-row` | `admin-preset-row` (!) |
| Empty state msg | different | different | different |
| Provider cache | ❌ | ✅ (`UI._providerCache`) | ❌ |
---
## 3. Role Configuration — 2 copies
| Context | Render function | Provider change handler | Save handler |
|---------|----------------|------------------------|-------------|
| **Admin Global** | `UI.loadAdminRoles()` ui-admin.js:134 | `adminRoleProviderChanged()` admin-handlers.js:199 | `adminSaveRole()` admin-handlers.js:216 |
| **User Personal** | `UI.loadUserRoles()` ui-settings.js:356 | `userRoleProviderChanged()` settings-handlers.js:214 | `saveUserRole()` settings-handlers.js:231 |
### Inconsistencies
| Feature | Admin | User |
|---------|-------|------|
| Fallback slot | ✅ primary + fallback | ❌ primary only |
| Test button | ✅ | ❌ |
| Clear/remove button | ❌ | ✅ |
| Models source | `window._adminModelList` (from admin API) | `App.models` (from enabled API) |
| Model ID field | `m.model_id` | `m.baseModelId` |
| Reload on save | ✅ (added in 0.10.4) | ✅ (always did) |
| BYOK gate check | ❌ (admin sees always) | ✅ (hidden if no BYOK) |
---
## 4. Capability Badges — 3 copies
The same badge rendering logic exists in 3 places with different levels of detail:
| Context | File:Line | Badges shown |
|---------|-----------|-------------|
| **Model selector** (chat) | ui-core.js:759-774 | max_output, max_context, tools, vision, thinking, reasoning, code, search — with titles and labels |
| **Admin model list** | ui-admin.js:375-378 | max_output, tools, vision, thinking — compact, no titles |
| **User model list** | ui-settings.js:596-599 | max_output, tools, vision, thinking — compact, no titles |
Admin and User are identical copies. Model selector has more detail.
---
## 5. Usage Dashboard — 3 copies
| Context | Function | File:Line | API call |
|---------|----------|-----------|----------|
| **Admin** | `UI.loadAdminUsage()` | ui-admin.js:190 | `API.adminGetUsage()` |
| **User (My Usage)** | `UI.loadMyUsage()` | ui-settings.js:246 | `API.getMyUsage()` |
| **Team** | `UI.loadTeamUsage()` | ui-settings.js:297 | `API.teamGetUsage()` |
### Inconsistencies
| Feature | Admin | User | Team |
|---------|-------|------|------|
| Pricing table | ✅ | ❌ | ❌ |
| "group by user" column header | ✅ | ❌ | ✅ |
| Stats card styling | default padding | `padding:8px; font-size:16px` | `padding:8px; font-size:16px` |
| Stats label font | default | `font-size:10px` | `font-size:10px` |
| Empty state check | `results.length === 0` | `!t.requests` | `!t.requests` |
| Grid layout | default | `grid-template-columns:repeat(4,1fr)` | `grid-template-columns:repeat(4,1fr)` |
| Table font size | default | `font-size:12px` | `font-size:12px` |
User and Team are near-identical. Admin is a separate fork.
---
## 6. Provider Type / Endpoint Constants — scattered everywhere
The "known provider types" concept is defined in **5 places**:
| # | Location | Format |
|---|----------|--------|
| 1 | index.html:408 | `<option>` tags in User BYOK select |
| 2 | index.html:626 | `<option>` tags in Admin select |
| 3 | settings-handlers.js:553 | JS object `{ openai: 'OpenAI-compatible', ... }` for Team dynamic build |
| 4 | settings-handlers.js:480 | Endpoint map for User BYOK |
| 5 | settings-handlers.js:542 | Endpoint map for Team (separate copy) |
| — | server/providers/registry.go | Backend `Init()` registers providers — source of truth |
Adding a new provider type requires touching 5 frontend locations.
---
## 7. Preset Form — ALREADY SHARED ✅
`renderPresetForm()` in ui-core.js:33 is used by:
- Admin presets (admin-handlers.js:303)
- Team presets (settings-handlers.js:511)
- User presets (settings-handlers.js:626)
This is the ONE pattern that's already been consolidated into a primitive.
Good reference for how the other patterns should work.
---
## 8. Model List — 2 copies
| Context | Function | File:Line |
|---------|----------|-----------|
| **Admin models** | `UI.loadAdminModels()` | ui-admin.js:365 |
| **User models** | `UI.loadUserModels()` | ui-settings.js:571 |
Admin shows visibility toggle, user shows hide/unhide toggle.
Both render capability badges (same compact format, duplicated).
---
## Summary: Primitives Needed
| Primitive | Replaces | Copies eliminated |
|-----------|----------|-------------------|
| `PROVIDERS` — type list, label map, endpoint defaults | 5 definitions → 1 | 4 |
| `renderProviderForm(container, options)` | 3 HTML forms + 6 handlers → 1 | ~200 lines |
| `renderProviderList(container, items, options)` | 3 list renderers → 1 | ~80 lines |
| `renderRoleConfig(container, options)` | 2 role UIs + 4 handlers → 1 | ~120 lines |
| `renderCapBadges(caps, compact?)` | 3 badge builders → 1 | ~30 lines |
| `renderUsageDashboard(container, options)` | 3 usage renderers → 1 | ~120 lines |
**Estimated net reduction: ~550 lines + single source of truth for all constants.**
---
## Proposed File: `ui-primitives.js`
Loaded after `ui-format.js`, before `ui-core.js`. Contains:
```
// Constants (single source of truth)
const PROVIDER_TYPES = [ ... ]; // { id, label, defaultEndpoint }
const ROLE_NAMES = ['utility', 'embedding'];
const ROLE_TYPE_MAP = { embedding: 'embedding', utility: 'chat' };
// Rendering primitives
function renderCapBadges(caps, opts) → HTML string
function renderProviderForm(container, opts) → { getValues, setValues, clear }
function renderProviderList(container, opts) → refresh function
function renderRoleConfig(container, opts) → refresh function
function renderUsageDashboard(container, opts) → refresh function
```
Each primitive follows the `renderPresetForm` pattern:
- Takes a container element + options object
- Returns a control object with getValues/setValues/refresh
- Options include callbacks (onSubmit, onDelete, etc.) and feature flags
- Scope-specific behavior (admin/team/personal) via options, not separate code
---
## 9. Extension Surface Implications
The extension spec (EXTENSIONS.md §6) defines injection points:
```
sidebar-top, sidebar-nav, sidebar-content, sidebar-bottom
surface-header, surface-main, surface-footer
```
Extensions interact with primitives through `ctx.ui.inject()` and
`ctx.ui.replace()`. The cleaner the primitive interface, the easier
extensions can:
1. **Add to** — e.g. a custom provider type, a new capability badge,
an extra usage metric column
2. **Hook into** — e.g. react when a provider is created, when a role
is saved, when models refresh
3. **Reuse** — e.g. render a provider form inside an extension's own
settings surface
### 9.1 What Extensions Will Want to Do
| Extension | Wants to... | Primitive it hooks |
|-----------|------------|-------------------|
| Custom provider adapter | Add a provider type (e.g. "Ollama", "vLLM") | `PROVIDER_TYPES` registry |
| Cost tracker | Add badges (cost/msg), extra usage columns | `renderCapBadges`, `renderUsageDashboard` |
| Image gen | Register a role + show config UI | `renderRoleConfig` |
| Smart routing | Show routing rules alongside role config | `renderRoleConfig` |
| Cluster manager | Render provider forms in its own surface | `renderProviderForm` |
| KB/RAG | Show embedding model status in sidebar | `ROLE_NAMES`, role resolution |
### 9.2 Design Rules for Extension-Ready Primitives
**Registry pattern, not constants.**
Don't just export `PROVIDER_TYPES = [...]` as a frozen array.
Make it a registry that extensions can `.add()` to:
```js
const Providers = {
_types: [ /* builtins */ ],
add(entry) { this._types.push(entry); },
all() { return this._types; },
get(id) { return this._types.find(t => t.id === id); },
endpoints() { /* map id → defaultEndpoint */ },
labels() { /* map id → label */ },
};
```
Same for roles — `Roles.add('generation', { typeFilter: 'image', label: '...' })`.
**Options objects, not positional args.**
Every primitive takes `(container, options)`. Options are the extension
point — an extension can wrap a primitive call and inject additional
options:
```js
// Extension adds a custom column to usage
renderUsageDashboard(el, {
apiCall: () => API.getMyUsage({ period, group_by }),
extraColumns: [
{ header: 'Est. Cost/msg', render: (r) => '$' + (r.total_cost / r.requests).toFixed(4) }
]
});
```
**Return control handles.**
Every primitive returns an object with methods, not just void:
```js
const provForm = renderProviderForm(el, opts);
// Extensions can:
provForm.getValues() // read current state
provForm.setValues({...}) // programmatically fill
provForm.clear() // reset
provForm.onTypeChange(fn) // hook into type selection
provForm.container // DOM reference for injection
```
**Emit events on state changes.**
Primitives should fire EventBus events that extensions can subscribe to:
```
provider.form.typeChanged — user picked a different provider type
provider.created — provider successfully saved (already exists)
provider.deleted — provider removed
role.saved — role config updated
models.refreshed — model list reloaded (already exists)
```
Most of these events already exist or will exist in the EventBus. The
primitives just need to emit them consistently rather than each copy
doing ad-hoc `UI.toast()` + `fetchModels()` in different orders.
**Scope via options, not separate code.**
The biggest win: one `renderProviderForm` handles admin/team/personal
through options, not three separate implementations:
```js
renderProviderForm(el, {
scope: 'admin', // → shows private checkbox, no BYOK gate
// scope: 'team', // → shows private checkbox, scoped API
// scope: 'personal', // → no private, BYOK gated
apiCreate: (vals) => API.adminCreateGlobalConfig(...),
apiUpdate: (id, vals) => API.adminUpdateGlobalConfig(id, ...),
onSaved: () => { UI.loadAdminProviders(); },
showPrivate: true,
showDefaultModel: true,
});
```
### 9.3 Primitive → Extension Migration Path
These primitives are **pre-extension infrastructure**. When the extension
loader (v0.11.0) lands, the migration is:
1. `Providers` registry gets a `ctx.providers.add()` wrapper in the
extension context
2. `renderCapBadges` checks a `capBadgeRegistry` that extensions can add to
3. `renderUsageDashboard` accepts `extraColumns` from extension settings
4. `renderRoleConfig` reads `Roles.all()` instead of hardcoded list
The primitives we build now become the extension API surface later.
No rewrite needed — just wrapping in the permission-scoped `ctx` object.
---
## 10. Implementation Plan
### Phase 1: Constants + Small Primitives
- `Providers` registry (types, labels, endpoints)
- `Roles` registry (names, type filters, descriptions)
- `renderCapBadges(caps, opts)` — consolidate 3 badge builders
### Phase 2: Provider Primitives
- `renderProviderForm(container, opts)` — replace 3 form implementations
- `renderProviderList(container, opts)` — replace 3 list renderers
- Update index.html: remove 2 static provider selects, add empty containers
### Phase 3: Dashboard Primitives
- `renderUsageDashboard(container, opts)` — replace 3 usage renderers
- `renderRoleConfig(container, opts)` — replace 2 role UIs
### Phase 4: Cleanup
- Remove dead code from admin-handlers.js, settings-handlers.js
- Update tests
- Verify all 3 scopes (admin/team/personal) work identically

View File

@@ -1 +1 @@
0.10.4
0.10.5

View File

@@ -101,10 +101,8 @@ a:hover { text-decoration: underline; }
}
.sb-brand:hover { background: var(--bg-hover); }
.brand-logo { flex-shrink: 0; width: 18px; height: 18px; display: grid; place-items: center; }
.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; transition: opacity var(--transition); }
.brand-collapse { flex-shrink: 0; width: 0; overflow: hidden; color: var(--text-2); opacity: 0; transition: opacity var(--transition); position: absolute; left: 10px; }
.sb-brand:hover .brand-logo-img { opacity: 0; }
.sb-brand:hover .brand-collapse { opacity: 1; }
.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; }
.brand-collapse { display: none; }
.brand-text { font-weight: 600; font-size: 14px; }
.sb-btn {
@@ -125,7 +123,6 @@ a:hover { text-decoration: underline; }
.sidebar.collapsed .sidebar-top { padding: 10px 0 8px; align-items: center; }
.sidebar.collapsed .sidebar-bottom { padding: 8px 0; display: flex; flex-direction: column; align-items: center; }
.sidebar.collapsed .sb-brand { justify-content: center; padding: 8px 0; gap: 0; }
.sidebar.collapsed .brand-collapse { left: 50%; transform: translateX(-50%); }
.sidebar.collapsed .sb-btn { justify-content: center; padding: 8px 0; gap: 0; }
.sidebar.collapsed .split-btn-main { justify-content: center; padding: 8px 0; gap: 0; }
.sidebar.collapsed .user-btn { justify-content: center; padding: 6px 0; gap: 0; }
@@ -1215,9 +1212,12 @@ button { font-family: var(--font); cursor: pointer; }
.admin-tab {
padding: 10px 14px; background: none; border: none; color: var(--text-3);
cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent;
white-space: nowrap; flex-shrink: 0;
white-space: nowrap; flex-shrink: 0; transition: color var(--transition);
outline: none;
}
.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.admin-tab:hover { color: var(--text-2); }
.admin-tab:focus { color: var(--text-3); }
.admin-tab.active, .admin-tab.active:focus { color: var(--accent); border-bottom-color: var(--accent); }
.admin-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 1rem; }
.admin-hint { font-size: 12px; color: var(--text-3); }
@@ -1669,6 +1669,59 @@ button { font-family: var(--font); cursor: pointer; }
}
@keyframes pwa-banner-in { from { opacity: 0; transform: translateX(-50%) translateY(20px); } }
/* ── Confirm Dialog ──────────────────────── */
.confirm-overlay {
position: fixed; inset: 0; z-index: 9999;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.55);
animation: fade-in 0.12s ease;
}
.confirm-dialog {
background: var(--bg-raised); border: 1px solid var(--border-light);
border-radius: var(--radius-lg); padding: 0;
min-width: 320px; max-width: 440px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
animation: confirm-in 0.15s ease;
}
.confirm-header {
padding: 16px 20px 0; font-weight: 600; font-size: 15px; color: var(--text);
}
.confirm-body {
padding: 12px 20px 20px; font-size: 13px; color: var(--text-2);
line-height: 1.5; white-space: pre-wrap;
}
.confirm-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 0 20px 16px;
}
@keyframes confirm-in { from { opacity: 0; transform: scale(0.95); } }
@keyframes fade-in { from { opacity: 0; } }
/* ── Popup Menu (shared base) ───────────── */
.popup-menu {
display: none; position: absolute; z-index: 200;
background: var(--bg-raised); border: 1px solid var(--border-light);
border-radius: var(--radius); padding: 4px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
min-width: 160px;
}
.popup-menu.open { display: block; }
.popup-menu-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; border-radius: var(--radius);
background: none; border: none; color: var(--text-2);
cursor: pointer; font-size: 13px; width: 100%;
transition: background var(--transition), color var(--transition);
}
.popup-menu-item:hover { background: var(--bg-hover); color: var(--text); }
.popup-menu-item.disabled { opacity: 0.4; pointer-events: none; }
.popup-menu-danger:hover { color: var(--danger); }
.popup-menu-icon { flex-shrink: 0; display: flex; align-items: center; }
.popup-menu-hint { margin-left: auto; font-size: 11px; color: var(--text-3); }
.popup-menu-divider { height: 1px; background: var(--border); margin: 4px 8px; }
/* ── Responsive ──────────────────────────── */
.mobile-menu-btn {

View File

@@ -404,12 +404,6 @@
<div id="providerList"></div>
<button class="btn-small" id="providerShowAddBtn">+ Add Provider</button>
<div id="providerAddForm" style="display:none">
<div class="form-group"><label>Name</label><input type="text" id="providerName" placeholder="My OpenAI Key"></div>
<div class="form-group"><label>Provider</label><select id="providerType"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="venice">Venice.ai</option><option value="openrouter">OpenRouter</option></select></div>
<div class="form-group"><label>Endpoint</label><input type="text" id="providerEndpoint" placeholder="https://api.openai.com/v1"></div>
<div class="form-group"><label>API Key</label><input type="password" id="providerApiKey" placeholder="sk-..."></div>
<div class="form-group"><label>Default Model</label><input type="text" id="providerDefaultModel" placeholder="gpt-4o"></div>
<div class="form-row"><button class="btn-small btn-primary" id="providerCreateBtn">Save</button><button class="btn-small" id="providerCancelBtn">Cancel</button></div>
</div>
</section>
<div id="userProvidersDisabled" class="settings-notice" style="display:none">
@@ -512,31 +506,7 @@
<!-- Providers tab -->
<div class="team-tab-content" id="teamTabProviders" style="display:none">
<div id="settingsTeamProviders"></div>
<div id="settingsTeamEditProvider" style="display:none;margin-top:8px" class="admin-inline-form">
<div class="form-grid-2col">
<div class="form-group"><label>Name</label><input id="teamProviderEditName"></div>
<div class="form-group"><label>Endpoint</label><input id="teamProviderEditEndpoint"></div>
<div class="form-group"><label>API Key</label><input id="teamProviderEditKey" type="password" placeholder="(unchanged)"></div>
<div class="form-group"><label>Default Model</label><input id="teamProviderEditDefault" placeholder="optional"></div>
</div>
<div style="margin:6px 0"><label><input type="checkbox" id="teamProviderEditPrivate"> Private provider (data stays on-prem)</label></div>
<div class="form-row">
<button class="btn-small btn-primary" id="settingsTeamEditProviderSubmit">Update</button>
<button class="btn-small" id="settingsTeamCancelEditProvider">Cancel</button>
</div>
</div>
<div id="settingsTeamAddProvider" style="display:none;margin-top:8px" class="admin-inline-form">
<div class="form-grid-2col">
<div class="form-group"><label>Name</label><input id="teamProviderName" placeholder="e.g. Team OpenAI"></div>
<div class="form-group"><label>Provider</label><select id="teamProviderType"></select></div>
<div class="form-group"><label>Endpoint</label><input id="teamProviderEndpoint" placeholder="https://api.openai.com/v1"></div>
<div class="form-group"><label>API Key</label><input id="teamProviderKey" type="password" placeholder="sk-..."></div>
</div>
<div style="margin:6px 0"><label><input type="checkbox" id="teamProviderPrivate"> Private provider (data stays on-prem)</label></div>
<div class="form-row">
<button class="btn-small btn-primary" id="settingsTeamAddProviderSubmit">Add Provider</button>
<button class="btn-small" id="settingsTeamCancelProvider">Cancel</button>
</div>
<div id="settingsTeamProviderForm" style="display:none;margin-top:8px" class="admin-inline-form">
</div>
<button class="btn-small" id="settingsTeamAddProviderBtn" style="margin-top:6px">+ Add Provider</button>
</div>
@@ -621,17 +591,6 @@
<button class="btn-small btn-primary" id="adminAddProviderBtn">+ Add Global Provider</button>
</div>
<div id="adminAddProviderForm" style="display:none" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>Name</label><input type="text" id="adminProvName" placeholder="OpenAI Production"></div>
<div class="form-group"><label>Provider</label><select id="adminProvType"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="venice">Venice.ai</option><option value="openrouter">OpenRouter</option></select></div>
</div>
<div class="form-group"><label>Endpoint</label><input type="text" id="adminProvEndpoint" placeholder="https://api.openai.com/v1"></div>
<div class="form-row">
<div class="form-group"><label>API Key</label><input type="password" id="adminProvKey" placeholder="sk-..."></div>
<div class="form-group"><label>Default Model</label><input type="text" id="adminProvModel" placeholder="gpt-4o"></div>
</div>
<label class="checkbox-label"><input type="checkbox" id="adminProvPrivate"> Private provider (data stays on-prem)</label>
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateProvBtn">Save</button><button class="btn-small" id="adminCancelProvBtn">Cancel</button></div>
</div>
<div id="adminProviderList"></div>
</div>
@@ -863,6 +822,7 @@
<script src="js/events.js?v=%%APP_VERSION%%"></script>
<script src="js/api.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-format.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-primitives.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-core.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-settings.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>

View File

@@ -61,7 +61,7 @@ async function toggleUserRole(id, role) {
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteUser(id, username) {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
if (!await showConfirm(`Delete user "${username}"? This cannot be undone.`)) return;
try { await API.adminDeleteUser(id); UI.toast('User deleted', 'success'); await UI.loadAdminUsers(); }
catch (e) { UI.toast(e.message, 'error'); }
}
@@ -89,68 +89,14 @@ async function adminResetUserPassword(id, username) {
);
if (!pw) return;
if (pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; }
if (!confirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return;
if (!await showConfirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return;
try {
await API.adminResetPassword(id, pw);
UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success');
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteGlobalProvider(id) {
if (!confirm('Delete this global provider?')) return;
try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); }
catch (e) { UI.toast(e.message, 'error'); }
}
var _editingProviderId = null;
function editGlobalProvider(id) {
const c = UI._providerCache?.[id];
if (!c) return;
_editingProviderId = id;
const form = document.getElementById('adminAddProviderForm');
form.style.display = '';
document.getElementById('adminProvName').value = c.name || '';
document.getElementById('adminProvType').value = c.provider || 'openai';
document.getElementById('adminProvEndpoint').value = c.endpoint || '';
document.getElementById('adminProvKey').value = '';
document.getElementById('adminProvKey').placeholder = c.has_key ? '(unchanged)' : 'sk-...';
document.getElementById('adminProvModel').value = c.model_default || '';
document.getElementById('adminProvPrivate').checked = !!c.is_private;
document.getElementById('adminCreateProvBtn').textContent = 'Update';
}
async function createGlobalProvider() {
try {
const name = document.getElementById('adminProvName').value.trim();
const prov = document.getElementById('adminProvType').value;
const ep = document.getElementById('adminProvEndpoint').value.trim();
const key = document.getElementById('adminProvKey').value;
const model = document.getElementById('adminProvModel').value.trim();
const isPrivate = document.getElementById('adminProvPrivate').checked;
if (_editingProviderId) {
// Update mode
const updates = {};
if (name) updates.name = name;
if (ep) updates.endpoint = ep;
if (key) updates.api_key = key;
updates.model_default = model;
updates.is_private = isPrivate;
await API.adminUpdateGlobalConfig(_editingProviderId, updates);
UI.toast('Provider updated', 'success');
} else {
// Create mode
if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
await API.adminCreateGlobalConfig(name, prov, ep, key, model, isPrivate);
UI.toast('Provider added', 'success');
}
_editingProviderId = null;
document.getElementById('adminAddProviderForm').style.display = 'none';
document.getElementById('adminCreateProvBtn').textContent = 'Save';
document.getElementById('adminProvKey').placeholder = 'sk-...';
await UI.loadAdminProviders();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Admin Provider / Role handlers — now managed by UI primitives in ui-admin.js ──
async function fetchAdminModels() {
const hint = document.getElementById('adminModelsHint');
@@ -194,64 +140,7 @@ async function bulkSetVisibility(visibility) {
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
// ── Admin Roles ────────────────────────────
async function adminRoleProviderChanged(role, slot) {
const provId = document.getElementById(`role-${role}-${slot}-provider`)?.value;
const modelSelect = document.getElementById(`role-${role}-${slot}-model`);
if (!modelSelect) return;
modelSelect.innerHTML = '<option value="">— select model —</option>';
if (!provId || !window._adminModelList) return;
// Filter by provider AND model type matching the role
const typeForRole = role === 'embedding' ? 'embedding' : 'chat';
const models = window._adminModelList.filter(m =>
m.provider_config_id === provId &&
(m.model_type || 'chat') === typeForRole
);
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.model_id;
opt.textContent = m.display_name || m.model_id;
modelSelect.appendChild(opt);
});
}
async function adminSaveRole(role) {
const status = document.getElementById(`role-${role}-status`);
try {
const getBinding = (slot) => {
const prov = document.getElementById(`role-${role}-${slot}-provider`)?.value;
const model = document.getElementById(`role-${role}-${slot}-model`)?.value;
return (prov && model) ? { provider_config_id: prov, model_id: model } : null;
};
await API.adminUpdateRole(role, {
primary: getBinding('primary'),
fallback: getBinding('fallback')
});
if (status) { status.textContent = '✓ Saved'; status.style.color = 'var(--success)'; }
// Reload to reflect saved state in dropdowns
setTimeout(() => UI.loadAdminRoles(), 500);
} catch (e) {
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; }
}
}
async function adminTestRole(role) {
const status = document.getElementById(`role-${role}-status`);
if (status) { status.textContent = 'Testing...'; status.style.color = 'var(--text-muted)'; }
try {
const result = await API.adminTestRole(role);
if (status) {
const fb = result.used_fallback ? ' (fallback)' : '';
status.textContent = `${result.model}${fb}`;
status.style.color = 'var(--success)';
}
} catch (e) {
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; }
}
}
// ── Admin Roles — now managed by renderRoleConfig primitive in ui-admin.js ──
// ── User Model Preferences ──────────────────
@@ -284,7 +173,7 @@ async function bulkSetUserModelVisibility(show) {
}
async function deleteUserPreset(id, name) {
if (!confirm(`Delete preset "${name}"?`)) return;
if (!await showConfirm(`Delete preset "${name}"?`)) return;
try {
await API.deleteUserPreset(id);
UI.toast('Preset deleted');
@@ -432,7 +321,7 @@ async function toggleAdminPreset(id, active) {
}
async function deleteAdminPreset(id, name) {
if (!confirm(`Delete preset "${name}"?`)) return;
if (!await showConfirm(`Delete preset "${name}"?`)) return;
try {
await API.adminDeletePreset(id);
UI.toast('Preset deleted', 'success');
@@ -452,7 +341,7 @@ async function toggleTeamActive(id, active) {
}
async function deleteTeam(id, name) {
if (!confirm(`Delete team "${name}"? All members will be removed.`)) return;
if (!await showConfirm(`Delete team "${name}"? All members will be removed.`)) return;
try {
await API.adminDeleteTeam(id);
UI.toast('Team deleted');
@@ -471,7 +360,7 @@ async function updateTeamMember(teamId, memberId, role) {
}
async function removeTeamMember(teamId, memberId, email) {
if (!confirm(`Remove ${email} from team?`)) return;
if (!await showConfirm(`Remove ${email} from team?`)) return;
try {
await API.adminRemoveMember(teamId, memberId);
UI.toast('Member removed');
@@ -491,7 +380,7 @@ async function settingsUpdateTeamMember(teamId, memberId, role) {
}
async function settingsRemoveTeamMember(teamId, memberId, email) {
if (!confirm(`Remove ${email} from team?`)) return;
if (!await showConfirm(`Remove ${email} from team?`)) return;
try {
await API.teamRemoveMember(teamId, memberId);
UI.toast('Member removed');
@@ -500,7 +389,7 @@ async function settingsRemoveTeamMember(teamId, memberId, email) {
}
async function settingsDeleteTeamPreset(teamId, presetId, name) {
if (!confirm(`Delete team preset "${name}"?`)) return;
if (!await showConfirm(`Delete team preset "${name}"?`)) return;
try {
await API.teamDeletePreset(teamId, presetId);
UI.toast('Preset deleted');
@@ -509,36 +398,7 @@ async function settingsDeleteTeamPreset(teamId, presetId, name) {
} catch (e) { UI.toast(e.message, 'error'); }
}
async function settingsToggleTeamProvider(teamId, providerId, currentlyActive) {
try {
await API.teamUpdateProvider(teamId, providerId, { is_active: !currentlyActive });
UI.toast(currentlyActive ? 'Provider deactivated' : 'Provider activated');
await UI.loadTeamManageProviders(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function settingsDeleteTeamProvider(teamId, providerId, name) {
if (!confirm(`Delete team provider "${name}"? This will remove all models from this provider for your team.`)) return;
try {
await API.teamDeleteProvider(teamId, providerId);
UI.toast('Provider deleted');
await UI.loadTeamManageProviders(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
function settingsEditTeamProvider(teamId, provId, name, endpoint, defaultModel, isPrivate) {
UI._editingTeamProviderId = provId;
document.getElementById('settingsTeamAddProvider').style.display = 'none';
const form = document.getElementById('settingsTeamEditProvider');
form.style.display = '';
document.getElementById('teamProviderEditName').value = name;
document.getElementById('teamProviderEditEndpoint').value = endpoint;
document.getElementById('teamProviderEditKey').value = '';
document.getElementById('teamProviderEditDefault').value = defaultModel;
document.getElementById('teamProviderEditPrivate').checked = isPrivate;
}
// ── Team Provider handlers — now managed by UI primitives in ui-settings.js ──
// ── Admin Listeners (extracted from initListeners) ──
@@ -558,26 +418,12 @@ function _initAdminListeners() {
document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; });
document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser);
// Admin — providers
// Admin — providers (form managed by primitives in loadAdminProviders)
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
_editingProviderId = null;
document.getElementById('adminCreateProvBtn').textContent = 'Save';
document.getElementById('adminProvKey').placeholder = 'sk-...';
document.getElementById('adminProvName').value = '';
document.getElementById('adminProvEndpoint').value = '';
document.getElementById('adminProvKey').value = '';
document.getElementById('adminProvModel').value = '';
document.getElementById('adminProvPrivate').checked = false;
const f = document.getElementById('adminAddProviderForm');
if (UI._adminProvForm) UI._adminProvForm.setCreateMode();
f.style.display = f.style.display === 'none' ? '' : 'none';
});
document.getElementById('adminCancelProvBtn')?.addEventListener('click', () => {
document.getElementById('adminAddProviderForm').style.display = 'none';
_editingProviderId = null;
document.getElementById('adminCreateProvBtn').textContent = 'Save';
document.getElementById('adminProvKey').placeholder = 'sk-...';
});
document.getElementById('adminCreateProvBtn')?.addEventListener('click', createGlobalProvider);
// Admin — models
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);

View File

@@ -314,7 +314,7 @@ async function handleRegister() {
}
async function handleLogout() {
if (!confirm('Sign out?')) return;
if (!await showConfirm('Sign out?')) return;
Events.disconnect();
Events.clear();
await API.logout();

View File

@@ -193,7 +193,7 @@ async function newChat() {
}
async function deleteChat(chatId) {
if (!confirm('Delete this chat?')) return;
if (!await showConfirm('Delete this chat?')) return;
try {
await API.deleteChannel(chatId);
App.chats = App.chats.filter(c => c.id !== chatId);

View File

@@ -546,8 +546,8 @@ function switchDebugTab(tab) {
DebugLog.render();
}
function clearDebugLog() {
if (confirm('Clear all debug logs?')) {
async function clearDebugLog() {
if (await showConfirm('Clear all debug logs?')) {
DebugLog.clear();
DebugLog.render();
}

View File

@@ -11,6 +11,13 @@ var _notesSort = 'updated_desc';
// ── Notes ─────────────────────────────────────
async function openNotes() {
const panel = document.getElementById('sidePanel');
const notesTab = document.getElementById('sidePanelNotes');
// If notes panel is already open and showing, close it
if (panel?.classList.contains('open') && notesTab?.style.display !== 'none') {
closeSidePanel();
return;
}
openSidePanel('notes');
_exitSelectMode();
await loadNotesList();
@@ -136,7 +143,7 @@ function _updateSelectedCount() {
async function _bulkDeleteSelected() {
if (_selectedNoteIds.size === 0) return;
const count = _selectedNoteIds.size;
if (!confirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
if (!await showConfirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
try {
const resp = await API.bulkDeleteNotes([..._selectedNoteIds]);
@@ -300,7 +307,7 @@ async function saveNote() {
async function deleteNote() {
if (!_editingNoteId) return;
if (!confirm('Delete this note?')) return;
if (!await showConfirm('Delete this note?')) return;
try {
await API.deleteNote(_editingNoteId);
UI.toast('Note deleted', 'success');

View File

@@ -85,83 +85,78 @@ async function handleChangePassword() {
} catch (e) { UI.toast(e.message, 'error'); }
}
async function handleCreateProvider() {
const name = document.getElementById('providerName').value.trim();
const provider = document.getElementById('providerType').value;
const endpoint = document.getElementById('providerEndpoint').value.trim();
const apiKey = document.getElementById('providerApiKey').value.trim();
const model = document.getElementById('providerDefaultModel').value.trim();
// ── User BYOK Provider — primitive instances (initialized in _initSettingsListeners) ──
var _userProvForm = null;
var _userProvList = null;
const editId = UI._editingProviderId;
function _initUserProviderPrimitives() {
const formEl = document.getElementById('providerAddForm');
const listEl = document.getElementById('providerList');
if (!formEl || !listEl) return;
if (editId) {
// Update mode — api_key optional (blank = keep existing)
if (!name || !endpoint) return UI.toast('Fill in required fields', 'warning');
const patch = { name, provider, endpoint, model_default: model };
if (apiKey) patch.api_key = apiKey;
try {
await API.updateConfig(editId, patch);
UI.toast('Provider updated', 'success');
UI._editingProviderId = null;
document.getElementById('providerApiKey').placeholder = 'API key';
UI.hideProviderForm();
UI.loadProviderList();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
} else {
// Create mode
if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning');
try {
const result = await API.createConfig(name, provider, endpoint, apiKey, model);
if (result.warning) {
UI.toast(`Provider added — ${result.warning}`, 'warning');
} else {
const count = result.models_fetched || 0;
UI.toast(`Provider added — ${count} model${count !== 1 ? 's' : ''} synced`, 'success');
}
UI.hideProviderForm();
UI.loadProviderList();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
}
_userProvForm = renderProviderForm(formEl, {
prefix: 'userProv',
showPrivate: false,
showDefaultModel: true,
onSubmit: async (vals, isEdit) => {
try {
if (isEdit) {
if (!vals.name || !vals.endpoint) return UI.toast('Fill in required fields', 'warning');
const patch = { name: vals.name, provider: vals.provider, endpoint: vals.endpoint, model_default: vals.model_default };
if (vals.api_key) patch.api_key = vals.api_key;
await API.updateConfig(vals._editId, patch);
UI.toast('Provider updated', 'success');
} else {
if (!vals.name || !vals.endpoint || !vals.api_key) return UI.toast('Fill in required fields', 'warning');
const result = await API.createConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default);
if (result.warning) UI.toast(`Provider added — ${result.warning}`, 'warning');
else {
const count = result.models_fetched || 0;
UI.toast(`Provider added — ${count} model${count !== 1 ? 's' : ''} synced`, 'success');
}
}
formEl.style.display = 'none';
_userProvForm.setCreateMode();
_userProvList.refresh();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => {
formEl.style.display = 'none';
_userProvForm.setCreateMode();
},
});
async function deleteProvider(id, name) {
if (!confirm(`Remove provider "${name}"?`)) return;
try {
await API.deleteConfig(id);
UI.toast('Provider removed', 'success');
UI.loadProviderList();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function refreshProviderModels(id, name) {
try {
UI.toast(`Fetching models for ${name}...`, 'info');
const result = await API.fetchProviderModels(id);
const total = result.total || 0;
UI.toast(`${name}: ${total} model${total !== 1 ? 's' : ''} synced`, 'success');
fetchModels(); // refresh the model selector
} catch (e) {
UI.toast(`Failed to fetch models: ${e.message}`, 'error');
}
}
async function editProvider(id) {
try {
const cfg = await API.getConfig(id);
// Populate the add form with existing values
document.getElementById('providerName').value = cfg.name || '';
document.getElementById('providerType').value = cfg.provider || 'openai';
document.getElementById('providerEndpoint').value = cfg.endpoint || '';
document.getElementById('providerApiKey').value = ''; // Don't expose key
document.getElementById('providerApiKey').placeholder = cfg.has_key ? '(unchanged — leave blank to keep)' : 'API key';
document.getElementById('providerDefaultModel').value = cfg.model_default || '';
// Show form and switch to edit mode
UI.showProviderForm();
UI._editingProviderId = id;
} catch (e) { UI.toast(e.message, 'error'); }
_userProvList = renderProviderList(listEl, {
apiFetch: () => API.listConfigs(),
showRefresh: true,
emptyMsg: 'No providers configured.',
onEdit: async (prov) => {
try {
const cfg = await API.getConfig(prov.id);
_userProvForm.setEditMode(cfg.id, cfg);
formEl.style.display = '';
} catch (e) { UI.toast(e.message, 'error'); }
},
onDelete: async (prov) => {
if (!await showConfirm(`Remove provider "${prov.name}"?`)) return;
try {
await API.deleteConfig(prov.id);
UI.toast('Provider removed', 'success');
_userProvList.refresh();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onRefresh: async (prov) => {
try {
UI.toast(`Fetching models for ${prov.name}...`, 'info');
const result = await API.fetchProviderModels(prov.id);
const total = result.total || 0;
UI.toast(`${prov.name}: ${total} model${total !== 1 ? 's' : ''} synced`, 'success');
fetchModels();
} catch (e) { UI.toast(`Failed to fetch models: ${e.message}`, 'error'); }
},
});
}
async function handleSaveAdminSettings() {
@@ -209,67 +204,50 @@ async function handleSaveAdminSettings() {
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── User Model Role Functions ─────────────────
// ── User Model Role — primitive instance (initialized in _initSettingsListeners) ──
var _userRoleConfig = null;
async function userRoleProviderChanged(role) {
const provSel = document.getElementById(`userRole-${role}-provider`);
const modelSel = document.getElementById(`userRole-${role}-model`);
if (!provSel || !modelSel) return;
function _initUserRolePrimitive() {
const el = document.getElementById('userRolesConfig');
if (!el) return;
const providerId = provSel.value;
modelSel.innerHTML = '<option value="">— select model —</option>';
if (!providerId) return;
// Filter by provider AND model type matching the role
const typeForRole = role === 'embedding' ? 'embedding' : 'chat';
const allModels = App.models || [];
const provModels = allModels.filter(m =>
m.configId === providerId &&
(m.model_type || 'chat') === typeForRole
);
provModels.forEach(m => {
modelSel.innerHTML += `<option value="${m.baseModelId}">${esc(m.name || m.baseModelId)}</option>`;
_userRoleConfig = renderRoleConfig(el, {
scope: 'personal',
showFallback: false,
modelIdField: 'baseModelId',
modelNameField: 'name',
providerIdField: 'configId',
noneLabel: '— use org default —',
fetchProviders: async () => {
const configs = await API.listConfigs();
const list = configs.configs || configs.data || [];
return list.filter(c => c.scope === 'personal');
},
fetchModels: async () => {
return App.models || [];
},
fetchRoles: async () => {
const settings = await API.getSettings();
return settings.model_roles || {};
},
onSave: async (roleId, config) => {
if (!config.primary) throw new Error('Select both a provider and model');
const settings = await API.getSettings();
const roles = settings.model_roles || {};
roles[roleId] = config;
await API.updateSettings({ model_roles: roles });
UI.toast(`${roleId} role override saved`, 'success');
},
onClear: async (roleId) => {
const settings = await API.getSettings();
const roles = settings.model_roles || {};
delete roles[roleId];
await API.updateSettings({ model_roles: Object.keys(roles).length > 0 ? roles : null });
UI.toast(`${roleId} role override removed — using org default`, 'success');
},
});
}
async function saveUserRole(role) {
const provSel = document.getElementById(`userRole-${role}-provider`);
const modelSel = document.getElementById(`userRole-${role}-model`);
if (!provSel || !modelSel) return;
const providerId = provSel.value;
const modelId = modelSel.value;
if (!providerId || !modelId) {
UI.toast('Select both a provider and model', 'warning');
return;
}
try {
const settings = await API.getSettings();
const roles = settings.model_roles || {};
roles[role] = { primary: { provider_config_id: providerId, model_id: modelId } };
await API.updateSettings({ model_roles: roles });
UI.toast(`${role} role override saved`, 'success');
UI.loadUserRoles();
} catch (e) {
UI.toast(e.message || 'Failed to save role', 'error');
}
}
async function clearUserRole(role) {
try {
const settings = await API.getSettings();
const roles = settings.model_roles || {};
delete roles[role];
await API.updateSettings({ model_roles: Object.keys(roles).length > 0 ? roles : null });
UI.toast(`${role} role override removed — using org default`, 'success');
UI.loadUserRoles();
} catch (e) {
UI.toast(e.message || 'Failed to clear role', 'error');
}
}
// ── Command Palette ──────────────────────────
const _cmdCommands = [
@@ -473,20 +451,13 @@ function _initSettingsListeners() {
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
});
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
document.getElementById('providerType').addEventListener('change', function() {
const endpoints = {
openai: 'https://api.openai.com/v1',
anthropic: 'https://api.anthropic.com',
venice: 'https://api.venice.ai/api/v1',
openrouter: 'https://openrouter.ai/api/v1',
};
const ep = document.getElementById('providerEndpoint');
if (!ep.value || Object.values(endpoints).includes(ep.value)) {
ep.value = endpoints[this.value] || '';
}
// User BYOK provider primitives
_initUserProviderPrimitives();
_initUserRolePrimitive();
document.getElementById('providerShowAddBtn').addEventListener('click', () => {
const formEl = document.getElementById('providerAddForm');
if (_userProvForm) _userProvForm.setCreateMode();
formEl.style.display = '';
});
// Settings — Team management (team admin self-service)
@@ -538,83 +509,12 @@ function _initSettingsListeners() {
UI.loadTeamPresetModelDropdown(UI._managingTeamId);
});
// Team — providers
const defaultEndpoints = {
openai: 'https://api.openai.com/v1',
anthropic: 'https://api.anthropic.com',
venice: 'https://api.venice.ai/api/v1',
openrouter: 'https://openrouter.ai/api/v1',
};
// Team — providers (primitive-based, initialized lazily via UI.loadTeamManageProviders)
document.getElementById('settingsTeamAddProviderBtn')?.addEventListener('click', () => {
const form = document.getElementById('settingsTeamAddProvider');
form.style.display = form.style.display === 'none' ? '' : 'none';
const sel = document.getElementById('teamProviderType');
if (sel && sel.options.length === 0) {
['openai', 'anthropic', 'venice', 'openrouter'].forEach(p => {
const labels = { openai: 'OpenAI-compatible', anthropic: 'Anthropic', venice: 'Venice.ai', openrouter: 'OpenRouter' };
const opt = document.createElement('option');
opt.value = p;
opt.textContent = labels[p] || p;
sel.appendChild(opt);
});
}
});
document.getElementById('teamProviderType')?.addEventListener('change', function() {
const ep = document.getElementById('teamProviderEndpoint');
if (!ep.value || Object.values(defaultEndpoints).includes(ep.value)) {
ep.value = defaultEndpoints[this.value] || '';
}
});
document.getElementById('settingsTeamCancelProvider')?.addEventListener('click', () => {
document.getElementById('settingsTeamAddProvider').style.display = 'none';
});
document.getElementById('settingsTeamAddProviderSubmit')?.addEventListener('click', async () => {
const teamId = UI._managingTeamId;
const name = document.getElementById('teamProviderName').value.trim();
const provider = document.getElementById('teamProviderType').value;
const endpoint = document.getElementById('teamProviderEndpoint').value.trim();
const apiKey = document.getElementById('teamProviderKey').value;
const isPrivate = document.getElementById('teamProviderPrivate').checked;
if (!name) return UI.toast('Name required', 'error');
if (!endpoint) return UI.toast('Endpoint required', 'error');
try {
await API.teamCreateProvider(teamId, { name, provider, endpoint, api_key: apiKey, is_private: isPrivate });
document.getElementById('settingsTeamAddProvider').style.display = 'none';
document.getElementById('teamProviderName').value = '';
document.getElementById('teamProviderEndpoint').value = '';
document.getElementById('teamProviderKey').value = '';
document.getElementById('teamProviderPrivate').checked = false;
UI.toast('Provider added');
await UI.loadTeamManageProviders(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
});
// Team — edit provider
document.getElementById('settingsTeamCancelEditProvider')?.addEventListener('click', () => {
document.getElementById('settingsTeamEditProvider').style.display = 'none';
});
document.getElementById('settingsTeamEditProviderSubmit')?.addEventListener('click', async () => {
const teamId = UI._managingTeamId;
const provId = UI._editingTeamProviderId;
if (!provId) return;
const updates = {};
const name = document.getElementById('teamProviderEditName').value.trim();
const endpoint = document.getElementById('teamProviderEditEndpoint').value.trim();
const apiKey = document.getElementById('teamProviderEditKey').value;
const defaultModel = document.getElementById('teamProviderEditDefault').value.trim();
const isPrivate = document.getElementById('teamProviderEditPrivate').checked;
if (name) updates.name = name;
if (endpoint) updates.endpoint = endpoint;
if (apiKey) updates.api_key = apiKey;
if (defaultModel) updates.model_default = defaultModel;
updates.is_private = isPrivate;
try {
await API.teamUpdateProvider(teamId, provId, updates);
document.getElementById('settingsTeamEditProvider').style.display = 'none';
UI.toast('Provider updated');
await UI.loadTeamManageProviders(teamId);
} catch (e) { UI.toast(e.message, 'error'); }
const formEl = document.getElementById('settingsTeamProviderForm');
if (!formEl) return;
if (UI._teamProvForm) UI._teamProvForm.setCreateMode();
formEl.style.display = formEl.style.display === 'none' ? '' : 'none';
});
// User — personal presets

View File

@@ -41,7 +41,12 @@ Object.assign(UI, {
closeTeamAdmin() { closeModal('teamAdminModal'); },
switchTeamTab(tab) {
document.querySelectorAll('#teamAdminTabs .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.ttab === tab));
const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab');
console.log('switchTeamTab:', tab, 'found tabs:', tabs.length);
tabs.forEach(t => {
t.classList.remove('active');
if (t.dataset.ttab === tab) t.classList.add('active');
});
document.querySelectorAll('.team-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`teamTab${tab.charAt(0).toUpperCase() + tab.slice(1)}`);
if (panel) panel.style.display = '';
@@ -56,7 +61,7 @@ Object.assign(UI, {
},
async switchAdminTab(tab) {
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('#adminModal .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
if (panel) panel.style.display = '';
@@ -134,131 +139,88 @@ Object.assign(UI, {
async loadAdminRoles() {
const el = document.getElementById('adminRolesContent');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const roles = await API.adminListRoles();
const configs = await API.adminListGlobalConfigs();
const providers = configs.configs || configs || [];
const models = await API.adminListModels();
const modelList = models.models || models.data || models || [];
const roleNames = ['utility', 'embedding'];
el.innerHTML = roleNames.map(role => {
const cfg = roles[role] || { primary: null, fallback: null };
const typeForRole = role === 'embedding' ? 'embedding' : 'chat';
const typeFilter = m => (m.model_type || 'chat') === typeForRole;
return `
<div class="settings-section" style="margin-bottom:16px">
<h3 style="font-size:14px;margin:0 0 8px;text-transform:capitalize">${role}</h3>
<div class="form-row" style="gap:8px;align-items:center">
<label style="min-width:60px;font-size:12px">Primary</label>
<select id="role-${role}-primary-provider" onchange="adminRoleProviderChanged('${role}','primary')" style="min-width:140px">
<option value="">— none —</option>
${providers.map(p => `<option value="${p.id}" ${cfg.primary?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
</select>
<select id="role-${role}-primary-model" style="min-width:200px">
<option value="">— select model —</option>
${cfg.primary ? modelList.filter(m => m.provider_config_id === cfg.primary.provider_config_id && typeFilter(m)).map(m => `<option value="${m.model_id}" ${m.model_id === cfg.primary.model_id ? 'selected' : ''}>${esc(m.display_name || m.model_id)}</option>`).join('') : ''}
</select>
</div>
<div class="form-row" style="gap:8px;align-items:center;margin-top:6px">
<label style="min-width:60px;font-size:12px">Fallback</label>
<select id="role-${role}-fallback-provider" onchange="adminRoleProviderChanged('${role}','fallback')" style="min-width:140px">
<option value="">— none —</option>
${providers.map(p => `<option value="${p.id}" ${cfg.fallback?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
</select>
<select id="role-${role}-fallback-model" style="min-width:200px">
<option value="">— select model —</option>
${cfg.fallback ? modelList.filter(m => m.provider_config_id === cfg.fallback.provider_config_id && typeFilter(m)).map(m => `<option value="${m.model_id}" ${m.model_id === cfg.fallback.model_id ? 'selected' : ''}>${esc(m.display_name || m.model_id)}</option>`).join('') : ''}
</select>
</div>
<div class="form-row" style="gap:8px;margin-top:8px">
<button class="btn-primary btn-small" onclick="adminSaveRole('${role}')">Save</button>
<button class="btn-secondary btn-small" onclick="adminTestRole('${role}')">Test</button>
<span id="role-${role}-status" style="font-size:12px"></span>
</div>
</div>`;
}).join('');
if (!UI._adminRoleConfig) {
UI._adminRoleConfig = renderRoleConfig(el, {
scope: 'admin',
showFallback: true,
modelIdField: 'model_id',
modelNameField: 'display_name',
providerIdField: 'provider_config_id',
fetchProviders: async () => {
const configs = await API.adminListGlobalConfigs();
return configs.configs || configs || [];
},
fetchModels: async () => {
const models = await API.adminListModels();
return models.models || models.data || models || [];
},
fetchRoles: () => API.adminListRoles(),
onSave: async (roleId, config) => {
await API.adminUpdateRole(roleId, config);
},
onTest: (roleId) => API.adminTestRole(roleId),
});
}
// Store models list for provider change handler
window._adminModelList = modelList;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
await UI._adminRoleConfig.refresh();
},
// ── Admin: Usage ────────────────────────
async loadAdminUsage() {
const period = document.getElementById('usagePeriod')?.value || '30d';
const groupBy = document.getElementById('usageGroupBy')?.value || 'model';
// Usage stats via primitive
const totalsEl = document.getElementById('adminUsageTotals');
const resultsEl = document.getElementById('adminUsageResults');
const pricingEl = document.getElementById('adminPricingTable');
if (!totalsEl) return;
// Wrap totals+results in a container for the primitive (first time only)
let usageContainer = document.getElementById('_adminUsageContainer');
if (!usageContainer) {
usageContainer = document.createElement('div');
usageContainer.id = '_adminUsageContainer';
const resultsEl = document.getElementById('adminUsageResults');
totalsEl.parentElement.insertBefore(usageContainer, totalsEl);
usageContainer.appendChild(totalsEl);
if (resultsEl) usageContainer.appendChild(resultsEl);
}
totalsEl.innerHTML = '<div class="loading">Loading...</div>';
resultsEl.innerHTML = '';
if (!UI._adminUsageDash) {
UI._adminUsageDash = renderUsageDashboard(usageContainer, {
apiFetch: (p) => API.adminGetUsage(p),
periodElId: 'usagePeriod',
groupByElId: 'usageGroupBy',
compact: false,
showUserColumn: true,
});
}
await UI._adminUsageDash.refresh();
try {
const data = await API.adminGetUsage({ period, group_by: groupBy });
const t = data.totals || {};
totalsEl.innerHTML = `
<div class="stats-grid">
<div class="stat-card"><div class="stat-value">${(t.requests || 0).toLocaleString()}</div><div class="stat-label">Requests</div></div>
<div class="stat-card"><div class="stat-value">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label">Input Tokens</div></div>
<div class="stat-card"><div class="stat-value">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label">Output Tokens</div></div>
<div class="stat-card"><div class="stat-value">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label">Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length === 0) {
resultsEl.innerHTML = '<div class="empty-hint">No usage data for this period</div>';
} else {
resultsEl.innerHTML = `
<table class="admin-table" style="margin-top:12px">
<thead><tr>
<th>${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}</th>
<th style="text-align:right">Requests</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Cost</th>
</tr></thead>
<tbody>${results.map(r => `<tr>
<td>${esc(r.label || r.group_key)}</td>
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { totalsEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
// Load pricing table
try {
const pricing = await API.adminListPricing();
const entries = pricing || [];
if (entries.length === 0) {
pricingEl.innerHTML = '<div class="empty-hint">No pricing configured. Sync providers to populate from catalog.</div>';
} else {
pricingEl.innerHTML = `
<table class="admin-table" style="margin-top:8px">
<thead><tr>
<th>Model</th>
<th style="text-align:right">Input $/M</th>
<th style="text-align:right">Output $/M</th>
<th>Source</th>
</tr></thead>
<tbody>${entries.map(p => `<tr>
<td>${esc(p.model_id)}</td>
<td style="text-align:right">${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}</td>
<td style="text-align:right">${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}</td>
<td><span class="badge-${p.source === 'manual' ? 'admin' : 'user'}">${p.source}</span></td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { pricingEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
// Pricing table (admin-only, separate from usage primitive)
const pricingEl = document.getElementById('adminPricingTable');
if (pricingEl) {
try {
const pricing = await API.adminListPricing();
const entries = pricing || [];
if (entries.length === 0) {
pricingEl.innerHTML = '<div class="empty-hint">No pricing configured. Sync providers to populate from catalog.</div>';
} else {
pricingEl.innerHTML = `
<table class="admin-table" style="margin-top:8px">
<thead><tr>
<th>Model</th>
<th style="text-align:right">Input $/M</th>
<th style="text-align:right">Output $/M</th>
<th>Source</th>
</tr></thead>
<tbody>${entries.map(p => `<tr>
<td>${esc(p.model_id)}</td>
<td style="text-align:right">${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : ''}</td>
<td style="text-align:right">${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}</td>
<td><span class="badge-${p.source === 'manual' ? 'admin' : 'user'}">${p.source}</span></td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { pricingEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}
},
_auditPage: 1,
@@ -342,24 +304,67 @@ Object.assign(UI, {
async loadAdminProviders(quiet) {
const el = document.getElementById('adminProviderList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListGlobalConfigs();
const list = data.configs || data.data || data || [];
const arr = Array.isArray(list) ? list : [];
UI._providerCache = {};
el.innerHTML = arr.map(c => {
UI._providerCache[c.id] = c;
return `<div class="admin-provider-row">
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)}</span>
${c.is_private ? '<span class="badge-private">private</span>' : ''}
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
<button class="btn-edit" onclick="editGlobalProvider('${c.id}')" title="Edit">✎</button>
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
</div>`;
}).join('') || '<div class="empty-hint">No global providers — add one above</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
const formEl = document.getElementById('adminAddProviderForm');
// Initialize admin provider form primitive (once)
if (!UI._adminProvForm && formEl) {
UI._adminProvForm = renderProviderForm(formEl, {
prefix: 'adminProv',
showPrivate: true,
showDefaultModel: true,
onSubmit: async (vals, isEdit) => {
try {
if (isEdit) {
const updates = {};
if (vals.name) updates.name = vals.name;
if (vals.endpoint) updates.endpoint = vals.endpoint;
if (vals.api_key) updates.api_key = vals.api_key;
updates.model_default = vals.model_default;
updates.is_private = vals.is_private || false;
await API.adminUpdateGlobalConfig(vals._editId, updates);
UI.toast('Provider updated', 'success');
} else {
if (!vals.name || !vals.endpoint || !vals.api_key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
await API.adminCreateGlobalConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default, vals.is_private || false);
UI.toast('Provider added', 'success');
}
formEl.style.display = 'none';
UI._adminProvForm.setCreateMode();
UI._adminProvList.refresh();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => {
formEl.style.display = 'none';
UI._adminProvForm.setCreateMode();
},
});
}
// Initialize admin provider list primitive (once)
if (!UI._adminProvList) {
UI._adminProvList = renderProviderList(el, {
apiFetch: () => API.adminListGlobalConfigs(),
showEndpoint: true,
showPrivate: true,
emptyMsg: 'No global providers — add one above',
onEdit: (prov) => {
if (UI._adminProvForm) {
UI._adminProvForm.setEditMode(prov.id, prov);
formEl.style.display = '';
}
},
onDelete: async (prov) => {
if (!await showConfirm('Delete this global provider?')) return;
try {
await API.adminDeleteGlobalConfig(prov.id);
UI.toast('Provider deleted', 'success');
UI._adminProvList.refresh();
} catch (e) { UI.toast(e.message, 'error'); }
},
});
}
await UI._adminProvList.refresh(quiet);
},
async loadAdminModels(quiet) {
@@ -371,18 +376,14 @@ Object.assign(UI, {
const arr = Array.isArray(list) ? list : [];
el.innerHTML = arr.map(m => {
const caps = m.capabilities || {};
const badges = [];
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
const badges = renderCapBadges(caps, { compact: true });
// Support both old is_enabled (bool) and new visibility (string)
const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled');
const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled';
const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : '';
return `<div class="admin-model-row">
<span class="model-name">${esc(m.model_id || m.id)}</span>
<span class="model-caps-inline">${badges.join('')}</span>
<span class="model-caps-inline">${badges}</span>
<span class="provider-meta">${esc(m.provider_name || '')}</span>
<button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}', '${vis}')">${visLabel}</button>
</div>`;

View File

@@ -211,7 +211,26 @@ const UI = {
toggleUserMenu() {
const flyout = document.getElementById('userFlyout');
const opening = !flyout.classList.contains('open');
flyout.classList.toggle('open');
// On open: refresh team admin button visibility (fire-and-forget)
if (opening) {
const menuBtn = document.getElementById('menuTeamAdmin');
if (menuBtn) {
// Show from cache immediately if available
if (UI._myTeams && UI._myTeams.length > 0) {
const cached = UI._myTeams.some(t => t.my_role === 'admin');
menuBtn.style.display = cached ? '' : 'none';
}
// Refresh in background
API.listMyTeams().then(resp => {
const teams = resp.data || [];
UI._myTeams = teams;
menuBtn.style.display = teams.some(t => t.my_role === 'admin') ? '' : 'none';
}).catch(() => {});
}
}
},
closeUserMenu() {
@@ -749,31 +768,7 @@ const UI = {
return;
}
const badges = [];
// Output tokens
if (caps.max_output_tokens > 0) {
const k = caps.max_output_tokens >= 1000
? (caps.max_output_tokens / 1000).toFixed(0) + 'K'
: caps.max_output_tokens;
badges.push(`<span class="cap-badge cap-context" title="Max output: ${caps.max_output_tokens.toLocaleString()} tokens">${k} out</span>`);
}
// Context window
if (caps.max_context > 0) {
const k = (caps.max_context / 1000).toFixed(0) + 'K';
badges.push(`<span class="cap-badge cap-context" title="Context window: ${caps.max_context.toLocaleString()} tokens">${k} ctx</span>`);
}
// Capability flags
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent" title="Supports tool/function calling">🔧 tools</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent" title="Supports image/vision input">👁 vision</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent" title="Supports extended thinking">💭 thinking</span>');
if (caps.reasoning) badges.push('<span class="cap-badge cap-accent" title="Reasoning model (chain-of-thought)">🧠 reasoning</span>');
if (caps.code_optimized) badges.push('<span class="cap-badge" title="Optimized for code generation">⟨/⟩ code</span>');
if (caps.web_search) badges.push('<span class="cap-badge" title="Supports web search">🔍 search</span>');
el.innerHTML = badges.join('');
el.innerHTML = renderCapBadges(caps);
},
// ── User / Connection ────────────────────

729
src/js/ui-primitives.js Normal file
View File

@@ -0,0 +1,729 @@
// ── Chat Switchboard — UI Primitives ────────────────────────────────────────
// Single source of truth for constants + reusable rendering functions.
// Loaded after ui-format.js, before ui-core.js.
// Follows the renderPresetForm() pattern: (container, options) → control object.
// Designed for extension hooks: registries are open for .add(), primitives
// accept options for customization, and return handles for programmatic control.
// ─────────────────────────────────────────────────────────────────────────────
'use strict';
// ══════════════════════════════════════════════════════════════════════════════
// §1 PROVIDER REGISTRY
// ══════════════════════════════════════════════════════════════════════════════
const Providers = (() => {
const _types = [
{ id: 'openai', label: 'OpenAI-compatible', endpoint: 'https://api.openai.com/v1' },
{ id: 'anthropic', label: 'Anthropic', endpoint: 'https://api.anthropic.com' },
{ id: 'venice', label: 'Venice.ai', endpoint: 'https://api.venice.ai/api/v1' },
{ id: 'openrouter', label: 'OpenRouter', endpoint: 'https://openrouter.ai/api/v1' },
];
return {
/** Register a new provider type (extension hook). */
add(entry) {
if (!entry?.id) throw new Error('Provider entry requires id');
if (_types.some(t => t.id === entry.id)) return; // no dupes
_types.push(entry);
},
/** All registered types. */
all() { return _types; },
/** Lookup by id. */
get(id) { return _types.find(t => t.id === id) || null; },
/** Map of id → label. */
labels() { return Object.fromEntries(_types.map(t => [t.id, t.label])); },
/** Map of id → default endpoint. */
endpoints() { return Object.fromEntries(_types.filter(t => t.endpoint).map(t => [t.id, t.endpoint])); },
/** Build <option> tags for a <select>. */
optionsHTML(selected) {
return _types.map(t =>
`<option value="${t.id}"${t.id === selected ? ' selected' : ''}>${esc(t.label)}</option>`
).join('');
},
};
})();
// ══════════════════════════════════════════════════════════════════════════════
// §2 ROLE REGISTRY
// ══════════════════════════════════════════════════════════════════════════════
const Roles = (() => {
const _roles = [
{ id: 'utility', label: 'Utility', typeFilter: 'chat', hint: 'summarization, title gen' },
{ id: 'embedding', label: 'Embedding', typeFilter: 'embedding', hint: 'future: KB search, note search' },
];
return {
/** Register a new role (extension hook). */
add(entry) {
if (!entry?.id) throw new Error('Role entry requires id');
if (_roles.some(r => r.id === entry.id)) return;
_roles.push(entry);
},
all() { return _roles; },
get(id) { return _roles.find(r => r.id === id) || null; },
/** Model type filter string for a given role. */
typeFor(roleId) {
const r = _roles.find(r => r.id === roleId);
return r ? r.typeFilter : 'chat';
},
};
})();
// ══════════════════════════════════════════════════════════════════════════════
// §3 CAPABILITY BADGES
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render capability badge HTML from a capabilities object.
*
* @param {Object} caps - Capabilities object (max_output_tokens, vision, etc.)
* @param {Object} [opts]
* @param {boolean} [opts.compact=false] - Compact mode (icons only, no labels/titles)
* @param {Array} [opts.extra] - Additional badge entries [{cap, icon, label, title, cssClass}]
* @returns {string} HTML string of badge spans
*/
function renderCapBadges(caps, opts = {}) {
if (!caps) return '';
const compact = opts.compact === true;
const badges = [];
// ── Token counts ──
if (caps.max_output_tokens > 0) {
const k = caps.max_output_tokens >= 1000
? (caps.max_output_tokens / 1000).toFixed(0) + 'K'
: String(caps.max_output_tokens);
const label = compact ? k : k + ' out';
const title = compact ? '' : ` title="Max output: ${caps.max_output_tokens.toLocaleString()} tokens"`;
badges.push(`<span class="cap-badge cap-context"${title}>${label}</span>`);
}
if (!compact && caps.max_context > 0) {
const k = (caps.max_context / 1000).toFixed(0) + 'K';
badges.push(`<span class="cap-badge cap-context" title="Context window: ${caps.max_context.toLocaleString()} tokens">${k} ctx</span>`);
}
// ── Capability flags (registry-driven for extensibility) ──
const flags = [
{ cap: 'tool_calling', icon: '🔧', label: 'tools', title: 'Supports tool/function calling', css: 'cap-accent' },
{ cap: 'vision', icon: '👁', label: 'vision', title: 'Supports image/vision input', css: 'cap-accent' },
{ cap: 'thinking', icon: '💭', label: 'thinking', title: 'Supports extended thinking', css: 'cap-accent' },
{ cap: 'reasoning', icon: '🧠', label: 'reasoning', title: 'Reasoning model (chain-of-thought)', css: 'cap-accent' },
{ cap: 'code_optimized', icon: '⟨/⟩', label: 'code', title: 'Optimized for code generation', css: '' },
{ cap: 'web_search', icon: '🔍', label: 'search', title: 'Supports web search', css: '' },
];
for (const f of flags) {
if (!caps[f.cap]) continue;
const text = compact ? f.icon : `${f.icon} ${f.label}`;
const cls = f.css ? `cap-badge ${f.css}` : 'cap-badge';
const title = compact ? '' : ` title="${f.title}"`;
badges.push(`<span class="${cls}"${title}>${text}</span>`);
}
// Extension-provided badges
if (opts.extra) {
for (const e of opts.extra) {
if (e.cap && !caps[e.cap]) continue;
const cls = e.cssClass || 'cap-badge';
const title = e.title && !compact ? ` title="${esc(e.title)}"` : '';
const text = compact ? (e.icon || '') : `${e.icon || ''} ${e.label || ''}`.trim();
badges.push(`<span class="${cls}"${title}>${text}</span>`);
}
}
return badges.join('');
}
// ══════════════════════════════════════════════════════════════════════════════
// §4 PROVIDER FORM
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render a provider create/edit form into a container.
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {string} [opts.prefix='prov'] - ID prefix for form elements
* @param {boolean} [opts.showPrivate=false] - Show "private provider" checkbox
* @param {boolean} [opts.showDefaultModel=true] - Show default model field
* @param {Function} [opts.onSubmit] - Called with (values, isEdit) on save
* @param {Function} [opts.onCancel] - Called on cancel
* @returns {{ getValues, setValues, clear, setEditMode, container }}
*/
function renderProviderForm(containerEl, opts = {}) {
const pfx = opts.prefix || 'prov';
const showPrivate = opts.showPrivate === true;
const showModel = opts.showDefaultModel !== false;
containerEl.innerHTML = `
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="My Provider"></div>
<div class="form-group"><label>Provider</label><select id="${pfx}_type">${Providers.optionsHTML()}</select></div>
<div class="form-group"><label>Endpoint</label><input type="text" id="${pfx}_endpoint" placeholder="https://api.openai.com/v1"></div>
<div class="form-group"><label>API Key</label><input type="password" id="${pfx}_key" placeholder="sk-..."></div>
${showModel ? `<div class="form-group"><label>Default Model</label><input type="text" id="${pfx}_model" placeholder="gpt-4o"></div>` : ''}
${showPrivate ? `<div style="margin:6px 0"><label class="checkbox-label"><input type="checkbox" id="${pfx}_private"> Private provider (data stays on-prem)</label></div>` : ''}
<div class="form-row">
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Save</button>
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
</div>
`;
// ── Endpoint auto-fill on type change ──
const typeSelect = document.getElementById(`${pfx}_type`);
const endpointInput = document.getElementById(`${pfx}_endpoint`);
const endpoints = Providers.endpoints();
typeSelect?.addEventListener('change', function () {
const epVal = endpointInput?.value || '';
if (!epVal || Object.values(endpoints).includes(epVal)) {
endpointInput.value = endpoints[this.value] || '';
}
});
// ── Submit / Cancel wiring ──
const submitBtn = document.getElementById(`${pfx}_submitBtn`);
const cancelBtn = document.getElementById(`${pfx}_cancelBtn`);
submitBtn?.addEventListener('click', () => {
if (opts.onSubmit) opts.onSubmit(form.getValues(), !!form._editId);
});
cancelBtn?.addEventListener('click', () => {
if (opts.onCancel) opts.onCancel();
});
const form = {
_editId: null,
container: containerEl,
getValues() {
const v = {
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
provider: document.getElementById(`${pfx}_type`)?.value || 'openai',
endpoint: document.getElementById(`${pfx}_endpoint`)?.value.trim() || '',
api_key: document.getElementById(`${pfx}_key`)?.value || '',
};
if (showModel) {
v.model_default = document.getElementById(`${pfx}_model`)?.value.trim() || '';
}
if (showPrivate) {
v.is_private = document.getElementById(`${pfx}_private`)?.checked || false;
}
if (form._editId) v._editId = form._editId;
return v;
},
setValues(cfg) {
const el = id => document.getElementById(`${pfx}_${id}`);
if (el('name')) el('name').value = cfg.name || '';
if (el('type')) el('type').value = cfg.provider || 'openai';
if (el('endpoint')) el('endpoint').value = cfg.endpoint || '';
if (el('key')) {
el('key').value = '';
el('key').placeholder = cfg.has_key ? '(unchanged — leave blank to keep)' : 'sk-...';
}
if (showModel && el('model')) el('model').value = cfg.model_default || '';
if (showPrivate && el('private')) el('private').checked = !!cfg.is_private;
},
clear() {
['name', 'endpoint', 'key'].forEach(f => {
const el = document.getElementById(`${pfx}_${f}`);
if (el) el.value = '';
});
if (showModel) {
const m = document.getElementById(`${pfx}_model`);
if (m) m.value = '';
}
if (showPrivate) {
const p = document.getElementById(`${pfx}_private`);
if (p) p.checked = false;
}
const typeSel = document.getElementById(`${pfx}_type`);
if (typeSel) typeSel.selectedIndex = 0;
const keyEl = document.getElementById(`${pfx}_key`);
if (keyEl) keyEl.placeholder = 'sk-...';
form._editId = null;
if (submitBtn) submitBtn.textContent = 'Save';
},
/** Switch form into edit mode for a given provider id. */
setEditMode(id, cfg) {
form._editId = id;
form.setValues(cfg);
if (submitBtn) submitBtn.textContent = 'Update';
},
/** Back to create mode. */
setCreateMode() {
form.clear();
},
/** Get the type select element (for extensions to hook into). */
getTypeSelect() { return typeSelect; },
};
return form;
}
// ══════════════════════════════════════════════════════════════════════════════
// §5 PROVIDER LIST
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render a provider list into a container.
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {Function} opts.apiFetch - async () → provider array
* @param {Function} [opts.onEdit] - (provider) → void
* @param {Function} [opts.onDelete] - (provider) → void
* @param {Function} [opts.onRefresh] - (provider) → void (refresh models btn)
* @param {Function} [opts.onToggleActive] - (provider) → void (active/inactive toggle)
* @param {boolean} [opts.showEndpoint=false]
* @param {boolean} [opts.showPrivate=false]
* @param {boolean} [opts.showActive=false]
* @param {boolean} [opts.showRefresh=false]
* @param {string} [opts.emptyMsg]
* @returns {{ refresh, getCache }}
*/
function renderProviderList(containerEl, opts = {}) {
const cache = {};
async function refresh(quiet) {
if (!quiet) containerEl.innerHTML = '<div class="loading">Loading...</div>';
try {
const raw = await opts.apiFetch();
// Normalize — APIs return different shapes
const list = Array.isArray(raw) ? raw : (raw.configs || raw.providers || raw.data || []);
// Clear and rebuild cache
Object.keys(cache).forEach(k => delete cache[k]);
list.forEach(c => { cache[c.id] = c; });
if (list.length === 0) {
containerEl.innerHTML = `<div class="empty-hint">${esc(opts.emptyMsg || 'No providers configured.')}</div>`;
return;
}
containerEl.innerHTML = list.map(c => {
const meta = [];
meta.push(esc(c.provider || ''));
if (c.has_key != null) meta.push(c.has_key ? '🔑' : '⚠️ no key');
if (opts.showEndpoint && c.endpoint) meta.push(esc(c.endpoint));
if (!opts.showEndpoint && c.model_default) meta.push(esc(c.model_default));
const badges = [];
if (opts.showPrivate && c.is_private)
badges.push('<span class="badge-private">private</span>');
if (opts.showActive) {
const active = c.is_active !== false;
badges.push(`<span class="${active ? 'badge-admin' : 'badge-user'}" style="font-size:10px">${active ? 'active' : 'inactive'}</span>`);
}
const actions = [];
if (opts.onRefresh)
actions.push(`<button class="btn-small" data-action="refresh" data-id="${c.id}">Refresh Models</button>`);
if (opts.onEdit)
actions.push(`<button class="btn-edit" data-action="edit" data-id="${c.id}" title="Edit">✎</button>`);
if (opts.onToggleActive) {
const active = c.is_active !== false;
actions.push(`<button class="admin-model-toggle ${active ? 'enabled' : ''}" data-action="toggle" data-id="${c.id}">${active ? '✓ Active' : 'Inactive'}</button>`);
}
if (opts.onDelete)
actions.push(`<button class="btn-delete" data-action="delete" data-id="${c.id}" title="Delete">✕</button>`);
return `<div class="provider-row" data-provider-id="${c.id}">
<div>
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${meta.join(' · ')}</span>
${badges.join(' ')}
</div>
<div class="provider-actions">${actions.join('')}</div>
</div>`;
}).join('');
} catch (e) {
containerEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
}
// ── Delegate clicks ──
containerEl.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const id = btn.dataset.id;
const provider = cache[id];
if (!provider) return;
switch (btn.dataset.action) {
case 'edit': opts.onEdit?.(provider); break;
case 'delete': opts.onDelete?.(provider); break;
case 'refresh': opts.onRefresh?.(provider); break;
case 'toggle': opts.onToggleActive?.(provider); break;
}
});
return { refresh, getCache: () => ({ ...cache }) };
}
// ══════════════════════════════════════════════════════════════════════════════
// §6 ROLE CONFIG
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render role configuration (provider + model dropdowns per role).
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {string} opts.scope - 'admin' | 'personal'
* @param {Function} opts.fetchProviders - async () → provider array
* @param {Function} opts.fetchModels - async () → model array
* @param {Function} opts.fetchRoles - async () → { utility: {primary, fallback}, ... }
* @param {Function} opts.onSave - async (roleId, config) → void
* @param {Function} [opts.onClear] - async (roleId) → void (personal only)
* @param {Function} [opts.onTest] - async (roleId) → result (admin only)
* @param {boolean} [opts.showFallback=false]
* @param {string} [opts.modelIdField='model_id'] - field name for model ID on model objects
* @param {string} [opts.modelNameField='display_name'] - field for model display name
* @param {string} [opts.providerIdField='provider_config_id'] - field on models for their provider
* @param {string} [opts.noneLabel='— none —']
* @returns {{ refresh }}
*/
function renderRoleConfig(containerEl, opts = {}) {
const scope = opts.scope || 'admin';
const showFallback = opts.showFallback === true;
const midField = opts.modelIdField || 'model_id';
const mnameField = opts.modelNameField || 'display_name';
const pidField = opts.providerIdField || 'provider_config_id';
const noneLabel = opts.noneLabel || '— none —';
// Cached data for provider-change handler
let _providers = [];
let _models = [];
let _roles = {};
function filterModels(providerId, roleId) {
const typeFilter = Roles.typeFor(roleId);
return _models.filter(m =>
m[pidField] === providerId &&
(m.model_type || 'chat') === typeFilter
);
}
function onProviderChange(roleId, slot) {
const provSel = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`);
const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`);
if (!provSel || !modelSel) return;
modelSel.innerHTML = '<option value="">— select model —</option>';
const provId = provSel.value;
if (!provId) return;
filterModels(provId, roleId).forEach(m => {
const opt = document.createElement('option');
opt.value = m[midField];
opt.textContent = m[mnameField] || m[midField];
modelSel.appendChild(opt);
});
}
function getBinding(roleId, slot) {
const prov = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`)?.value;
const model = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`)?.value;
return (prov && model) ? { provider_config_id: prov, model_id: model } : null;
}
async function handleSave(roleId) {
const statusEl = containerEl.querySelector(`[data-role-status="${roleId}"]`);
try {
const config = { primary: getBinding(roleId, 'primary') };
if (showFallback) config.fallback = getBinding(roleId, 'fallback');
await opts.onSave(roleId, config);
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--success)'; }
// Reload after short delay so dropdowns reflect saved state
setTimeout(() => refresh(), 500);
} catch (e) {
if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--error)'; }
}
}
async function handleTest(roleId) {
if (!opts.onTest) return;
const statusEl = containerEl.querySelector(`[data-role-status="${roleId}"]`);
if (statusEl) { statusEl.textContent = 'Testing...'; statusEl.style.color = 'var(--text-muted)'; }
try {
const result = await opts.onTest(roleId);
if (statusEl) {
const fb = result.used_fallback ? ' (fallback)' : '';
statusEl.textContent = `${result.model}${fb}`;
statusEl.style.color = 'var(--success)';
}
} catch (e) {
if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--error)'; }
}
}
async function handleClear(roleId) {
if (!opts.onClear) return;
try {
await opts.onClear(roleId);
refresh();
} catch (e) { UI.toast(e.message, 'error'); }
}
function renderSlot(roleId, slotName, cfg, slotLabel) {
const binding = cfg?.[slotName];
const selectedProv = binding?.provider_config_id || '';
const selectedModel = binding?.model_id || '';
const provOptions = _providers.map(p =>
`<option value="${p.id}"${p.id === selectedProv ? ' selected' : ''}>${esc(p.name)}</option>`
).join('');
const modelOptions = selectedProv
? filterModels(selectedProv, roleId).map(m =>
`<option value="${m[midField]}"${m[midField] === selectedModel ? ' selected' : ''}>${esc(m[mnameField] || m[midField])}</option>`
).join('')
: '';
return `
<div class="form-row" style="gap:8px;align-items:center${slotName === 'fallback' ? ';margin-top:6px' : ''}">
<label style="min-width:60px;font-size:12px">${slotLabel}</label>
<select data-role-provider="${roleId}-${slotName}" style="min-width:140px">
<option value="">${noneLabel}</option>
${provOptions}
</select>
<select data-role-model="${roleId}-${slotName}" style="min-width:200px">
<option value="">— select model —</option>
${modelOptions}
</select>
</div>`;
}
async function refresh() {
containerEl.innerHTML = '<div class="loading">Loading...</div>';
try {
const [provRaw, modelRaw, roleData] = await Promise.all([
opts.fetchProviders(),
opts.fetchModels(),
opts.fetchRoles(),
]);
// Normalize
_providers = Array.isArray(provRaw) ? provRaw : (provRaw.configs || provRaw.providers || provRaw.data || []);
_models = Array.isArray(modelRaw) ? modelRaw : (modelRaw.models || modelRaw.data || []);
_roles = roleData || {};
const roles = Roles.all();
containerEl.innerHTML = roles.map(role => {
const cfg = _roles[role.id] || { primary: null, fallback: null };
const hasClear = scope === 'personal' && opts.onClear && cfg.primary;
return `
<div class="settings-section" style="margin-bottom:${scope === 'admin' ? 16 : 12}px">
<${scope === 'admin' ? 'h3' : 'h4'} style="font-size:${scope === 'admin' ? 14 : 13}px;margin:0 0 ${scope === 'admin' ? 8 : 6}px;text-transform:capitalize">
${esc(role.label || role.id)}
${scope === 'personal' && role.hint ? `<span class="form-hint">(${esc(role.hint)})</span>` : ''}
</${scope === 'admin' ? 'h3' : 'h4'}>
${renderSlot(role.id, 'primary', cfg, scope === 'personal' ? 'Provider' : 'Primary')}
${showFallback ? renderSlot(role.id, 'fallback', cfg, 'Fallback') : ''}
<div class="form-row" style="gap:8px;margin-top:8px">
<button class="btn-primary btn-small" data-role-save="${role.id}">Save</button>
${opts.onTest ? `<button class="btn-secondary btn-small" data-role-test="${role.id}">Test</button>` : ''}
${hasClear ? `<button class="btn-small btn-danger" data-role-clear="${role.id}" title="Remove override, use org default">Clear</button>` : ''}
<span data-role-status="${role.id}" style="font-size:12px"></span>
</div>
</div>`;
}).join('');
} catch (e) {
containerEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
}
// ── Delegate events ──
containerEl.addEventListener('change', (e) => {
const provSel = e.target.closest('[data-role-provider]');
if (!provSel) return;
const [roleId, slot] = provSel.dataset.roleProvider.split('-');
onProviderChange(roleId, slot);
});
containerEl.addEventListener('click', (e) => {
const saveBtn = e.target.closest('[data-role-save]');
if (saveBtn) { handleSave(saveBtn.dataset.roleSave); return; }
const testBtn = e.target.closest('[data-role-test]');
if (testBtn) { handleTest(testBtn.dataset.roleTest); return; }
const clearBtn = e.target.closest('[data-role-clear]');
if (clearBtn) { handleClear(clearBtn.dataset.roleClear); return; }
});
return { refresh };
}
// ══════════════════════════════════════════════════════════════════════════════
// §7 USAGE DASHBOARD
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render usage stats + table into a container.
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {Function} opts.apiFetch - async ({period, group_by}) → { totals, results }
* @param {string} [opts.periodElId] - ID of period <select>
* @param {string} [opts.groupByElId] - ID of groupBy <select>
* @param {boolean} [opts.compact=false] - Use compact sizing (user/team)
* @param {boolean} [opts.showUserColumn=false] - Include "User" in group_by options
* @param {Array} [opts.extraColumns] - [{header, render: (row) → html}]
* @returns {{ refresh }}
*/
function renderUsageDashboard(containerEl, opts = {}) {
const compact = opts.compact === true;
const cardStyle = compact ? ' style="padding:8px"' : '';
const valueStyle = compact ? ' style="font-size:16px"' : '';
const labelStyle = compact ? ' style="font-size:10px"' : '';
const gridStyle = compact ? ' style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px"' : '';
const tableStyle = compact ? ' style="font-size:12px"' : ' style="margin-top:12px"';
// We render into two sub-zones
let totalsEl, resultsEl;
function ensureStructure() {
if (!containerEl.querySelector('[data-usage-totals]')) {
containerEl.innerHTML = `
<div data-usage-totals></div>
<div data-usage-results></div>
`;
}
totalsEl = containerEl.querySelector('[data-usage-totals]');
resultsEl = containerEl.querySelector('[data-usage-results]');
}
async function refresh() {
ensureStructure();
const period = opts.periodElId ? (document.getElementById(opts.periodElId)?.value || '30d') : '30d';
const groupBy = opts.groupByElId ? (document.getElementById(opts.groupByElId)?.value || 'model') : 'model';
totalsEl.innerHTML = compact
? '<div class="loading" style="font-size:12px">Loading...</div>'
: '<div class="loading">Loading...</div>';
resultsEl.innerHTML = '';
try {
const data = await opts.apiFetch({ period, group_by: groupBy });
const t = data.totals || {};
if (!t.requests && compact) {
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded in this period</div>';
return;
}
totalsEl.innerHTML = `
<div class="stats-grid"${gridStyle}>
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.requests || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Requests</div></div>
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Input${compact ? '' : ' Tokens'}</div></div>
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Output${compact ? '' : ' Tokens'}</div></div>
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label"${labelStyle}>Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length === 0) {
resultsEl.innerHTML = '<div class="empty-hint">No usage data for this period</div>';
return;
}
// Column header depends on groupBy
let firstCol;
if (groupBy === 'day') firstCol = 'Date';
else if (groupBy === 'user') firstCol = 'User';
else firstCol = 'Model';
const extraHeaders = (opts.extraColumns || []).map(c =>
`<th style="text-align:right">${esc(c.header)}</th>`
).join('');
resultsEl.innerHTML = `
<table class="admin-table"${tableStyle}>
<thead><tr>
<th>${firstCol}</th>
<th style="text-align:right">${compact ? 'Reqs' : 'Requests'}</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Cost</th>
${extraHeaders}
</tr></thead>
<tbody>${results.map(r => {
const extraCells = (opts.extraColumns || []).map(c =>
`<td style="text-align:right">${c.render(r)}</td>`
).join('');
return `<tr>
<td>${esc(r.label || r.group_key)}</td>
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
${extraCells}
</tr>`;
}).join('')}</tbody>
</table>`;
} catch (e) {
totalsEl.innerHTML = `<div class="error-hint"${compact ? ' style="font-size:12px"' : ''}>${esc(e.message)}</div>`;
}
}
return { refresh };
}
// ══════════════════════════════════════════════════════════════════════════════
// §8 CONFIRM DIALOG
// ══════════════════════════════════════════════════════════════════════════════
/**
* Custom confirm dialog replacing native confirm().
* Returns a Promise<boolean>.
*
* @param {string} message
* @param {Object} [opts]
* @param {string} [opts.title='Confirm']
* @param {string} [opts.ok='Confirm']
* @param {string} [opts.cancel='Cancel']
* @param {boolean} [opts.danger=false] - Styles the confirm button as destructive
* @returns {Promise<boolean>}
*/
function showConfirm(message, opts = {}) {
return new Promise(resolve => {
const title = opts.title || 'Confirm';
const okText = opts.ok || 'Confirm';
const caText = opts.cancel || 'Cancel';
const danger = opts.danger !== false; // default true for destructive actions
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.innerHTML = `
<div class="confirm-dialog">
<div class="confirm-header">${esc(title)}</div>
<div class="confirm-body">${esc(message)}</div>
<div class="confirm-footer">
<button class="btn-small" data-action="cancel">${esc(caText)}</button>
<button class="btn-small ${danger ? 'btn-danger' : 'btn-primary'}" data-action="ok">${esc(okText)}</button>
</div>
</div>`;
function close(result) {
overlay.remove();
document.removeEventListener('keydown', onKey);
resolve(result);
}
function onKey(e) {
if (e.key === 'Escape') close(false);
if (e.key === 'Enter') close(true);
}
overlay.querySelector('[data-action="ok"]').addEventListener('click', () => close(true));
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
overlay.querySelector('[data-action="cancel"]').focus();
});
}

View File

@@ -191,37 +191,92 @@ Object.assign(UI, {
async loadTeamManageProviders(teamId) {
const el = document.getElementById('settingsTeamProviders');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
const addBtn = document.getElementById('settingsTeamAddProviderBtn');
try {
const resp = await API.teamListProviders(teamId);
const provs = resp.providers || [];
const allowed = resp.allow_team_providers !== false;
const formEl = document.getElementById('settingsTeamProviderForm');
// Show/hide add button and entire section based on policy
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
// Initialize team provider form primitive (once)
if (!UI._teamProvForm && formEl) {
UI._teamProvForm = renderProviderForm(formEl, {
prefix: 'teamProv',
showPrivate: true,
showDefaultModel: true,
onSubmit: async (vals, isEdit) => {
const tid = UI._managingTeamId;
try {
if (isEdit) {
const updates = {};
if (vals.name) updates.name = vals.name;
if (vals.endpoint) updates.endpoint = vals.endpoint;
if (vals.api_key) updates.api_key = vals.api_key;
if (vals.model_default) updates.model_default = vals.model_default;
updates.is_private = vals.is_private || false;
await API.teamUpdateProvider(tid, vals._editId, updates);
UI.toast('Provider updated');
} else {
if (!vals.name) return UI.toast('Name required', 'error');
if (!vals.endpoint) return UI.toast('Endpoint required', 'error');
await API.teamCreateProvider(tid, {
name: vals.name, provider: vals.provider, endpoint: vals.endpoint,
api_key: vals.api_key, is_private: vals.is_private || false,
});
UI.toast('Provider added');
}
formEl.style.display = 'none';
UI._teamProvForm.setCreateMode();
UI._teamProvList.refresh();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => {
formEl.style.display = 'none';
UI._teamProvForm.setCreateMode();
},
});
}
if (!allowed && provs.length === 0) {
el.innerHTML = '<div class="empty-hint">Team provider management is disabled by your administrator</div>';
return;
}
el.innerHTML = provs.map(p => {
const statusCls = p.is_active ? 'badge-admin' : 'badge-user';
const statusLabel = p.is_active ? 'active' : 'inactive';
const privateBadge = p.is_private ? ' <span class="badge-user" style="font-size:9px">🔒 private</span>' : '';
return `<div class="admin-preset-row" style="padding:6px 0">
<div class="preset-info">
<strong>${esc(p.name)}</strong>
<div class="preset-meta" style="font-size:11px">${esc(p.provider)} · ${p.has_key ? '🔑' : 'no key'} <span class="${statusCls}" style="font-size:10px">${statusLabel}</span>${privateBadge}</div>
</div>
<div class="preset-actions">
<button class="btn-small" onclick="settingsEditTeamProvider('${teamId}','${p.id}','${esc(p.name)}','${esc(p.endpoint || '')}','${esc(p.model_default || '')}',${!!p.is_private})" title="Edit">✎</button>
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="settingsToggleTeamProvider('${teamId}','${p.id}',${p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="settingsDeleteTeamProvider('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No team providers — add one to give your team access to additional models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
// Initialize team provider list primitive (once)
if (!UI._teamProvList) {
UI._teamProvList = renderProviderList(el, {
apiFetch: async () => {
const resp = await API.teamListProviders(UI._managingTeamId);
// Handle policy check
const allowed = resp.allow_team_providers !== false;
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (!allowed && !(resp.providers || []).length) {
throw { message: 'Team provider management is disabled by your administrator', _empty: true };
}
return resp;
},
showPrivate: true,
showActive: true,
emptyMsg: 'No team providers — add one to give your team access to additional models',
onEdit: (prov) => {
if (UI._teamProvForm) {
UI._teamProvForm.setEditMode(prov.id, prov);
formEl.style.display = '';
}
},
onDelete: async (prov) => {
if (!await showConfirm(`Delete team provider "${prov.name}"? This will remove all models from this provider for your team.`)) return;
try {
await API.teamDeleteProvider(UI._managingTeamId, prov.id);
UI.toast('Provider deleted');
UI._teamProvList.refresh();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onToggleActive: async (prov) => {
try {
await API.teamUpdateProvider(UI._managingTeamId, prov.id, { is_active: !prov.is_active });
UI.toast(prov.is_active ? 'Provider deactivated' : 'Provider activated');
UI._teamProvList.refresh();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
});
}
await UI._teamProvList.refresh();
},
async loadTeamManagePresets(teamId) {
@@ -244,108 +299,34 @@ Object.assign(UI, {
},
async loadMyUsage() {
const totalsEl = document.getElementById('myUsageTotals');
const resultsEl = document.getElementById('myUsageResults');
if (!totalsEl) return;
const period = document.getElementById('myUsagePeriod')?.value || '30d';
const groupBy = document.getElementById('myUsageGroupBy')?.value || 'model';
totalsEl.innerHTML = '<div class="loading" style="font-size:12px">Loading...</div>';
resultsEl.innerHTML = '';
try {
const data = await API.getMyUsage({ period, group_by: groupBy });
const t = data.totals || {};
if (!t.requests) {
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded in this period</div>';
return;
}
totalsEl.innerHTML = `
<div class="stats-grid" style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px">
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.requests || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Requests</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Input</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Output</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label" style="font-size:10px">Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length > 0) {
resultsEl.innerHTML = `
<table class="admin-table" style="font-size:12px">
<thead><tr>
<th>${groupBy === 'day' ? 'Date' : 'Model'}</th>
<th style="text-align:right">Reqs</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Cost</th>
</tr></thead>
<tbody>${results.map(r => `<tr>
<td>${esc(r.label || r.group_key)}</td>
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { totalsEl.innerHTML = `<div class="error-hint" style="font-size:12px">${esc(e.message)}</div>`; }
const el = document.getElementById('myUsageTotals')?.parentElement;
if (!el) return;
if (!UI._myUsageDash) {
UI._myUsageDash = renderUsageDashboard(el, {
apiFetch: (p) => API.getMyUsage(p),
periodElId: 'myUsagePeriod',
groupByElId: 'myUsageGroupBy',
compact: true,
});
}
await UI._myUsageDash.refresh();
},
async loadTeamUsage() {
const teamId = UI._managingTeamId;
if (!teamId) return;
const totalsEl = document.getElementById('settingsTeamUsageTotals');
const resultsEl = document.getElementById('settingsTeamUsageResults');
if (!totalsEl) return;
const period = document.getElementById('teamUsagePeriod')?.value || '30d';
const groupBy = document.getElementById('teamUsageGroupBy')?.value || 'model';
totalsEl.innerHTML = '<div class="loading" style="font-size:12px">Loading...</div>';
resultsEl.innerHTML = '';
try {
const data = await API.teamGetUsage(teamId, { period, group_by: groupBy });
const t = data.totals || {};
if (!t.requests) {
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded for team providers in this period</div>';
return;
}
totalsEl.innerHTML = `
<div class="stats-grid" style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px">
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.requests || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Requests</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Input</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Output</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label" style="font-size:10px">Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length > 0) {
resultsEl.innerHTML = `
<table class="admin-table" style="font-size:12px">
<thead><tr>
<th>${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}</th>
<th style="text-align:right">Reqs</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Cost</th>
</tr></thead>
<tbody>${results.map(r => `<tr>
<td>${esc(r.label || r.group_key)}</td>
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { totalsEl.innerHTML = `<div class="error-hint" style="font-size:12px">${esc(e.message)}</div>`; }
const el = document.getElementById('settingsTeamUsageTotals')?.parentElement;
if (!el) return;
if (!UI._teamUsageDash) {
UI._teamUsageDash = renderUsageDashboard(el, {
apiFetch: (p) => API.teamGetUsage(teamId, p),
periodElId: 'teamUsagePeriod',
groupByElId: 'teamUsageGroupBy',
compact: true,
showUserColumn: true,
});
}
await UI._teamUsageDash.refresh();
},
_teamAuditPage: 1,
@@ -364,8 +345,8 @@ Object.assign(UI, {
if (tabBtn) tabBtn.style.display = byokAllowed ? '' : 'none';
if (!byokAllowed) return;
// Check if user has personal providers
try {
// Get user's personal providers and their models
const configs = await API.listConfigs();
const configList = configs.configs || configs.data || [];
const personalProviders = configList.filter(c => c.scope === 'personal');
@@ -377,45 +358,12 @@ Object.assign(UI, {
}
el.style.display = '';
if (notice) notice.style.display = 'none';
// Load all models for personal providers
const allModels = App.models || [];
const personalModels = allModels.filter(m =>
personalProviders.some(p => p.id === m.configId)
);
// Load current user settings to get existing role overrides
const settings = await API.getSettings();
const userRoles = settings.model_roles || {};
const roleNames = ['utility', 'embedding'];
el.innerHTML = roleNames.map(role => {
const cfg = userRoles[role] || { primary: null };
const typeForRole = role === 'embedding' ? 'embedding' : 'chat';
const typeFilter = m => (m.model_type || 'chat') === typeForRole;
return `
<div class="settings-section" style="margin-bottom:12px">
<h4 style="font-size:13px;margin:0 0 6px;text-transform:capitalize">${role}
<span class="form-hint">${role === 'utility' ? '(summarization, title gen)' : '(future: KB search, note search)'}</span>
</h4>
<div class="form-row" style="gap:8px;align-items:center">
<label style="min-width:60px;font-size:12px">Provider</label>
<select id="userRole-${role}-provider" onchange="userRoleProviderChanged('${role}')" style="min-width:140px;font-size:12px">
<option value="">— use org default —</option>
${personalProviders.map(p => `<option value="${p.id}" ${cfg.primary?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
</select>
<select id="userRole-${role}-model" style="min-width:200px;font-size:12px">
<option value="">— select model —</option>
${cfg.primary ? personalModels.filter(m => m.configId === cfg.primary.provider_config_id && typeFilter(m)).map(m => `<option value="${m.baseModelId}" ${m.baseModelId === cfg.primary.model_id ? 'selected' : ''}>${esc(m.name || m.baseModelId)}</option>`).join('') : ''}
</select>
<button class="btn-small" onclick="saveUserRole('${role}')" style="font-size:11px">Save</button>
${cfg.primary ? `<button class="btn-small btn-danger" onclick="clearUserRole('${role}')" style="font-size:11px" title="Remove override, use org default">Clear</button>` : ''}
</div>
</div>`;
}).join('');
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
return;
}
if (_userRoleConfig) await _userRoleConfig.refresh();
},
async loadTeamAuditLog(page) {
@@ -532,38 +480,16 @@ Object.assign(UI, {
// ── Providers ────────────────────────────
async loadProviderList() {
if (_userProvList) { await _userProvList.refresh(); return; }
// Fallback if primitives not yet initialized
const el = document.getElementById('providerList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.listConfigs();
const list = Array.isArray(data) ? data : (data.configs || data.data || []);
if (list.length === 0) {
el.innerHTML = '<div class="empty-hint">No providers configured.</div>';
return;
}
el.innerHTML = list.map(c => `
<div class="provider-row">
<div>
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)} · ${esc(c.model_default || 'no default')}</span>
</div>
<div class="provider-actions">
${c.has_key ? '🔑' : '⚠️'}
<button class="btn-small" onclick="refreshProviderModels('${c.id}','${esc(c.name)}')">Refresh Models</button>
<button class="btn-small" onclick="editProvider('${c.id}')">Edit</button>
<button class="btn-small btn-danger" onclick="deleteProvider('${c.id}','${esc(c.name)}')">Remove</button>
</div>
</div>`).join('');
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
showProviderForm() { document.getElementById('providerAddForm').style.display = ''; },
hideProviderForm() {
document.getElementById('providerAddForm').style.display = 'none';
UI._editingProviderId = null;
document.getElementById('providerApiKey').placeholder = 'API key';
if (_userProvForm) _userProvForm.setCreateMode();
},
// ── User Model List ─────────────────────
@@ -592,11 +518,7 @@ Object.assign(UI, {
el.innerHTML = models.map(m => {
const mid = m.model_id || m.id;
const caps = m.capabilities || {};
const badges = [];
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
const badges = renderCapBadges(caps, { compact: true });
const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>'
: m.source === 'team' ? `<span class="badge-team" style="font-size:10px">👥 ${esc(m.team_name || 'team')}</span>` : '';
const hidden = App.hiddenModels.has(mid);
@@ -604,7 +526,7 @@ Object.assign(UI, {
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
return `<div class="model-list-item ${hidden ? 'model-hidden' : ''}">
<span class="model-name">${esc(mid)}</span>
<span class="model-caps-inline">${badges.join('')}</span>
<span class="model-caps-inline">${badges}</span>
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
${src}
<button class="admin-model-toggle ${toggleCls}" onclick="toggleUserModelVisibility('${esc(mid)}', ${hidden})" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button>