This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/UI-AUDIT-0.10.5.md
2026-02-26 21:19:55 +00:00

359 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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