mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 12:27:41 +08:00
refactor(provider-settings): model + provider lists
Signed-off-by: Siin Xu <31815270+SiinXu@users.noreply.github.com>
This commit is contained in:
@@ -169,7 +169,7 @@ export default function HealthCheckDrawer({
|
||||
{isChecking ? (
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="font-medium text-[13px] text-foreground/85">
|
||||
<span className="text-(length:--font-size-body-sm) font-medium text-foreground/85">
|
||||
{t('settings.models.check.pipeline_heading')}
|
||||
</span>
|
||||
<span className={drawerClasses.healthProgressMeta}>
|
||||
@@ -242,7 +242,7 @@ export default function HealthCheckDrawer({
|
||||
rightCell = (
|
||||
<Tooltip
|
||||
content={
|
||||
<span className="block max-w-full whitespace-pre-wrap text-left text-[12px] leading-snug">
|
||||
<span className="text-(length:--font-size-body-xs) block max-w-full whitespace-pre-wrap text-left leading-snug">
|
||||
{skipReasonText}
|
||||
</span>
|
||||
}
|
||||
@@ -250,7 +250,7 @@ export default function HealthCheckDrawer({
|
||||
classNames={{
|
||||
placeholder: 'block min-w-0 w-full max-w-full overflow-hidden'
|
||||
}}>
|
||||
<span className="block w-full min-w-0 cursor-default truncate text-end text-[12px] text-muted-foreground/80">
|
||||
<span className="text-(length:--font-size-body-xs) block w-full min-w-0 cursor-default truncate text-end text-muted-foreground/80">
|
||||
{t('settings.models.check.status_skipped')}
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -258,7 +258,7 @@ export default function HealthCheckDrawer({
|
||||
} else if (checking) {
|
||||
statusCell = <Loader2 className="size-4 shrink-0 animate-spin text-warning" aria-hidden />
|
||||
rightCell = (
|
||||
<span className="shrink-0 font-medium text-[12px] text-warning">
|
||||
<span className="text-(length:--font-size-body-xs) shrink-0 font-medium text-warning">
|
||||
{t('settings.models.check.status_checking')}
|
||||
</span>
|
||||
)
|
||||
@@ -266,16 +266,16 @@ export default function HealthCheckDrawer({
|
||||
statusCell = (
|
||||
<span className="mx-auto block size-1.5 shrink-0 rounded-full bg-muted-foreground/35" aria-hidden />
|
||||
)
|
||||
rightCell = <span className="shrink-0 text-[12px] text-muted-foreground/50" />
|
||||
rightCell = <span className="text-(length:--font-size-body-xs) shrink-0 text-muted-foreground/50" />
|
||||
} else if (status === HealthStatus.SUCCESS) {
|
||||
statusCell = <CheckCircle2 className="size-4 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||
rightCell =
|
||||
latency != null ? (
|
||||
<span className="shrink-0 text-[12px] text-muted-foreground/80 tabular-nums">
|
||||
<span className="text-(length:--font-size-body-xs) shrink-0 text-muted-foreground/80 tabular-nums">
|
||||
{Math.round(latency)}ms
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 text-[12px] text-muted-foreground/80">
|
||||
<span className="text-(length:--font-size-body-xs) shrink-0 text-muted-foreground/80">
|
||||
{t('settings.models.check.passed')}
|
||||
</span>
|
||||
)
|
||||
@@ -286,7 +286,7 @@ export default function HealthCheckDrawer({
|
||||
errText !== '' ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<span className="block max-w-full whitespace-pre-wrap break-all text-left text-[12px] leading-snug">
|
||||
<span className="text-(length:--font-size-body-xs) block max-w-full whitespace-pre-wrap break-all text-left leading-snug">
|
||||
{errText}
|
||||
</span>
|
||||
}
|
||||
@@ -294,12 +294,12 @@ export default function HealthCheckDrawer({
|
||||
classNames={{
|
||||
placeholder: 'block min-w-0 w-full max-w-full overflow-hidden'
|
||||
}}>
|
||||
<span className="block w-full min-w-0 cursor-default truncate text-end text-[12px] text-destructive/85">
|
||||
<span className="text-(length:--font-size-body-xs) block w-full min-w-0 cursor-default truncate text-end text-destructive/85">
|
||||
{errText}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="shrink-0 text-[12px] text-destructive/85">
|
||||
<span className="text-(length:--font-size-body-xs) shrink-0 text-destructive/85">
|
||||
{t('settings.models.check.failed')}
|
||||
</span>
|
||||
)
|
||||
@@ -316,11 +316,11 @@ export default function HealthCheckDrawer({
|
||||
{Icon ? (
|
||||
<Icon.Avatar size={22} />
|
||||
) : (
|
||||
<Avatar className="size-5.5 shrink-0 rounded-md text-[10px]">
|
||||
<Avatar className="text-(length:--font-size-body-2xs) size-5.5 shrink-0 rounded-md">
|
||||
<AvatarFallback className="rounded-md">{model.name?.[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-foreground/85">
|
||||
<span className="text-(length:--font-size-body-sm) min-w-0 flex-1 truncate font-mono text-foreground/85">
|
||||
{model.name}
|
||||
</span>
|
||||
<div
|
||||
@@ -388,7 +388,7 @@ export default function HealthCheckDrawer({
|
||||
|
||||
{keyCheckMode === 'single' && hasMultipleKeys ? (
|
||||
<div className="space-y-3 rounded-xl border border-border-muted bg-muted/20 p-4">
|
||||
<div className="font-medium text-[13px] text-foreground/85">
|
||||
<div className="text-(length:--font-size-body-sm) font-medium text-foreground/85">
|
||||
{t('settings.models.check.select_api_key')}
|
||||
</div>
|
||||
<RadioGroup
|
||||
@@ -399,7 +399,9 @@ export default function HealthCheckDrawer({
|
||||
key={`${key}-${index}`}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-2 py-1.5 hover:bg-accent/30">
|
||||
<RadioGroupItem value={String(index)} size="sm" />
|
||||
<span className="truncate font-mono text-[12px] text-foreground/70">{maskApiKey(key)}</span>
|
||||
<span className="text-(length:--font-size-body-xs) truncate font-mono text-foreground/70">
|
||||
{maskApiKey(key)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
Button,
|
||||
DescriptionSwitch,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
SelectValue,
|
||||
Switch,
|
||||
Tooltip
|
||||
} from '@cherrystudio/ui'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import { useModelMutations } from '@renderer/hooks/useModel'
|
||||
@@ -15,7 +16,7 @@ import { getDefaultGroupName } from '@renderer/utils/naming'
|
||||
import { CURRENCY, type Currency, type EndpointType, type Model } from '@shared/data/types/model'
|
||||
import { parseUniqueModelId } from '@shared/data/types/model'
|
||||
import { isNewApiProvider } from '@shared/utils/provider'
|
||||
import { ChevronDown, ChevronUp, SaveIcon } from 'lucide-react'
|
||||
import { ChevronDown, ChevronUp, CircleHelp, SaveIcon } from 'lucide-react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -320,18 +321,20 @@ export default function EditModelDrawer({ providerId, open, model: modelProp, on
|
||||
endpointTypes
|
||||
}}
|
||||
showEndpointType={mode === 'new-api'}
|
||||
horizontal
|
||||
modelIdDisabled
|
||||
modelIdAction={
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label={t('message.copied')}
|
||||
className={fieldClasses.iconButton}
|
||||
className={fieldClasses.inputActionButton}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(apiModelId)
|
||||
window.toast.success(t('message.copied'))
|
||||
}}>
|
||||
<CopyIcon size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
endpointTypeError={endpointTypeTouched ? t('settings.models.add.endpoint_type.required') : undefined}
|
||||
onModelIdChange={(value) => {
|
||||
@@ -362,13 +365,18 @@ export default function EditModelDrawer({ providerId, open, model: modelProp, on
|
||||
{showMoreSettings && (
|
||||
<ProviderSection className={drawerClasses.section}>
|
||||
<div data-testid="provider-settings-model-more-settings" className="space-y-4">
|
||||
<div className={drawerClasses.sectionCard}>
|
||||
<ModelCapabilityToggles
|
||||
selectedCaps={selectedCaps}
|
||||
hasUserModified={hasUserModified}
|
||||
onToggle={handleToggleCapability}
|
||||
onReset={handleResetCapabilities}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-(length:--font-size-body-xs) mb-2 px-1 font-medium text-foreground">
|
||||
{t('settings.models.add.section.capabilities')}
|
||||
</div>
|
||||
<div className={drawerClasses.sectionCard}>
|
||||
<ModelCapabilityToggles
|
||||
selectedCaps={selectedCaps}
|
||||
hasUserModified={hasUserModified}
|
||||
onToggle={handleToggleCapability}
|
||||
onReset={handleResetCapabilities}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={drawerClasses.sectionCard}>
|
||||
@@ -380,14 +388,21 @@ export default function EditModelDrawer({ providerId, open, model: modelProp, on
|
||||
onMaxInputTokensChange={setMaxInputTokens}
|
||||
onMaxOutputTokensChange={setMaxOutputTokens}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={drawerClasses.sectionCard}>
|
||||
<div className={drawerClasses.switchCard}>
|
||||
<DescriptionSwitch
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className={`flex min-w-0 items-center ${drawerClasses.fieldTitle}`}>
|
||||
<label htmlFor="supports-text-delta-switch" className="cursor-pointer truncate">
|
||||
{t('settings.models.add.supported_text_delta.label')}
|
||||
</label>
|
||||
<Tooltip content={t('settings.models.add.supported_text_delta.tooltip')} placement="top">
|
||||
<CircleHelp
|
||||
size={14}
|
||||
className="ml-1.5 shrink-0 cursor-pointer text-foreground-muted [stroke-width:var(--icon-stroke)]"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
id="supports-text-delta-switch"
|
||||
size="sm"
|
||||
label={t('settings.models.add.supported_text_delta.label')}
|
||||
description={t('settings.models.add.supported_text_delta.tooltip')}
|
||||
checked={supportsStreaming ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
setSupportsStreaming(checked)
|
||||
@@ -397,9 +412,15 @@ export default function EditModelDrawer({ providerId, open, model: modelProp, on
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={drawerClasses.sectionCard}>
|
||||
<ProviderField title={t('models.price.currency')} titleClassName={drawerClasses.fieldTitle}>
|
||||
<div className={drawerClasses.inlineRow}>
|
||||
<div>
|
||||
<div className="text-(length:--font-size-body-xs) mb-2 px-1 font-medium text-foreground">
|
||||
{t('settings.models.add.section.pricing')}
|
||||
</div>
|
||||
<div className={drawerClasses.sectionCard}>
|
||||
<ProviderField
|
||||
title={t('models.price.currency')}
|
||||
horizontal
|
||||
titleClassName={drawerClasses.fieldTitle}>
|
||||
<Select
|
||||
value={currencySymbol}
|
||||
onValueChange={(nextValue) => {
|
||||
@@ -410,10 +431,10 @@ export default function EditModelDrawer({ providerId, open, model: modelProp, on
|
||||
setCurrencySymbol(nextValue)
|
||||
autoSave({ currencySymbol: nextValue })
|
||||
}}>
|
||||
<SelectTrigger aria-label={t('models.price.currency')} className={drawerClasses.selectTrigger}>
|
||||
<SelectTrigger aria-label={t('models.price.currency')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className={drawerClasses.selectContent}>
|
||||
<SelectContent className="provider-settings-default-scope">
|
||||
{MODEL_DRAWER_CURRENCY_SYMBOLS.map((symbol) => (
|
||||
<SelectItem key={symbol} value={symbol}>
|
||||
{symbol}
|
||||
@@ -421,50 +442,58 @@ export default function EditModelDrawer({ providerId, open, model: modelProp, on
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</ProviderField>
|
||||
</ProviderField>
|
||||
|
||||
<ProviderField title={t('models.price.input')} titleClassName={drawerClasses.fieldTitle}>
|
||||
<div className={drawerClasses.responsiveValueRow}>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
aria-label={t('models.price.input')}
|
||||
value={inputPrice}
|
||||
placeholder="0.00"
|
||||
className={drawerClasses.input}
|
||||
onChange={(event) => {
|
||||
setInputPrice(event.target.value)
|
||||
}}
|
||||
onBlur={() => autoSave({ inputPrice })}
|
||||
/>
|
||||
<span className={drawerClasses.valueSuffix}>
|
||||
{currentCurrency} / {t('models.price.million_tokens')}
|
||||
</span>
|
||||
</div>
|
||||
</ProviderField>
|
||||
<ProviderField
|
||||
title={t('models.price.input')}
|
||||
horizontal
|
||||
controlClassName="w-48"
|
||||
titleClassName={drawerClasses.fieldTitle}>
|
||||
<div className={drawerClasses.responsiveValueRow}>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
aria-label={t('models.price.input')}
|
||||
value={inputPrice}
|
||||
placeholder="0.00"
|
||||
className={drawerClasses.input}
|
||||
onChange={(event) => {
|
||||
setInputPrice(event.target.value)
|
||||
}}
|
||||
onBlur={() => autoSave({ inputPrice })}
|
||||
/>
|
||||
<span className={drawerClasses.valueSuffix}>
|
||||
{currentCurrency} / {t('models.price.million_tokens')}
|
||||
</span>
|
||||
</div>
|
||||
</ProviderField>
|
||||
|
||||
<ProviderField title={t('models.price.output')} titleClassName={drawerClasses.fieldTitle}>
|
||||
<div className={drawerClasses.responsiveValueRow}>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
aria-label={t('models.price.output')}
|
||||
value={outputPrice}
|
||||
placeholder="0.00"
|
||||
className={drawerClasses.input}
|
||||
onChange={(event) => {
|
||||
setOutputPrice(event.target.value)
|
||||
}}
|
||||
onBlur={() => autoSave({ outputPrice })}
|
||||
/>
|
||||
<span className={drawerClasses.valueSuffix}>
|
||||
{currentCurrency} / {t('models.price.million_tokens')}
|
||||
</span>
|
||||
</div>
|
||||
</ProviderField>
|
||||
<ProviderField
|
||||
title={t('models.price.output')}
|
||||
horizontal
|
||||
controlClassName="w-48"
|
||||
titleClassName={drawerClasses.fieldTitle}>
|
||||
<div className={drawerClasses.responsiveValueRow}>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
aria-label={t('models.price.output')}
|
||||
value={outputPrice}
|
||||
placeholder="0.00"
|
||||
className={drawerClasses.input}
|
||||
onChange={(event) => {
|
||||
setOutputPrice(event.target.value)
|
||||
}}
|
||||
onBlur={() => autoSave({ outputPrice })}
|
||||
/>
|
||||
<span className={drawerClasses.valueSuffix}>
|
||||
{currentCurrency} / {t('models.price.million_tokens')}
|
||||
</span>
|
||||
</div>
|
||||
</ProviderField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ProviderSection>
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ModelBasicFieldsProps {
|
||||
modelIdDisabled?: boolean
|
||||
modelIdAction?: ReactNode
|
||||
endpointTypeError?: string
|
||||
horizontal?: boolean
|
||||
onModelIdChange: (value: string) => void
|
||||
onNameChange: (value: string) => void
|
||||
onGroupChange: (value: string) => void
|
||||
@@ -26,6 +27,7 @@ export function ModelBasicFields({
|
||||
modelIdDisabled = false,
|
||||
modelIdAction,
|
||||
endpointTypeError,
|
||||
horizontal = false,
|
||||
onModelIdChange,
|
||||
onNameChange,
|
||||
onGroupChange,
|
||||
@@ -38,8 +40,10 @@ export function ModelBasicFields({
|
||||
<ProviderField
|
||||
title={t('settings.models.add.model_id.label')}
|
||||
titleClassName={drawerClasses.fieldTitle}
|
||||
className={drawerClasses.field}>
|
||||
<div className={drawerClasses.valueRow}>
|
||||
className={drawerClasses.field}
|
||||
horizontal={horizontal}
|
||||
controlClassName={horizontal ? 'w-60' : undefined}>
|
||||
<div className={cn(drawerClasses.valueRow, horizontal && 'w-full')}>
|
||||
<Input
|
||||
required
|
||||
spellCheck={false}
|
||||
@@ -58,7 +62,9 @@ export function ModelBasicFields({
|
||||
<ProviderField
|
||||
title={t('settings.models.add.model_name.label')}
|
||||
titleClassName={drawerClasses.fieldTitle}
|
||||
className={drawerClasses.field}>
|
||||
className={drawerClasses.field}
|
||||
horizontal={horizontal}
|
||||
controlClassName={horizontal ? 'w-60' : undefined}>
|
||||
<Input
|
||||
spellCheck={false}
|
||||
aria-label={t('settings.models.add.model_name.label')}
|
||||
@@ -72,7 +78,9 @@ export function ModelBasicFields({
|
||||
<ProviderField
|
||||
title={t('settings.models.add.group_name.label')}
|
||||
titleClassName={drawerClasses.fieldTitle}
|
||||
className={drawerClasses.field}>
|
||||
className={drawerClasses.field}
|
||||
horizontal={horizontal}
|
||||
controlClassName={horizontal ? 'w-60' : undefined}>
|
||||
<Input
|
||||
spellCheck={false}
|
||||
aria-label={t('settings.models.add.group_name.label')}
|
||||
@@ -88,6 +96,8 @@ export function ModelBasicFields({
|
||||
title={t('settings.models.add.endpoint_type.label')}
|
||||
titleClassName={drawerClasses.fieldTitle}
|
||||
className={drawerClasses.field}
|
||||
horizontal={horizontal}
|
||||
controlClassName={horizontal ? 'w-60' : undefined}
|
||||
help={endpointTypeError ? <div className={drawerClasses.errorText}>{endpointTypeError}</div> : null}>
|
||||
<div data-testid="provider-settings-model-endpoint-type-field">
|
||||
<ModelEndpointTypeChips value={values.endpointTypes ?? []} onChange={onEndpointTypesChange} />
|
||||
|
||||
@@ -34,7 +34,7 @@ export function ModelCapabilityToggles({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1 font-semibold text-foreground/90 text-sm leading-5">
|
||||
<div className="text-(length:--font-size-body-xs) flex items-center gap-1 font-normal text-foreground leading-(--line-height-body-xs)">
|
||||
{t('models.type.select')}
|
||||
<WarnTooltip content={t('settings.moresetting.check.warn')} />
|
||||
</div>
|
||||
|
||||
@@ -26,8 +26,10 @@ export function ModelContextWindowFields({
|
||||
<>
|
||||
<ProviderField
|
||||
title={t('settings.models.add.context_window.label')}
|
||||
horizontal
|
||||
className={drawerClasses.field}
|
||||
titleClassName={drawerClasses.fieldTitle}
|
||||
className={drawerClasses.field}>
|
||||
controlClassName="w-32">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -43,8 +45,10 @@ export function ModelContextWindowFields({
|
||||
|
||||
<ProviderField
|
||||
title={t('settings.models.add.max_input_tokens.label')}
|
||||
horizontal
|
||||
className={drawerClasses.field}
|
||||
titleClassName={drawerClasses.fieldTitle}
|
||||
className={drawerClasses.field}>
|
||||
controlClassName="w-32">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -60,8 +64,10 @@ export function ModelContextWindowFields({
|
||||
|
||||
<ProviderField
|
||||
title={t('settings.models.add.max_output_tokens.label')}
|
||||
horizontal
|
||||
className={drawerClasses.field}
|
||||
titleClassName={drawerClasses.fieldTitle}
|
||||
className={drawerClasses.field}>
|
||||
controlClassName="w-32">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { SearchInput } from '@cherrystudio/ui'
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -55,26 +55,16 @@ const ModelListHeader: React.FC<ModelListHeaderProps> = ({
|
||||
<div className={modelListClasses.titleRow}>
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<div className={modelListClasses.titleWrap}>
|
||||
<div className={modelListClasses.searchWrap}>
|
||||
<Search className={modelListClasses.searchIcon} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchText}
|
||||
placeholder={t('models.search.placeholder')}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
className={modelListClasses.searchInput}
|
||||
/>
|
||||
{searchText ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchText('')}
|
||||
className={modelListClasses.searchClear}
|
||||
aria-label={t('common.clear')}>
|
||||
<X size={9} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<SearchInput
|
||||
containerClassName={modelListClasses.searchWrap}
|
||||
className={modelListClasses.searchInput}
|
||||
value={searchText}
|
||||
placeholder={t('models.search.placeholder')}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
onClear={() => setSearchText('')}
|
||||
clearLabel={t('common.clear')}
|
||||
/>
|
||||
{!hasNoModels ? (
|
||||
<ModelListCapabilityChips
|
||||
capabilityOptions={capabilityOptions}
|
||||
|
||||
@@ -66,7 +66,7 @@ const ModelListItem: React.FC<ModelListItemProps> = ({ ref, model, disabled, onE
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-7 min-w-0 shrink items-center overflow-hidden text-ellipsis whitespace-nowrap text-left font-normal text-foreground/90 text-sm leading-none',
|
||||
'text-(length:--font-size-body-md) inline-flex h-7 min-w-0 shrink items-center overflow-hidden text-ellipsis whitespace-nowrap text-left font-normal text-foreground/90 leading-none',
|
||||
modelListClasses.rowNameCopyable
|
||||
)}
|
||||
onClick={handleEdit}>
|
||||
|
||||
@@ -4,6 +4,13 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import ProviderSettingsDrawer from '../primitives/ProviderSettingsDrawer'
|
||||
import { modelListClasses } from '../primitives/ProviderSettingsPrimitives'
|
||||
import ModelListCapabilityChips from './ModelListCapabilityChips'
|
||||
import {
|
||||
getCapabilityModelCounts,
|
||||
matchesCapabilityFilter,
|
||||
MODEL_LIST_CAPABILITY_FILTERS,
|
||||
type ModelListCapabilityFilter
|
||||
} from './modelListDerivedState'
|
||||
import ModelSyncPreviewPanel, { ModelSyncPreviewFooter } from './ModelSyncPreviewPanel'
|
||||
import type { ModelSyncPreviewResponse } from './modelSyncPreviewTypes'
|
||||
import { type ModelPullApplyPayload, useModelListSyncSelections } from './useModelListSyncSelections'
|
||||
@@ -20,31 +27,50 @@ interface ModelListSyncDrawerProps {
|
||||
export default function ModelListSyncDrawer({ open, preview, isApplying, onApply, onClose }: ModelListSyncDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [selectedCapabilityFilter, setSelectedCapabilityFilter] = useState<ModelListCapabilityFilter>('all')
|
||||
const selections = useModelListSyncSelections(preview)
|
||||
const searchActive = Boolean(searchText.trim())
|
||||
const capabilityActive = selectedCapabilityFilter !== 'all'
|
||||
const filterActive = searchActive || capabilityActive
|
||||
const hasModels = !!preview && (preview.added.length > 0 || preview.missing.length > 0)
|
||||
|
||||
useEffect(() => {
|
||||
setSearchText('')
|
||||
setSelectedCapabilityFilter('all')
|
||||
}, [open, preview])
|
||||
|
||||
// Capability counts span the full unfiltered preview (added + missing) so the chip badges
|
||||
// reflect every model the sync touches, not just the search-narrowed subset.
|
||||
const capabilityModelCounts = useMemo(
|
||||
() => getCapabilityModelCounts(preview ? [...preview.added, ...preview.missing.map((item) => item.model)] : []),
|
||||
[preview]
|
||||
)
|
||||
|
||||
const filteredPreview = useMemo<ModelSyncPreviewResponse | null>(() => {
|
||||
if (!preview || !searchActive) {
|
||||
if (!preview || !filterActive) {
|
||||
return preview
|
||||
}
|
||||
|
||||
const added = filterProviderSettingModelsByKeywords(searchText, preview.added)
|
||||
const visibleMissingModels = filterProviderSettingModelsByKeywords(
|
||||
searchText,
|
||||
preview.missing.map((item) => item.model)
|
||||
const searchedAdded = searchActive
|
||||
? filterProviderSettingModelsByKeywords(searchText, preview.added)
|
||||
: preview.added
|
||||
const added = searchedAdded.filter((model) => matchesCapabilityFilter(model, selectedCapabilityFilter))
|
||||
|
||||
const missingModels = preview.missing.map((item) => item.model)
|
||||
const searchedMissing = searchActive
|
||||
? filterProviderSettingModelsByKeywords(searchText, missingModels)
|
||||
: missingModels
|
||||
const visibleMissingIds = new Set(
|
||||
searchedMissing
|
||||
.filter((model) => matchesCapabilityFilter(model, selectedCapabilityFilter))
|
||||
.map((model) => model.id)
|
||||
)
|
||||
const visibleMissingIds = new Set(visibleMissingModels.map((model) => model.id))
|
||||
|
||||
return {
|
||||
added,
|
||||
missing: preview.missing.filter((item) => visibleMissingIds.has(item.model.id))
|
||||
}
|
||||
}, [preview, searchActive, searchText])
|
||||
}, [preview, filterActive, searchActive, searchText, selectedCapabilityFilter])
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const payload = selections.getApplyPayload()
|
||||
@@ -75,32 +101,40 @@ export default function ModelListSyncDrawer({ open, preview, isApplying, onApply
|
||||
{filteredPreview ? (
|
||||
<>
|
||||
{hasModels ? (
|
||||
<div className={modelListClasses.searchWrap}>
|
||||
<Search className={modelListClasses.searchIcon} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchText}
|
||||
placeholder={t('models.search.placeholder')}
|
||||
disabled={isApplying}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
className={modelListClasses.searchInput}
|
||||
<div className={modelListClasses.titleWrap}>
|
||||
<div className={modelListClasses.searchWrap}>
|
||||
<Search className={modelListClasses.searchIcon} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchText}
|
||||
placeholder={t('models.search.placeholder')}
|
||||
disabled={isApplying}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
className={modelListClasses.searchInput}
|
||||
/>
|
||||
{searchText ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchText('')}
|
||||
className={modelListClasses.searchClear}
|
||||
aria-label={t('common.clear')}>
|
||||
<X size={9} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<ModelListCapabilityChips
|
||||
capabilityOptions={MODEL_LIST_CAPABILITY_FILTERS}
|
||||
selectedCapabilityFilter={selectedCapabilityFilter}
|
||||
capabilityModelCounts={capabilityModelCounts}
|
||||
onSelectCapabilityFilter={setSelectedCapabilityFilter}
|
||||
/>
|
||||
{searchText ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchText('')}
|
||||
className={modelListClasses.searchClear}
|
||||
aria-label={t('common.clear')}>
|
||||
<X size={9} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<ModelSyncPreviewPanel
|
||||
preview={filteredPreview}
|
||||
selections={selections}
|
||||
isApplying={isApplying}
|
||||
searchActive={searchActive}
|
||||
searchActive={filterActive}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -136,7 +136,6 @@ export default function ModelSyncPreviewPanel({
|
||||
</span>
|
||||
<ModelGlyph model={model} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={modelSyncClasses.fetchRowTitle}>{model.name}</p>
|
||||
<p className={modelSyncClasses.fetchRowId}>{modelIdLine(model.id, model.apiModelId)}</p>
|
||||
</div>
|
||||
<div className={modelSyncClasses.fetchCapabilityStrip}>
|
||||
@@ -211,7 +210,6 @@ export default function ModelSyncPreviewPanel({
|
||||
</span>
|
||||
<ModelGlyph model={item.model} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={modelSyncClasses.fetchRowTitleStrike}>{item.model.name}</p>
|
||||
<p className={modelSyncClasses.fetchRowIdStrike}>
|
||||
{modelIdLine(item.model.id, item.model.apiModelId)}
|
||||
</p>
|
||||
|
||||
@@ -113,10 +113,12 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve, batchModels
|
||||
onCancel()
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="gap-5 rounded-2xl border-border-muted bg-popover p-5 sm:max-w-md">
|
||||
<DialogContent className="provider-settings-default-scope gap-5 rounded-2xl border-(--color-border-fg-muted) bg-popover p-5 sm:max-w-md">
|
||||
<DialogHeader className="gap-1.5 pr-6">
|
||||
<DialogTitle className="text-foreground/90 text-sm leading-5">{title}</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground/80 text-sm leading-5">
|
||||
<DialogTitle className="text-(length:--font-size-body-md) text-foreground/90 leading-(--line-height-body-md)">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-(length:--font-size-body-sm) text-muted-foreground/80 leading-(--line-height-body-sm)">
|
||||
{t('settings.models.add.endpoint_type.tooltip')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -131,32 +131,32 @@ describe('ModelListSyncDrawer', () => {
|
||||
expect(searchInput).toBeInTheDocument()
|
||||
expect(screen.getByTestId('drawer-content')).toHaveClass('w-[min(calc(100vw-24px),520px)]')
|
||||
expect(screen.getByTestId('drawer-body')).toHaveClass('pt-0')
|
||||
expect(screen.getByText('GPT 5')).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Sonnet')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mistral Large')).toBeInTheDocument()
|
||||
expect(screen.getByText('Legacy Model')).toBeInTheDocument()
|
||||
expect(screen.getByText('gpt-5')).toBeInTheDocument()
|
||||
expect(screen.getByText('claude-sonnet')).toBeInTheDocument()
|
||||
expect(screen.getByText('mistral-large')).toBeInTheDocument()
|
||||
expect(screen.getByText('legacy-model')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: 'claude' } })
|
||||
|
||||
expect(screen.queryByText('GPT 5')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Sonnet')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Mistral Large')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Legacy Model')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('gpt-5')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('claude-sonnet')).toBeInTheDocument()
|
||||
expect(screen.queryByText('mistral-large')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('legacy-model')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears pull-result search and restores rows', () => {
|
||||
render(<ModelListSyncDrawer open preview={preview} isApplying={false} onApply={vi.fn()} onClose={vi.fn()} />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('models.search.placeholder'), { target: { value: 'legacy-model' } })
|
||||
expect(screen.getByText('Legacy Model')).toBeInTheDocument()
|
||||
expect(screen.queryByText('GPT 5')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('legacy-model')).toBeInTheDocument()
|
||||
expect(screen.queryByText('gpt-5')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.clear' }))
|
||||
|
||||
expect(screen.getByPlaceholderText('models.search.placeholder')).toHaveValue('')
|
||||
expect(screen.getByText('GPT 5')).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Sonnet')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mistral Large')).toBeInTheDocument()
|
||||
expect(screen.getByText('gpt-5')).toBeInTheDocument()
|
||||
expect(screen.getByText('claude-sonnet')).toBeInTheDocument()
|
||||
expect(screen.getByText('mistral-large')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects only visible added rows while preserving hidden selections', async () => {
|
||||
@@ -170,7 +170,7 @@ describe('ModelListSyncDrawer', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'settings.models.manage.fetch_deselect_all_add' }))
|
||||
expect(screen.getByText('settings.models.manage.fetch_summary_add:0/3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('GPT 5'))
|
||||
fireEvent.click(screen.getByText('gpt-5'))
|
||||
expect(screen.getByText('settings.models.manage.fetch_summary_add:1/3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('models.search.placeholder'), { target: { value: 'claude' } })
|
||||
|
||||
@@ -365,7 +365,7 @@ export default function ProviderEditorDrawer({
|
||||
)}
|
||||
|
||||
{duplicateSource && !duplicateNeedsBaseUrl(duplicateSource.authType) && (
|
||||
<p className="text-muted-foreground/80 text-xs leading-[1.4]">
|
||||
<p className="text-(length:--font-size-body-xs) text-muted-foreground/80 leading-[1.4]">
|
||||
{t('settings.provider.duplicate.fill_after_create')}
|
||||
</p>
|
||||
)}
|
||||
@@ -379,7 +379,7 @@ function DuplicateHeader({ source }: { source: Provider }) {
|
||||
const presetId = source.presetProviderId
|
||||
const label = presetId ? t(getProviderLabelKey(presetId)) : source.name
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border-muted bg-muted/40 px-3 py-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-(--section-border) bg-muted/40 px-3 py-2">
|
||||
<ProviderAvatar provider={{ id: presetId ?? source.id, name: label }} size={18} />
|
||||
<span className="truncate text-foreground/85 text-sm">{label}</span>
|
||||
</div>
|
||||
@@ -606,7 +606,7 @@ function ApiKeyField({ value, onChange }: ApiKeyFieldProps) {
|
||||
type="button"
|
||||
aria-label={t(visible ? 'settings.provider.api_key.hide_key' : 'settings.provider.api_key.show_key')}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="-translate-y-1/2 absolute top-1/2 right-2 rounded-md p-1 text-muted-foreground/70 transition-colors hover:bg-accent/40 hover:text-foreground">
|
||||
className="-translate-y-1/2 absolute top-1/2 right-2 rounded-md p-1 text-muted-foreground/70 transition-colors hover:bg-(--color-surface-fg-subtle) hover:text-foreground">
|
||||
{visible ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PageHeader } from '@cherrystudio/ui'
|
||||
import { Button, PageHeader } from '@cherrystudio/ui'
|
||||
import { useReorder } from '@data/hooks/useReorder'
|
||||
import { useModels } from '@renderer/hooks/useModel'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
@@ -33,12 +33,12 @@ export interface ProviderListProps {
|
||||
export default function ProviderList({ selectedProviderId, filterModeHint, onSelectProvider }: ProviderListProps) {
|
||||
const { t } = useTranslation()
|
||||
const { providers } = useProviders()
|
||||
const { models: allModels } = useModels()
|
||||
const { applyReorderedList } = useReorder('/providers', { revalidateOnSuccess: false })
|
||||
const { isSupported: isOvmsSupported } = useOvmsSupport()
|
||||
|
||||
const [filterMode, setFilterMode] = useState<ProviderFilterMode>(filterModeHint ?? 'all')
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { models: allModels } = useModels(undefined, { fetchEnabled: Boolean(searchText.trim()) })
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [contextProviderId, setContextProviderId] = useState<string | null>(null)
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({})
|
||||
@@ -263,18 +263,20 @@ export default function ProviderList({ selectedProviderId, filterModeHint, onSel
|
||||
const handleAddAnother = useCallback((template: Provider) => startAddFrom(template), [startAddFrom])
|
||||
|
||||
return (
|
||||
<aside className={`${providerListClasses.shell}`}>
|
||||
<aside className={`provider-settings-default-scope ${providerListClasses.shell}`}>
|
||||
<PageHeader
|
||||
title={t('settings.provider.title')}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={t('settings.provider.add.title')}
|
||||
disabled={dragging}
|
||||
onClick={startAdd}
|
||||
className={providerListClasses.headerAddButton}>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
className="hover:bg-[var(--color-surface-hover-soft)] [&_svg]:[stroke-width:var(--icon-stroke)]">
|
||||
<Plus size={14} />
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ProviderListSearchField
|
||||
@@ -285,9 +287,8 @@ export default function ProviderList({ selectedProviderId, filterModeHint, onSel
|
||||
<ProviderListHeaderFilterMenu
|
||||
filterMode={filterMode}
|
||||
disabled={dragging}
|
||||
triggerClassName={providerListClasses.searchInlineAddButton}
|
||||
triggerIconSize={13}
|
||||
onFilterChange={setFilterMode}
|
||||
className={providerListClasses.searchInlineAddButton}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function ProviderListContent({
|
||||
onReorder={onReorder}
|
||||
onReorderError={onReorderError}
|
||||
className="w-full"
|
||||
gap="0.5rem"
|
||||
gap="var(--provider-list-row-gap)"
|
||||
restrictions={{ scrollableAncestor: true }}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
@@ -187,7 +187,7 @@ export default function ProviderListContent({
|
||||
// the source rect — otherwise dnd-kit stretches the compact header.
|
||||
adjustScale={false}
|
||||
className="w-full"
|
||||
gap="0.5rem"
|
||||
gap="var(--provider-list-row-gap)"
|
||||
restrictions={{ scrollableAncestor: true }}
|
||||
renderItem={(item, state) => {
|
||||
if (item.kind === 'single') {
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function ProviderListGroup({
|
||||
onReorder={onReorder}
|
||||
onReorderError={onReorderError}
|
||||
className="w-full"
|
||||
gap="0.5rem"
|
||||
gap="var(--provider-list-row-gap)"
|
||||
restrictions={{ scrollableAncestor: true }}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
|
||||
@@ -17,17 +17,15 @@ const FILTER_MENU_OPTIONS: { mode: ProviderFilterMode; labelKey: string }[] = [
|
||||
interface ProviderListHeaderFilterMenuProps {
|
||||
filterMode: ProviderFilterMode
|
||||
disabled: boolean
|
||||
triggerClassName?: string
|
||||
triggerIconSize?: number
|
||||
onFilterChange: (mode: ProviderFilterMode) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function ProviderListHeaderFilterMenu({
|
||||
filterMode,
|
||||
disabled,
|
||||
triggerClassName = providerListClasses.headerIconButton,
|
||||
triggerIconSize = 14,
|
||||
onFilterChange
|
||||
onFilterChange,
|
||||
className
|
||||
}: ProviderListHeaderFilterMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -40,14 +38,8 @@ export default function ProviderListHeaderFilterMenu({
|
||||
type="button"
|
||||
aria-label={t('settings.provider.filter.label')}
|
||||
disabled={disabled}
|
||||
className={cn('group', triggerClassName)}>
|
||||
<Filter
|
||||
size={triggerIconSize}
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
hasActiveFilter ? 'text-primary!' : 'text-muted-foreground/60 group-hover:text-muted-foreground/80'
|
||||
)}
|
||||
/>
|
||||
className={cn(className ?? providerListClasses.headerIconButton, hasActiveFilter && 'bg-accent')}>
|
||||
<Filter size={14} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-fit min-w-32 rounded-xl p-1.5">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CommandContextMenu, type CommandContextMenuExtraItem, CommandPopupMenu
|
||||
import ModelNotesPopup from '@renderer/pages/settings/ProviderSettings/ModelNotesPopup'
|
||||
import { providerListClasses } from '@renderer/pages/settings/ProviderSettings/primitives/ProviderSettingsPrimitives'
|
||||
import { getFancyProviderName } from '@renderer/pages/settings/ProviderSettings/utils/providerDisplay'
|
||||
import { cn } from '@renderer/utils/style'
|
||||
import type { Provider } from '@shared/data/types/provider'
|
||||
import { CopyPlus, Edit, Trash2, UserPen } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
@@ -25,6 +26,14 @@ interface ProviderListItemWithContextMenuProps {
|
||||
onSetListItemRef: (providerId: string, element: HTMLDivElement | null) => void
|
||||
}
|
||||
|
||||
// CommandContextMenu/CommandPopupMenu's extra items have no per-item className, so the
|
||||
// provider list's row-height/radius styling is applied via descendant selectors instead.
|
||||
const menuContentClassName = cn(
|
||||
providerListClasses.itemMenuContent,
|
||||
'[&_[data-slot=context-menu-item]]:h-8 [&_[data-slot=context-menu-item]]:rounded-lg [&_[data-slot=context-menu-item]]:px-2.5',
|
||||
'[&_[data-slot=dropdown-menu-item]]:h-8 [&_[data-slot=dropdown-menu-item]]:rounded-lg [&_[data-slot=dropdown-menu-item]]:px-2.5'
|
||||
)
|
||||
|
||||
export default function ProviderListItemWithContextMenu({
|
||||
provider,
|
||||
selected,
|
||||
@@ -40,66 +49,70 @@ export default function ProviderListItemWithContextMenu({
|
||||
}: ProviderListItemWithContextMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const menuItems = useMemo<readonly CommandContextMenuExtraItem[]>(() => {
|
||||
const menuItems = useMemo<CommandContextMenuExtraItem[]>(() => {
|
||||
const items: CommandContextMenuExtraItem[] = []
|
||||
|
||||
if (showManagementActions) {
|
||||
items.push({
|
||||
type: 'item',
|
||||
id: 'edit',
|
||||
label: t('common.edit'),
|
||||
icon: <Edit size={14} />,
|
||||
icon: <Edit className="size-3.5 text-current" strokeWidth={1.6} />,
|
||||
onSelect: onEdit
|
||||
})
|
||||
}
|
||||
|
||||
if (onDuplicate) {
|
||||
items.push({
|
||||
type: 'item',
|
||||
id: 'duplicate',
|
||||
label: t('settings.provider.duplicate.menu_label'),
|
||||
icon: <CopyPlus size={14} />,
|
||||
icon: <CopyPlus className="size-3.5 text-current" strokeWidth={1.6} />,
|
||||
onSelect: onDuplicate
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
type: 'item',
|
||||
id: 'notes',
|
||||
label: t('settings.provider.notes.title'),
|
||||
icon: <UserPen size={14} />,
|
||||
icon: <UserPen className="size-3.5 text-current" strokeWidth={1.6} />,
|
||||
onSelect: () => ModelNotesPopup.show({ providerId: provider.id })
|
||||
})
|
||||
|
||||
if (showManagementActions) {
|
||||
items.push({
|
||||
type: 'item',
|
||||
id: 'delete',
|
||||
label: t('common.delete'),
|
||||
icon: <Trash2 size={14} />,
|
||||
icon: <Trash2 className="size-3.5 text-current" strokeWidth={1.6} />,
|
||||
destructive: true,
|
||||
onSelect: onDelete
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}, [onDelete, onDuplicate, onEdit, provider.id, showManagementActions, t])
|
||||
|
||||
// Right-click stays uncontrolled — Radix handles cross-popup mutex naturally.
|
||||
// The more-button popup remains controlled so the parent's single-row-active-at-a-time
|
||||
// tracking (`contextProviderId`) keeps working across clicks between rows.
|
||||
return (
|
||||
<CommandContextMenu location="webcontents.context" extraItems={menuItems}>
|
||||
<CommandContextMenu location="webcontents.context" extraItems={menuItems} contentClassName={menuContentClassName}>
|
||||
<div className="w-full" ref={(element) => onSetListItemRef(provider.id, element)}>
|
||||
<ProviderListItem
|
||||
provider={{ ...provider, name: getFancyProviderName(provider) }}
|
||||
selected={selected}
|
||||
dragging={listState.dragging}
|
||||
onClick={onSelect}
|
||||
onOpenMenu={() => onContextOpenChange(true)}
|
||||
// Opening is handled by CommandPopupMenu's own Radix trigger below; this only
|
||||
// needs to be truthy so ProviderListItem renders the button (with stopPropagation).
|
||||
onOpenMenu={() => {}}
|
||||
renderMenuButton={(button) => (
|
||||
<CommandPopupMenu
|
||||
location="webcontents.context"
|
||||
extraItems={menuItems}
|
||||
open={contextOpen}
|
||||
onOpenChange={onContextOpenChange}
|
||||
align="end"
|
||||
contentClassName={providerListClasses.itemMenuContent}>
|
||||
contentClassName={menuContentClassName}
|
||||
open={contextOpen}
|
||||
onOpenChange={onContextOpenChange}>
|
||||
{button}
|
||||
</CommandPopupMenu>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SearchInput } from '@cherrystudio/ui'
|
||||
import { providerListClasses } from '@renderer/pages/settings/ProviderSettings/primitives/ProviderSettingsPrimitives'
|
||||
import { Search } from 'lucide-react'
|
||||
import type { ChangeEvent, KeyboardEvent, ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -21,23 +21,21 @@ export default function ProviderListSearchField({
|
||||
|
||||
return (
|
||||
<div className={providerListClasses.searchRow}>
|
||||
<div className={`${providerListClasses.searchWrap} min-w-0 flex-1`}>
|
||||
<Search className={providerListClasses.searchIcon} />
|
||||
<input
|
||||
value={value}
|
||||
placeholder={t('settings.provider.search')}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(event.target.value)}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
onValueChange('')
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={providerListClasses.searchInput}
|
||||
/>
|
||||
{trailing}
|
||||
</div>
|
||||
<SearchInput
|
||||
containerClassName={`${providerListClasses.searchWrap} min-w-0 flex-1`}
|
||||
className={providerListClasses.searchInput}
|
||||
value={value}
|
||||
placeholder={t('settings.provider.search')}
|
||||
disabled={disabled}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(event.target.value)}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
onValueChange('')
|
||||
}
|
||||
}}
|
||||
trailing={trailing}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user