359 lines
15 KiB
Markdown
359 lines
15 KiB
Markdown
# 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
|