diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index e6dd249ca..732a4c7e5 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -30,14 +30,6 @@ WORKDIR /v2_ui COPY application/v2_ui/package.json application/v2_ui/package-lock.json* ./ RUN npm ci --no-audit --no-fund -# The model catalog UI is shared source: the V2 SPA imports the same module and stylesheet -# that the legacy admin page loads, by relative path out of application/v2_ui. Those files -# must exist at /single_app/static for `tsc -b && vite build` to resolve the import. -COPY application/single_app/static/js/admin/model_catalog_ui.js \ - application/single_app/static/js/admin/model_catalog_ui.d.ts \ - /single_app/static/js/admin/ -COPY application/single_app/static/css/model-catalog.css /single_app/static/css/ - COPY application/v2_ui/ ./ # Vite is configured to emit into ../single_app/static/v2, so the build output lands at diff --git a/application/single_app/config.py b/application/single_app/config.py index d1e7e92e9..7778bc66a 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.257" +VERSION = "0.261.258" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_models.py b/application/single_app/route_backend_models.py index d75f9f7b6..e5eb57623 100644 --- a/application/single_app/route_backend_models.py +++ b/application/single_app/route_backend_models.py @@ -100,9 +100,13 @@ def catalog_response(settings, *, admin=False): ), None) if profile is not None: effective = apply_model_profile(model, endpoint, settings, profiles) + # The ids let the admin catalog open the exact connection and model; + # names alone can repeat across connections. links[profile["id"]].append({ "connection": endpoint.get("name") or endpoint.get("id"), + "connection_id": str(endpoint.get("id") or ""), "model": model.get("displayName") or model.get("deploymentName") or model.get("modelName"), + "model_id": str(model.get("id") or ""), "enabled": bool(endpoint.get("enabled", True) and model.get("enabled", True)), "capabilities": {key: value for key, value in effective.get("capabilities", {}).items() if type(value) is bool}, diff --git a/application/single_app/static/js/admin/model_catalog_ui.d.ts b/application/single_app/static/js/admin/model_catalog_ui.d.ts deleted file mode 100644 index 48efdd9ea..000000000 --- a/application/single_app/static/js/admin/model_catalog_ui.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -// model_catalog_ui.d.ts -export interface CatalogProfile { - id: string; - displayName: string; - [key: string]: unknown; -} -export interface CatalogResponse { - profiles: CatalogProfile[]; - tasks: Record; - etag?: string; -} -export type CatalogRequest = (path: string, options?: { method?: string; body?: unknown; signal?: AbortSignal }) => Promise; -export function mountModelCatalog(root: HTMLElement, options?: { request?: CatalogRequest; onSaved?: () => void }): () => void; -export function mountProfilePicker(root: HTMLElement, model: { catalogProfileId?: string }, onChange: (id: string) => void, request?: CatalogRequest): () => void; diff --git a/application/v2_ui/src/components/admin/ModelCatalogDetail.tsx b/application/v2_ui/src/components/admin/ModelCatalogDetail.tsx new file mode 100644 index 000000000..b37fceda2 --- /dev/null +++ b/application/v2_ui/src/components/admin/ModelCatalogDetail.tsx @@ -0,0 +1,621 @@ +// ModelCatalogDetail.tsx +// One catalog profile: what it is, what it is good at, and where it is connected. +// +// The classic detail panel printed every fact in one column, which made a single profile +// several screens long. Here the header keeps what an administrator acts on -- the name, +// where the profile comes from, and its routing preferences -- and the rest is split into +// tabs, each short enough to read without scrolling on a wide screen. + +import { useId, useRef, type KeyboardEvent, type ReactNode, type RefObject } from 'react'; +import { clsx } from 'clsx'; +import { + Archive, + ArchiveRestore, + ArrowUpRight, + CircleCheck, + CircleHelp, + CircleX, + Copy, + ExternalLink, + Pencil, + Server, + Star, + TriangleAlert, +} from 'lucide-react'; +import { + CAPABILITY_GROUP_LABELS, + CATALOG_CAPABILITIES, + PRIORITY_OPTIONS, + SUITABILITY_LABELS, + capabilityLabel, + evidenceDescription, + evidenceLabel, + formatTokenLimit, + isLifecycleWarning, + lifecycleLabel, + linkedModelCount, + publisherLabel, + readSuitability, + safeEvidenceUrl, + type CapabilityGroup, + type CatalogConnectionTarget, + type CatalogPriority, + type CatalogProfile, + type TaskSuitability, +} from '../../lib/modelCatalog'; +import { GlassButton } from '../ui/primitives'; +import { inputClass } from './fields'; + +export type CatalogDetailTab = 'overview' | 'capabilities' | 'connections' | 'evidence'; + +const TABS: ReadonlyArray<{ id: CatalogDetailTab; label: string }> = [ + { id: 'overview', label: 'Overview' }, + { id: 'capabilities', label: 'Capabilities' }, + { id: 'connections', label: 'Connections' }, + { id: 'evidence', label: 'Evidence' }, +]; + +/** Technical facts that only make sense as structured data, shown collapsed. */ +const ADVANCED_TECHNICAL: ReadonlyArray<[string, string]> = [ + ['tokenLimitEvidence', 'Token capacity evidence'], + ['tokenLimitProfiles', 'Hosted capacity profiles'], + ['reasoningPolicy', 'Reasoning policy'], + ['embeddingPolicy', 'Embedding policy'], + ['imageProfiles', 'Image operation profiles'], +]; + +const SUITABILITY_TONE: Readonly> = { + strong: 'bg-ok-soft text-ok', + suitable: 'bg-accent-soft text-accent', + unsuitable: 'bg-danger-soft text-danger', + unknown: 'bg-surface-sunken text-text-3', +}; + +function Chip({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'warn' }) { + return ( + + {children} + + ); +} + +function SectionTitle({ children }: { children: ReactNode }) { + return

