Changeset 0.10.5 (#61)
This commit is contained in:
47
CHANGELOG.md
47
CHANGELOG.md
@@ -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
|
||||
|
||||
24
ROADMAP.md
24
ROADMAP.md
@@ -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
358
UI-AUDIT-0.10.5.md
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
_userProvForm = renderProviderForm(formEl, {
|
||||
prefix: 'userProv',
|
||||
showPrivate: false,
|
||||
showDefaultModel: true,
|
||||
onSubmit: async (vals, isEdit) => {
|
||||
try {
|
||||
await API.updateConfig(editId, patch);
|
||||
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');
|
||||
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 {
|
||||
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');
|
||||
}
|
||||
UI.hideProviderForm();
|
||||
UI.loadProviderList();
|
||||
}
|
||||
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;
|
||||
_userProvList = renderProviderList(listEl, {
|
||||
apiFetch: () => API.listConfigs(),
|
||||
showRefresh: true,
|
||||
emptyMsg: 'No providers configured.',
|
||||
onEdit: async (prov) => {
|
||||
try {
|
||||
await API.deleteConfig(id);
|
||||
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');
|
||||
UI.loadProviderList();
|
||||
_userProvList.refresh();
|
||||
fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function refreshProviderModels(id, name) {
|
||||
},
|
||||
onRefresh: async (prov) => {
|
||||
try {
|
||||
UI.toast(`Fetching models for ${name}...`, 'info');
|
||||
const result = await API.fetchProviderModels(id);
|
||||
UI.toast(`Fetching models for ${prov.name}...`, 'info');
|
||||
const result = await API.fetchProviderModels(prov.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'); }
|
||||
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,65 +204,48 @@ 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>`;
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
_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[role] = { primary: { provider_config_id: providerId, model_id: modelId } };
|
||||
roles[roleId] = config;
|
||||
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 {
|
||||
UI.toast(`${roleId} role override saved`, 'success');
|
||||
},
|
||||
onClear: async (roleId) => {
|
||||
const settings = await API.getSettings();
|
||||
const roles = settings.model_roles || {};
|
||||
delete roles[role];
|
||||
delete roles[roleId];
|
||||
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');
|
||||
}
|
||||
UI.toast(`${roleId} role override removed — using org default`, 'success');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Command Palette ──────────────────────────
|
||||
@@ -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
|
||||
|
||||
@@ -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,108 +139,64 @@ 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();
|
||||
|
||||
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();
|
||||
const providers = configs.configs || configs || [];
|
||||
return configs.configs || configs || [];
|
||||
},
|
||||
fetchModels: async () => {
|
||||
const models = await API.adminListModels();
|
||||
const modelList = models.models || models.data || models || [];
|
||||
return models.models || models.data || models || [];
|
||||
},
|
||||
fetchRoles: () => API.adminListRoles(),
|
||||
onSave: async (roleId, config) => {
|
||||
await API.adminUpdateRole(roleId, config);
|
||||
},
|
||||
onTest: (roleId) => API.adminTestRole(roleId),
|
||||
});
|
||||
}
|
||||
|
||||
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('');
|
||||
|
||||
// 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;
|
||||
|
||||
totalsEl.innerHTML = '<div class="loading">Loading...</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
|
||||
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>`;
|
||||
// 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);
|
||||
}
|
||||
} catch (e) { totalsEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
|
||||
// Load pricing table
|
||||
if (!UI._adminUsageDash) {
|
||||
UI._adminUsageDash = renderUsageDashboard(usageContainer, {
|
||||
apiFetch: (p) => API.adminGetUsage(p),
|
||||
periodElId: 'usagePeriod',
|
||||
groupByElId: 'usageGroupBy',
|
||||
compact: false,
|
||||
showUserColumn: true,
|
||||
});
|
||||
}
|
||||
await UI._adminUsageDash.refresh();
|
||||
|
||||
// Pricing table (admin-only, separate from usage primitive)
|
||||
const pricingEl = document.getElementById('adminPricingTable');
|
||||
if (pricingEl) {
|
||||
try {
|
||||
const pricing = await API.adminListPricing();
|
||||
const entries = pricing || [];
|
||||
@@ -259,6 +220,7 @@ Object.assign(UI, {
|
||||
</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>';
|
||||
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 {
|
||||
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>`; }
|
||||
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>`;
|
||||
|
||||
@@ -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
729
src/js/ui-primitives.js
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
const formEl = document.getElementById('settingsTeamProviderForm');
|
||||
|
||||
// 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 {
|
||||
const resp = await API.teamListProviders(teamId);
|
||||
const provs = resp.providers || [];
|
||||
const allowed = resp.allow_team_providers !== false;
|
||||
|
||||
// Show/hide add button and entire section based on policy
|
||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||
|
||||
if (!allowed && provs.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">Team provider management is disabled by your administrator</div>';
|
||||
return;
|
||||
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');
|
||||
}
|
||||
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>`; }
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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>`; }
|
||||
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;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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>`; }
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user