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

15 KiB
Raw Blame History

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:

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:

// 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:

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:

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