{children}

; +} + +function TextList({ title, items }: { title: string; items: string[] }) { + return ( +
+ {title} + {items.length ? ( +
    + {items.map((item, index) => ( +
  • {item}
  • + ))} +
+ ) : ( +

Not documented.

+ )} +
+ ); +} + +function CapabilityRow({ label, value }: { label: string; value: unknown }) { + const state = value === true ? 'supported' : value === false ? 'unsupported' : 'unknown'; + const Icon = state === 'supported' ? CircleCheck : state === 'unsupported' ? CircleX : CircleHelp; + const stateText = state === 'supported' ? 'Supported' : state === 'unsupported' ? 'Not supported' : 'Unknown'; + // The icon's shape carries the state for sighted readers; the text is there for + // assistive technology and on hover, which keeps three columns readable. + return ( +
  • +
  • + ); +} + +function OverviewPanel({ profile, tasks }: { profile: CatalogProfile; tasks: Record }) { + // Every declared task is listed, so "Unknown" reads as a rating rather than an omission. + const taskEntries: [string, string][] = [ + ...Object.entries(tasks), + ...Object.keys(profile.tasks ?? {}) + .filter((key) => !(key in tasks)) + .map((key): [string, string] => [key, key]), + ]; + return ( +
    + {profile.chatCompletions === false ? ( +

    +

    + ) : null} + +
    + + +
    + +
    + Task suitability +
      + {taskEntries.map(([key, label]) => { + const rating = readSuitability(profile.tasks?.[key]); + return ( +
    • + {label} + + {SUITABILITY_LABELS[rating]} + +
    • + ); + })} +
    +
    + +
    + Routing +

    + Auto ranks eligible models by task suitability first, then priority, then + favorite. Preferences do not change access or chat defaults. +

    +
    +
    + ); +} + +function CapabilitiesPanel({ profile }: { profile: CatalogProfile }) { + const technical = profile.technical ?? {}; + const known = new Set(CATALOG_CAPABILITIES.map((item) => item.key)); + const others = Object.keys(profile.capabilities ?? {}).filter((key) => !known.has(key)).sort(); + const lifecycle = lifecycleLabel(technical.lifecycle); + const advanced = ADVANCED_TECHNICAL.filter(([key]) => technical[key] != null); + + return ( +
    +
    + {(['input', 'output', 'feature'] as CapabilityGroup[]).map((group) => ( +
    + {CAPABILITY_GROUP_LABELS[group]} +
      + {CATALOG_CAPABILITIES.filter((item) => item.group === group).map((item) => ( + + ))} +
    +
    + ))} +
    + + {others.length ? ( +
    + Other operations +
      + {others.map((key) => ( + + ))} +
    +
    + ) : null} + +
    + Limits and lifecycle +
    + {([ + ['Context window', formatTokenLimit(technical.contextWindow)], + ['Input token limit', formatTokenLimit(technical.inputTokenLimit)], + ['Output token limit', formatTokenLimit(technical.outputTokenLimit)], + ['Lifecycle', lifecycle ?? 'Not documented'], + ] as [string, string][]).map(([label, value]) => ( +
    +
    {label}
    +
    {value}
    +
    + ))} +
    +

    + Capacity and operation support remain provider-qualified. Connection + overrides and access still apply. +

    +
    + + {advanced.length ? ( +
    + Technical detail + {advanced.map(([key, label]) => ( +
    + + {label} + +
    +                                {JSON.stringify(technical[key], null, 2)}
    +                            
    +
    + ))} +
    + ) : null} +
    + ); +} + +function ConnectionsPanel({ + profile, + onOpenConnection, +}: { + profile: CatalogProfile; + onOpenConnection?: (target?: CatalogConnectionTarget) => void; +}) { + const links = profile.linked_models ?? []; + return ( +
    + {links.length === 0 ? ( +
    +

    + No global AI Connection uses this profile yet. +

    +

    + To link one, edit a model in AI Connections and choose this profile as + its Catalog profile. One profile can describe several deployments. +

    + {onOpenConnection ? ( + onOpenConnection()} + > + Go to AI Connections + + ) : null} +
    + ) : ( +
      + {links.map((link, index) => { + const support = Object.entries(link.capabilities ?? {}) + .filter(([, value]) => value) + .map(([key]) => capabilityLabel(key)); + return ( +
    • +
    • + ); + })} +
    + )} +

    + Personal and group connections are managed in their own workspaces. A profile + never publishes a deployment. +

    +
    + ); +} + +function EvidencePanel({ profile }: { profile: CatalogProfile }) { + const sources = (profile.sources ?? []) + .map((source) => ({ source, href: safeEvidenceUrl(source) })) + .filter((entry) => entry.href); + return ( +
    +
    +
    +
    Evidence
    +
    {evidenceLabel(profile.evidence)}
    +
    + {evidenceDescription(profile.evidence)} +
    +
    +
    +
    Profile ID
    +
    {profile.id}
    + {profile.verifiedAt ? ( +
    Reviewed {profile.verifiedAt}
    + ) : null} +
    +
    + +
    + Sources + {sources.length ? ( + + ) : ( +

    No sources recorded.

    + )} +
    + +
    + Aliases + {profile.aliases?.length ? ( +
      + {profile.aliases.map((alias) => ( +
    • + {alias} +
    • + ))} +
    + ) : ( +

    None.

    + )} +
    +
    + ); +} + +export function ModelCatalogDetail({ + profile, + tasks, + tab, + onTabChange, + onToggleFavorite, + onPriorityChange, + onDuplicate, + onEdit, + onToggleArchive, + onOpenConnection, + headingRef, +}: { + profile: CatalogProfile; + tasks: Record; + tab: CatalogDetailTab; + onTabChange: (tab: CatalogDetailTab) => void; + onToggleFavorite: () => void; + onPriorityChange: (priority: CatalogPriority) => void; + onDuplicate: () => void; + onEdit: () => void; + onToggleArchive: () => void; + onOpenConnection?: (target?: CatalogConnectionTarget) => void; + headingRef?: RefObject; +}) { + const baseId = useId(); + const tabRefs = useRef>>({}); + const favorite = Boolean(profile.preferences?.favorite); + const lifecycle = profile.technical?.lifecycle; + const lifecycleText = lifecycleLabel(lifecycle); + const custom = profile.origin === 'custom'; + const connected = linkedModelCount(profile); + + // Arrow keys move between tabs and select as they go, the usual tablist behaviour. + const onTabKeyDown = (event: KeyboardEvent) => { + const index = TABS.findIndex((item) => item.id === tab); + let next: number; + if (event.key === 'ArrowRight') next = (index + 1) % TABS.length; + else if (event.key === 'ArrowLeft') next = (index - 1 + TABS.length) % TABS.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = TABS.length - 1; + else return; + event.preventDefault(); + const nextTab = TABS[next].id; + onTabChange(nextTab); + tabRefs.current[nextTab]?.focus(); + }; + + return ( +
    +
    +
    +
    +

    + {profile.displayName} +

    +
    + {publisherLabel(profile.publisher)} + {custom ? 'Custom' : 'Built-in'} + {evidenceLabel(profile.evidence)} + {lifecycleText ? ( + {lifecycleText} + ) : null} + {profile.archived ? Archived : null} +
    +
    + +
    + + +
    + + +
    +
    +
    + + {profile.summary ? ( +

    {profile.summary}

    + ) : null} + +
    + + + {custom ? ( + <> + + + + {profile.archived ? ( + + + ) : null} +
    +
    + +
    + {TABS.map((item) => { + const selected = item.id === tab; + return ( + + ); + })} +
    + +
    + {tab === 'overview' ? : null} + {tab === 'capabilities' ? : null} + {tab === 'connections' ? ( + + ) : null} + {tab === 'evidence' ? : null} +
    +
    + ); +} diff --git a/application/v2_ui/src/components/admin/ModelCatalogEditor.tsx b/application/v2_ui/src/components/admin/ModelCatalogEditor.tsx new file mode 100644 index 000000000..bc9dacc1d --- /dev/null +++ b/application/v2_ui/src/components/admin/ModelCatalogEditor.tsx @@ -0,0 +1,217 @@ +// ModelCatalogEditor.tsx +// The form for a custom catalog profile. +// +// Field labels match the classic editor so the two interfaces describe a profile in the +// same words. On a wide pane the description lists, task ratings, and capability ratings +// lay out in columns, which keeps the whole profile on roughly one screen. + +import { useId, type ReactNode } from 'react'; +import { clsx } from 'clsx'; +import { Save } from 'lucide-react'; +import { + CATALOG_CAPABILITIES, + type CatalogProfileForm, + type TaskSuitability, +} from '../../lib/modelCatalog'; +import { GlassButton } from '../ui/primitives'; +import { inputClass } from './fields'; + +const SUITABILITY_CHOICES: ReadonlyArray<[TaskSuitability, string]> = [ + ['unknown', 'Unknown'], + ['suitable', 'Suitable'], + ['strong', 'Strong'], + ['unsuitable', 'Unsuitable'], +]; + +const CAPABILITY_CHOICES: ReadonlyArray<[string, string]> = [ + ['unknown', 'Unknown'], + ['true', 'Supported'], + ['false', 'Not supported'], +]; + +function Labelled({ id, label, children }: { id: string; label: string; children: ReactNode }) { + return ( +
    + + {children} +
    + ); +} + +function Group({ legend, hint, children }: { legend: string; hint?: string; children: ReactNode }) { + return ( +
    + {legend} + {hint ?

    {hint}

    : null} +
    {children}
    +
    + ); +} + +export function ModelCatalogEditor({ + form, + tasks, + isNew, + onChange, + onSave, + onCancel, +}: { + form: CatalogProfileForm; + tasks: Record; + isNew: boolean; + onChange: (next: CatalogProfileForm) => void; + onSave: () => void; + onCancel: () => void; +}) { + const baseId = useId(); + const id = (name: string) => `${baseId}-${name}`; + const set = (key: K, value: CatalogProfileForm[K]) => + onChange({ ...form, [key]: value }); + + const textArea = (key: 'strengths' | 'limitations' | 'aliases' | 'sources', label: string) => ( + +