cc-haha/desktop/src/components/controls/ModelSelector.tsx
程序员阿江(Relakkes) 78d85f0c64 fix(desktop): apply active provider before first prompt (#844)
Materialize the active provider runtime for empty-session first prompts before the WebSocket prewarm and user message are sent. This keeps a clean install that adds a MiniMax provider from starting the first turn without the selected provider runtime.

Tested: bun test src/server/__tests__/conversations.test.ts -t "MiniMax provider turn"
Tested: bun test src/server/__tests__/websocket-handler.test.ts src/server/__tests__/conversations.test.ts -t "prewarm|active provider id|MiniMax provider turn|first-turn runtime synchronization"
Tested: cd desktop && bun run test -- --run src/pages/EmptySession.test.tsx -t "draft runtime|active provider runtime|creates a new session"
Tested: cd desktop && bun run test -- --run src/components/controls/ModelSelector.test.tsx -t "defaults blank provider-scoped runtime selections|selects provider-scoped runtime models"
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Not-tested: bun run check:desktop, unrelated full-suite failures in MessageList timestamp formatting and TabBar AbortSignal cleanup blocked the gate.
Confidence: medium
Scope-risk: narrow
2026-07-02 21:02:58 +08:00

579 lines
20 KiB
TypeScript

import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { OFFICIAL_MODELS } from '../../constants/modelCatalog'
import {
OPENAI_OFFICIAL_MODELS,
OPENAI_OFFICIAL_PROVIDER_ID,
} from '../../constants/openaiOfficialProvider'
import { useTranslation } from '../../i18n'
import { useChatStore } from '../../stores/chatStore'
import { useProviderStore } from '../../stores/providerStore'
import { DRAFT_RUNTIME_SELECTION_KEY, useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
import { useSettingsStore } from '../../stores/settingsStore'
import type { SavedProvider } from '../../types/provider'
import type { RuntimeSelection } from '../../types/runtime'
import type { EffortLevel, ModelInfo } from '../../types/settings'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isDesktopRuntime } from '../../lib/desktopRuntime'
import { resolveDefaultRuntimeSelection } from '../../lib/runtimeSelection'
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
type ProviderChoice = {
providerId: string | null
providerName: string
isDefault: boolean
models: ModelInfo[]
}
type Props = {
value?: string
onChange?: (modelId: string) => void
runtimeSelection?: RuntimeSelection
onRuntimeSelectionChange?: (selection: RuntimeSelection) => void
runtimeKey?: string
disabled?: boolean
compact?: boolean
}
export type ModelSelectorHandle = {
open: () => void
}
type DropdownPosition = {
top: number | undefined
bottom: number | undefined
left: number
width: number
maxHeight: number
}
const DROPDOWN_WIDTH = 360
const DROPDOWN_GAP = 8
const VIEWPORT_MARGIN = 16
const DROPDOWN_MAX_HEIGHT = 420
const DROPDOWN_MIN_HEIGHT = 180
function officialChoices(
providerId: string | null,
models: ModelInfo[],
isDefault: boolean,
officialName: string,
): ProviderChoice {
return {
providerId,
providerName: officialName,
isDefault,
models,
}
}
function buildProviderModels(
provider: SavedProvider,
labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>,
): ModelInfo[] {
const entries: Array<{ id: string; label: string }> = [
{ id: provider.models.main.trim(), label: labels.main },
{ id: provider.models.haiku.trim(), label: labels.haiku },
{ id: provider.models.sonnet.trim(), label: labels.sonnet },
{ id: provider.models.opus.trim(), label: labels.opus },
]
const byId = new Map<string, { id: string; labels: string[] }>()
for (const entry of entries) {
if (!entry.id) continue
const existing = byId.get(entry.id)
if (existing) {
if (!existing.labels.includes(entry.label)) {
existing.labels.push(entry.label)
}
continue
}
byId.set(entry.id, { id: entry.id, labels: [entry.label] })
}
return [...byId.values()].map((entry) => ({
id: entry.id,
name: entry.id,
description: entry.labels.join(' · '),
context: '',
}))
}
function buildProviderChoices(
providers: SavedProvider[],
activeId: string | null,
availableModels: ModelInfo[],
officialName: string,
openAIOfficialName: string,
labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>,
claudeOfficialLoggedIn: boolean,
openAIOfficialLoggedIn: boolean,
): ProviderChoice[] {
const claudeOfficialModels = activeId === null && availableModels.length > 0
? availableModels
: OFFICIAL_MODELS
const openAIOfficialModels = activeId === OPENAI_OFFICIAL_PROVIDER_ID && availableModels.length > 0
? availableModels
: OPENAI_OFFICIAL_MODELS
const choices: ProviderChoice[] = []
if (claudeOfficialLoggedIn) {
choices.push(officialChoices(null, claudeOfficialModels, activeId === null, officialName))
}
if (openAIOfficialLoggedIn) {
choices.push(officialChoices(
OPENAI_OFFICIAL_PROVIDER_ID,
openAIOfficialModels,
activeId === OPENAI_OFFICIAL_PROVIDER_ID,
openAIOfficialName,
))
}
for (const provider of providers) {
choices.push({
providerId: provider.id,
providerName: provider.name,
isDefault: activeId === provider.id,
models: buildProviderModels(provider, labels),
})
}
return choices
}
export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function ModelSelector({
value,
onChange,
runtimeSelection: controlledRuntimeSelection,
onRuntimeSelectionChange,
runtimeKey,
disabled = false,
compact = false,
}: Props = {}, selectorRef) {
const t = useTranslation()
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
const {
currentModel: storeModel,
availableModels,
effortLevel,
activeProviderName,
setModel,
} = useSettingsStore()
const {
providers,
activeId,
isLoading: providersLoading,
fetchProviders,
} = useProviderStore()
const claudeOAuthStatus = useHahaOAuthStore((s) => s.status)
const fetchClaudeOAuthStatus = useHahaOAuthStore((s) => s.fetchStatus)
const openAIOAuthStatus = useHahaOpenAIOAuthStore((s) => s.status)
const fetchOpenAIOAuthStatus = useHahaOpenAIOAuthStore((s) => s.fetchStatus)
const runtimeSelection = useSessionRuntimeStore((state) =>
runtimeKey ? state.selections[runtimeKey] : undefined,
)
const [open, setOpen] = useState(false)
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null)
const ref = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const requestedProvidersRef = useRef(false)
const requestedOAuthStatusRef = useRef(false)
const EFFORT_OPTIONS: { value: EffortLevel; label: string }[] = [
{ value: 'low', label: t('settings.general.effort.low') },
{ value: 'medium', label: t('settings.general.effort.medium') },
{ value: 'high', label: t('settings.general.effort.high') },
{ value: 'max', label: t('settings.general.effort.max') },
]
const isControlled = value !== undefined
const isRuntimeScoped =
!isControlled &&
(runtimeKey !== undefined || onRuntimeSelectionChange !== undefined)
const canEditRuntimeEffort = runtimeKey !== undefined
useEffect(() => {
if (!isRuntimeScoped || providersLoading || requestedProvidersRef.current) return
requestedProvidersRef.current = true
void fetchProviders()
}, [fetchProviders, isRuntimeScoped, providersLoading])
useEffect(() => {
if (!isRuntimeScoped || !open || requestedOAuthStatusRef.current) return
requestedOAuthStatusRef.current = true
void fetchClaudeOAuthStatus()
void fetchOpenAIOAuthStatus()
}, [fetchClaudeOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open])
const openSelector = useCallback(() => {
if (!disabled) setOpen(true)
}, [disabled])
useImperativeHandle(selectorRef, () => ({
open: openSelector,
}), [openSelector])
useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
const target = e.target as Node
if (
ref.current &&
!ref.current.contains(target) &&
!dropdownRef.current?.contains(target)
) {
setOpen(false)
}
}
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleEsc)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleEsc)
}
}, [open])
const updateDropdownPosition = useCallback(() => {
const anchor = ref.current
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const viewportWidth = window.innerWidth || document.documentElement.clientWidth
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
const width = Math.min(DROPDOWN_WIDTH, Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2))
const left = Math.min(
Math.max(VIEWPORT_MARGIN, rect.right - width),
Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN),
)
const spaceBelow = viewportHeight - rect.bottom - DROPDOWN_GAP - VIEWPORT_MARGIN
const spaceAbove = rect.top - DROPDOWN_GAP - VIEWPORT_MARGIN
const placeBelow = spaceBelow >= DROPDOWN_MIN_HEIGHT || spaceBelow >= spaceAbove
const availableHeight = Math.max(
DROPDOWN_MIN_HEIGHT,
placeBelow ? spaceBelow : spaceAbove,
)
const maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, availableHeight)
setDropdownPosition({
top: placeBelow ? rect.bottom + DROPDOWN_GAP : undefined,
bottom: placeBelow ? undefined : (viewportHeight - rect.top + DROPDOWN_GAP),
left,
width,
maxHeight,
})
}, [])
useLayoutEffect(() => {
if (!open) {
setDropdownPosition(null)
return
}
updateDropdownPosition()
}, [open, updateDropdownPosition])
useEffect(() => {
if (!open) return
window.addEventListener('resize', updateDropdownPosition)
window.addEventListener('scroll', updateDropdownPosition, true)
return () => {
window.removeEventListener('resize', updateDropdownPosition)
window.removeEventListener('scroll', updateDropdownPosition, true)
}
}, [open, updateDropdownPosition])
const roleLabels = useMemo(
() => ({
main: t('settings.providers.mainModel'),
haiku: t('settings.providers.haikuModel'),
sonnet: t('settings.providers.sonnetModel'),
opus: t('settings.providers.opusModel'),
}),
[t],
)
const providerChoices = useMemo(
() => buildProviderChoices(
providers,
activeId,
availableModels,
t('settings.providers.officialName'),
t('settings.providers.openaiOfficialName'),
roleLabels,
claudeOAuthStatus?.loggedIn === true,
openAIOAuthStatus?.loggedIn === true,
),
[activeId, availableModels, providers, roleLabels, t, claudeOAuthStatus, openAIOAuthStatus],
)
const selectedModel = isControlled
? availableModels.find((model) => model.id === value) || null
: storeModel
const activeRuntimeSelection = isRuntimeScoped
? controlledRuntimeSelection ?? runtimeSelection ?? resolveDefaultRuntimeSelection(
activeId,
activeProviderName,
providers,
storeModel?.id,
)
: null
const selectedProviderChoice = activeRuntimeSelection
? providerChoices.find((choice) => choice.providerId === activeRuntimeSelection.providerId) ?? null
: null
const selectedRuntimeModel = activeRuntimeSelection
? selectedProviderChoice?.models.find((model) => model.id === activeRuntimeSelection.modelId)
?? {
id: activeRuntimeSelection.modelId,
name: activeRuntimeSelection.modelId,
description: '',
context: '',
}
: null
const buttonModelLabel = isRuntimeScoped
? selectedRuntimeModel?.name ?? storeModel?.name ?? t('model.selectModel')
: selectedModel?.name ?? t('model.selectModel')
const buttonProviderLabel = isRuntimeScoped
? selectedProviderChoice?.providerName ?? activeProviderName ?? t('settings.providers.officialName')
: null
const selectedRuntimeEffort = activeRuntimeSelection?.effortLevel ?? effortLevel
const handleRuntimeSelect = (selection: RuntimeSelection) => {
onRuntimeSelectionChange?.(selection)
if (runtimeKey) {
useSessionRuntimeStore.getState().setSelection(runtimeKey, selection)
if (runtimeKey !== DRAFT_RUNTIME_SELECTION_KEY) {
useChatStore.getState().setSessionRuntime(runtimeKey, selection)
}
}
setOpen(false)
}
const handleRuntimeEffortSelect = (level: EffortLevel) => {
if (!activeRuntimeSelection) return
handleRuntimeSelect({
...activeRuntimeSelection,
effortLevel: level,
})
}
const dropdownContent = (
<>
<div className={`overflow-y-auto ${isMobileBrowser ? 'p-1' : 'p-3'}`} style={{ maxHeight: isMobileBrowser ? undefined : dropdownPosition?.maxHeight }}>
{!isMobileBrowser && (
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('model.configuration')}
</div>
)}
{isRuntimeScoped ? (
<div className="space-y-3">
{providerChoices.map((choice) => (
<div key={choice.providerId ?? 'official'} className="space-y-1.5">
<div className="flex items-center justify-between px-2 pt-1">
<span className="truncate text-[11px] font-semibold tracking-[0.01em] text-[var(--color-text-secondary)]">
{choice.providerName}
</span>
{choice.isDefault && (
<span className="flex-shrink-0 text-[10px] font-medium text-[var(--color-text-tertiary)]">
{t('settings.providers.default')}
</span>
)}
</div>
<div className="space-y-1">
{choice.models.map((model) => {
const isSelected =
activeRuntimeSelection?.providerId === choice.providerId &&
activeRuntimeSelection.modelId === model.id
return (
<button
key={`${choice.providerId ?? 'official'}:${model.id}`}
onClick={() => handleRuntimeSelect({
providerId: choice.providerId,
modelId: model.id,
effortLevel: selectedRuntimeEffort,
})}
className={`
w-full rounded-lg border px-3 text-left transition-colors
${isMobileBrowser ? 'min-h-[56px] py-3' : 'py-2.5'}
${isSelected
? 'border-[var(--color-model-option-selected-border)] bg-[var(--color-model-option-selected-bg)]'
: 'border-transparent hover:bg-[var(--color-surface-hover)]'
}
`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
{model.name}
</div>
{model.description && (
<div className="mt-0.5 truncate pr-[6px] text-[10px] text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
</div>
</button>
)
})}
</div>
</div>
))}
</div>
) : (
<div className="space-y-1">
{availableModels.map((model) => {
const isSelected = model.id === selectedModel?.id
return (
<button
key={model.id}
onClick={() => {
if (isControlled) {
onChange?.(model.id)
} else {
void setModel(model.id)
}
setOpen(false)
}}
className={`
w-full rounded-lg px-3 text-left transition-colors
${isMobileBrowser ? 'min-h-[56px] py-3' : 'py-2.5'}
${isSelected
? 'border border-[var(--color-model-option-selected-border)] bg-[var(--color-model-option-selected-bg)]'
: 'hover:bg-[var(--color-surface-hover)]'
}
`}
>
<div className="flex items-center gap-3">
<div className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{model.name}</div>
{model.description && (
<div className="mt-0.5 truncate text-[10px] text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
</div>
</button>
)
})}
</div>
)}
</div>
{canEditRuntimeEffort && (
<div className="border-t border-[var(--color-border)] p-3">
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('model.effort')}
</div>
<div className="grid grid-cols-4 gap-1.5">
{EFFORT_OPTIONS.map((opt) => {
const isSelected = opt.value === selectedRuntimeEffort
return (
<button
key={opt.value}
onClick={() => {
handleRuntimeEffortSelect(opt.value)
}}
className={`
rounded-lg py-2 text-center text-xs font-semibold transition-colors
${isSelected
? 'bg-[var(--color-brand)] text-white'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
{opt.label}
</button>
)
})}
</div>
</div>
)}
</>
)
const dropdown = open && dropdownPosition
? isMobileBrowser ? (
<MobileBottomSheet
open={open}
onClose={() => setOpen(false)}
title={t('model.configuration')}
closeLabel={t('tabs.close')}
ariaLabel={t('model.configuration')}
contentClassName="p-3"
panelRef={dropdownRef}
testId="model-selector-dropdown"
>
{dropdownContent}
</MobileBottomSheet>
) : createPortal(
<div
ref={dropdownRef}
data-testid="model-selector-dropdown"
className="fixed z-[80] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
style={{
top: dropdownPosition.top,
bottom: dropdownPosition.bottom,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{dropdownContent}
</div>,
document.body,
)
: null
return (
<div ref={ref} className="relative min-w-0 shrink-0">
<button
onClick={() => !disabled && setOpen(!open)}
disabled={disabled}
className={`flex items-center gap-2 rounded-full bg-[var(--color-surface-container-low)] text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-50 ${
compact ? 'max-w-[112px] px-2.5 py-1.5' : 'max-w-[280px] px-3 py-1.5'
}`}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className={`${compact ? 'text-xs' : 'text-sm'} min-w-0 flex-1 truncate font-semibold text-[var(--color-text-primary)]`}>
{buttonModelLabel}
</span>
{!compact && buttonProviderLabel && (
<span className="max-w-[108px] flex-shrink-0 truncate text-[11px] text-[var(--color-text-tertiary)]">
{buttonProviderLabel}
</span>
)}
</div>
<span className="material-symbols-outlined flex-shrink-0 text-[12px]">expand_more</span>
</button>
{dropdown}
</div>
)
})