agent-ecosystem/src/renderer/components/runtime/ProviderModelBadges.tsx
2026-05-20 17:15:14 +03:00

258 lines
8.7 KiB
TypeScript

import { useLayoutEffect, useRef, useState } from 'react';
import { cn } from '@renderer/lib/utils';
import {
getTeamModelBadgeLabel,
getVisibleTeamProviderModels,
} from '@renderer/utils/teamModelCatalog';
import { ChevronDown, ChevronUp } from 'lucide-react';
import type {
CliProviderId,
CliProviderModelAvailability,
CliProviderModelAvailabilityStatus,
CliProviderStatus,
} from '@shared/types';
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
return getTeamModelBadgeLabel(providerId, model) ?? model;
}
function getAvailabilityStatus(
model: string,
modelAvailability: CliProviderModelAvailability[] | undefined
): CliProviderModelAvailabilityStatus | null {
return modelAvailability?.find((item) => item.modelId === model)?.status ?? null;
}
function getAvailabilityReason(
model: string,
modelAvailability: CliProviderModelAvailability[] | undefined
): string | null {
return modelAvailability?.find((item) => item.modelId === model)?.reason ?? null;
}
function getAvailabilityChip(status: CliProviderModelAvailabilityStatus | null): string | null {
switch (status) {
case 'checking':
return 'Checking';
case 'unavailable':
return 'Unavailable';
case 'unknown':
return 'Check failed';
case 'available':
default:
return null;
}
}
function getCatalogBadgeLabel(
model: string,
providerStatus: Pick<CliProviderStatus, 'modelCatalog'> | null | undefined
): string | null {
const catalogItem = providerStatus?.modelCatalog?.models.find(
(item) => item.launchModel === model || item.id === model
);
const badgeLabel = catalogItem?.badgeLabel?.trim();
if (badgeLabel) {
return badgeLabel;
}
return catalogItem?.metadata?.free === true ? 'Free' : null;
}
function normalizeBadgeText(value: string): string {
return value.trim().replace(/\s+/g, ' ').toLowerCase();
}
function shouldRenderCatalogBadge(modelLabel: string, catalogBadgeLabel: string | null): boolean {
if (!catalogBadgeLabel) {
return false;
}
return normalizeBadgeText(modelLabel) !== normalizeBadgeText(catalogBadgeLabel);
}
function hasChildAfterRowLimit(container: HTMLElement, rowLimit: number): boolean {
const rowTops: number[] = [];
const children = Array.from(container.children) as HTMLElement[];
for (const child of children) {
const top = child.offsetTop;
let rowIndex = rowTops.findIndex((rowTop) => Math.abs(rowTop - top) <= 1);
if (rowIndex < 0) {
rowTops.push(top);
rowIndex = rowTops.length - 1;
}
if (rowIndex >= rowLimit) {
return true;
}
}
return false;
}
export const ProviderModelBadges = ({
providerId,
models,
modelAvailability,
providerStatus,
collapseAfter,
maxCollapsedRows,
}: {
readonly providerId: CliProviderId;
readonly models: string[];
readonly modelAvailability?: CliProviderModelAvailability[];
readonly providerStatus?: Pick<
CliProviderStatus,
'providerId' | 'authMethod' | 'backend' | 'modelCatalog'
> | null;
readonly collapseAfter?: number;
readonly maxCollapsedRows?: number;
}): React.JSX.Element => {
const [expanded, setExpanded] = useState(false);
const [collapsedModelLimit, setCollapsedModelLimit] = useState<number | null>(null);
const [measureTick, setMeasureTick] = useState(0);
const listRef = useRef<HTMLDivElement | null>(null);
const visibleModels = getVisibleTeamProviderModels(providerId, models, providerStatus);
const displayModelAvailability = providerId === 'opencode' ? undefined : modelAvailability;
const shouldCollapse =
typeof collapseAfter === 'number' && collapseAfter > 0 && visibleModels.length > collapseAfter;
const collapsedBaseLimit = shouldCollapse ? collapseAfter : visibleModels.length;
const collapsedLimit =
shouldCollapse && !expanded
? Math.max(0, Math.min(collapsedModelLimit ?? collapsedBaseLimit, collapsedBaseLimit))
: visibleModels.length;
const displayedModels =
shouldCollapse && !expanded ? visibleModels.slice(0, collapsedLimit) : visibleModels;
const hiddenCount = shouldCollapse ? visibleModels.length - displayedModels.length : 0;
useLayoutEffect(() => {
setCollapsedModelLimit(null);
}, [collapseAfter, maxCollapsedRows, models, providerStatus]);
useLayoutEffect(() => {
if (!shouldCollapse || expanded || !maxCollapsedRows || maxCollapsedRows < 1) {
return;
}
const container = listRef.current;
if (!container) {
return;
}
if (!hasChildAfterRowLimit(container, maxCollapsedRows)) {
return;
}
const nextLimit = Math.max(0, collapsedLimit - 1);
if (nextLimit !== collapsedLimit) {
setCollapsedModelLimit(nextLimit);
}
}, [collapsedLimit, expanded, maxCollapsedRows, measureTick, shouldCollapse]);
useLayoutEffect(() => {
if (!shouldCollapse || expanded || !maxCollapsedRows || typeof ResizeObserver === 'undefined') {
return;
}
const container = listRef.current;
if (!container) {
return;
}
let lastWidth = container.clientWidth;
const observer = new ResizeObserver((entries) => {
const width = Math.round(entries[0]?.contentRect.width ?? container.clientWidth);
if (width === lastWidth) {
return;
}
lastWidth = width;
setCollapsedModelLimit(null);
setMeasureTick((value) => value + 1);
});
observer.observe(container);
return () => observer.disconnect();
}, [expanded, maxCollapsedRows, shouldCollapse]);
const badgeClassName =
'inline-flex items-center gap-1 rounded-md border px-1.5 py-px font-mono text-[10px] leading-4';
const badgeStyle = {
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
color: 'var(--color-text-secondary)',
};
const buttonClassName =
'inline-flex items-center gap-1 rounded-full border border-[rgba(59,130,246,0.35)] bg-[rgba(59,130,246,0.12)] px-2 py-px text-[10px] font-medium leading-4 text-[rgb(147,197,253)] transition-colors hover:border-[rgba(59,130,246,0.55)] hover:bg-[rgba(59,130,246,0.18)] hover:text-[rgb(191,219,254)]';
const listClassName = cn('flex flex-wrap gap-1.5');
const renderModelBadge = (model: string, index: number): React.JSX.Element => {
const availabilityStatus = getAvailabilityStatus(model, displayModelAvailability);
const availabilityReason = getAvailabilityReason(model, displayModelAvailability);
const availabilityChip = getAvailabilityChip(availabilityStatus);
const modelLabel = formatModelBadgeLabel(providerId, model);
const catalogBadgeLabel = getCatalogBadgeLabel(model, providerStatus);
const showCatalogBadge = shouldRenderCatalogBadge(modelLabel, catalogBadgeLabel);
const title = [
availabilityReason ?? availabilityChip,
showCatalogBadge && catalogBadgeLabel === 'Free'
? 'Reported by OpenCode metadata. Availability and limits may change.'
: null,
]
.filter(Boolean)
.join(' - ');
return (
<span
key={`${model}-${index}`}
className={badgeClassName}
style={badgeStyle}
title={title || undefined}
>
<span>{modelLabel}</span>
{showCatalogBadge ? (
<span className="rounded bg-[rgba(34,197,94,0.14)] px-1 py-0 text-[9px] font-medium uppercase tracking-[0.06em] text-[rgb(74,222,128)]">
{catalogBadgeLabel}
</span>
) : null}
{availabilityChip ? (
<span
className={cn(
'rounded px-1 py-0 text-[9px] font-medium uppercase tracking-[0.06em]',
availabilityStatus === 'checking'
? 'bg-[rgba(59,130,246,0.12)] text-[var(--color-text-secondary)]'
: availabilityStatus === 'unavailable'
? 'bg-[rgba(239,68,68,0.12)] text-[rgb(248,113,113)]'
: 'bg-[rgba(245,158,11,0.12)] text-[rgb(251,191,36)]'
)}
>
{availabilityChip}
</span>
) : null}
</span>
);
};
if (!shouldCollapse) {
return <div className="flex flex-wrap gap-1.5">{displayedModels.map(renderModelBadge)}</div>;
}
return (
<div className="flex flex-col items-start gap-1.5">
<div ref={listRef} className={listClassName}>
{displayedModels.map(renderModelBadge)}
{shouldCollapse && !expanded ? (
<button type="button" className={buttonClassName} onClick={() => setExpanded(true)}>
<ChevronDown className="size-3" />
<span>+{hiddenCount} more</span>
</button>
) : null}
</div>
{shouldCollapse && expanded ? (
<button type="button" className={buttonClassName} onClick={() => setExpanded(false)}>
<ChevronUp className="size-3" />
<span>Hide</span>
</button>
) : null}
</div>
);
};