Files
microsoft--magentic-ui/frontend/src/components/common/ModelConfigCard.tsx
T
wehub-resource-sync 25576b0be6
Checks (magui2.0) / python-lint (push) Failing after 1s
Checks (magui2.0) / python-test (push) Failing after 0s
Checks (magui2.0) / frontend-lint (push) Failing after 0s
CodeQL Advanced / Analyze (actions) (push) Failing after 1s
Checks (magui2.0) / python-format (push) Failing after 1s
CodeQL Advanced / Analyze (python) (push) Failing after 0s
Checks (magui2.0) / python-pyright (push) Failing after 1s
Checks (magui2.0) / frontend-format (push) Failing after 1s
Checks (magui2.0) / frontend-typecheck (push) Failing after 0s
Checks (magui2.0) / frontend-test (push) Failing after 2s
CodeQL Advanced / Analyze (javascript-typescript) (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:24:32 +08:00

190 lines
6.5 KiB
TypeScript

import { type ReactNode } from 'react'
import { type ModelEndpointConfig } from '@/stores/onboardingStore'
import { TriangleAlert } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { cn } from '@/lib/utils'
import { API_KEY_MASKED } from '@/api/onboarding'
interface ModelConfigCardProps {
/** Display name shown as card title */
name: string
/** Current field values */
config: ModelEndpointConfig
/** Field change handler */
onChange: (config: Partial<ModelEndpointConfig>) => void
/** Visual state */
state?: 'default' | 'filled' | 'error'
/** Whether inputs are disabled */
disabled?: boolean
/** Custom placeholders per field */
placeholders?: Partial<Record<keyof ModelEndpointConfig, string>>
/**
* When provided, renders a checkbox in the card header. The card body is
* rendered grayed-out and inputs disabled when `enabled` is false.
*/
enabled?: boolean
/** Called when the header checkbox is toggled. Required if `enabled` is set. */
onEnabledChange?: (enabled: boolean) => void
/** Description text rendered under the title */
description?: ReactNode
/** Optional row rendered below the description (e.g. recommended model link) */
recommendation?: ReactNode
}
const DEFAULT_PLACEHOLDERS: Record<keyof ModelEndpointConfig, string> = {
endpointUrl: 'e.g. http://localhost:6000/v1',
modelName: 'e.g. magenticbrain-v1',
apiKey: '',
}
const FIELDS: { key: keyof ModelEndpointConfig; label: string }[] = [
{ key: 'endpointUrl', label: 'Endpoint URL' },
{ key: 'modelName', label: 'Model Name' },
{ key: 'apiKey', label: 'API Key (optional)' },
]
export function ModelConfigCard({
name,
config,
onChange,
state = 'default',
disabled = false,
placeholders,
enabled,
onEnabledChange,
description,
recommendation,
}: ModelConfigCardProps) {
const isMasked = config.apiKey === API_KEY_MASKED
const idPrefix = name.toLowerCase().replace(/\s+/g, '-')
const hasCheckbox = enabled !== undefined
// When the card supports a checkbox and is unchecked, force-disable inputs.
const inputsDisabled = disabled || (hasCheckbox && !enabled)
const dimmed = hasCheckbox && !enabled
return (
<Card
className={cn(
'flex flex-col gap-3 p-4',
state === 'error' && 'border-destructive shadow-[0_0_0_3px_var(--card-error-ring)]'
)}
>
<div className={cn('flex flex-col gap-1', dimmed && 'opacity-50')}>
{/* Title row. When the card has a checkbox, wrap it in a <label> so
clicking the title also toggles the checkbox. */}
{hasCheckbox ? (
<label
htmlFor={`${idPrefix}-enabled`}
className={cn(
'flex items-center gap-2 self-start',
disabled ? 'cursor-not-allowed' : 'cursor-pointer'
)}
>
<Checkbox
id={`${idPrefix}-enabled`}
checked={enabled}
onCheckedChange={(v) => onEnabledChange?.(v === true)}
disabled={disabled}
className="size-5"
aria-label={`Enable ${name}`}
/>
<h4 className="text-foreground text-base font-bold">{name}</h4>
</label>
) : (
<h4 className="text-foreground text-base font-bold">{name}</h4>
)}
{(description || recommendation) && (
// Reserve a minimum height so adjacent cards line up at the form
// fields below — sized for ~2 lines of description plus one line of
// recommendation.
<div className="flex min-h-[75px] flex-col gap-1">
{description && (
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
)}
{recommendation && <div className="text-xs">{recommendation}</div>}
</div>
)}
</div>
<div className={cn('flex flex-col gap-2', dimmed && 'opacity-50')}>
{FIELDS.map((field) => {
const isApiKeyField = field.key === 'apiKey'
const showMasked = isApiKeyField && isMasked
const inputId = `${idPrefix}-${field.key}`
return (
<div key={field.key} className="flex flex-col gap-1">
<label htmlFor={inputId} className="text-muted-foreground text-xs">
{field.label}
</label>
<Input
id={inputId}
value={showMasked ? '••••••••' : config[field.key]}
onChange={(e) => onChange({ [field.key]: e.target.value })}
onFocus={showMasked ? () => onChange({ apiKey: '' }) : undefined}
readOnly={showMasked}
placeholder={placeholders?.[field.key] ?? DEFAULT_PLACEHOLDERS[field.key]}
disabled={inputsDisabled}
className={cn(
'bg-background h-8 text-sm shadow-none',
showMasked && 'cursor-pointer'
)}
/>
</div>
)
})}
</div>
</Card>
)
}
/**
* Shared alert for verification errors.
* Renders each error line with the model name in bold.
*/
export function VerificationAlert({ errors }: { errors: string[] }) {
if (errors.length === 0) return null
return (
<Alert variant="destructive">
<TriangleAlert className="size-4" />
<AlertTitle>Verification Failed</AlertTitle>
<AlertDescription className="text-sm">
{errors.map((err, i) => {
const colonIdx = err.indexOf(': ')
if (colonIdx === -1) return <div key={i}>{err}</div>
return (
<div key={i}>
<span className="font-semibold">{err.slice(0, colonIdx)}</span>
{err.slice(colonIdx)}
</div>
)
})}
</AlertDescription>
</Alert>
)
}
/**
* Recommended-model row shown inside each ModelConfigCard's recommendation slot.
* Wraps the model name in an external link.
*/
export function RecommendedModel({ name, url }: { name: string; url: string }) {
return (
<span className="text-muted-foreground">
<span className="text-foreground font-bold">Recommended model:</span>{' '}
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-2 hover:underline"
>
{name}
</a>
</span>
)
}