feat(desktop): improve provider model selection

This commit is contained in:
程序员阿江(Relakkes)
2026-08-04 13:02:03 +08:00
parent 45633c977d
commit e8d2ceaa06
8 changed files with 459 additions and 50 deletions
+86 -9
View File
@@ -1983,8 +1983,9 @@ describe('Settings > Providers tab', () => {
fireEvent.click(screen.getByRole('button', { name: /添加服务商/i }))
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByPlaceholderText('模型 ID')).toBeInTheDocument()
expect(within(dialog).queryByPlaceholderText('Model ID')).not.toBeInTheDocument()
expect(within(dialog).getByPlaceholderText('例如: deepseek-v4-flash')).toBeInTheDocument()
expect(within(dialog).getByText('要填写 API Key 才能获取模型列表')).toBeInTheDocument()
expect(within(dialog).queryByPlaceholderText('e.g. deepseek-v4-flash')).not.toBeInTheDocument()
})
it('normalizes blank model mappings to the main model when saving a provider', async () => {
@@ -2534,7 +2535,7 @@ describe('Settings > Providers tab', () => {
expect(await screen.findByText(/No cc-switch configuration was found/i)).toBeInTheDocument()
})
it('keeps the model fetch disabled until the base URL and API key are both filled in', () => {
it('explains that an API key is required before models can be fetched', () => {
setCustomPreset()
render(<Settings />)
@@ -2544,11 +2545,12 @@ describe('Settings > Providers tab', () => {
const fetchButton = within(dialog).getByRole('button', { name: /Fetch models|获取模型/i })
expect(fetchButton).toBeDisabled()
expect(within(dialog).getByText(/Fill in the base URL and API key first/i)).toBeInTheDocument()
expect(within(dialog).getByText(/Enter an API key to fetch the model list/i)).toBeInTheDocument()
fireEvent.change(within(dialog).getByPlaceholderText('sk-...'), { target: { value: 'sk-test' } })
expect(fetchButton).toBeEnabled()
expect(within(dialog).getByText(/only when the upstream provider publishes a model list endpoint/i)).toBeInTheDocument()
})
it('gives every model slot a picker once the model list is fetched', async () => {
@@ -2575,9 +2577,16 @@ describe('Settings > Providers tab', () => {
// The picker supplements the field; a model id that is not on the list must
// still be typeable. Queried by role because the picker's own accessible
// name also contains the slot label.
const mainInput = within(dialog).getByRole('textbox', { name: /Main Model|主模型/i })
const mainInput = within(dialog).getByRole('combobox', { name: /Main Model|主模型/i })
fireEvent.change(mainInput, { target: { value: 'gpt-5-mini[1m]' } })
expect(mainInput).toHaveValue('gpt-5-mini')
expect(within(dialog).getByRole('option', { name: 'gpt-5-mini' })).toBeInTheDocument()
expect(within(dialog).queryByText(/No matching models/i)).not.toBeInTheDocument()
fireEvent.change(mainInput, { target: { value: 'typed-by-hand' } })
expect(mainInput).toHaveValue('typed-by-hand')
const noMatches = within(dialog).getByRole('option', { name: /No matching models/i })
expect(noMatches).toHaveAttribute('aria-disabled', 'true')
})
it('routes a picked model through the shared model change handler', async () => {
@@ -2591,10 +2600,18 @@ describe('Settings > Providers tab', () => {
})
fireEvent.click(within(dialog).getByRole('button', { name: /Fetch models|获取模型/i }))
fireEvent.click(await within(dialog).findByRole('button', { name: /Main Model.*fetched list/i }))
fireEvent.click(within(dialog).getByRole('option', { name: /gpt-5-mini/i }))
const mainCombobox = await within(dialog).findByRole('combobox', { name: /Main Model|主模型/i })
const pickerButton = within(dialog).getByRole('button', { name: /Main Model.*fetched list/i })
expect(pickerButton).toHaveAttribute('tabindex', '-1')
expect(within(dialog).getByRole('textbox', { name: /Main Model|主模型/i })).toHaveValue('gpt-5-mini')
fireEvent.click(mainCombobox)
expect(mainCombobox).toHaveAttribute('aria-expanded', 'true')
expect(within(dialog).getByRole('option', { name: /gpt-5-mini/i })).toHaveAttribute('tabindex', '-1')
fireEvent.keyDown(mainCombobox, { key: 'ArrowDown' })
fireEvent.keyDown(mainCombobox, { key: 'Enter' })
expect(mainCombobox).toHaveValue('gpt-5-mini')
expect(mainCombobox).toHaveAttribute('aria-expanded', 'false')
// Going through handleModelChange is what keeps the settings JSON in sync —
// a bare setState would leave the textarea on the old model id.
await waitFor(() => {
@@ -2602,6 +2619,57 @@ describe('Settings > Providers tab', () => {
})
})
it('closes the previous model popup when keyboard focus moves to another field', async () => {
const dialog = await openProviderFormWithModels({
ok: true,
endpoint: 'https://api.example.com/v1/models',
models: [
{ id: 'gpt-5-mini', ownedBy: 'openai' },
{ id: 'claude-sonnet-4-6', ownedBy: 'anthropic' },
],
})
fireEvent.click(within(dialog).getByRole('button', { name: /Fetch models|获取模型/i }))
const comboboxes = await within(dialog).findAllByRole('combobox')
const mainCombobox = comboboxes[0]!
const haikuCombobox = comboboxes[1]!
fireEvent.focus(mainCombobox)
expect(mainCombobox).toHaveAttribute('aria-expanded', 'true')
expect(within(dialog).getAllByRole('listbox')).toHaveLength(1)
fireEvent.blur(mainCombobox, { relatedTarget: haikuCombobox })
fireEvent.focus(haikuCombobox)
expect(mainCombobox).toHaveAttribute('aria-expanded', 'false')
expect(haikuCombobox).toHaveAttribute('aria-expanded', 'true')
expect(within(dialog).getAllByRole('listbox')).toHaveLength(1)
})
it('limits a large catalog until the user narrows the model input', async () => {
const dialog = await openProviderFormWithModels({
ok: true,
endpoint: 'https://api.example.com/v1/models',
models: Array.from({ length: 101 }, (_, index) => ({
id: `model-${String(index).padStart(3, '0')}`,
ownedBy: 'openai',
})),
})
fireEvent.click(within(dialog).getByRole('button', { name: /Fetch models|获取模型/i }))
const mainCombobox = await within(dialog).findByRole('combobox', { name: /Main Model|主模型/i })
fireEvent.click(mainCombobox)
expect(within(dialog).getAllByRole('option')).toHaveLength(100)
expect(within(dialog).getByText(/More models are available/i)).toBeInTheDocument()
fireEvent.change(mainCombobox, { target: { value: 'model-100' } })
expect(within(dialog).getAllByRole('option')).toHaveLength(1)
expect(within(dialog).getByRole('option', { name: 'model-100' })).toBeInTheDocument()
expect(within(dialog).queryByText(/More models are available/i)).not.toBeInTheDocument()
})
it.each([
['auth-failed' as const, /API key was rejected/i],
['not-supported' as const, /does not publish a model list/i],
@@ -2625,6 +2693,7 @@ describe('Settings > Providers tab', () => {
// code still picks the headline, the raw text only rides along under it.
expect(within(dialog).getByText(/upstream said no/)).toBeInTheDocument()
expect(within(dialog).queryByRole('button', { name: /from the fetched list/i })).not.toBeInTheDocument()
expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument()
})
it('surfaces the upstream message when a 200 response cloaks an auth failure', async () => {
@@ -2775,7 +2844,7 @@ describe('Settings > Providers tab', () => {
expect(within(dialog).queryByRole('button', { name: /from the fetched list/i })).not.toBeInTheDocument()
})
it('drops a fetched model list once the API key changes', async () => {
it('drops an open model list when the API key changes and does not reopen it after refetching', async () => {
const dialog = await openProviderFormWithModels({
ok: true,
endpoint: 'https://api.example.com/v1/models',
@@ -2784,10 +2853,18 @@ describe('Settings > Providers tab', () => {
fireEvent.click(within(dialog).getByRole('button', { name: /Fetch models|获取模型/i }))
expect(await within(dialog).findByText(/Model list loaded \(1\)/i)).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('combobox', { name: /Main Model|主模型/i }))
expect(within(dialog).getByRole('listbox')).toBeInTheDocument()
fireEvent.change(within(dialog).getByPlaceholderText('sk-...'), { target: { value: 'sk-other' } })
expect(within(dialog).queryByText(/Model list loaded/i)).not.toBeInTheDocument()
expect(within(dialog).queryByRole('listbox')).not.toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('button', { name: /Fetch models|获取模型/i }))
const mainCombobox = await within(dialog).findByRole('combobox', { name: /Main Model|主模型/i })
expect(mainCombobox).toHaveAttribute('aria-expanded', 'false')
expect(within(dialog).queryByRole('listbox')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,325 @@
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from 'react'
import { IconButton } from '@/components/ui/IconButton'
import { FIELD_BASE_CLASSES, FIELD_SIZE_CLASSES, fieldStateClasses } from '@/components/ui/Input'
import { useDismissable } from '@/hooks/useDismissable'
import { cx } from '@/lib/cx'
import type { ProviderModelGroup } from '@/lib/providerModels'
type ModelIdComboboxProps = {
label: string
value: string
onChange: (value: string) => void
placeholder: string
groups: ProviderModelGroup[]
pickerLabel: string
noMatchesLabel: string
moreResultsLabel: string
required?: boolean
}
const MAX_VISIBLE_MODELS = 100
export function ModelIdCombobox({
label,
value,
onChange,
placeholder,
groups,
pickerLabel,
noMatchesLabel,
moreResultsLabel,
required = false,
}: ModelIdComboboxProps) {
const inputId = useId()
const listId = `${inputId}-models`
const rootRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const optionRefs = useRef<Array<HTMLButtonElement | null>>([])
const [open, setOpen] = useState(false)
const [filtering, setFiltering] = useState(false)
const [activeIndex, setActiveIndex] = useState(-1)
const hasModels = groups.some((group) => group.models.length > 0)
const normalizedQuery = filtering ? value.trim().toLocaleLowerCase() : ''
const { visibleGroups, totalMatches } = useMemo(() => {
if (!open) return { visibleGroups: [], totalMatches: 0 }
let remaining = MAX_VISIBLE_MODELS
let matchCount = 0
const nextGroups = groups.flatMap((group) => {
const models = normalizedQuery
? group.models.filter((model) => (
model.id.toLocaleLowerCase().includes(normalizedQuery)
|| group.group.toLocaleLowerCase().includes(normalizedQuery)
))
: group.models
matchCount += models.length
if (remaining === 0 || models.length === 0) return []
const visibleModels = models.slice(0, remaining)
remaining -= visibleModels.length
return [{ ...group, models: visibleModels }]
})
return { visibleGroups: nextGroups, totalMatches: matchCount }
}, [groups, normalizedQuery, open])
const visibleModels = useMemo(
() => visibleGroups.flatMap((group) => group.models.map((model) => model.id)),
[visibleGroups],
)
const optionIndexById = useMemo(
() => new Map(visibleModels.map((modelId, index) => [modelId, index])),
[visibleModels],
)
const hasMoreModels = totalMatches > visibleModels.length
const close = useCallback(() => {
setOpen(false)
setFiltering(false)
setActiveIndex(-1)
}, [])
useDismissable({
open: open && hasModels,
refs: [rootRef],
onDismiss: close,
stopEscapePropagation: true,
})
useEffect(() => {
if (!hasModels && open) close()
}, [close, hasModels, open])
useEffect(() => {
if (!open) return
if (visibleModels.length === 0) {
setActiveIndex(-1)
return
}
const selectedIndex = normalizedQuery
? -1
: visibleModels.findIndex((modelId) => modelId === value)
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0)
}, [normalizedQuery, open, value, visibleModels])
useEffect(() => {
if (!open || activeIndex < 0) return
optionRefs.current[activeIndex]?.scrollIntoView?.({ block: 'nearest' })
}, [activeIndex, open])
const openAllModels = () => {
if (!hasModels) return
setFiltering(false)
setOpen(true)
}
const selectModel = (modelId: string) => {
onChange(modelId)
close()
}
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (!hasModels) return
if (event.key === 'ArrowDown') {
event.preventDefault()
if (!open) {
openAllModels()
return
}
setActiveIndex((current) => visibleModels.length > 0
? (current + 1 + visibleModels.length) % visibleModels.length
: -1)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
if (!open) {
openAllModels()
return
}
setActiveIndex((current) => visibleModels.length > 0
? (current - 1 + visibleModels.length) % visibleModels.length
: -1)
return
}
if (event.key === 'Enter' && open && activeIndex >= 0) {
event.preventDefault()
const activeModel = visibleModels[activeIndex]
if (activeModel) selectModel(activeModel)
}
}
return (
<div
ref={rootRef}
className="relative flex min-w-0 flex-col gap-1"
onBlur={(event) => {
const nextFocus = event.relatedTarget
if (!(nextFocus instanceof Node) || !event.currentTarget.contains(nextFocus)) close()
}}
>
<label htmlFor={inputId} className="text-sm font-medium text-[var(--color-text-primary)]">
{label}
{required && <span className="ml-0.5 text-[var(--color-error)]">*</span>}
</label>
<div className="relative">
<input
ref={inputRef}
id={inputId}
value={value}
required={required}
placeholder={placeholder}
autoComplete="off"
role={hasModels ? 'combobox' : undefined}
aria-autocomplete={hasModels ? 'list' : undefined}
aria-haspopup={hasModels ? 'listbox' : undefined}
aria-expanded={hasModels ? open : undefined}
aria-controls={hasModels && open ? listId : undefined}
aria-activedescendant={hasModels && open && activeIndex >= 0 ? `${listId}-${activeIndex}` : undefined}
onFocus={() => {
if (!open) openAllModels()
}}
onClick={() => {
if (!open) openAllModels()
}}
onChange={(event) => {
const nextValue = event.target.value
onChange(nextValue)
if (hasModels) {
setFiltering(true)
setOpen(true)
}
}}
onKeyDown={handleKeyDown}
className={cx(
FIELD_BASE_CLASSES,
FIELD_SIZE_CLASSES.lg,
fieldStateClasses(false),
hasModels && 'pr-11',
)}
/>
{hasModels && (
<IconButton
icon={(
<span
aria-hidden="true"
className={cx(
'material-symbols-outlined text-[18px] transition-transform duration-150',
open && 'rotate-180',
)}
>
expand_more
</span>
)}
label={pickerLabel}
showTooltip={false}
size="md"
tone="secondary"
tabIndex={-1}
aria-expanded={open}
aria-controls={open ? listId : undefined}
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
if (open) {
close()
return
}
openAllModels()
inputRef.current?.focus()
}}
className="absolute right-1 top-1/2 -translate-y-1/2 active:translate-y-[calc(-50%+1px)]"
/>
)}
</div>
{open && hasModels && (
<div className="absolute left-0 right-0 top-full z-[var(--z-dropdown)] mt-1 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)] animate-overlay-in-top">
<div
id={listId}
role="listbox"
aria-label={pickerLabel}
className="max-h-64 overflow-y-auto p-1.5"
>
{visibleGroups.length === 0 ? (
<div
role="option"
aria-disabled="true"
aria-selected="false"
className="px-3 py-5 text-center text-xs text-[var(--color-text-tertiary)]"
>
{noMatchesLabel}
</div>
) : visibleGroups.map((group) => (
<div key={group.group} role="group" aria-label={group.group}>
{visibleGroups.length > 1 && (
<div className="px-2.5 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)] first:pt-1">
{group.group}
</div>
)}
<div className="space-y-0.5">
{group.models.map((model) => {
const index = optionIndexById.get(model.id) ?? -1
const selected = model.id === value
const active = index === activeIndex
return (
<button
ref={(node) => {
optionRefs.current[index] = node
}}
key={model.id}
id={`${listId}-${index}`}
type="button"
role="option"
tabIndex={-1}
aria-selected={selected}
onPointerDown={(event) => event.preventDefault()}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => selectModel(model.id)}
className={cx(
'flex min-h-9 w-full items-center gap-2 rounded-[var(--radius-sm)] px-2.5 py-1.5 text-left text-sm outline-none transition-colors duration-150',
active
? 'bg-[var(--color-surface-hover)] text-[var(--color-text-primary)]'
: selected
? 'bg-[var(--color-model-option-selected-bg)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]',
)}
>
<span className="min-w-0 flex-1 truncate font-mono text-[13px]">{model.id}</span>
{selected && (
<span aria-hidden="true" className="material-symbols-outlined flex-none text-[16px] text-[var(--color-brand)]">
check
</span>
)}
</button>
)
})}
</div>
</div>
))}
</div>
{hasMoreModels && (
<div role="status" className="border-t border-[var(--color-border-separator)] px-3 py-2 text-center text-[11px] text-[var(--color-text-tertiary)]">
{moreResultsLabel}
</div>
)}
</div>
)}
</div>
)
}
+5 -2
View File
@@ -707,7 +707,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'settings.providers.model1mSupportShort': '1M',
'settings.providers.model1mSupportHint': 'Only tick “1M” when the endpoint really serves a 1M-token context — ticking it otherwise stops auto-compaction from firing.',
'settings.providers.model1mSupportTooltip': 'Declares that this model endpoint supports a 1M-token context: the model name gets a [1m] suffix and the context window is set to 1,000,000. If the endpoint is actually smaller, auto-compaction never fires and requests hit the provider’s hard context limit. Clearing the box restores the preset window.',
'settings.providers.modelIdPlaceholder': 'Model ID',
'settings.providers.modelIdPlaceholder': 'e.g. deepseek-v4-flash',
'settings.providers.sameAsMain': 'Same as main',
'settings.providers.contextSettingsTitle': 'Context and auto-compact',
'settings.providers.contextSettingsDesc': 'Preset limits are applied automatically. Edit only when the provider changes limits or you use a custom model ID.',
@@ -756,11 +756,14 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'settings.providers.disableExperimentalBetas': 'Disable experimental beta headers',
'settings.providers.disableExperimentalBetasDesc': 'Sets CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 for this provider to avoid beta API shapes that third-party gateways may reject.',
'settings.providers.fetchModels': 'Fetch models',
'settings.providers.fetchModelsHint': 'Fill in the base URL and API key first.',
'settings.providers.fetchModelsHint': 'Enter the base URL first.',
'settings.providers.fetchModelsApiKeyHint': 'Enter an API key to fetch the model list.',
'settings.providers.fetchModelsSupportHint': 'This works only when the upstream provider publishes a model list endpoint.',
'settings.providers.fetchModelsLoaded': 'Model list loaded ({count}). Pick one or keep typing the ID.',
'settings.providers.fetchModelsEmpty': 'No models found. Enter the model ID manually.',
'settings.providers.fetchModelsPick': 'Choose {label} from the fetched list',
'settings.providers.fetchModelsGroupOther': 'Other',
'settings.providers.fetchModelsMoreResults': 'More models are available. Type to narrow the list.',
'settings.providers.fetchModelsErrorMissingConfig': 'Base URL and API key are required.',
'settings.providers.fetchModelsErrorAuthFailed': 'The API key was rejected. Check the key and the auth variable.',
'settings.providers.fetchModelsErrorEndpointNotFound': 'No model list endpoint was found under this base URL.',
+5 -2
View File
@@ -709,7 +709,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.providers.model1mSupportShort': '1M',
'settings.providers.model1mSupportHint': '「1M」は、そのモデルのエンドポイントが実際に 100 万トークンのコンテキストに対応している場合のみチェックしてください。誤ってチェックすると自動圧縮が働かなくなります。',
'settings.providers.model1mSupportTooltip': 'このモデルのエンドポイントが 100 万トークンのコンテキストに対応していることを宣言します。モデル名に [1m] が付き、コンテキストウィンドウは 1,000,000 に設定されます。実際のウィンドウが 1M より小さい場合、自動圧縮は実行されず、リクエストはプロバイダーのコンテキスト上限に達します。チェックを外すとプリセットのウィンドウに戻ります。',
'settings.providers.modelIdPlaceholder': 'モデル ID',
'settings.providers.modelIdPlaceholder': '例: deepseek-v4-flash',
'settings.providers.sameAsMain': 'メインと同じ',
'settings.providers.contextSettingsTitle': 'コンテキストと自動コンパクト',
'settings.providers.contextSettingsDesc': 'プリセットの上限は自動的に適用されます。プロバイダーが上限を変更した場合やカスタムモデル ID を使用する場合のみ編集してください。',
@@ -758,11 +758,14 @@ export const jp: Record<TranslationKey, string> = {
'settings.providers.disableExperimentalBetas': '実験的な Beta ヘッダーを無効化',
'settings.providers.disableExperimentalBetasDesc': 'このプロバイダーに CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 を設定し、サードパーティゲートウェイが拒否する可能性のある beta API 形式を避けます。',
'settings.providers.fetchModels': 'モデルを取得',
'settings.providers.fetchModelsHint': '先にベース URL と API キーを入力してください。',
'settings.providers.fetchModelsHint': '先にベース URL を入力してください。',
'settings.providers.fetchModelsApiKeyHint': 'モデル一覧を取得するには API キーを入力してください。',
'settings.providers.fetchModelsSupportHint': '上流プロバイダーがモデル一覧エンドポイントを公開している場合のみ取得できます。',
'settings.providers.fetchModelsLoaded': 'モデル一覧を取得しました({count} 件)。選択しても、手入力を続けても構いません。',
'settings.providers.fetchModelsEmpty': 'モデルが見つかりません。モデル ID を手入力してください。',
'settings.providers.fetchModelsPick': '取得したリストから{label}を選択',
'settings.providers.fetchModelsGroupOther': 'その他',
'settings.providers.fetchModelsMoreResults': 'さらにモデルがあります。入力して候補を絞り込んでください。',
'settings.providers.fetchModelsErrorMissingConfig': 'ベース URL と API キーが必要です。',
'settings.providers.fetchModelsErrorAuthFailed': 'API キーが拒否されました。キーと認証変数を確認してください。',
'settings.providers.fetchModelsErrorEndpointNotFound': 'このベース URL 配下にモデル一覧のエンドポイントが見つかりません。',
+5 -2
View File
@@ -709,7 +709,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.providers.model1mSupportShort': '1M',
'settings.providers.model1mSupportHint': '해당 모델 엔드포인트가 실제로 100만 토큰 컨텍스트를 지원할 때만 「1M」을 선택하세요. 잘못 선택하면 자동 압축이 작동하지 않습니다.',
'settings.providers.model1mSupportTooltip': '이 모델 엔드포인트가 100만 토큰 컨텍스트를 지원한다고 선언합니다. 모델 이름에 [1m]이 추가되고 컨텍스트 창이 1,000,000으로 설정됩니다. 엔드포인트의 실제 창이 1M보다 작으면 자동 압축이 실행되지 않고 요청이 공급자의 컨텍스트 한도에 도달합니다. 선택을 해제하면 사전 설정 창으로 되돌아갑니다.',
'settings.providers.modelIdPlaceholder': '모델 ID',
'settings.providers.modelIdPlaceholder': '예: deepseek-v4-flash',
'settings.providers.sameAsMain': '메인과 동일',
'settings.providers.contextSettingsTitle': '컨텍스트 및 자동 압축',
'settings.providers.contextSettingsDesc': '사전 설정 한도가 자동으로 적용됩니다. 공급자가 한도를 변경하거나 사용자 지정 모델 ID를 사용할 때만 편집하세요.',
@@ -758,11 +758,14 @@ export const kr: Record<TranslationKey, string> = {
'settings.providers.disableExperimentalBetas': '실험적 Beta 헤더 비활성화',
'settings.providers.disableExperimentalBetasDesc': '이 공급자에 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1을 설정해 타사 게이트웨이가 거부할 수 있는 beta API 형식을 피합니다.',
'settings.providers.fetchModels': '모델 가져오기',
'settings.providers.fetchModelsHint': '기본 URL과 API 키를 먼저 입력하세요.',
'settings.providers.fetchModelsHint': '기본 URL을 먼저 입력하세요.',
'settings.providers.fetchModelsApiKeyHint': '모델 목록을 가져오려면 API 키를 입력하세요.',
'settings.providers.fetchModelsSupportHint': '상위 공급자가 모델 목록 엔드포인트를 제공하는 경우에만 가져올 수 있습니다.',
'settings.providers.fetchModelsLoaded': '모델 목록을 가져왔습니다({count}개). 선택하거나 직접 입력해도 됩니다.',
'settings.providers.fetchModelsEmpty': '모델을 찾지 못했습니다. 모델 ID를 직접 입력하세요.',
'settings.providers.fetchModelsPick': '가져온 목록에서 {label} 선택',
'settings.providers.fetchModelsGroupOther': '기타',
'settings.providers.fetchModelsMoreResults': '더 많은 모델이 있습니다. 입력하여 목록을 좁히세요.',
'settings.providers.fetchModelsErrorMissingConfig': '기본 URL과 API 키가 필요합니다.',
'settings.providers.fetchModelsErrorAuthFailed': 'API 키가 거부되었습니다. 키와 인증 변수를 확인하세요.',
'settings.providers.fetchModelsErrorEndpointNotFound': '이 기본 URL에서 모델 목록 엔드포인트를 찾지 못했습니다.',
+5 -2
View File
@@ -708,7 +708,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.model1mSupportShort': '1M',
'settings.providers.model1mSupportHint': '「1M」只在該模型端點確實提供 100 萬 token 上下文時勾選,勾錯會讓自動壓縮失效。',
'settings.providers.model1mSupportTooltip': '宣告該模型端點支援 100 萬 token 上下文:模型名稱會附加 [1m],上下文視窗自動填為 1,000,000。若端點實際視窗小於 1M,自動壓縮不會觸發,請求會撞上服務商的上下文硬限制。取消勾選會還原為預設視窗。',
'settings.providers.modelIdPlaceholder': '模型 ID',
'settings.providers.modelIdPlaceholder': '例如: deepseek-v4-flash',
'settings.providers.sameAsMain': '與主模型相同',
'settings.providers.contextSettingsTitle': '上下文與自動壓縮',
'settings.providers.contextSettingsDesc': '預設會自動應用模型視窗。只有服務商調整限制,或使用自定義模型 ID 時才需要修改。',
@@ -757,11 +757,14 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.disableExperimentalBetas': '關閉實驗性 Beta 標頭',
'settings.providers.disableExperimentalBetasDesc': '為此服務商設定 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1,避免第三方通道拒絕 beta API 形態。',
'settings.providers.fetchModels': '取得模型',
'settings.providers.fetchModelsHint': '請先填寫介面地址與 API 金鑰。',
'settings.providers.fetchModelsHint': '請先填寫介面地址。',
'settings.providers.fetchModelsApiKeyHint': '要填寫 API Key 才能取得模型清單',
'settings.providers.fetchModelsSupportHint': '僅當上游服務商提供模型清單端點時才能取得。',
'settings.providers.fetchModelsLoaded': '已取得模型清單({count} 個),可直接選擇,也可繼續手動輸入。',
'settings.providers.fetchModelsEmpty': '沒有找到模型,請手動填寫模型 ID。',
'settings.providers.fetchModelsPick': '從已取得的清單中選擇{label}',
'settings.providers.fetchModelsGroupOther': '其他',
'settings.providers.fetchModelsMoreResults': '模型較多,請輸入關鍵字繼續篩選。',
'settings.providers.fetchModelsErrorMissingConfig': '需要先填寫介面地址與 API 金鑰。',
'settings.providers.fetchModelsErrorAuthFailed': 'API 金鑰遭拒絕,請檢查金鑰與認證變數。',
'settings.providers.fetchModelsErrorEndpointNotFound': '此介面地址下找不到模型清單端點。',
+5 -2
View File
@@ -708,7 +708,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.model1mSupportShort': '1M',
'settings.providers.model1mSupportHint': '「1M」只在该模型端点确实提供 100 万 token 上下文时勾选,勾错会让自动压缩失效。',
'settings.providers.model1mSupportTooltip': '声明该模型端点支持 100 万 token 上下文:模型名会追加 [1m],上下文窗口自动填为 1,000,000。若端点实际窗口小于 1M,自动压缩不会触发,请求会撞上服务商的上下文硬限制。取消勾选会恢复预设窗口。',
'settings.providers.modelIdPlaceholder': '模型 ID',
'settings.providers.modelIdPlaceholder': '例如: deepseek-v4-flash',
'settings.providers.sameAsMain': '与主模型相同',
'settings.providers.contextSettingsTitle': '上下文与自动压缩',
'settings.providers.contextSettingsDesc': '预设会自动应用模型窗口。只有服务商调整限制,或使用自定义模型 ID 时才需要修改。',
@@ -757,11 +757,14 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.disableExperimentalBetas': '关闭实验性 Beta 头',
'settings.providers.disableExperimentalBetasDesc': '为此服务商设置 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1,避免第三方通道拒绝 beta API 形态。',
'settings.providers.fetchModels': '获取模型',
'settings.providers.fetchModelsHint': '请先填写接口地址与 API 密钥。',
'settings.providers.fetchModelsHint': '请先填写接口地址。',
'settings.providers.fetchModelsApiKeyHint': '要填写 API Key 才能获取模型列表',
'settings.providers.fetchModelsSupportHint': '仅当上游服务商提供模型列表接口时才能获取。',
'settings.providers.fetchModelsLoaded': '已获取模型列表({count} 个),可直接选择,也可继续手动输入。',
'settings.providers.fetchModelsEmpty': '没有找到模型,请手动填写模型 ID。',
'settings.providers.fetchModelsPick': '从已获取的列表中选择{label}',
'settings.providers.fetchModelsGroupOther': '其他',
'settings.providers.fetchModelsMoreResults': '模型较多,请输入关键词继续筛选。',
'settings.providers.fetchModelsErrorMissingConfig': '需要先填写接口地址与 API 密钥。',
'settings.providers.fetchModelsErrorAuthFailed': 'API 密钥被拒绝,请检查密钥与认证变量。',
'settings.providers.fetchModelsErrorEndpointNotFound': '该接口地址下没有找到模型列表接口。',
+23 -31
View File
@@ -79,6 +79,7 @@ import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogi
import { GrokOfficialLogin } from '../components/settings/GrokOfficialLogin'
import { AgentManager } from '../components/settings/AgentManager'
import { CcSwitchImportModal } from '../components/settings/CcSwitchImportModal'
import { ModelIdCombobox } from '../components/settings/ModelIdCombobox'
import {
BUILT_IN_PROVIDER_IDS,
CLAUDE_OFFICIAL_PROVIDER_ID,
@@ -1617,7 +1618,9 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
buildModelContextWindows(models, nextInputs),
))
}
const canFetchModels = Boolean(baseUrl.trim() && apiKey.trim())
const hasModelsBaseUrl = Boolean(baseUrl.trim())
const hasModelsApiKey = Boolean(apiKey.trim())
const canFetchModels = hasModelsBaseUrl && hasModelsApiKey
const handleFetchModels = async () => {
if (!canFetchModels || isFetchingModels) return
const requestId = modelsRequestRef.current + 1
@@ -1658,15 +1661,11 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
const modelsErrorUpstream = modelsErrorMessage && modelsErrorMessage !== modelsErrorText
? modelsErrorMessage
: null
const modelPickerItems = useMemo(
const modelPickerGroups = useMemo(
() => groupProviderModels(
fetchedModels ?? [],
t('settings.providers.fetchModelsGroupOther'),
).flatMap((group) => group.models.map((model) => ({
value: model.id,
label: model.id,
description: group.group,
}))),
),
[fetchedModels, t],
)
const renderPresetButton = (preset: ProviderPreset) => (
@@ -2007,7 +2006,9 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
{t('settings.providers.fetchModels')}
</Button>
</div>
{!canFetchModels ? (
{!hasModelsApiKey ? (
<p className="mb-2 text-[11px] text-[var(--color-text-tertiary)]">{t('settings.providers.fetchModelsApiKeyHint')}</p>
) : !hasModelsBaseUrl ? (
<p className="mb-2 text-[11px] text-[var(--color-text-tertiary)]">{t('settings.providers.fetchModelsHint')}</p>
) : modelsErrorCode ? (
<div role="alert" className="mb-2 flex flex-col gap-0.5">
@@ -2024,7 +2025,9 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
<p className="mb-2 text-[11px] text-[var(--color-text-secondary)]">
{t('settings.providers.fetchModelsLoaded', { count: fetchedModels.length })}
</p>
) : null}
) : (
<p className="mb-2 text-[11px] text-[var(--color-text-tertiary)]">{t('settings.providers.fetchModelsSupportHint')}</p>
)}
<div className="grid grid-cols-2 gap-2">
{MODEL_SLOTS.map((slot) => {
const labelKey = slot === 'main'
@@ -2038,28 +2041,17 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
const pickLabel = t('settings.providers.fetchModelsPick', { label })
return (
<div key={slot} className="min-w-0">
<div className="flex items-end gap-1.5">
<Input
containerClassName="min-w-0 flex-1"
label={label}
required={slot === 'main'}
value={models[slot]}
onChange={(e) => handleModelChange(slot, e.target.value)}
placeholder={slot === 'main' ? t('settings.providers.modelIdPlaceholder') : t('settings.providers.sameAsMain')}
/>
{/* The picker only supplements the field — the id stays typeable. */}
{modelPickerItems.length > 0 && (
<Dropdown<string>
items={modelPickerItems}
value={models[slot]}
onChange={(value) => handleModelChange(slot, value)}
label={pickLabel}
align="right"
maxHeight={260}
trigger={<IconButton icon="expand_more" label={pickLabel} size="xl" tone="secondary" bordered />}
/>
)}
</div>
<ModelIdCombobox
label={label}
required={slot === 'main'}
value={models[slot]}
onChange={(value) => handleModelChange(slot, value)}
placeholder={slot === 'main' ? t('settings.providers.modelIdPlaceholder') : t('settings.providers.sameAsMain')}
groups={modelPickerGroups}
pickerLabel={pickLabel}
noMatchesLabel={t('model.noMatches')}
moreResultsLabel={t('settings.providers.fetchModelsMoreResults')}
/>
<Tooltip content={t('settings.providers.model1mSupportTooltip')} placement="bottom-start">
<label className="mt-1 inline-flex h-6 w-fit cursor-pointer items-center gap-1.5 rounded-[var(--radius-sm)] px-1 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]">
<input