Changeset 0.10.5 (#61)
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user