refactor(renderer-v1v2): retire v1 provider/model hooks + drop fromSharedModel bridges

Continues the v1→v2 renderer cleanup. Migrates the remaining surfaces off
the Redux llm-slice provider hooks and the v1 Model fromSharedModel bridge
onto the v2 DataApi hooks (useProviders/useModels/useAssistant) and
@shared/utils predicates:

- Removes v1 hooks/useProvider.ts consumers across paintings, ChatNavBar
  settings, Inputbar tools, quick-assistant, code-cli, openclaw, onboarding.
- useAssistant.setModel takes a v2 Model (bridges once internally for the
  still-v1 reasoning reconcile chain); useModels setters take the v2
  UniqueModelId directly, fixing a latent createUniqueModelId double-prefix.
- MCPToolsButton/WebSearchButton resolve provider via useProvider instead
  of v1 getProviderByModel.
- Avatars/ProviderSelect widened to shape-agnostic props.
- Removes dead ModelSelectButton / AnthropicProviderListPopover.

config/models + _bridge foundational adapter migration and T2.x/T4.2/T3.x
entity migrations remain as separate follow-up PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: suyao <sy20010504@gmail.com>
This commit is contained in:
suyao
2026-05-16 13:08:13 +08:00
parent f2229a8814
commit d15e15e1f9
59 changed files with 592 additions and 1647 deletions

View File

@@ -1,202 +0,0 @@
import { Popover, PopoverContent, PopoverTrigger } from '@cherrystudio/ui'
import { ProviderAvatar } from '@renderer/components/ProviderAvatar'
import { useAllProviders } from '@renderer/hooks/useProvider'
import ImageStorage from '@renderer/services/ImageStorage'
import type { Provider } from '@renderer/types'
import { getFancyProviderName } from '@renderer/utils'
import { getClaudeSupportedProviders } from '@renderer/utils/provider'
import { ArrowUpRight, HelpCircle } from 'lucide-react'
import type { ComponentProps, CSSProperties, FC, ReactNode } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
type PopoverPlacement =
| 'top'
| 'topLeft'
| 'topRight'
| 'bottom'
| 'bottomLeft'
| 'bottomRight'
| 'left'
| 'leftTop'
| 'leftBottom'
| 'right'
| 'rightTop'
| 'rightBottom'
type PopoverSide = ComponentProps<typeof PopoverContent>['side']
type PopoverAlign = ComponentProps<typeof PopoverContent>['align']
interface AnthropicProviderListPopoverProps {
/** Callback when provider is clicked */
onProviderClick?: () => void
/** Use window.navigate instead of Link (for non-router context like TopView) */
useWindowNavigate?: boolean
/** Custom trigger element, defaults to HelpCircle icon */
children?: ReactNode
/** Popover placement */
placement?: PopoverPlacement
/** Custom filter function for providers, defaults to getClaudeSupportedProviders */
filterProviders?: (providers: Provider[]) => Provider[]
}
const getPopoverSide = (placement: PopoverPlacement): PopoverSide => {
if (placement.startsWith('top')) return 'top'
if (placement.startsWith('bottom')) return 'bottom'
if (placement.startsWith('left')) return 'left'
return 'right'
}
const getPopoverAlign = (placement: PopoverPlacement): PopoverAlign => {
if (placement.endsWith('Left') || placement.endsWith('Top')) return 'start'
if (placement.endsWith('Right') || placement.endsWith('Bottom')) return 'end'
return 'center'
}
const providerItemClassName =
'flex w-full cursor-pointer items-center gap-1 rounded-sm bg-transparent p-0 text-left text-sm text-foreground no-underline transition-colors hover:text-link'
const popoverContentStyle: CSSProperties = {
zIndex: 1100
}
const AnthropicProviderListPopover: FC<AnthropicProviderListPopoverProps> = ({
onProviderClick,
useWindowNavigate = false,
children,
placement = 'right',
filterProviders = getClaudeSupportedProviders
}) => {
const { t } = useTranslation()
const allProviders = useAllProviders()
const providers = filterProviders(allProviders)
const [providerLogos, setProviderLogos] = useState<Record<string, string>>({})
const [open, setOpen] = useState(false)
const closeTimerRef = useRef<number | undefined>(undefined)
useEffect(() => {
const loadAllLogos = async () => {
const logos: Record<string, string> = {}
for (const provider of providers) {
if (provider.id) {
try {
const logoData = await ImageStorage.get(`provider-${provider.id}`)
if (logoData) {
logos[provider.id] = logoData
}
} catch {
// Ignore errors loading logos
}
}
}
setProviderLogos(logos)
}
void loadAllLogos()
}, [providers])
useEffect(() => {
return () => {
if (closeTimerRef.current) {
window.clearTimeout(closeTimerRef.current)
}
}
}, [])
const openPopover = () => {
if (closeTimerRef.current) {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = undefined
}
setOpen(true)
}
const closePopover = () => {
if (closeTimerRef.current) {
window.clearTimeout(closeTimerRef.current)
}
closeTimerRef.current = window.setTimeout(() => {
setOpen(false)
closeTimerRef.current = undefined
}, 120)
}
const handleClick = (providerId: string) => {
onProviderClick?.()
setOpen(false)
if (useWindowNavigate) {
void window.navigate({ to: '/settings/provider', search: { id: providerId } })
}
}
const side = getPopoverSide(placement)
const align = getPopoverAlign(placement)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{children ? (
<span className="inline-flex" onMouseEnter={openPopover} onMouseLeave={closePopover}>
{children}
</span>
) : (
<button
type="button"
aria-label={t('code.supported_providers')}
className="inline-flex cursor-pointer items-center border-0 bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground"
onMouseEnter={openPopover}
onMouseLeave={closePopover}>
<HelpCircle size={14} />
</button>
)}
</PopoverTrigger>
<PopoverContent
side={side}
align={align}
className="w-[200px] p-3"
style={popoverContentStyle}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onMouseEnter={openPopover}
onMouseLeave={closePopover}>
<div className="mb-2 font-medium text-sm">{t('code.supported_providers')}</div>
<div className="flex flex-col gap-2">
{providers.map((provider) =>
useWindowNavigate ? (
<button
key={provider.id}
type="button"
className={providerItemClassName}
onClick={() => handleClick(provider.id)}>
<ProviderAvatar
provider={provider}
customLogos={providerLogos}
size={20}
style={{ width: 20, height: 20 }}
/>
{getFancyProviderName(provider)}
<ArrowUpRight size={14} />
</button>
) : (
<a
key={provider.id}
href={`/settings/provider?id=${provider.id}`}
className={providerItemClassName}
onClick={() => handleClick(provider.id)}>
<ProviderAvatar
provider={provider}
customLogos={providerLogos}
size={20}
style={{ width: 20, height: 20 }}
/>
{getFancyProviderName(provider)}
<ArrowUpRight size={14} />
</a>
)
)}
</div>
</PopoverContent>
</Popover>
)
}
export default AnthropicProviderListPopover

View File

@@ -1,5 +1,4 @@
import EmojiIcon from '@renderer/components/EmojiIcon'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import type { Assistant } from '@renderer/types'
@@ -18,12 +17,11 @@ interface AssistantAvatarProps {
const AssistantAvatar: FC<AssistantAvatarProps> = ({ assistant, size = 24, className }) => {
const { assistantIconType } = useSettings()
const { model } = useAssistant(assistant.id)
const v1Model = useMemo(() => (model ? fromSharedModel(model) : undefined), [model])
const assistantName = useMemo(() => assistant.name || '', [assistant.name])
if (assistantIconType === 'model') {
return <ModelAvatar model={v1Model} size={size} className={className} />
return <ModelAvatar model={model} size={size} className={className} />
}
if (assistantIconType === 'emoji') {

View File

@@ -1,12 +1,23 @@
import { Avatar, AvatarFallback } from '@cherrystudio/ui'
import { getModelLogo } from '@renderer/config/models'
import type { Model } from '@renderer/types'
import { cn } from '@renderer/utils'
import { first } from 'lodash'
import type { FC } from 'react'
/**
* Structural minimum the avatar needs. `getModelLogo` is shape-agnostic
* (accepts both v1 `provider` and v2 `providerId`), so this component works
* with either Model shape — no v1 `@renderer/types` dependency.
*/
interface AvatarModel {
id: string
name: string
provider?: string
providerId?: string
}
interface Props {
model?: Model
model?: AvatarModel
size: number
className?: string
}

View File

@@ -2,7 +2,7 @@ import { Button, Input, Tooltip } from '@cherrystudio/ui'
import { cn } from '@cherrystudio/ui/lib/utils'
import { loggerService } from '@logger'
import { RefreshIcon } from '@renderer/components/Icons'
import { useProvider } from '@renderer/hooks/useProvider'
import { useProvider } from '@renderer/hooks/useProviders'
import type { Model } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils'
import { createUniqueModelId } from '@shared/data/types/model'

View File

@@ -1,49 +0,0 @@
import type { TooltipProps } from '@cherrystudio/ui'
import { Button, Tooltip } from '@cherrystudio/ui'
import { ModelSelector } from '@renderer/components/ModelSelector'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import type { Model } from '@renderer/types'
import type { Model as SharedModel } from '@shared/data/types/model'
import { useCallback, useMemo } from 'react'
import ModelAvatar from './Avatar/ModelAvatar'
type Props = {
model: Model
onSelectModel: (model: Model) => void
/** Filter operates on the shared Model (the same shape ModelSelector iterates). */
modelFilter?: (model: SharedModel) => boolean
noTooltip?: boolean
tooltipProps?: TooltipProps
}
const ModelSelectButton = ({ model, onSelectModel, modelFilter, noTooltip, tooltipProps }: Props) => {
const handleSelect = useCallback(
(next: SharedModel | undefined) => {
if (!next) return
onSelectModel(fromSharedModel(next))
},
[onSelectModel]
)
const button = useMemo(
() => (
<Button variant="ghost" className="rounded-full" size="icon">
<ModelAvatar model={model} size={22} />
</Button>
),
[model]
)
const triggerWithTooltip = noTooltip ? (
button
) : (
<Tooltip content={model.name} {...tooltipProps}>
{button}
</Tooltip>
)
return <ModelSelector multiple={false} onSelect={handleSelect} filter={modelFilter} trigger={triggerWithTooltip} />
}
export default ModelSelectButton

View File

@@ -1,280 +0,0 @@
import { loggerService } from '@logger'
import type { Model, PreprocessProvider, Provider } from '@renderer/types'
import { isPreprocessProviderId } from '@renderer/types'
import type { ApiKeyConnectivity, ApiKeyWithStatus } from '@renderer/types/healthCheck'
import { HealthStatus } from '@renderer/types/healthCheck'
import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api'
import { serializeHealthCheckError } from '@renderer/utils/error'
import { createUniqueModelId } from '@shared/data/types/model'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ApiKeyValidity, ApiProvider, UpdateApiProviderFunc } from './types'
interface UseApiKeysProps {
provider: ApiProvider
updateProvider: UpdateApiProviderFunc
}
const logger = loggerService.withContext('ApiKeyListPopup')
/**
* API Keys 管理 hook
*/
export function useApiKeys({ provider, updateProvider }: UseApiKeysProps) {
const { t } = useTranslation()
// 连通性检查的 UI 状态管理
const [connectivityStates, setConnectivityStates] = useState<Map<string, ApiKeyConnectivity>>(new Map())
// 保存 apiKey 到 provider
const updateProviderWithKey = useCallback(
(newKeys: string[]) => {
const validKeys = newKeys.filter((k) => k.trim())
const formattedKeyString = formatApiKeys(validKeys.join(','))
updateProvider({ apiKey: formattedKeyString })
},
[updateProvider]
)
// 解析 keyString 为数组.
const keys = useMemo(() => {
if (!provider.apiKey) return []
const formattedApiKeys = formatApiKeys(provider.apiKey)
const keys = splitApiKeyString(formattedApiKeys)
return Array.from(new Set(keys))
}, [provider.apiKey])
// 合并基本数据和连通性状态
const keysWithStatus = useMemo((): ApiKeyWithStatus[] => {
return keys.map((key) => {
const connectivityState = connectivityStates.get(key) || {
status: HealthStatus.NOT_CHECKED,
checking: false,
error: undefined,
model: undefined,
latency: undefined
}
return {
key,
...connectivityState
}
})
}, [keys, connectivityStates])
// 更新单个 key 的连通性状态
const updateConnectivityState = useCallback((key: string, state: Partial<ApiKeyConnectivity>) => {
setConnectivityStates((prev) => {
const newMap = new Map(prev)
const currentState = prev.get(key) || {
status: HealthStatus.NOT_CHECKED,
checking: false,
error: undefined,
model: undefined,
latency: undefined
}
newMap.set(key, { ...currentState, ...state })
return newMap
})
}, [])
// 验证 API key 格式
const validateApiKey = useCallback(
(key: string, existingKeys: string[] = []): ApiKeyValidity => {
const trimmedKey = key.trim()
if (!trimmedKey) {
return { isValid: false, error: t('settings.provider.api.key.error.empty') }
}
if (existingKeys.includes(trimmedKey)) {
return { isValid: false, error: t('settings.provider.api.key.error.duplicate') }
}
return { isValid: true }
},
[t]
)
// 添加新 key
const addKey = useCallback(
(key: string): ApiKeyValidity => {
const validation = validateApiKey(key, keys)
if (!validation.isValid) {
return validation
}
updateProviderWithKey([...keys, key.trim()])
return { isValid: true }
},
[validateApiKey, keys, updateProviderWithKey]
)
// 更新 key
const updateKey = useCallback(
(index: number, key: string): ApiKeyValidity => {
if (index < 0 || index >= keys.length) {
logger.error('invalid key index', { index })
return { isValid: false, error: 'Invalid index' }
}
const otherKeys = keys.filter((_, i) => i !== index)
const validation = validateApiKey(key, otherKeys)
if (!validation.isValid) {
return validation
}
// 清除旧 key 的连通性状态
const oldKey = keys[index]
if (oldKey !== key.trim()) {
setConnectivityStates((prev) => {
const newMap = new Map(prev)
newMap.delete(oldKey)
return newMap
})
}
const newKeys = [...keys]
newKeys[index] = key.trim()
updateProviderWithKey(newKeys)
return { isValid: true }
},
[keys, validateApiKey, updateProviderWithKey]
)
// 移除 key
const removeKey = useCallback(
(index: number) => {
if (index < 0 || index >= keys.length) return
const keyToRemove = keys[index]
const newKeys = keys.filter((_, i) => i !== index)
// 清除对应的连通性状态
setConnectivityStates((prev) => {
const newMap = new Map(prev)
newMap.delete(keyToRemove)
return newMap
})
updateProviderWithKey(newKeys)
},
[keys, updateProviderWithKey]
)
// 移除连通性检查失败的 keys
const removeInvalidKeys = useCallback(() => {
const validKeys = keysWithStatus.filter((keyStatus) => keyStatus.status !== HealthStatus.FAILED).map((k) => k.key)
// 清除被删除的 keys 的连通性状态
const keysToRemove = keysWithStatus
.filter((keyStatus) => keyStatus.status === HealthStatus.FAILED)
.map((k) => k.key)
setConnectivityStates((prev) => {
const newMap = new Map(prev)
keysToRemove.forEach((key) => newMap.delete(key))
return newMap
})
updateProviderWithKey(validKeys)
}, [keysWithStatus, updateProviderWithKey])
// 检查单个 key 的连通性,不负责选择和验证模型
const runConnectivityCheck = useCallback(
async (index: number, model?: Model): Promise<void> => {
const keyToCheck = keys[index]
const currentState = connectivityStates.get(keyToCheck)
if (currentState?.checking) return
// 设置检查状态
updateConnectivityState(keyToCheck, { checking: true })
try {
const startTime = Date.now()
if (isLlmProvider(provider) && model) {
await window.api.ai.checkModel({
uniqueModelId: createUniqueModelId(provider.id, model.id),
timeout: 15000
})
} else {
// 不处理预处理供应商\\
}
const latency = Date.now() - startTime
// 连通性检查成功
updateConnectivityState(keyToCheck, {
checking: false,
status: HealthStatus.SUCCESS,
model,
latency,
error: undefined
})
} catch (error: unknown) {
// 连通性检查失败
const serializedError = serializeHealthCheckError(error)
updateConnectivityState(keyToCheck, {
checking: false,
status: HealthStatus.FAILED,
error: serializedError,
model: undefined,
latency: undefined
})
logger.error('failed to validate the connectivity of the api key', error as Error)
}
},
[keys, connectivityStates, updateConnectivityState, provider]
)
// 检查单个 key 的连通性
const checkKeyConnectivity = useCallback(
async (index: number): Promise<void> => {
if (!provider || index < 0 || index >= keys.length) return
const keyToCheck = keys[index]
const currentState = connectivityStates.get(keyToCheck)
if (currentState?.checking) return
await runConnectivityCheck(index, undefined)
},
[provider, keys, connectivityStates, t, runConnectivityCheck]
)
// 检查所有 keys 的连通性
const checkAllKeysConnectivity = useCallback(async () => {
if (!provider || keys.length === 0) return
await Promise.allSettled(keys.map((_, index) => runConnectivityCheck(index, undefined)))
}, [provider, keys, t, runConnectivityCheck])
// 计算是否有 key 正在检查
const isChecking = useMemo(() => {
return Array.from(connectivityStates.values()).some((state) => state.checking)
}, [connectivityStates])
return {
keys: keysWithStatus,
addKey,
updateKey,
removeKey,
removeInvalidKeys,
checkKeyConnectivity,
checkAllKeysConnectivity,
isChecking
}
}
export function isLlmProvider(provider: ApiProvider): provider is Provider {
return 'models' in provider
}
export function isPreprocessProvider(provider: ApiProvider): provider is PreprocessProvider {
// NOTE: mistral 同时提供预处理和llm服务所以其llm provier可能被误判为预处理provider
// 后面需要使用更严格的判断方式
return isPreprocessProviderId(provider.id) && !isLlmProvider(provider)
}

View File

@@ -1,2 +0,0 @@
export { default as ApiKeyListPopup } from './popup'
export * from './types'

View File

@@ -1,182 +0,0 @@
import { Button, Flex, Tooltip } from '@cherrystudio/ui'
import { type HealthResult, HealthStatusIndicator } from '@renderer/components/HealthStatusIndicator'
import { EditIcon } from '@renderer/components/Icons'
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import type { ApiKeyWithStatus } from '@renderer/types/healthCheck'
import { maskApiKey } from '@renderer/utils/api'
import type { InputRef } from 'antd'
import { Input, List, Popconfirm, Typography } from 'antd'
import { Check, Minus, X } from 'lucide-react'
import type { FC } from 'react'
import { memo, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import type { ApiKeyValidity } from './types'
export interface ApiKeyItemProps {
keyStatus: ApiKeyWithStatus
onUpdate: (newKey: string) => ApiKeyValidity
onRemove: () => void
onCheck: () => Promise<void>
disabled?: boolean
showHealthCheck?: boolean
isNew?: boolean
}
/**
* API Key 项组件
* 支持编辑、删除、连接检查等操作
*/
const ApiKeyItem: FC<ApiKeyItemProps> = ({
keyStatus,
onUpdate,
onRemove,
onCheck,
disabled: _disabled = false,
showHealthCheck = true,
isNew = false
}) => {
const { t } = useTranslation()
const [isEditing, setIsEditing] = useState(isNew || !keyStatus.key.trim())
const [editValue, setEditValue] = useState(keyStatus.key)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const inputRef = useRef<InputRef>(null)
const disabled = keyStatus.checking || _disabled
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
}
}, [isEditing])
useEffect(() => {
setHasUnsavedChanges(editValue.trim() !== keyStatus.key.trim())
}, [editValue, keyStatus.key])
const handleEdit = () => {
if (disabled) return
setIsEditing(true)
setEditValue(keyStatus.key)
}
const handleSave = () => {
const result = onUpdate(editValue)
if (!result.isValid) {
window.toast.warning(result.error)
return
}
setIsEditing(false)
}
const handleCancelEdit = () => {
if (isNew || !keyStatus.key.trim()) {
// 临时项取消时直接移除
onRemove()
} else {
// 现有项取消时恢复原值
setEditValue(keyStatus.key)
setIsEditing(false)
}
}
const healthResults: HealthResult[] = [
{
status: keyStatus.status,
latency: keyStatus.latency,
error: keyStatus.error,
label: keyStatus.model?.name
}
]
return (
<List.Item>
<ItemInnerContainer className="gap-2 px-3">
{isEditing ? (
<>
<Input.Password
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onPressEnter={handleSave}
placeholder={t('settings.provider.api.key.new_key.placeholder')}
style={{ flex: 1, fontSize: '14px' }}
spellCheck={false}
disabled={disabled}
/>
<Flex className="items-center gap-0">
<Tooltip content={t('common.save')}>
<Button
variant={hasUnsavedChanges ? 'default' : 'ghost'}
onClick={handleSave}
disabled={disabled}
size="icon">
<Check size={16} />
</Button>
</Tooltip>
<Tooltip content={t('common.cancel')}>
<Button variant="ghost" onClick={handleCancelEdit} disabled={disabled} size="icon">
<X size={16} />
</Button>
</Tooltip>
</Flex>
</>
) : (
<>
<Tooltip
content={
<Typography.Text style={{ color: 'white' }} copyable={{ text: keyStatus.key }}>
{keyStatus.key}
</Typography.Text>
}
delay={500}>
<span style={{ cursor: 'help' }}>{maskApiKey(keyStatus.key)}</span>
</Tooltip>
<Flex className="items-center gap-2.5">
<HealthStatusIndicator results={healthResults} loading={false} />
<Flex className="items-center gap-0">
{showHealthCheck && (
<Tooltip content={t('settings.provider.check')}>
<Button variant="ghost" onClick={onCheck} disabled={disabled} size="icon">
<StreamlineGoodHealthAndWellBeing size={18} isActive={keyStatus.checking} />
</Button>
</Tooltip>
)}
<Tooltip content={t('common.edit')}>
<Button variant="ghost" onClick={handleEdit} disabled={disabled} size="icon">
<EditIcon size={16} />
</Button>
</Tooltip>
<Popconfirm
title={t('common.delete_confirm')}
onConfirm={onRemove}
disabled={disabled}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
okButtonProps={{ color: 'danger' }}>
<Tooltip content={t('common.delete')}>
<Button variant="ghost" disabled={disabled} size="icon">
<Minus size={16} />
</Button>
</Tooltip>
</Popconfirm>
</Flex>
</Flex>
</>
)}
</ItemInnerContainer>
</List.Item>
)
}
const ItemInnerContainer = styled(Flex)`
flex: 1;
justify-content: space-between;
align-items: center;
`
export default memo(ApiKeyItem)

View File

@@ -1,218 +0,0 @@
import { Button, Flex, Tooltip } from '@cherrystudio/ui'
import { DeleteIcon } from '@renderer/components/Icons'
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import Scrollbar from '@renderer/components/Scrollbar'
import { usePreprocessProvider } from '@renderer/hooks/usePreprocess'
import { useProvider } from '@renderer/hooks/useProvider'
import { useSyncZhipuWebSearchApiKeys } from '@renderer/hooks/useWebSearch'
import { SettingHelpText } from '@renderer/pages/settings'
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import type { PreprocessProviderId, Provider } from '@renderer/types'
import type { ApiKeyWithStatus } from '@renderer/types/healthCheck'
import { HealthStatus } from '@renderer/types/healthCheck'
import { Card, List, Popconfirm, Space, Typography } from 'antd'
import { Plus } from 'lucide-react'
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { isLlmProvider, isWebSearchProvider, useApiKeys } from './hook'
import ApiKeyItem from './item'
import type { ApiProvider, UpdateApiProviderFunc } from './types'
interface ApiKeyListProps {
provider: ApiProvider
updateProvider: UpdateApiProviderFunc
showHealthCheck?: boolean
}
/**
* Api key 列表,管理 CRUD 操作、连接检查
*/
export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, showHealthCheck = true }) => {
const { t } = useTranslation()
// 临时新项状态
const [pendingNewKey, setPendingNewKey] = useState<{ key: string; id: string } | null>(null)
const {
keys,
addKey,
updateKey,
removeKey,
removeInvalidKeys,
checkKeyConnectivity,
checkAllKeysConnectivity,
isChecking
} = useApiKeys({ provider, updateProvider })
// 创建一个临时新项
const handleAddNew = () => {
setPendingNewKey({ key: '', id: Date.now().toString() })
}
const handleUpdate = (index: number, newKey: string, isNew: boolean) => {
if (isNew) {
// 新项保存时,调用真正的 addKey然后清除临时状态
const result = addKey(newKey)
if (result.isValid) {
setPendingNewKey(null)
}
return result
} else {
// 现有项更新
return updateKey(index, newKey)
}
}
const handleRemove = (index: number, isNew: boolean) => {
if (isNew) {
setPendingNewKey(null) // 新项取消时,直接清除临时状态
} else {
removeKey(index) // 现有项删除
}
}
const shouldAutoFocus = () => {
const hasKey = isLlmProvider(provider)
? Boolean(provider.apiKey)
: isWebSearchProvider(provider)
? provider.apiKeys.length > 0
: Boolean(provider.apiKey)
if (hasKey) return false
return isLlmProvider(provider) && provider.enabled && !isProviderSupportAuth(provider)
}
// 合并真实 keys 和临时新项
const displayKeys: ApiKeyWithStatus[] = pendingNewKey
? [
...keys,
{
key: pendingNewKey.key,
status: HealthStatus.NOT_CHECKED,
checking: false
}
]
: keys
return (
<ListContainer>
{/* Keys 列表 */}
<Card
size="small"
type="inner"
styles={{ body: { padding: 0 } }}
style={{ marginBottom: '5px', border: '0.5px solid var(--color-border)' }}>
{displayKeys.length === 0 ? (
<Typography.Text type="secondary" style={{ padding: '4px 11px', display: 'block' }}>
{t('error.no_api_key')}
</Typography.Text>
) : (
<Scrollbar style={{ maxHeight: '60vh', overflowX: 'hidden' }}>
<List
size="small"
dataSource={displayKeys}
renderItem={(keyStatus, index) => {
const isNew = pendingNewKey && index === displayKeys.length - 1
return (
<ApiKeyItem
key={isNew ? pendingNewKey.id : index}
keyStatus={keyStatus}
showHealthCheck={showHealthCheck}
isNew={!!isNew}
onUpdate={(newKey) => handleUpdate(index, newKey, !!isNew)}
onRemove={() => handleRemove(index, !!isNew)}
onCheck={() => checkKeyConnectivity(index)}
/>
)
}}
/>
</Scrollbar>
)}
</Card>
<Flex className="mt-[15px] flex-row items-center justify-between">
{/* 帮助文本 */}
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
{/* 标题和操作按钮 */}
<Space style={{ gap: 6 }}>
{/* 批量删除无效 keys */}
{showHealthCheck && keys.length > 1 && (
<Space style={{ gap: 0 }}>
<Popconfirm
title={t('common.delete_confirm')}
onConfirm={removeInvalidKeys}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
okButtonProps={{ color: 'danger' }}>
<Tooltip content={t('settings.provider.remove_invalid_keys')}>
<Button variant="ghost" disabled={isChecking || !!pendingNewKey} size="icon">
<DeleteIcon size={16} className="lucide-custom" />
</Button>
</Tooltip>
</Popconfirm>
{/* 批量检查 */}
<Tooltip content={t('settings.provider.check_all_keys')}>
<Button
variant="ghost"
onClick={checkAllKeysConnectivity}
disabled={isChecking || !!pendingNewKey}
size="icon">
<StreamlineGoodHealthAndWellBeing size={'1.2em'} />
</Button>
</Tooltip>
</Space>
)}
{/* 添加新 key */}
<Button
key="add"
onClick={handleAddNew}
autoFocus={shouldAutoFocus()}
disabled={isChecking || !!pendingNewKey}>
<Plus size={16} />
{t('common.add')}
</Button>
</Space>
</Flex>
</ListContainer>
)
}
interface SpecificApiKeyListProps {
providerId: string
showHealthCheck?: boolean
}
type DocPreprocessApiKeyListProps = SpecificApiKeyListProps & {
providerId: PreprocessProviderId
}
export const LlmApiKeyList: FC<SpecificApiKeyListProps> = ({ providerId, showHealthCheck = true }) => {
const { provider, updateProvider } = useProvider(providerId)
const syncZhipuWebSearchApiKeys = useSyncZhipuWebSearchApiKeys()
const updateLlmProvider = (updates: Partial<Provider>) => {
updateProvider(updates)
if (updates.apiKey !== undefined) {
// Zhipu web search shares the LLM provider API key; keep its web-search override in sync.
syncZhipuWebSearchApiKeys(providerId, updates.apiKey)
}
}
return <ApiKeyList provider={provider} updateProvider={updateLlmProvider} showHealthCheck={showHealthCheck} />
}
export const DocPreprocessApiKeyList: FC<DocPreprocessApiKeyListProps> = ({ providerId, showHealthCheck = true }) => {
const { provider, updateProvider } = usePreprocessProvider(providerId)
return <ApiKeyList provider={provider} updateProvider={updateProvider} showHealthCheck={showHealthCheck} />
}
const ListContainer = styled.div`
padding-top: 15px;
padding-bottom: 15px;
`

View File

@@ -1,98 +0,0 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@cherrystudio/ui'
import { TopView } from '@renderer/components/TopView'
import { isPreprocessProviderId } from '@renderer/types'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DocPreprocessApiKeyList, LlmApiKeyList } from './list'
const CLOSE_ANIMATION_MS = 200
interface ShowParams {
providerId: string
title?: string
showHealthCheck?: boolean
providerType?: 'llm' | 'preprocess'
}
interface Props extends ShowParams {
resolve: (value: any) => void
}
/**
* API Key 列表弹窗容器组件
*/
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true, providerType }) => {
const [open, setOpen] = useState(true)
const resolvedRef = useRef(false)
const { t } = useTranslation()
const resolveAfterClose = () => {
if (resolvedRef.current) return
resolvedRef.current = true
window.setTimeout(() => {
resolve(null)
}, CLOSE_ANIMATION_MS)
}
const closePopup = () => {
setOpen(false)
resolveAfterClose()
}
const onOpenChange = (next: boolean) => {
if (!next) {
closePopup()
}
}
const dialogTitle = title || t('settings.provider.api.key.list.title')
const ListComponent = useMemo(() => {
const type = providerType || (isPreprocessProviderId(providerId) ? 'preprocess' : 'llm')
switch (type) {
case 'preprocess':
return <DocPreprocessApiKeyList providerId={providerId as any} showHealthCheck={showHealthCheck} />
case 'llm':
default:
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}
}, [providerId, showHealthCheck, providerType])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
{ListComponent}
</DialogContent>
</Dialog>
)
}
const TopViewKey = 'ApiKeyListPopup'
export default class ApiKeyListPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@@ -1,22 +0,0 @@
import type { PreprocessProvider, Provider } from '@renderer/types'
/**
* API key 格式有效性
*/
export type ApiKeyValidity =
| {
isValid: true
error?: never
}
| {
isValid: false
error: string
}
export type ApiProvider = Provider | PreprocessProvider
export type UpdateProviderFunc = (p: Partial<Provider>) => void
export type UpdatePreprocessProviderFunc = (p: Partial<PreprocessProvider>) => void
export type UpdateApiProviderFunc = UpdateProviderFunc | UpdatePreprocessProviderFunc

View File

@@ -1,7 +1,6 @@
import type { CompoundIcon } from '@cherrystudio/ui'
import { Avatar, AvatarFallback, AvatarImage } from '@cherrystudio/ui'
import { resolveProviderIcon } from '@cherrystudio/ui/icons'
import type { Provider } from '@renderer/types'
import { generateColorFromChar, getFirstCharacter, getForegroundColor } from '@renderer/utils'
import React from 'react'
@@ -18,7 +17,8 @@ interface ProviderAvatarPrimitiveProps {
}
interface ProviderAvatarProps {
provider: Provider
/** Structural minimum: only id + name are read, so this accepts v1 or v2 Provider. */
provider: { id: string; name: string }
customLogos?: Record<string, string>
size?: number
className?: string

View File

@@ -101,8 +101,8 @@ vi.mock('@cherrystudio/ui', () => ({
}))
// Mock dependencies
vi.mock('@renderer/hooks/useProvider', () => ({
useProvider: () => ({ provider: { id: 'test-provider', name: 'Test Provider', apiKey: 'test-key' } })
vi.mock('@renderer/hooks/useProviders', () => ({
useProvider: () => ({ provider: { id: 'test-provider', name: 'Test Provider' } })
}))
vi.mock('@renderer/services/ApiService', () => ({

View File

@@ -39,17 +39,10 @@ vi.stubGlobal('window', {
})
const isQwenMTModelMock = vi.fn()
vi.mock('@renderer/config/models', () => ({
vi.mock('@shared/utils/model', () => ({
isQwenMTModel: (m: any) => isQwenMTModelMock(m)
}))
// _bridge is exercised by the hook to convert v2 SharedModel → v1 Model; the
// pure helpers receive the v1-shaped Model directly so this mock only matters
// for the hook surface tests.
vi.mock('@renderer/config/models/_bridge', () => ({
fromSharedModel: (m: any) => m
}))
const useDefaultModelMock = vi.fn(() => ({ quickModel: TEST_MODEL }))
vi.mock('@renderer/hooks/useModels', () => ({
useDefaultModel: () => useDefaultModelMock()

View File

@@ -1,9 +1,6 @@
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import { isQwenMTModel } from '@renderer/config/models'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useDefaultModel } from '@renderer/hooks/useModels'
import type { Model } from '@renderer/types'
import { UNKNOWN_LANG_CODE } from '@renderer/utils/translate'
import { LANG_DETECT_PROMPT } from '@shared/config/prompts'
import {
@@ -12,10 +9,11 @@ import {
type TranslateLangCode
} from '@shared/data/preference/preferenceTypes'
import { BUILTIN_LANGUAGE } from '@shared/data/presets/translate-languages'
import { createUniqueModelId } from '@shared/data/types/model'
import type { Model } from '@shared/data/types/model'
import { isQwenMTModel } from '@shared/utils/model'
import { franc } from 'franc-min'
import i18n from 'i18next'
import { useCallback, useMemo, useRef } from 'react'
import { useCallback, useRef } from 'react'
import { estimateTokenCount, sliceByTokens } from 'tokenx'
import { useLanguages } from './useTranslateLanguages'
@@ -60,7 +58,7 @@ export const detectLanguageByLLM = async (
const systemPrompt = LANG_DETECT_PROMPT.replace('{{list_lang}}', listLangText).replace('{{input}}', text)
const { text: result } = await window.api.ai.generateText({
uniqueModelId: createUniqueModelId(model.provider, model.id),
uniqueModelId: model.id,
system: systemPrompt,
prompt: 'follow system prompt'
})
@@ -169,11 +167,7 @@ export const detectWithMethod = async (
export const useDetectLang = () => {
const [method] = usePreference('feature.translate.auto_detection_method')
const { languages } = useLanguages()
const { quickModel: sharedQuickModel } = useDefaultModel()
const quickModel: Model | undefined = useMemo(
() => (sharedQuickModel ? fromSharedModel(sharedQuickModel) : undefined),
[sharedQuickModel]
)
const { quickModel } = useDefaultModel()
const toastedNotReadyRef = useRef(false)
const toastedEmptyRef = useRef(false)

View File

@@ -1,11 +1,13 @@
import { usePreference } from '@data/hooks/usePreference'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useAssistantApiById, useAssistantMutations, useAssistantsApi } from '@renderer/hooks/useAssistantDataApi'
import { useDefaultModel, useModelById } from '@renderer/hooks/useModels'
import { composeDefaultAssistant } from '@renderer/services/defaultAssistant'
import type { Assistant, AssistantSettings, Model } from '@renderer/types'
import type { Assistant, AssistantSettings } from '@renderer/types'
import { reconcileReasoningEffortForModel, reconcileWebSearchForModel } from '@renderer/utils/modelReconcile'
import type { CreateAssistantDto, UpdateAssistantDto } from '@shared/data/api/schemas/assistants'
import { createUniqueModelId, type UniqueModelId } from '@shared/data/types/model'
import type { Model } from '@shared/data/types/model'
import { type UniqueModelId } from '@shared/data/types/model'
import { useCallback, useMemo } from 'react'
export function useAssistants() {
@@ -67,18 +69,17 @@ export function useAssistant(id: string | null | undefined) {
model,
setModel: (next: Model, extraSettings?: Partial<AssistantSettings>) => {
if (!id || !assistant) return
const reasoning = reconcileReasoningEffortForModel(next, assistant.settings.reasoning_effort, id)
const webSearch = reconcileWebSearchForModel(next, assistant.settings)
// reconcile* still consume the v1 Model shape (their reasoning-effort
// chain goes through the /config/models v1 adapter); bridge once here
// so call sites pass the v2 Model directly. next.id is the UniqueModelId.
const v1Next = fromSharedModel(next)
const reasoning = reconcileReasoningEffortForModel(v1Next, assistant.settings.reasoning_effort, id)
const webSearch = reconcileWebSearchForModel(v1Next, assistant.settings)
const settingsPatch =
extraSettings || reasoning || webSearch
? { ...assistant.settings, ...extraSettings, ...reasoning, ...webSearch }
: undefined
void patchAssistant(
id,
settingsPatch
? { modelId: createUniqueModelId(next.provider, next.id), settings: settingsPatch }
: { modelId: createUniqueModelId(next.provider, next.id) }
)
void patchAssistant(id, settingsPatch ? { modelId: next.id, settings: settingsPatch } : { modelId: next.id })
},
updateAssistant: (patch: UpdateAssistantDto) => {
if (!id) return Promise.resolve(undefined)

View File

@@ -36,12 +36,10 @@ export function useDefaultModel() {
defaultModel,
quickModel,
translateModel,
setDefaultModel: (next: { id: string; provider?: string; providerId?: string }) =>
setDefaultModelId(createUniqueModelId(next.provider ?? next.providerId ?? '', next.id)),
setQuickModel: (next: { id: string; provider?: string; providerId?: string }) =>
setQuickModelId(createUniqueModelId(next.provider ?? next.providerId ?? '', next.id)),
setTranslateModel: (next: { id: string; provider?: string; providerId?: string }) =>
setTranslateModelId(createUniqueModelId(next.provider ?? next.providerId ?? '', next.id))
// v2 Model.id is already the UniqueModelId — store it directly.
setDefaultModel: (next: { id: UniqueModelId }) => setDefaultModelId(next.id),
setQuickModel: (next: { id: UniqueModelId }) => setQuickModelId(next.id),
setTranslateModel: (next: { id: UniqueModelId }) => setTranslateModelId(next.id)
}
}

View File

@@ -233,12 +233,6 @@ export function useProviderApiKeys(providerId: string) {
return useQuery('/providers/:providerId/api-keys', { params: { providerId } })
}
export function useProviderRegistryModels(providerId: string) {
const result = useQuery('/providers/:providerId/registry-models', { params: { providerId } })
// Schema: GET /providers/:id/registry-models -> Model[]
return { ...result, data: result.data }
}
/**
* Pure resolver for a provider's display name. System providers get the
* i18n label; custom providers use their user-set name. Returns empty

View File

@@ -5,7 +5,6 @@ import HorizontalScrollContainer from '@renderer/components/HorizontalScrollCont
import { ModelSelector } from '@renderer/components/ModelSelector'
import NavbarIcon from '@renderer/components/NavbarIcon'
import { AgentSelector } from '@renderer/components/ResourceSelector'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useUpdateAgent } from '@renderer/hooks/agents/useAgentDataApi'
import { useAgentModelFilter } from '@renderer/hooks/agents/useAgentModelFilter'
import { useActiveSession, useUpdateSession } from '@renderer/hooks/agents/useSessionDataApi'
@@ -18,7 +17,7 @@ import type { Model as SharedModel, UniqueModelId } from '@shared/data/types/mod
import { Menu, PanelLeftClose, PanelRightClose } from 'lucide-react'
import { ChevronDown } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useCallback, useMemo } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import AgentSidePanelDrawer from '../AgentSidePanelDrawer'
@@ -41,10 +40,6 @@ const AgentContent = ({ activeAgent }: AgentContentProps) => {
const modelFilter = useAgentModelFilter(activeAgent.type)
const { model: currentSharedModel } = useModelById((activeAgent.model ?? '') as UniqueModelId)
const currentRendererModel = useMemo(
() => (currentSharedModel ? fromSharedModel(currentSharedModel) : undefined),
[currentSharedModel]
)
const providerName = useProviderDisplayName(currentSharedModel?.providerId)
const handleAgentChange = useCallback(
@@ -118,9 +113,9 @@ const AgentContent = ({ activeAgent }: AgentContentProps) => {
filter={modelFilter}
trigger={
<Button variant="ghost" size="sm" className="h-7 gap-1.5 rounded-full px-2 text-xs">
<ModelAvatar model={currentRendererModel} size={20} />
<ModelAvatar model={currentSharedModel} size={20} />
<span className="max-w-60 truncate">
{currentRendererModel ? currentRendererModel.name : t('button.select_model')}
{currentSharedModel ? currentSharedModel.name : t('button.select_model')}
{providerName ? ` | ${providerName}` : ''}
</span>
<ChevronDown size={14} className="text-muted-foreground" />

View File

@@ -4,11 +4,12 @@ import { loggerService } from '@logger'
import type { QuickPanelTriggerInfo } from '@renderer/components/QuickPanel'
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useAgent } from '@renderer/hooks/agents/useAgentDataApi'
import { useSession } from '@renderer/hooks/agents/useSessionDataApi'
import { useApiServer } from '@renderer/hooks/useApiServer'
import { useInputText } from '@renderer/hooks/useInputText'
import { useProviders } from '@renderer/hooks/useProvider'
import { useModels } from '@renderer/hooks/useModels'
import { useTextareaResize } from '@renderer/hooks/useTextareaResize'
import { useTimer } from '@renderer/hooks/useTimer'
import { isSoulModeEnabled } from '@renderer/pages/agents/AgentSettings/shared'
@@ -31,6 +32,7 @@ import { getSendMessageShortcutLabel } from '@renderer/utils/input'
import { documentExts, imageExts, textExts } from '@shared/config/constant'
import { getBuiltinSlashCommands } from '@shared/data/types/agentSlashCommands'
import { DEFAULT_ASSISTANT_SETTINGS } from '@shared/data/types/assistant'
import { parseUniqueModelId } from '@shared/data/types/model'
import { createUniqueModelId } from '@shared/data/types/model'
import type { FC } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -68,7 +70,7 @@ const AgentSessionInputbar = ({
const { t } = useTranslation()
const { session } = useSession(sessionId)
const { agent } = useAgent(agentId)
const { providers } = useProviders()
const { models } = useModels()
// FIXME: 不应该使用ref将action传到context提供给tool权宜之计
const actionsRef = useRef({
resizeTextArea: () => {},
@@ -83,8 +85,11 @@ const AgentSessionInputbar = ({
if (!agent?.model) return undefined
const [providerId, actualModelId] = agent.model.split(':')
if (!providerId || !actualModelId) return undefined
return providers.flatMap((p) => p.models).find((m) => m.id === actualModelId && m.provider === providerId)
}, [agent?.model, providers])
const v2Model = models.find(
(m) => m.providerId === providerId && (m.apiModelId ?? parseUniqueModelId(m.id).modelId) === actualModelId
)
return v2Model ? fromSharedModel(v2Model) : undefined
}, [agent?.model, models])
// v2-shape Assistant stub for tools that expect a real assistant record.
const assistantStub = useMemo<Assistant | null>(() => {

View File

@@ -1,23 +1,32 @@
import { Button, Checkbox, Label, SelectDropdown, Textarea } from '@cherrystudio/ui'
import { dataApiService } from '@data/DataApiService'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { isMac, isWin } from '@renderer/config/constant'
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
import { usePersistCache } from '@renderer/data/hooks/useCache'
import { useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useCodeCli } from '@renderer/hooks/useCodeCli'
import { useProviders } from '@renderer/hooks/useProvider'
import { useModels } from '@renderer/hooks/useModels'
import { getProviderDisplayName, useProviders } from '@renderer/hooks/useProviders'
import { useTimer } from '@renderer/hooks/useTimer'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { loggerService } from '@renderer/services/LoggerService'
import type { EndpointType, Model, Provider } from '@renderer/types'
import { getFancyProviderName } from '@renderer/utils/naming'
import { getRotatedProviderApiKey } from '@renderer/utils/providerAuth'
import { formatProviderApiHost } from '@renderer/utils/providerHost'
import { EFFORT_RATIO } from '@renderer/types'
import { getThinkingBudget } from '@shared/ai/reasoningBudget'
import type { TerminalConfig } from '@shared/config/constant'
import { codeCLI, terminalApps } from '@shared/config/constant'
import { CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS, isSiliconAnthropicCompatibleModel } from '@shared/config/providers'
import { createUniqueModelId } from '@shared/data/types/model'
import { type Model, parseUniqueModelId } from '@shared/data/types/model'
import type { ApiKeyEntry } from '@shared/data/types/provider'
import type { Provider } from '@shared/data/types/provider'
import {
isEmbeddingModel,
isReasoningModel,
isRerankModel,
isSupportedReasoningEffortModel,
isSupportedThinkingTokenClaudeModel,
isTextToImageModel
} from '@shared/utils/model'
import { isAnthropicProvider, isOpenAIProvider } from '@shared/utils/provider'
import { Check, FolderOpen } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
@@ -59,6 +68,8 @@ interface TerminalItem {
const CodeCliPage: FC = () => {
const { t } = useTranslation()
const { providers } = useProviders()
const { models } = useModels()
const providerMap = useMemo(() => new Map(providers.map((p) => [p.id, p])), [providers])
const [isBunInstalled, setIsBunInstalled] = usePersistCache('feature.mcp.is_bun_installed')
const {
selectedCliTool,
@@ -90,48 +101,54 @@ const CodeCliPage: FC = () => {
const [dialogOpen, setDialogOpen] = useState(false)
const rawModelId = useCallback((m: Model) => m.apiModelId ?? parseUniqueModelId(m.id).modelId, [])
const modelPredicate = useCallback(
(m: Model) => {
if (isEmbeddingModel(m) || isRerankModel(m) || isTextToImageModel(m)) {
return false
}
if (m.provider === 'cherryai') {
if (m.providerId === 'cherryai') {
return false
}
const provider = providerMap.get(m.providerId)
if (!provider) {
return false
}
const eps = m.endpointTypes ?? []
const id = rawModelId(m)
if (selectedCliTool === codeCLI.claudeCode) {
if (m.supported_endpoint_types) {
return m.supported_endpoint_types.includes('anthropic')
if (eps.length) {
return eps.includes('anthropic-messages')
}
if (m.provider === 'silicon') {
return isSiliconAnthropicCompatibleModel(m.id)
if (m.providerId === 'silicon') {
return isSiliconAnthropicCompatibleModel(id)
}
const modelProvider = providers.find((p) => p.id === m.provider)
if (modelProvider?.type === 'anthropic' || modelProvider?.anthropicApiHost) {
if (isAnthropicProvider(provider)) {
return true
}
return m.id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.provider)
return id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.providerId)
}
if (selectedCliTool === codeCLI.geminiCli) {
if (m.supported_endpoint_types) {
return m.supported_endpoint_types.includes('gemini')
if (eps.length) {
return eps.includes('google-generate-content')
}
return m.id.includes('gemini')
return id.includes('gemini')
}
if (selectedCliTool === codeCLI.openaiCodex) {
if (m.supported_endpoint_types) {
return ['openai', 'openai-response'].some((type) =>
m.supported_endpoint_types?.includes(type as EndpointType)
)
if (eps.length) {
return eps.includes('openai-chat-completions') || eps.includes('openai-responses')
}
const openaiProvider = providers.find((p) => p.id === m.provider)
if (openaiProvider?.type === 'openai-response') {
if (isOpenAIProvider(provider)) {
return true
}
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
return id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.providerId)
}
if (selectedCliTool === codeCLI.githubCopilotCli) {
@@ -139,27 +156,26 @@ const CodeCliPage: FC = () => {
}
if (selectedCliTool === codeCLI.qwenCode || selectedCliTool === codeCLI.iFlowCli) {
if (m.supported_endpoint_types) {
return ['openai', 'openai-response'].some((type) =>
m.supported_endpoint_types?.includes(type as EndpointType)
)
if (eps.length) {
return eps.includes('openai-chat-completions') || eps.includes('openai-responses')
}
return true
}
if (selectedCliTool === codeCLI.openCode) {
if (m.supported_endpoint_types) {
return ['openai', 'openai-response', 'anthropic'].some((type) =>
m.supported_endpoint_types?.includes(type as EndpointType)
if (eps.length) {
return (
eps.includes('openai-chat-completions') ||
eps.includes('openai-responses') ||
eps.includes('anthropic-messages')
)
}
const provider = providers.find((p) => p.id === m.provider)
return !!['openai', 'openai-response', 'anthropic'].includes(provider?.type ?? '')
return isOpenAIProvider(provider) || isAnthropicProvider(provider)
}
return true
},
[selectedCliTool, providers]
[selectedCliTool, providerMap, rawModelId]
)
const availableProviders = useMemo(() => {
@@ -168,15 +184,17 @@ const CodeCliPage: FC = () => {
}, [providers, selectedCliTool])
const modelItems = useMemo<ModelItem[]>(() => {
const allowed = new Set(availableProviders.map((p) => p.id))
const items: ModelItem[] = []
for (const provider of availableProviders) {
for (const m of provider.models || []) {
if (!modelPredicate(m)) continue
items.push({ id: createUniqueModelId(m.provider, m.id), model: m, provider })
}
for (const m of models) {
if (!allowed.has(m.providerId)) continue
const provider = providerMap.get(m.providerId)
if (!provider) continue
if (!modelPredicate(m)) continue
items.push({ id: m.id, model: m, provider })
}
return items
}, [availableProviders, modelPredicate])
}, [availableProviders, models, providerMap, modelPredicate])
const terminalItems = useMemo<TerminalItem[]>(
() => availableTerminals.map((terminal) => ({ id: terminal.id, name: terminal.name })),
@@ -187,14 +205,12 @@ const CodeCliPage: FC = () => {
const resolveModel = useCallback(
(modelIdStr: string): Model | null => {
for (const provider of providers || []) {
const model = provider.models.find((m) => createUniqueModelId(m.provider, m.id) === modelIdStr)
if (model) return model
}
const model = models.find((m) => m.id === modelIdStr)
if (model) return model
logger.warn(`Model not found for ID: ${modelIdStr}`)
return null
},
[providers]
[models]
)
const handleModelChange = (value: string) => {
@@ -278,22 +294,48 @@ const CodeCliPage: FC = () => {
const resolvedModel = resolveModel(selectedModel)
if (!resolvedModel) return null
const modelProvider = getProviderByModel(resolvedModel)
const modelProvider = providerMap.get(resolvedModel.providerId)
if (!modelProvider) {
logger.warn(`Provider not found for model: ${resolvedModel.id}`)
return null
}
const actualProvider = formatProviderApiHost(modelProvider)
const baseUrl = actualProvider.apiHost
const apiKey = getRotatedProviderApiKey(actualProvider)
const isAnthropic = isAnthropicProvider(modelProvider)
const defaultEndpoint = modelProvider.defaultChatEndpoint ?? 'openai-chat-completions'
const baseUrl = modelProvider.endpointConfigs?.[defaultEndpoint]?.baseUrl ?? ''
const anthropicBaseUrl = modelProvider.endpointConfigs?.['anthropic-messages']?.baseUrl
let apiKey = ''
try {
const { keys } = (await dataApiService.get(`/providers/${modelProvider.id}/api-keys`)) as {
keys: ApiKeyEntry[]
}
apiKey = keys.find((k) => k.isEnabled)?.key ?? keys[0]?.key ?? ''
} catch (error) {
logger.error(`Failed to load api keys for provider: ${modelProvider.id}`, error as Error)
}
const id = resolvedModel.apiModelId ?? parseUniqueModelId(resolvedModel.id).modelId
const reasoning = {
isReasoning: isReasoningModel(resolvedModel),
supportsReasoningEffort: isSupportedReasoningEffortModel(resolvedModel),
budgetTokens: isSupportedThinkingTokenClaudeModel(resolvedModel)
? getThinkingBudget(maxTokens, reasoning_effort, id, EFFORT_RATIO, { fallbackOnUnknown: true })
: undefined
}
const { env: toolEnv } = generateToolEnvironment({
tool: selectedCliTool,
model: resolvedModel,
modelProvider,
rawModelId: id,
modelName: resolvedModel.name,
endpointType: resolvedModel.endpointTypes?.[0],
providerId: modelProvider.id,
fancyProviderName: getProviderDisplayName(modelProvider),
isAnthropic,
anthropicBaseUrl,
apiKey,
baseUrl,
context: { maxTokens, reasoningEffort: reasoning_effort }
reasoning
})
const userEnv = parseEnvironmentVariables(environmentVariables)
@@ -308,7 +350,10 @@ const CodeCliPage: FC = () => {
window.toast.error(t('code.model_required'))
return
}
const modelId = selectedCliTool === codeCLI.githubCopilotCli ? '' : (resolvedModel?.id ?? '')
const modelId =
selectedCliTool === codeCLI.githubCopilotCli || !resolvedModel
? ''
: (resolvedModel.apiModelId ?? parseUniqueModelId(resolvedModel.id).modelId)
const runOptions = {
autoUpdateToLatest,
@@ -461,7 +506,7 @@ const CodeCliPage: FC = () => {
<ModelAvatar model={item.model} size={18} />
<span className="flex-1 truncate">{item.model.name || item.model.id}</span>
<span className="shrink-0 text-muted-foreground text-xs">
{getFancyProviderName(item.provider)}
{getProviderDisplayName(item.provider)}
</span>
{isSelected && <Check size={11} className="ml-0.5 shrink-0 text-foreground" />}
</div>

View File

@@ -56,12 +56,6 @@ vi.mock('@renderer/config/constant', () => ({
isWin: false
}))
vi.mock('@renderer/config/models', () => ({
isEmbeddingModel: () => false,
isRerankModel: () => false,
isTextToImageModel: () => false
}))
vi.mock('@renderer/data/hooks/useCache', () => ({
usePersistCache: () => [true, vi.fn()]
}))
@@ -85,8 +79,13 @@ vi.mock('@renderer/hooks/useCodeCli', () => ({
})
}))
vi.mock('@renderer/hooks/useProvider', () => ({
useProviders: () => ({ providers: [] })
vi.mock('@renderer/hooks/useProviders', () => ({
useProviders: () => ({ providers: [] }),
getProviderDisplayName: (provider: { name?: string; id?: string }) => provider?.name ?? provider?.id ?? ''
}))
vi.mock('@renderer/hooks/useModels', () => ({
useModels: () => ({ models: [] })
}))
vi.mock('@renderer/hooks/useTimer', () => ({

View File

@@ -1,8 +1,7 @@
import type { Model, Provider } from '@renderer/types'
import { codeCLI } from '@shared/config/constant'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CLI_TOOLS, generateToolEnvironment } from '../index'
import { CLI_TOOLS, generateToolEnvironment, type ToolEnvironmentConfig } from '../index'
// Mock CodeCliPage which is default export
vi.mock('../CodeCliPage', () => ({ default: () => null }))
@@ -27,15 +26,6 @@ vi.mock('@renderer/hooks/useCodeCli', () => ({
})
}))
vi.mock('@renderer/hooks/useProvider', () => ({
useProviders: () => ({ providers: [] }),
useAllProviders: () => []
}))
vi.mock('@renderer/services/AssistantService', () => ({
getProviderByModel: vi.fn()
}))
vi.mock('@renderer/services/LoggerService', () => ({
loggerService: {
withContext: () => ({
@@ -75,21 +65,16 @@ vi.mock('react-i18next', async (importOriginal) => {
})
describe('generateToolEnvironment', () => {
const createMockModel = (id: string, provider: string): Model => ({
id,
name: id,
provider,
group: provider
})
const createMockProvider = (id: string, apiHost: string): Provider => ({
id,
type: 'openai',
name: id,
const baseConfig = (
overrides: Partial<ToolEnvironmentConfig> & Pick<ToolEnvironmentConfig, 'tool' | 'baseUrl'>
): ToolEnvironmentConfig => ({
rawModelId: 'test-model',
modelName: 'test-model',
providerId: 'dashscope',
fancyProviderName: 'DashScope',
isAnthropic: false,
apiKey: 'test-key',
apiHost,
models: [],
isSystem: true
...overrides
})
beforeEach(() => {
@@ -97,121 +82,63 @@ describe('generateToolEnvironment', () => {
})
it('should format baseUrl with /v1 for qwenCode when missing', () => {
const model = createMockModel('qwen-turbo', 'dashscope')
const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/compatible-mode')
const { env } = generateToolEnvironment({
tool: codeCLI.qwenCode,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode'
})
const { env } = generateToolEnvironment(
baseConfig({ tool: codeCLI.qwenCode, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode' })
)
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
})
it('should not duplicate /v1 when already present for qwenCode', () => {
const model = createMockModel('qwen-turbo', 'dashscope')
const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/compatible-mode/v1')
const { env } = generateToolEnvironment({
tool: codeCLI.qwenCode,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
})
const { env } = generateToolEnvironment(
baseConfig({ tool: codeCLI.qwenCode, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' })
)
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
})
it('should handle empty baseUrl gracefully', () => {
const model = createMockModel('qwen-turbo', 'dashscope')
const provider = createMockProvider('dashscope', '')
const { env } = generateToolEnvironment({
tool: codeCLI.qwenCode,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: ''
})
const { env } = generateToolEnvironment(baseConfig({ tool: codeCLI.qwenCode, baseUrl: '' }))
expect(env.OPENAI_BASE_URL).toBe('')
})
it('should preserve other API versions when present', () => {
const model = createMockModel('qwen-plus', 'dashscope')
const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/v2')
const { env } = generateToolEnvironment({
tool: codeCLI.qwenCode,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://dashscope.aliyuncs.com/v2'
})
const { env } = generateToolEnvironment(
baseConfig({ tool: codeCLI.qwenCode, baseUrl: 'https://dashscope.aliyuncs.com/v2' })
)
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/v2')
})
it('should format baseUrl with /v1 for openaiCodex when missing', () => {
const model = createMockModel('gpt-4', 'openai')
const provider = createMockProvider('openai', 'https://api.openai.com')
const { env } = generateToolEnvironment({
tool: codeCLI.openaiCodex,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://api.openai.com'
})
const { env } = generateToolEnvironment(
baseConfig({ tool: codeCLI.openaiCodex, providerId: 'openai', baseUrl: 'https://api.openai.com' })
)
expect(env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1')
})
it('should format baseUrl with /v1 for iFlowCli when missing', () => {
const model = createMockModel('gpt-4', 'iflow')
const provider = createMockProvider('iflow', 'https://api.iflow.cn')
const { env } = generateToolEnvironment({
tool: codeCLI.iFlowCli,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://api.iflow.cn'
})
const { env } = generateToolEnvironment(
baseConfig({ tool: codeCLI.iFlowCli, providerId: 'iflow', baseUrl: 'https://api.iflow.cn' })
)
expect(env.IFLOW_BASE_URL).toBe('https://api.iflow.cn/v1')
})
it('should handle trailing slash correctly', () => {
const model = createMockModel('qwen-turbo', 'dashscope')
const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/compatible-mode/')
const { env } = generateToolEnvironment({
tool: codeCLI.qwenCode,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/'
})
const { env } = generateToolEnvironment(
baseConfig({ tool: codeCLI.qwenCode, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/' })
)
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
})
it('should handle v2beta version correctly', () => {
const model = createMockModel('qwen-plus', 'dashscope')
const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/v2beta')
const { env } = generateToolEnvironment({
tool: codeCLI.qwenCode,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://dashscope.aliyuncs.com/v2beta'
})
const { env } = generateToolEnvironment(
baseConfig({ tool: codeCLI.qwenCode, baseUrl: 'https://dashscope.aliyuncs.com/v2beta' })
)
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/v2beta')
})

View File

@@ -9,32 +9,52 @@ import {
OpenCode,
QwenCode
} from '@cherrystudio/ui/icons'
import {
isReasoningModel,
isSupportedReasoningEffortModel,
isSupportedThinkingTokenClaudeModel
} from '@renderer/config/models/reasoning'
import { EFFORT_RATIO, type EndpointType, type Model, type Provider } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api'
import { getFancyProviderName, sanitizeProviderName } from '@renderer/utils/naming'
import { getThinkingBudget } from '@shared/ai/reasoningBudget'
import { sanitizeProviderName } from '@renderer/utils/naming'
import { codeCLI } from '@shared/config/constant'
import { CLAUDE_SUPPORTED_PROVIDERS } from '@shared/config/providers'
import type { EndpointType } from '@shared/data/types/model'
import type { Provider } from '@shared/data/types/provider'
import {
isAIGatewayProvider,
isAnthropicProvider,
isGeminiProvider,
isOpenAICompatibleProvider,
isOpenAIProvider
} from '@shared/utils/provider'
export interface LaunchValidationResult {
isValid: boolean
message?: string
}
/**
* Shape-agnostic env config. The caller (CodeCliPage) resolves all
* provider/model fields from the v2 DataApi and passes primitives, so this
* module no longer depends on the v1 Provider/Model shape.
*/
export interface ToolEnvironmentConfig {
tool: codeCLI
model: Model
modelProvider: Provider
/** Raw provider model id (e.g. `claude-sonnet-4`), NOT the `providerId::modelId` unique id. */
rawModelId: string
/** Human-facing model name (v2 `model.name`). */
modelName: string
/** First v2 endpoint type for the model, or undefined. */
endpointType?: EndpointType
providerId: string
/** Display name (already fancy-formatted by the caller). */
fancyProviderName: string
/** True when the target provider speaks the Anthropic Messages API. */
isAnthropic: boolean
/** v2 anthropic-messages endpoint baseUrl, if configured. */
anthropicBaseUrl?: string
apiKey: string
baseUrl: string
context?: {
maxTokens?: number
reasoningEffort?: string
/** Precomputed by caller via @shared/utils/model (v2). */
reasoning?: {
isReasoning: boolean
supportsReasoningEffort: boolean
budgetTokens?: number
}
}
@@ -55,24 +75,29 @@ export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'che
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api', 'cherryin']
// Provider 过滤映射
const ANTHROPIC_MESSAGES_ENDPOINT = 'anthropic-messages'
const hasAnthropicEndpoint = (p: Provider): boolean =>
Boolean(p.endpointConfigs?.[ANTHROPIC_MESSAGES_ENDPOINT]?.baseUrl)
const isOpenAILikeProvider = (p: Provider): boolean => isOpenAICompatibleProvider(p) || isOpenAIProvider(p)
export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Provider[]> = {
[codeCLI.claudeCode]: (providers) =>
providers.filter(
(p) => p.type === 'anthropic' || CLAUDE_SUPPORTED_PROVIDERS.includes(p.id) || !!p.anthropicApiHost
(p) => isAnthropicProvider(p) || CLAUDE_SUPPORTED_PROVIDERS.includes(p.id) || hasAnthropicEndpoint(p)
),
[codeCLI.geminiCli]: (providers) =>
providers.filter((p) => p.type === 'gemini' || GEMINI_SUPPORTED_PROVIDERS.includes(p.id)),
[codeCLI.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')),
providers.filter((p) => isGeminiProvider(p) || GEMINI_SUPPORTED_PROVIDERS.includes(p.id)),
[codeCLI.qwenCode]: (providers) => providers.filter(isOpenAILikeProvider),
[codeCLI.openaiCodex]: (providers) =>
providers.filter((p) => p.type === 'openai-response' || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)),
[codeCLI.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai')),
providers.filter((p) => isOpenAIProvider(p) || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)),
[codeCLI.iFlowCli]: (providers) => providers.filter(isOpenAILikeProvider),
[codeCLI.githubCopilotCli]: () => [],
[codeCLI.kimiCli]: (providers) => providers.filter((p) => p.type.includes('openai')),
[codeCLI.kimiCli]: (providers) => providers.filter(isOpenAILikeProvider),
[codeCLI.openCode]: (providers) =>
providers.filter((p) => ['openai', 'openai-response', 'anthropic', 'new-api'].includes(p.type))
providers.filter((p) => isOpenAILikeProvider(p) || isAnthropicProvider(p) || isAIGatewayProvider(p))
}
export const getCodeCliApiBaseUrl = (model: Model, type: EndpointType) => {
export const getCodeCliApiBaseUrl = (providerId: string, type: 'anthropic' | 'gemini') => {
const CODE_CLI_API_ENDPOINTS = {
aihubmix: {
gemini: {
@@ -116,9 +141,7 @@ export const getCodeCliApiBaseUrl = (model: Model, type: EndpointType) => {
}
}
const provider = model.provider
return CODE_CLI_API_ENDPOINTS[provider]?.[type]?.api_base_url
return CODE_CLI_API_ENDPOINTS[providerId]?.[type]?.api_base_url
}
// 解析环境变量字符串为对象
@@ -141,34 +164,34 @@ export const parseEnvironmentVariables = (envVars: string): Record<string, strin
return env
}
/**
* Opencode expects a wire-format string in OPENCODE_PROVIDER_TYPE. v2 has no
* `provider.type`; the caller derives this from v2 predicates.
*/
export type ProviderWireType = 'anthropic' | 'openai-response' | 'openai'
// 为不同 CLI 工具生成环境变量配置
export const generateToolEnvironment = ({
tool,
model,
modelProvider,
rawModelId,
modelName,
endpointType,
providerId,
fancyProviderName,
isAnthropic,
anthropicBaseUrl,
apiKey,
baseUrl,
context
}: {
tool: codeCLI
model: Model
modelProvider: Provider
apiKey: string
baseUrl: string
context?: {
maxTokens?: number
reasoningEffort?: string
}
}): { env: Record<string, string> } => {
reasoning
}: ToolEnvironmentConfig & { providerWireType?: ProviderWireType }): { env: Record<string, string> } => {
const env: Record<string, string> = {}
const formattedBaseUrl = formatApiHost(baseUrl)
switch (tool) {
case codeCLI.claudeCode: {
env.ANTHROPIC_BASE_URL =
getCodeCliApiBaseUrl(model, 'anthropic') || modelProvider.anthropicApiHost || modelProvider.apiHost
env.ANTHROPIC_MODEL = model.id
if (modelProvider.type === 'anthropic') {
env.ANTHROPIC_BASE_URL = getCodeCliApiBaseUrl(providerId, 'anthropic') || anthropicBaseUrl || baseUrl
env.ANTHROPIC_MODEL = rawModelId
if (isAnthropic) {
env.ANTHROPIC_API_KEY = apiKey
} else {
env.ANTHROPIC_AUTH_TOKEN = apiKey
@@ -177,31 +200,31 @@ export const generateToolEnvironment = ({
}
case codeCLI.geminiCli: {
const apiBaseUrl = getCodeCliApiBaseUrl(model, 'gemini') || modelProvider.apiHost
const apiBaseUrl = getCodeCliApiBaseUrl(providerId, 'gemini') || baseUrl
env.GEMINI_API_KEY = apiKey
env.GEMINI_BASE_URL = apiBaseUrl
env.GOOGLE_GEMINI_BASE_URL = apiBaseUrl
env.GEMINI_MODEL = model.id
env.GEMINI_MODEL = rawModelId
break
}
case codeCLI.qwenCode:
env.OPENAI_API_KEY = apiKey
env.OPENAI_BASE_URL = formattedBaseUrl
env.OPENAI_MODEL = model.id
env.OPENAI_MODEL = rawModelId
break
case codeCLI.openaiCodex:
env.OPENAI_API_KEY = apiKey
env.OPENAI_BASE_URL = formattedBaseUrl
env.OPENAI_MODEL = model.id
env.OPENAI_MODEL_PROVIDER = modelProvider.id
env.OPENAI_MODEL_PROVIDER_NAME = modelProvider.name
env.OPENAI_MODEL = rawModelId
env.OPENAI_MODEL_PROVIDER = providerId
env.OPENAI_MODEL_PROVIDER_NAME = fancyProviderName
break
case codeCLI.iFlowCli:
env.IFLOW_API_KEY = apiKey
env.IFLOW_BASE_URL = formattedBaseUrl
env.IFLOW_MODEL_NAME = model.id
env.IFLOW_MODEL_NAME = rawModelId
break
case codeCLI.githubCopilotCli:
@@ -211,7 +234,7 @@ export const generateToolEnvironment = ({
case codeCLI.kimiCli:
env.KIMI_API_KEY = apiKey
env.KIMI_BASE_URL = formattedBaseUrl
env.KIMI_MODEL_NAME = model.id
env.KIMI_MODEL_NAME = rawModelId
break
case codeCLI.openCode:
@@ -221,30 +244,20 @@ export const generateToolEnvironment = ({
// anthropic: use formatApiHost(url, false) to preserve existing /v1 from provider config
// @ai-sdk/anthropic appends /messages to the baseURL (not /v1/messages)
// others: append /v1 (standard OpenAI-compatible endpoint)
const endpointType = model.endpoint_type
const isAnthropicEndpoint =
endpointType === 'anthropic' || (!endpointType && modelProvider.type === 'anthropic')
const isAnthropicEndpoint = endpointType === 'anthropic-messages' || (!endpointType && isAnthropic)
const openCodeBaseUrl = isAnthropicEndpoint ? formatApiHost(baseUrl, false) : formattedBaseUrl
env.OPENCODE_BASE_URL = openCodeBaseUrl
env.OPENCODE_MODEL_NAME = model.name
env.OPENCODE_MODEL_ENDPOINT_TYPE = endpointType || ''
// Calculate OpenCode-specific config internally
const isReasoning = isReasoningModel(model)
const supportsReasoningEffort = isSupportedReasoningEffortModel(model)
const budgetTokens = isSupportedThinkingTokenClaudeModel(model)
? getThinkingBudget(context?.maxTokens, context?.reasoningEffort, model.id, EFFORT_RATIO, {
fallbackOnUnknown: true
})
: undefined
const providerType = modelProvider.type
const providerName = sanitizeProviderName(getFancyProviderName(modelProvider))
env.OPENCODE_MODEL_IS_REASONING = String(isReasoning)
env.OPENCODE_MODEL_SUPPORTS_REASONING_EFFORT = String(supportsReasoningEffort)
if (budgetTokens !== undefined) {
env.OPENCODE_MODEL_BUDGET_TOKENS = String(budgetTokens)
env.OPENCODE_MODEL_NAME = modelName
env.OPENCODE_MODEL_ENDPOINT_TYPE = endpointType ?? ''
// Reasoning flags are precomputed by the caller (v2 @shared/utils/model).
const providerName = sanitizeProviderName(fancyProviderName)
env.OPENCODE_MODEL_IS_REASONING = String(reasoning?.isReasoning ?? false)
env.OPENCODE_MODEL_SUPPORTS_REASONING_EFFORT = String(reasoning?.supportsReasoningEffort ?? false)
if (reasoning?.budgetTokens !== undefined) {
env.OPENCODE_MODEL_BUDGET_TOKENS = String(reasoning.budgetTokens)
}
env.OPENCODE_PROVIDER_TYPE = providerType
env.OPENCODE_PROVIDER_TYPE = isAnthropic ? 'anthropic' : 'openai'
env.OPENCODE_PROVIDER_NAME = providerName
const envVarKey = `OPENCODE_API_KEY_${providerName.toUpperCase().replace(/[-.]/g, '_')}`
env[envVarKey] = apiKey

View File

@@ -15,7 +15,7 @@ import type { Assistant, AssistantSettings } from '@renderer/types'
import { modalConfirm } from '@renderer/utils'
import { reconcileReasoningEffortForModel, reconcileWebSearchForModel } from '@renderer/utils/modelReconcile'
import type { UpdateAssistantDto } from '@shared/data/api/schemas/assistants'
import { createUniqueModelId, type Model as SharedModel, type UniqueModelId } from '@shared/data/types/model'
import { type Model as SharedModel, type UniqueModelId } from '@shared/data/types/model'
import { isNonChatModel } from '@shared/utils/model'
import { Col, Divider, Input, InputNumber, Row, Select, Slider } from 'antd'
import { isNull } from 'lodash'
@@ -53,11 +53,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
() => assistant?.settings?.enableMaxToolCalls ?? DEFAULT_ASSISTANT_SETTINGS.enableMaxToolCalls,
[assistant?.settings?.enableMaxToolCalls]
)
const { model: apiDefaultModel } = useModelById(assistant?.modelId as UniqueModelId)
const defaultModel = useMemo(
() => (apiDefaultModel ? fromSharedModel(apiDefaultModel) : undefined),
[apiDefaultModel]
)
const { model: defaultModel } = useModelById(assistant?.modelId as UniqueModelId)
const [topP, setTopP] = useState(assistant?.settings?.topP ?? 1)
const enableTopP = useMemo(
() => assistant?.settings?.enableTopP ?? DEFAULT_ASSISTANT_SETTINGS.enableTopP,
@@ -211,16 +207,18 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
const onSelectModel = useCallback(
(selected: SharedModel | undefined) => {
if (!selected) return
// reconcile* still consume the v1 Model shape; bridge once here.
// selected.id is already the UniqueModelId.
const next = fromSharedModel(selected)
const reasoning = reconcileReasoningEffortForModel(next, assistant.settings.reasoning_effort, assistant.id)
const webSearch = reconcileWebSearchForModel(next, assistant.settings)
updateAssistant(
reasoning || webSearch
? {
modelId: createUniqueModelId(next.provider, next.id),
modelId: selected.id,
settings: { ...assistant.settings, ...reasoning, ...webSearch }
}
: { modelId: createUniqueModelId(next.provider, next.id) }
: { modelId: selected.id }
)
},
[assistant.settings, assistant.id, updateAssistant]
@@ -238,7 +236,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
<RowFlex className="items-center gap-[5px]">
<ModelSelector
multiple={false}
value={apiDefaultModel}
value={defaultModel}
onSelect={onSelectModel}
filter={modelFilter}
trigger={

View File

@@ -1,8 +1,7 @@
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import CustomTag from '@renderer/components/Tags/CustomTag'
import { useProviders } from '@renderer/hooks/useProvider'
import { getProviderDisplayName, useProviders } from '@renderer/hooks/useProviders'
import type { Model } from '@renderer/types'
import { getFancyProviderName } from '@renderer/utils'
import { createUniqueModelId } from '@shared/data/types/model'
import type { FC } from 'react'
import styled from 'styled-components'
@@ -15,7 +14,7 @@ const MentionModelsInput: FC<{
const getProviderName = (model: Model) => {
const provider = providers.find((p) => p.id === model?.provider)
return provider ? getFancyProviderName(provider) : ''
return provider ? getProviderDisplayName(provider) : ''
}
return (

View File

@@ -2,19 +2,18 @@ import { Tooltip } from '@cherrystudio/ui'
import { ActionIconButton } from '@renderer/components/Buttons'
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
import { isGemini3Model, isGeminiModel } from '@renderer/config/models'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { useProvider } from '@renderer/hooks/useProviders'
import { useTimer } from '@renderer/hooks/useTimer'
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { EventEmitter } from '@renderer/services/EventService'
import type { AssistantSettings, McpMode, MCPPrompt, MCPResource } from '@renderer/types'
import { getEffectiveMcpMode } from '@renderer/types'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import { isGeminiWebSearchProvider } from '@renderer/utils/provider'
import type { MCPServer } from '@shared/data/types/mcpServer'
import { isGemini3Model, isGeminiModel } from '@shared/utils/model'
import { isGeminiWebSearchProvider } from '@shared/utils/provider'
import { useNavigate } from '@tanstack/react-router'
import { Form, Input } from 'antd'
import { CircleX, Hammer, Plus, Sparkles } from 'lucide-react'
@@ -122,13 +121,9 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
const [form] = Form.useForm()
const { assistant, model, updateAssistant } = useAssistant(assistantId)
const { provider: modelProvider } = useProvider(model?.providerId ?? '')
const { setTimeoutTimer } = useTimer()
// Adapter: v1 model utilities (`isGeminiModel`, `getProviderByModel`, …)
// still take the renderer-shape `Model`. Convert at the boundary; goes
// away with the wider Model utility migration (reasoning.ts wave).
const v1Model = useMemo(() => (model ? fromSharedModel(model) : undefined), [model])
const isMountedRef = useRef(true)
useEffect(() => {
@@ -170,20 +165,13 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
: [...Array.from(mcpServerIds), server.id]
const settingsPatch: Partial<AssistantSettings> = { mcpMode: 'manual' }
if (
nextServerIds.length > 0 &&
v1Model &&
isGeminiModel(v1Model) &&
assistant &&
isToolUseModeFunction(assistant)
) {
const provider = getProviderByModel(v1Model)
if (nextServerIds.length > 0 && model && isGeminiModel(model) && assistant && isToolUseModeFunction(assistant)) {
// Gemini 3+ supports combining built-in tools with function calling
if (
provider &&
isGeminiWebSearchProvider(provider) &&
modelProvider &&
isGeminiWebSearchProvider(modelProvider) &&
assistant.settings?.enableWebSearch &&
!isGemini3Model(v1Model)
!isGemini3Model(model)
) {
window.toast.warning(t('chat.mcp.warning.gemini_web_search'))
settingsPatch.enableWebSearch = false
@@ -197,7 +185,7 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
settings: nextSettings
})
},
[assistant, mcpServerIds, v1Model, mergeSettings, t, updateAssistant]
[assistant, mcpServerIds, model, modelProvider, mergeSettings, t, updateAssistant]
)
const handleMcpServerSelectRef = useRef(handleMcpServerSelect)

View File

@@ -1,22 +1,21 @@
import { ActionIconButton } from '@renderer/components/Buttons'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useProvider } from '@renderer/hooks/useProviders'
import { useTimer } from '@renderer/hooks/useTimer'
import { useWebSearchProviders } from '@renderer/hooks/useWebSearch'
import { getWebSearchProviderLogo } from '@renderer/pages/settings/WebSearchSettings/utils/webSearchProviderMeta'
import { getEffectiveMcpMode } from '@renderer/types'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import type { WebSearchProviderId } from '@shared/data/preference/preferenceTypes'
import { checkWebSearchAvailability } from '@shared/data/utils/webSearchPreferences'
import {
isGemini3Model,
isGeminiModel,
isGPT5SeriesReasoningModel,
isOpenAIWebSearchModel,
isWebSearchModel
} from '@renderer/config/models'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useTimer } from '@renderer/hooks/useTimer'
import { useWebSearchProviders } from '@renderer/hooks/useWebSearch'
import { getWebSearchProviderLogo } from '@renderer/pages/settings/WebSearchSettings/utils/webSearchProviderMeta'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { getEffectiveMcpMode } from '@renderer/types'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import { isGeminiWebSearchProvider } from '@renderer/utils/provider'
import type { WebSearchProviderId } from '@shared/data/preference/preferenceTypes'
import { checkWebSearchAvailability } from '@shared/data/utils/webSearchPreferences'
} from '@shared/utils/model'
import { isGeminiWebSearchProvider } from '@shared/utils/provider'
import { useNavigate } from '@tanstack/react-router'
import { Tooltip } from 'antd'
import { Globe } from 'lucide-react'
@@ -38,12 +37,12 @@ const WebSearchButton: FC<Props> = ({ assistantId }) => {
const { t } = useTranslation()
const navigate = useNavigate()
const { assistant, model, updateAssistant } = useAssistant(assistantId)
const { provider: modelProvider } = useProvider(model?.providerId ?? '')
const { setTimeoutTimer } = useTimer()
const { defaultSearchKeywordsProvider } = useWebSearchProviders()
const v1Model = useMemo(() => (model ? fromSharedModel(model) : undefined), [model])
const enableWebSearch = assistant?.settings.enableWebSearch ?? false
const hasBuiltinWebSearch = v1Model ? isWebSearchModel(v1Model) : false
const hasBuiltinWebSearch = model ? isWebSearchModel(model) : false
const activeProviderId = useMemo(() => {
if (
@@ -60,7 +59,7 @@ const WebSearchButton: FC<Props> = ({ assistantId }) => {
const providerLogo = !hasBuiltinWebSearch && activeProviderId ? getWebSearchProviderLogo(activeProviderId) : undefined
const onClick = useCallback(() => {
if (!assistant || !v1Model) {
if (!assistant || !model) {
window.toast.error(t('error.model.not_exists'))
return
}
@@ -84,12 +83,11 @@ const WebSearchButton: FC<Props> = ({ assistantId }) => {
// Compatibility guards before enabling. Mirrors the previous
// `updateToModelBuiltinWebSearch` checks; toast feedback stays in the
// renderer for immediacy.
const provider = getProviderByModel(v1Model)
if (
provider &&
isGeminiWebSearchProvider(provider) &&
isGeminiModel(v1Model) &&
!isGemini3Model(v1Model) &&
modelProvider &&
isGeminiWebSearchProvider(modelProvider) &&
isGeminiModel(model) &&
!isGemini3Model(model) &&
isToolUseModeFunction(assistant) &&
getEffectiveMcpMode(assistant) !== 'disabled'
) {
@@ -97,8 +95,8 @@ const WebSearchButton: FC<Props> = ({ assistantId }) => {
return
}
if (
isOpenAIWebSearchModel(v1Model) &&
isGPT5SeriesReasoningModel(v1Model) &&
isOpenAIWebSearchModel(model) &&
isGPT5SeriesReasoningModel(model) &&
assistant.settings.reasoning_effort === 'minimal'
) {
window.toast.warning(t('chat.web_search.warning.openai'))
@@ -115,7 +113,8 @@ const WebSearchButton: FC<Props> = ({ assistantId }) => {
setTimeoutTimer,
t,
updateAssistant,
v1Model
model,
modelProvider
])
const ariaLabel = enableWebSearch ? t('common.close') : t('chat.input.web_search.label')

View File

@@ -2,12 +2,13 @@ import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
import { QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
import { getModelLogo, isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
import { useModels } from '@renderer/hooks/useModels'
import { getProviderDisplayName, useProviders } from '@renderer/hooks/useProviders'
import type { ToolQuickPanelApi, ToolQuickPanelController } from '@renderer/pages/home/Inputbar/types'
import type { FileMetadata, Model } from '@renderer/types'
import { FILE_TYPE } from '@renderer/types'
import { getFancyProviderName } from '@renderer/utils'
import { createUniqueModelId } from '@shared/data/types/model'
import { useNavigate } from '@tanstack/react-router'
import { Avatar } from 'antd'
@@ -44,6 +45,16 @@ export const useMentionModelsPanel = (params: Params, role: 'button' | 'manager'
const { registerRootMenu, registerTrigger } = quickPanel
const { open, close, updateList, isVisible, symbol } = quickPanelController
const { providers } = useProviders()
const { models: v2Models } = useModels()
const v1ModelsByProvider = useMemo(() => {
const map = new Map<string, Model[]>()
for (const m of v2Models) {
const arr = map.get(m.providerId) ?? []
arr.push(fromSharedModel(m))
map.set(m.providerId, arr)
}
return map
}, [v2Models])
const { t } = useTranslation()
const navigate = useNavigate()
@@ -134,14 +145,14 @@ export const useMentionModelsPanel = (params: Params, role: 'button' | 'manager'
if (pinnedModels.length > 0) {
const pinnedItems = providers.flatMap((provider) =>
provider.models
(v1ModelsByProvider.get(provider.id) ?? [])
.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model))
.filter((model) => pinnedModels.includes(createUniqueModelId(model.provider, model.id)))
.filter((model) => couldMentionNotVisionModel || (!couldMentionNotVisionModel && isVisionModel(model)))
.map((model) => ({
label: (
<>
<ProviderName>{getFancyProviderName(provider)}</ProviderName>
<ProviderName>{getProviderDisplayName(provider)}</ProviderName>
<span style={{ opacity: 0.8 }}> | {model.name}</span>
</>
),
@@ -150,7 +161,7 @@ export const useMentionModelsPanel = (params: Params, role: 'button' | 'manager'
const Icon = getModelLogo(model)
return Icon ? <Icon.Avatar size={20} /> : <Avatar size={20}>{first(model.name)}</Avatar>
})(),
filterText: getFancyProviderName(provider) + model.name,
filterText: getProviderDisplayName(provider) + model.name,
action: () => onMentionModel(model),
isSelected: mentionedModels.some(
(selected) =>
@@ -166,7 +177,7 @@ export const useMentionModelsPanel = (params: Params, role: 'button' | 'manager'
providers.forEach((provider) => {
const providerModels = sortBy(
provider.models
(v1ModelsByProvider.get(provider.id) ?? [])
.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model))
.filter((model) => !pinnedModels.includes(createUniqueModelId(model.provider, model.id)))
.filter((model) => couldMentionNotVisionModel || (!couldMentionNotVisionModel && isVisionModel(model))),
@@ -176,7 +187,7 @@ export const useMentionModelsPanel = (params: Params, role: 'button' | 'manager'
const providerItems = providerModels.map((model) => ({
label: (
<>
<ProviderName>{getFancyProviderName(provider)}</ProviderName>
<ProviderName>{getProviderDisplayName(provider)}</ProviderName>
<span style={{ opacity: 0.8 }}> | {model.name}</span>
</>
),
@@ -185,7 +196,7 @@ export const useMentionModelsPanel = (params: Params, role: 'button' | 'manager'
const Icon = getModelLogo(model)
return Icon ? <Icon.Avatar size={20} /> : <Avatar size={20}>{first(model.name)}</Avatar>
})(),
filterText: getFancyProviderName(provider) + model.name,
filterText: getProviderDisplayName(provider) + model.name,
action: () => onMentionModel(model),
isSelected: mentionedModels.some(
(selected) =>
@@ -235,6 +246,7 @@ export const useMentionModelsPanel = (params: Params, role: 'button' | 'manager'
onMentionModel,
pinnedModels,
providers,
v1ModelsByProvider,
removeAtSymbolAndText,
setText,
t

View File

@@ -4,8 +4,6 @@ import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import CustomTag from '@renderer/components/Tags/CustomTag'
import TranslateButton from '@renderer/components/TranslateButton'
import { isVisionModel } from '@renderer/config/models'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
@@ -18,6 +16,7 @@ import { classNames } from '@renderer/utils'
import { getFilesFromDropEvent, isSendMessageKeyPressed } from '@renderer/utils/input'
import { documentExts, imageExts, textExts } from '@shared/config/constant'
import type { CherryMessagePart } from '@shared/data/types/message'
import { isVisionModel } from '@shared/utils/model'
import { Space } from 'antd'
import type { TextAreaRef } from 'antd/es/input/TextArea'
import TextArea from 'antd/es/input/TextArea'
@@ -46,8 +45,8 @@ const MessageEditor: FC<Props> = ({ message, onSave, onResend, onCancel }) => {
const [files, setFiles] = useState<FileMetadata[]>([])
const [isProcessing, setIsProcessing] = useState(false)
const [isFileDragging, setIsFileDragging] = useState(false)
const { model: v2Model } = useAssistant(message.assistantId)
const model = useMemo(() => (v2Model ? fromSharedModel(v2Model) : undefined), [v2Model])
// v1 message
const { model } = useAssistant(message.assistantId)
const { pasteLongTextAsFile } = useSettings()
const [pasteLongTextThreshold] = usePreference('chat.input.paste_long_text_threshold')

View File

@@ -86,7 +86,7 @@ interface Props {
isAssistantMessage: boolean
isProcessing: boolean
messageContainerRef: React.RefObject<HTMLDivElement>
setModel: (model: Model) => void
setModel: (model: SharedModel) => void
onUpdateUseful?: (msgId: string) => void
}

View File

@@ -4,7 +4,6 @@ import EmojiIcon from '@renderer/components/EmojiIcon'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import { ModelSelector } from '@renderer/components/ModelSelector'
import { AssistantSelector } from '@renderer/components/ResourceSelector'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useProviderDisplayName } from '@renderer/hooks/useProviders'
import { useTopicMutations } from '@renderer/hooks/useTopicDataApi'
@@ -30,10 +29,6 @@ const TopicContent = ({ assistantId, topicId }: TopicContentProps) => {
const { assistant, model: currentSharedModel, setModel } = useAssistant(assistantId)
const { updateTopic } = useTopicMutations()
const assistantName = useMemo(() => assistant?.name || t('chat.default.name'), [assistant?.name, t])
const currentRendererModel = useMemo(
() => (currentSharedModel ? fromSharedModel(currentSharedModel) : undefined),
[currentSharedModel]
)
const providerName = useProviderDisplayName(currentSharedModel?.providerId)
const handleAssistantChange = useCallback(
@@ -48,8 +43,7 @@ const TopicContent = ({ assistantId, topicId }: TopicContentProps) => {
(model: SharedModel | undefined) => {
if (!model || !assistant) return
const enabledWebSearch = isWebSearchModel(model)
const next = fromSharedModel(model)
setModel(next, { enableWebSearch: enabledWebSearch && assistant.settings.enableWebSearch })
setModel(model, { enableWebSearch: enabledWebSearch && assistant.settings.enableWebSearch })
},
[assistant, setModel]
)
@@ -78,9 +72,9 @@ const TopicContent = ({ assistantId, topicId }: TopicContentProps) => {
shortcut="chat.select_model"
trigger={
<Button variant="ghost" size="sm" className="h-7 gap-1.5 rounded-full px-2 text-xs">
<ModelAvatar model={currentRendererModel} size={20} />
<ModelAvatar model={currentSharedModel} size={20} />
<span className="max-w-60 truncate">
{currentRendererModel ? currentRendererModel.name : t('button.select_model')}
{currentSharedModel ? currentSharedModel.name : t('button.select_model')}
{providerName ? ` | ${providerName}` : ''}
</span>
<ChevronDown size={14} className="text-muted-foreground" />

View File

@@ -11,26 +11,21 @@ import {
import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference'
import EditableNumber from '@renderer/components/EditableNumber'
import Scrollbar from '@renderer/components/Scrollbar'
import { isOpenAIModel, isSupportVerbosityModel } from '@renderer/config/models'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useLanguages } from '@renderer/hooks/translate'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useDefaultModel } from '@renderer/hooks/useModels'
import { useProvider } from '@renderer/hooks/useProvider'
import { useProvider } from '@renderer/hooks/useProviders'
import { SettingDivider, SettingRow, SettingRowTitle } from '@renderer/pages/settings'
import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup'
import type { Assistant, CodeStyleVarious, MathEngine } from '@renderer/types'
import { isGroqSystemProvider } from '@renderer/types'
import { SystemProviderIds } from '@renderer/types'
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
import {
isOpenAICompatibleProvider,
isSupportServiceTierProvider,
isSupportVerbosityProvider
} from '@renderer/utils/provider'
import type { SendMessageShortcut } from '@shared/data/preference/preferenceTypes'
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
import { isOpenAIModel, isSupportVerbosityModel } from '@shared/utils/model'
import { isOpenAICompatibleProvider } from '@shared/utils/provider'
import { Col, Row, Slider } from 'antd'
import type { FC } from 'react'
import { useCallback, useMemo, useState } from 'react'
@@ -104,12 +99,7 @@ const AssistantSettingsTab: FC<Props> = (props) => {
const { model: apiModel } = useAssistant(props.assistant.id)
const { defaultModel: apiDefaultModel } = useDefaultModel()
const v1Model = useMemo(() => (apiModel ? fromSharedModel(apiModel) : undefined), [apiModel])
const v1DefaultModel = useMemo(
() => (apiDefaultModel ? fromSharedModel(apiDefaultModel) : undefined),
[apiDefaultModel]
)
const { provider } = useProvider(v1Model?.provider ?? '')
const { provider } = useProvider(apiModel?.providerId ?? '')
const { theme } = useTheme()
const { themeNames } = useCodeStyle()
@@ -193,15 +183,15 @@ const AssistantSettingsTab: FC<Props> = (props) => {
[theme, codeEditor.enabled, setCodeEditor, setCodeViewer]
)
const model = v1Model || v1DefaultModel
const model = apiModel || apiDefaultModel
const showOpenAiSettings =
!!provider &&
!!model &&
(isOpenAICompatibleProvider(provider) ||
isOpenAIModel(model) ||
isSupportServiceTierProvider(provider) ||
(isSupportVerbosityModel(model) && isSupportVerbosityProvider(provider)))
(provider.apiFeatures?.serviceTier ?? false) ||
(isSupportVerbosityModel(model) && (provider.apiFeatures?.verbosity ?? false)))
return (
<Container className="settings-tab">
@@ -213,7 +203,7 @@ const AssistantSettingsTab: FC<Props> = (props) => {
SettingRowTitleSmall={SettingRowTitleSmall}
/>
)}
{provider && isGroqSystemProvider(provider) && (
{provider?.id === SystemProviderIds.groq && (
<GroqSettingsGroup SettingGroup={SettingGroup} SettingRowTitleSmall={SettingRowTitleSmall} />
)}
<CollapsibleSettingGroup title={t('settings.messages.title')} defaultExpanded={true}>

View File

@@ -1,10 +1,10 @@
import Selector from '@renderer/components/Selector'
import { useProvider } from '@renderer/hooks/useProvider'
import { useProvider } from '@renderer/hooks/useProviders'
import { SettingDivider, SettingRow } from '@renderer/pages/settings'
import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup'
import type { GroqServiceTier, ServiceTier } from '@renderer/types'
import { SystemProviderIds } from '@renderer/types'
import { toOptionValue, toRealValue } from '@renderer/utils/select'
import type { GroqServiceTier, ServiceTier } from '@shared/data/types/provider'
import { Tooltip } from 'antd'
import { CircleHelp } from 'lucide-react'
import type { FC } from 'react'
@@ -21,13 +21,16 @@ interface Props {
const GroqSettingsGroup: FC<Props> = ({ SettingGroup, SettingRowTitleSmall }) => {
const { t } = useTranslation()
const { provider, updateProvider } = useProvider(SystemProviderIds.groq)
const serviceTierMode = provider.serviceTier
const serviceTierMode = provider?.settings?.serviceTier as GroqServiceTier | undefined
const setServiceTierMode = useCallback(
(value: ServiceTier) => {
updateProvider({ serviceTier: value })
if (!provider) return
updateProvider({
providerSettings: { ...provider.settings, serviceTier: value ?? undefined }
})
},
[updateProvider]
[provider, updateProvider]
)
const serviceTierOptions = useMemo(() => {

View File

@@ -1,9 +1,10 @@
import Selector from '@renderer/components/Selector'
import { isSupportFlexServiceTierModel } from '@renderer/config/models'
import { useProvider } from '@renderer/hooks/useProvider'
import { useProvider } from '@renderer/hooks/useProviders'
import { SettingRow } from '@renderer/pages/settings'
import type { Model, OpenAIServiceTier, ServiceTier } from '@renderer/types'
import { toOptionValue, toRealValue } from '@renderer/utils/select'
import type { Model } from '@shared/data/types/model'
import type { OpenAIServiceTier, ServiceTier } from '@shared/data/types/provider'
import { isSupportFlexServiceTierModel } from '@shared/utils/model'
import { Tooltip } from 'antd'
import { CircleHelp } from 'lucide-react'
import type { FC } from 'react'
@@ -21,14 +22,17 @@ interface Props {
const ServiceTierSetting: FC<Props> = ({ model, providerId, SettingRowTitleSmall }) => {
const { t } = useTranslation()
const { provider, updateProvider } = useProvider(providerId)
const serviceTierMode = provider.serviceTier
const serviceTierMode = provider?.settings?.serviceTier as OpenAIServiceTier | undefined
const isSupportFlexServiceTier = isSupportFlexServiceTierModel(model)
const setServiceTierMode = useCallback(
(value: ServiceTier) => {
updateProvider({ serviceTier: value })
if (!provider) return
updateProvider({
providerSettings: { ...provider.settings, serviceTier: value ?? undefined }
})
},
[updateProvider]
[provider, updateProvider]
)
const serviceTierOptions = useMemo(() => {

View File

@@ -1,12 +1,12 @@
import Selector from '@renderer/components/Selector'
import { getModelSupportedVerbosity } from '@renderer/config/models'
import { SettingRow } from '@renderer/pages/settings'
import type { RootState } from '@renderer/store'
import { useAppDispatch } from '@renderer/store'
import { setOpenAIVerbosity } from '@renderer/store/settings'
import type { Model } from '@renderer/types'
import { toOptionValue, toRealValue } from '@renderer/utils/select'
import type { Model } from '@shared/data/types/model'
import type { OpenAIVerbosity } from '@shared/types/aiSdk'
import { getModelSupportedVerbosity } from '@shared/utils/model'
import { Tooltip } from 'antd'
import { CircleHelp } from 'lucide-react'
import type { FC } from 'react'
@@ -68,7 +68,7 @@ const VerbositySetting: FC<Props> = ({ model, SettingRowTitleSmall }) => {
const supportedVerbosityLevels = getModelSupportedVerbosity(model)
// Default to the highest supported verbosity level
const defaultVerbosity = supportedVerbosityLevels[supportedVerbosityLevels.length - 1]
setVerbosity(defaultVerbosity)
setVerbosity(defaultVerbosity as OpenAIVerbosity)
}
}, [model, verbosity, verbosityOptions, setVerbosity])

View File

@@ -1,14 +1,9 @@
import { isSupportedReasoningEffortOpenAIModel, isSupportVerbosityModel } from '@renderer/config/models'
import { useProvider } from '@renderer/hooks/useProvider'
import { useProvider } from '@renderer/hooks/useProviders'
import { SettingDivider } from '@renderer/pages/settings'
import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup'
import type { Model } from '@renderer/types'
import { SystemProviderIds } from '@renderer/types'
import {
isSupportServiceTierProvider,
isSupportStreamOptionsProvider,
isSupportVerbosityProvider
} from '@renderer/utils/provider'
import type { Model } from '@shared/data/types/model'
import { isSupportedReasoningEffortOpenAIModel, isSupportVerbosityModel } from '@shared/utils/model'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
@@ -31,11 +26,13 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
const showSummarySetting =
isSupportedReasoningEffortOpenAIModel(model) &&
!model.id.includes('o1-pro') &&
(provider.type === 'openai-response' || model.endpoint_type === 'openai-response' || provider.id === 'aihubmix')
const showVerbositySetting = isSupportVerbosityModel(model) && isSupportVerbosityProvider(provider)
const isSupportServiceTier = isSupportServiceTierProvider(provider)
(provider?.defaultChatEndpoint === 'openai-responses' ||
model.endpointTypes?.includes('openai-responses') ||
provider?.id === 'aihubmix')
const showVerbositySetting = isSupportVerbosityModel(model) && (provider?.apiFeatures?.verbosity ?? false)
const isSupportServiceTier = provider?.apiFeatures?.serviceTier ?? false
const showServiceTierSetting = isSupportServiceTier && providerId !== SystemProviderIds.groq
const showStreamOptionsSetting = isSupportStreamOptionsProvider(provider)
const showStreamOptionsSetting = provider?.apiFeatures?.streamOptions ?? false
if (!showSummarySetting && !showServiceTierSetting && !showVerbositySetting && !showStreamOptionsSetting) {
return null

View File

@@ -1,9 +1,12 @@
import { dataApiService } from '@data/DataApiService'
import { loggerService } from '@logger'
import CherryStudioLogo from '@renderer/assets/images/logo.png'
import { useProvider } from '@renderer/hooks/useProvider'
import { useModelMutations } from '@renderer/hooks/useModels'
import { useProvider } from '@renderer/hooks/useProviders'
import { fetchModels } from '@renderer/services/ApiService'
import { useAppStore } from '@renderer/store'
import { oauthWithCherryIn } from '@renderer/utils/oauth'
import type { CreateModelDto } from '@shared/data/api/schemas/models'
import { parseUniqueModelId } from '@shared/data/types/model'
import { Button, Divider } from 'antd'
import type { FC } from 'react'
import { useCallback, useState } from 'react'
@@ -23,25 +26,34 @@ interface WelcomePageProps {
const WelcomePage: FC<WelcomePageProps> = ({ setStep, setCherryInLoggedIn }) => {
const { t } = useTranslation()
const { provider, updateProvider, addModel } = useProvider('cherryin')
const store = useAppStore()
const { provider, updateProvider, addApiKey } = useProvider('cherryin')
const { createModels } = useModelMutations()
const [isAddingModels, setIsAddingModels] = useState(false)
const handleCherryInLogin = useCallback(async () => {
try {
await oauthWithCherryIn(
async (apiKeys: string) => {
updateProvider({ apiKey: apiKeys, enabled: true })
// Persist the OAuth key + enable the provider via DataApi. Main reads
// the key from DB on the subsequent listModels IPC.
await addApiKey(apiKeys, 'OAuth')
await updateProvider({ isEnabled: true })
// Fetch and add models
setIsAddingModels(true)
try {
const updatedProvider = { ...provider, apiKey: apiKeys, enabled: true }
const models = await fetchModels(updatedProvider)
if (models.length > 0) {
models.forEach((model) => addModel(model))
logger.info(`Auto-added ${models.length} models from CherryIN`)
const models = provider ? await fetchModels(provider) : []
const dtos: CreateModelDto[] = models
.filter((m): m is typeof m & { id: string } => Boolean(m.id))
.map((m) => ({
providerId: 'cherryin',
modelId: m.apiModelId ?? parseUniqueModelId(m.id).modelId,
name: m.name,
group: m.group,
...(m.endpointTypes ? { endpointTypes: m.endpointTypes } : {})
}))
if (dtos.length > 0) {
await createModels(dtos)
logger.info(`Auto-added ${dtos.length} models from CherryIN`)
}
} catch (fetchError) {
logger.warn('Failed to auto-fetch models:', fetchError as Error)
@@ -60,12 +72,15 @@ const WelcomePage: FC<WelcomePageProps> = ({ setStep, setCherryInLoggedIn }) =>
} catch (error) {
logger.error('OAuth Error:', error as Error)
}
}, [provider, updateProvider, addModel, setCherryInLoggedIn, setStep, t])
}, [provider, updateProvider, addApiKey, createModels, setCherryInLoggedIn, setStep, t])
const handleSelectProvider = async () => {
await ProviderPopup.show()
const hasAvailableProvider = store.getState().llm.providers.some((p) => p.enabled && p.models.length > 0)
hasAvailableProvider && setStep('select-model')
// One-shot fresh read for the gate — SWR cache would be stale this tick.
const enabled = await dataApiService.get('/providers', { query: { enabled: true } })
if (enabled.length > 0) {
setStep('select-model')
}
}
return (

View File

@@ -4,7 +4,6 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { CopyIcon } from '@renderer/components/Icons'
import { ModelSelector } from '@renderer/components/ModelSelector'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useSharedCache } from '@renderer/data/hooks/useCache'
import { usePreference } from '@renderer/data/hooks/usePreference'
import { useMiniAppPopup } from '@renderer/hooks/useMiniAppPopup'
@@ -507,7 +506,7 @@ const OpenClawPage: FC = () => {
onSelect={handleModelSelect}
trigger={
<Button variant="outline" className="w-full justify-start">
{selectedModel ? <ModelAvatar model={fromSharedModel(selectedModel)} size={18} /> : null}
{selectedModel ? <ModelAvatar model={selectedModel} size={18} /> : null}
<span className="flex-1 truncate text-left">
{selectedModel ? selectedModel.name : t('openclaw.model_config.select_model')}
</span>

View File

@@ -10,7 +10,7 @@ import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useProviderApiKeys, useProviders } from '@renderer/hooks/useProviders'
import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
import type { FileMetadata } from '@renderer/types'
@@ -71,12 +71,17 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
const { t } = useTranslation()
const { theme } = useTheme()
const providers = useAllProviders()
const { providers } = useProviders()
const navigate = useNavigate()
const location = useLocation()
const [autoTranslateWithSpace] = usePreference('chat.input.translate.auto_translate_with_space')
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
const aihubmixProvider = providers.find((p) => p.id === 'aihubmix')!
const aihubmixProvider = providers.find((p) => p.id === 'aihubmix')
const { data: aihubmixKeyData } = useProviderApiKeys('aihubmix')
const aihubmixApiKey = aihubmixKeyData?.keys.find((k) => k.isEnabled)?.key ?? ''
const aihubmixApiHost =
aihubmixProvider?.endpointConfigs?.[aihubmixProvider.defaultChatEndpoint ?? 'openai-chat-completions']?.baseUrl ??
''
const modeOptions = [
{ label: t('paintings.mode.generate'), value: 'aihubmix_image_generate' },
@@ -136,6 +141,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
}
const onGenerate = async () => {
if (!aihubmixProvider) return
await checkProviderEnabled(aihubmixProvider, t)
if (painting.files.length > 0) {
@@ -151,7 +157,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || ''
updatePaintingState({ prompt })
if (!aihubmixProvider.apiKey) {
if (!aihubmixApiKey) {
window.modal.error({
content: t('error.no_api_key'),
centered: true
@@ -169,9 +175,9 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
let body: string | FormData = ''
let headers: Record<string, string> = {
'Api-Key': aihubmixProvider.apiKey
'Api-Key': aihubmixApiKey
}
let url = aihubmixProvider.apiHost + `/ideogram/` + mode
let url = aihubmixApiHost + `/ideogram/` + mode
try {
if (mode === 'aihubmix_image_generate') {
@@ -201,10 +207,10 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
return
} else if (painting.model === 'gemini-3-pro-image-preview') {
const geminiUrl = `${aihubmixProvider.apiHost}/gemini/v1beta/models/gemini-3-pro-image-preview:streamGenerateContent`
const geminiUrl = `${aihubmixApiHost}/gemini/v1beta/models/gemini-3-pro-image-preview:streamGenerateContent`
const geminiHeaders = {
'Content-Type': 'application/json',
'x-goog-api-key': aihubmixProvider.apiKey
'x-goog-api-key': aihubmixApiKey
}
const requestBody = {
@@ -323,13 +329,13 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
body = formData
// For V3 endpoints - 使用模板字符串而不是字符串连接
logger.silly(`API 端点: ${aihubmixProvider.apiHost}/ideogram/v1/ideogram-v3/generate`)
logger.silly(`API 端点: ${aihubmixApiHost}/ideogram/v1/ideogram-v3/generate`)
// 调整请求头可能需要指定multipart/form-data
// 注意FormData会自动设置Content-Type不应手动设置
const apiHeaders = { 'Api-Key': aihubmixProvider.apiKey }
const apiHeaders = { 'Api-Key': aihubmixApiKey }
const response = await fetch(`${aihubmixProvider.apiHost}/ideogram/v1/ideogram-v3/generate`, {
const response = await fetch(`${aihubmixApiHost}/ideogram/v1/ideogram-v3/generate`, {
method: 'POST',
headers: apiHeaders,
body
@@ -362,9 +368,9 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
quality: painting.quality,
...(painting.model === 'gpt-image-1' ? { moderation: painting.moderation } : {})
}
url = aihubmixProvider.apiHost + `/v1/images/generations`
url = aihubmixApiHost + `/v1/images/generations`
headers = {
Authorization: `Bearer ${aihubmixProvider.apiKey}`
Authorization: `Bearer ${aihubmixApiKey}`
}
} else if (painting.model === 'FLUX.1-Kontext-pro') {
requestData = {
@@ -374,9 +380,9 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
// height: painting.height,
safety_tolerance: painting.safetyTolerance || 6
}
url = aihubmixProvider.apiHost + `/v1/images/generations`
url = aihubmixApiHost + `/v1/images/generations`
headers = {
Authorization: `Bearer ${aihubmixProvider.apiKey}`
Authorization: `Bearer ${aihubmixApiKey}`
}
} else {
// Existing V1/V2 API
@@ -450,9 +456,9 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
body = formData
// For V3 Remix endpoint
const response = await fetch(`${aihubmixProvider.apiHost}/ideogram/v1/ideogram-v3/remix`, {
const response = await fetch(`${aihubmixApiHost}/ideogram/v1/ideogram-v3/remix`, {
method: 'POST',
headers: { 'Api-Key': aihubmixProvider.apiKey },
headers: { 'Api-Key': aihubmixApiKey },
body
})
@@ -886,16 +892,16 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
<LeftContainer>
<ProviderTitleContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<SettingHelpLink target="_blank" href={aihubmixProvider.apiHost}>
<SettingHelpLink target="_blank" href={aihubmixApiHost}>
{t('paintings.learn_more')}
{(() => {
const Icon = resolveProviderIcon(aihubmixProvider.id)
const Icon = resolveProviderIcon(aihubmixProvider?.id ?? 'aihubmix')
return Icon ? <Icon.Avatar size={16} className="ml-1.25" /> : null
})()}
</SettingHelpLink>
</ProviderTitleContainer>
<ProviderSelect
provider={aihubmixProvider}
provider={aihubmixProvider ?? { id: 'aihubmix' }}
options={Options}
onChange={handleProviderChange}
className={'mb-4'}

View File

@@ -6,7 +6,7 @@ import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navb
import Scrollbar from '@renderer/components/Scrollbar'
import { isMac } from '@renderer/config/constant'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useProviderApiKeys, useProviders } from '@renderer/hooks/useProviders'
import FileManager from '@renderer/services/FileManager'
import type { FileMetadata } from '@renderer/types'
import { convertToBase64, uuid } from '@renderer/utils'
@@ -42,9 +42,13 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
const { dmxapi_paintings, addPainting, removePainting, updatePainting } = usePaintings()
const [painting, setPainting] = useState<DmxapiPainting>(dmxapi_paintings?.[0] || DEFAULT_PAINTING)
const { t } = useTranslation()
const providers = useAllProviders()
const { providers } = useProviders()
const dmxapiProvider = providers.find((p) => p.id === 'dmxapi')!
const dmxapiProvider = providers.find((p) => p.id === 'dmxapi')
const { data: dmxapiKeyData } = useProviderApiKeys('dmxapi')
const dmxapiApiKey = dmxapiKeyData?.keys.find((k) => k.isEnabled)?.key ?? ''
const dmxapiApiHost =
dmxapiProvider?.endpointConfigs?.[dmxapiProvider.defaultChatEndpoint ?? 'openai-chat-completions']?.baseUrl ?? ''
// 动态模型数据状态
const [dynamicModelGroups, setDynamicModelGroups] = useState<any>(null)
@@ -330,11 +334,11 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
// 检查提供者状态函数
const checkProviderStatus = () => {
if (!dmxapiProvider.enabled) {
if (!dmxapiProvider?.isEnabled) {
throw new Error('error.provider_disabled')
}
if (!dmxapiProvider.apiKey) {
if (!dmxapiApiKey) {
throw new Error('error.no_api_key')
}
@@ -394,7 +398,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
return {
body: JSON.stringify(params),
headerExpand: headerExpand,
endpoint: `${dmxapiProvider.apiHost}/v1/images/generations`
endpoint: `${dmxapiApiHost}/v1/images/generations`
}
}
@@ -429,7 +433,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
return {
body: formData,
endpoint: `${dmxapiProvider.apiHost}/v1/images/edits`
endpoint: `${dmxapiApiHost}/v1/images/edits`
}
}
@@ -442,7 +446,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${dmxapiProvider.apiKey}`,
Authorization: `Bearer ${dmxapiApiKey}`,
'User-Agent': 'DMXAPI/1.0.0 (https://www.dmxapi.com)',
...headerExpand
}
@@ -530,8 +534,8 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
return
}
if (!dmxapiProvider.enabled) {
void checkProviderEnabled(dmxapiProvider, t)
if (!dmxapiProvider?.isEnabled) {
if (dmxapiProvider) void checkProviderEnabled(dmxapiProvider, t)
return
}
@@ -798,13 +802,13 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
{t('paintings.top_up')}
</SettingHelpLink>
{(() => {
const Icon = resolveProviderIcon(dmxapiProvider.id)
const Icon = resolveProviderIcon(dmxapiProvider?.id ?? 'dmxapi')
return Icon ? <Icon.Avatar size={16} className="ml-1" /> : null
})()}
</div>
</ProviderTitleContainer>
<ProviderSelect
provider={dmxapiProvider}
provider={dmxapiProvider ?? { id: 'dmxapi' }}
options={Options}
onChange={handleProviderChange}
className="mb-4"

View File

@@ -10,8 +10,9 @@ import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant'
import { PROVIDER_URLS } from '@renderer/config/providers'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useModels } from '@renderer/hooks/useModels'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useProviderApiKeys, useProviders } from '@renderer/hooks/useProviders'
import {
getPaintingsBackgroundOptionsLabel,
getPaintingsImageSizeOptionsLabel,
@@ -25,9 +26,9 @@ import { translateText } from '@renderer/services/TranslateService'
import type { PaintingAction, PaintingsState } from '@renderer/types'
import type { FileMetadata } from '@renderer/types'
import { getErrorMessage, uuid } from '@renderer/utils'
import { isNewApiProvider } from '@renderer/utils/provider'
import { getRotatedProviderApiKey } from '@renderer/utils/providerAuth'
import { BUILTIN_LANGUAGE } from '@shared/data/presets/translate-languages'
import { parseUniqueModelId } from '@shared/data/types/model'
import { isNewApiProvider } from '@shared/utils/provider'
import { useLocation, useNavigate } from '@tanstack/react-router'
import { Empty, InputNumber, Segmented, Select, Upload } from 'antd'
import TextArea from 'antd/es/input/TextArea'
@@ -68,7 +69,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const { t } = useTranslation()
const { theme } = useTheme()
const providers = useAllProviders()
const { providers } = useProviders()
const location = useLocation()
const routeName = location.pathname.split('/').pop() || 'new-api'
const newApiProviders = providers.filter((p) => isNewApiProvider(p))
@@ -77,12 +78,22 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const [autoTranslateWithSpace] = usePreference('chat.input.translate.auto_translate_with_space')
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
const newApiProvider = newApiProviders.find((p) => p.id === routeName) || newApiProviders[0]
const npId = newApiProvider?.id ?? ''
const { models: allV2Models } = useModels()
const { data: npKeyData } = useProviderApiKeys(npId)
const npApiKey = npKeyData?.keys.find((k) => k.isEnabled)?.key ?? ''
const npApiHost =
newApiProvider?.endpointConfigs?.[newApiProvider.defaultChatEndpoint ?? 'openai-chat-completions']?.baseUrl ?? ''
const npImageModels = useMemo(
() => allV2Models.filter((m) => m.providerId === npId && m.capabilities.includes('image-generation')),
[allV2Models, npId]
)
const filteredPaintings = useMemo(
() => (newApiPaintings[mode] || []).filter((p) => p.providerId === newApiProvider.id),
[newApiPaintings, mode, newApiProvider.id]
() => (newApiPaintings[mode] || []).filter((p) => p.providerId === npId),
[newApiPaintings, mode, npId]
)
const [painting, setPainting] = useState<PaintingAction>({ ...DEFAULT_PAINTING, providerId: newApiProvider.id })
const [painting, setPainting] = useState<PaintingAction>({ ...DEFAULT_PAINTING, providerId: npId })
const modeOptions = [
{ label: t('paintings.mode.generate'), value: 'openai_image_generate' },
@@ -141,27 +152,28 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const updatePaintingState = useCallback(
(updates: Partial<PaintingAction>) => {
const updatedPainting = { ...painting, providerId: newApiProvider.id, ...updates }
const updatedPainting = { ...painting, providerId: npId, ...updates }
setPainting(updatedPainting)
updatePainting(mode, updatedPainting)
},
[painting, newApiProvider.id, mode, updatePainting]
[painting, npId, mode, updatePainting]
)
// ---------------- Model Related Configurations ----------------
// const modelOptions = MODELS.map((m) => ({ label: m.name, value: m.name }))
const modelOptions = useMemo(() => {
const customModels = newApiProvider.models
.filter((m) => m.endpoint_type && m.endpoint_type === 'image-generation')
.map((m) => ({
const customModels = npImageModels.map((m) => {
const rawId = m.apiModelId ?? parseUniqueModelId(m.id).modelId
return {
label: m.name,
value: m.id,
custom: !SUPPORTED_MODELS.includes(m.id),
group: m.group
}))
value: rawId,
custom: !SUPPORTED_MODELS.includes(rawId),
group: m.group ?? ''
}
})
return [...customModels]
}, [newApiProvider.models])
}, [npImageModels])
// 根据 group 将模型进行分组,便于在下拉列表中分组渲染
const groupedModelOptions = useMemo(() => {
@@ -180,9 +192,9 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
...DEFAULT_PAINTING,
model: painting.model || modelOptions[0]?.value || '',
id: uuid(),
providerId: newApiProvider.id
providerId: npId
}
}, [modelOptions, painting.model, newApiProvider.id])
}, [modelOptions, painting.model, npId])
const selectedModelConfig = useMemo(
() => MODELS.find((m) => m.name === painting.model) || MODELS[0],
@@ -261,6 +273,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
}
const onGenerate = async () => {
if (!newApiProvider) return
await checkProviderEnabled(newApiProvider, t)
if (painting.files.length > 0) {
@@ -276,7 +289,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || ''
updatePaintingState({ prompt })
const apiKey = getRotatedProviderApiKey(newApiProvider)
const apiKey = npApiKey
if (!apiKey) {
window.modal.error({
@@ -300,11 +313,11 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
}
// NOTE: Cherry Studio当下 newapi只接受v1/images/xxx的请求
// TODO: support gemini https://www.newapi.ai/zh/docs/api/ai-model/images/gemini/geminirelayv1beta-383837589
let url = newApiProvider.apiHost.replace(/\/v1$/, '') + `/v1/images/generations`
let editUrl = newApiProvider.apiHost.replace(/\/v1$/, '') + `/v1/images/edits`
if (newApiProvider.id === 'aionly') {
url = newApiProvider.apiHost.replace(/\/v1$/, '') + `/openai/v1/images/generations`
editUrl = newApiProvider.apiHost.replace(/\/v1$/, '') + `/openai/v1/images/edits`
let url = npApiHost.replace(/\/v1$/, '') + `/v1/images/generations`
let editUrl = npApiHost.replace(/\/v1$/, '') + `/v1/images/edits`
if (npId === 'aionly') {
url = npApiHost.replace(/\/v1$/, '') + `/openai/v1/images/generations`
editUrl = npApiHost.replace(/\/v1$/, '') + `/openai/v1/images/edits`
}
try {
@@ -494,11 +507,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
setMode(nextMode)
if (nextMode === 'openai_image_edit' && mode === 'openai_image_generate' && painting.files.length > 0) {
const existingEditPainting = findPaintingByFiles(
newApiPaintings.openai_image_edit || [],
newApiProvider.id,
painting.files
)
const existingEditPainting = findPaintingByFiles(newApiPaintings.openai_image_edit || [], npId, painting.files)
if (existingEditPainting) {
setPainting(existingEditPainting)
@@ -508,7 +517,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const seededPainting = {
...painting,
id: uuid(),
providerId: newApiProvider.id
providerId: npId
}
addPainting(nextMode, seededPainting)
@@ -516,8 +525,8 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
return
}
const list = (newApiPaintings[nextMode] || []).filter((p) => p.providerId === newApiProvider.id)
setPainting(list[0] || { ...DEFAULT_PAINTING, providerId: newApiProvider.id })
const list = (newApiPaintings[nextMode] || []).filter((p) => p.providerId === npId)
setPainting(list[0] || { ...DEFAULT_PAINTING, providerId: npId })
}
// 渲染配置项的函数
@@ -534,7 +543,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
// 当 modelOptions 为空时,引导用户跳转到 Provider 设置页面,新增 image-generation 端点模型
const handleShowAddModelPopup = () => {
void navigate({ to: `/settings/provider?id=${newApiProvider.id}` })
void navigate({ to: `/settings/provider?id=${npId}` })
}
useEffect(() => {
@@ -588,16 +597,16 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<SettingHelpLink
target="_blank"
href={PROVIDER_URLS[newApiProvider.id]?.websites?.docs || 'https://docs.newapi.pro/apps/cherry-studio/'}>
href={PROVIDER_URLS[npId]?.websites?.docs || 'https://docs.newapi.pro/apps/cherry-studio/'}>
{t('paintings.learn_more')}
{(() => {
const Icon = resolveProviderIcon(newApiProvider.id)
const Icon = resolveProviderIcon(npId)
return Icon ? <Icon.Avatar size={16} className="ml-1.25" /> : null
})()}
</SettingHelpLink>
</ProviderTitleContainer>
<ProviderSelect provider={newApiProvider} options={Options} onChange={handleProviderChange} />
<ProviderSelect provider={newApiProvider ?? { id: npId }} options={Options} onChange={handleProviderChange} />
{/* 当没有可用的 Image Generation 模型时,提示用户先去新增 */}
{modelOptions.length === 0 && (

View File

@@ -5,8 +5,9 @@ import { loggerService } from '@logger'
import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar'
import Scrollbar from '@renderer/components/Scrollbar'
import { isMac } from '@renderer/config/constant'
import { useModels } from '@renderer/hooks/useModels'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useProviders } from '@renderer/hooks/useProviders'
import { useSettings } from '@renderer/hooks/useSettings'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager'
@@ -14,6 +15,7 @@ import { translateText } from '@renderer/services/TranslateService'
import type { FileMetadata, OvmsPainting } from '@renderer/types'
import { getErrorMessage, uuid } from '@renderer/utils'
import { BUILTIN_LANGUAGE } from '@shared/data/presets/translate-languages'
import { parseUniqueModelId } from '@shared/data/types/model'
import { useLocation, useNavigate } from '@tanstack/react-router'
import { Input, InputNumber, Select, Slider } from 'antd'
import TextArea from 'antd/es/input/TextArea'
@@ -51,7 +53,8 @@ const OvmsPage: FC<{ Options: string[] }> = ({ Options }) => {
const [ovmsConfig, setOvmsConfig] = useState<ConfigItem[]>([])
const { t } = useTranslation()
const providers = useAllProviders()
const { providers } = useProviders()
const { models: ovmsV2Models } = useModels({ providerId: 'ovms' })
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
if (provider) {
@@ -71,7 +74,9 @@ const OvmsPage: FC<{ Options: string[] }> = ({ Options }) => {
const location = useLocation()
const { autoTranslateWithSpace } = useSettings()
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
const ovmsProvider = providers.find((p) => p.id === 'ovms')!
const ovmsProvider = providers.find((p) => p.id === 'ovms')
const ovmsApiHost =
ovmsProvider?.endpointConfigs?.[ovmsProvider.defaultChatEndpoint ?? 'openai-chat-completions']?.baseUrl ?? ''
const getNewPainting = useCallback(() => {
if (availableModels.length > 0) {
@@ -89,9 +94,11 @@ const OvmsPage: FC<{ Options: string[] }> = ({ Options }) => {
useEffect(() => {
const loadModels = () => {
try {
// Get OVMS provider to access its models
const ovmsProvider = providers.find((p) => p.id === 'ovms')
const providerModels = ovmsProvider?.models || []
// OVMS provider models now come from the v2 DataApi.
const providerModels = ovmsV2Models.map((m) => ({
id: m.apiModelId ?? parseUniqueModelId(m.id).modelId,
name: m.name
}))
// Filter and format models for image generation
const filteredModels = getOvmsModels(providerModels)
@@ -111,7 +118,7 @@ const OvmsPage: FC<{ Options: string[] }> = ({ Options }) => {
}
loadModels()
}, [providers, painting.model]) // Re-run when providers change
}, [ovmsV2Models, painting.model]) // Re-run when OVMS models change
const updatePaintingState = (updates: Partial<OvmsPainting>) => {
const updatedPainting = { ...painting, ...updates }
@@ -188,7 +195,7 @@ const OvmsPage: FC<{ Options: string[] }> = ({ Options }) => {
logger.info('OVMS API request:', requestBody)
const response = await fetch(`${ovmsProvider.apiHost}images/generations`, {
const response = await fetch(`${ovmsApiHost}images/generations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -495,7 +502,7 @@ const OvmsPage: FC<{ Options: string[] }> = ({ Options }) => {
href="https://docs.openvino.ai/2025/model-server/ovms_demos_image_generation.html">
{t('paintings.learn_more')}
{(() => {
const Icon = resolveProviderIcon(ovmsProvider.id)
const Icon = resolveProviderIcon(ovmsProvider?.id ?? 'ovms')
return Icon ? <Icon.Avatar size={16} className="ml-1.25" /> : null
})()}
</SettingHelpLink>

View File

@@ -1,10 +1,10 @@
import { loggerService } from '@logger'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useProviders } from '@renderer/hooks/useProviders'
import { useAppDispatch } from '@renderer/store'
import { setDefaultPaintingProvider } from '@renderer/store/settings'
import { updateTab } from '@renderer/store/tabs'
import type { PaintingProvider, SystemProviderId } from '@renderer/types'
import { isNewApiProvider } from '@renderer/utils/provider'
import { isNewApiProvider } from '@shared/utils/provider'
import { useParams } from '@tanstack/react-router'
import type { FC } from 'react'
import { useEffect, useMemo, useState } from 'react'
@@ -26,7 +26,7 @@ const PaintingsRoutePage: FC = () => {
const params = useParams({ strict: false })
const provider = params._splat
const dispatch = useAppDispatch()
const providers = useAllProviders()
const { providers } = useProviders()
const [isOvmsSupported, setIsOvmsSupported] = useState(false)
const [ovmsStatus, setOvmsStatus] = useState<'not-installed' | 'not-running' | 'running'>('not-running')

View File

@@ -8,7 +8,7 @@ import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useProviderApiKeys, useProviders } from '@renderer/hooks/useProviders'
import { useSettings } from '@renderer/hooks/useSettings'
import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
@@ -72,8 +72,10 @@ const PpioPage: FC<{ Options: string[] }> = ({ Options }) => {
const [painting, setPainting] = useState<PpioPainting>(filteredPaintings[0] || getDefaultPainting(mode))
const providers = useAllProviders()
const { providers } = useProviders()
const ppioProvider = providers.find((p) => p.id === 'ppio')
const { data: ppioKeyData } = useProviderApiKeys('ppio')
const ppioApiKey = ppioKeyData?.keys.find((k) => k.isEnabled)?.key ?? ''
const [currentImageIndex, setCurrentImageIndex] = useState(0)
const [isLoading, setIsLoading] = useState(false)
@@ -189,6 +191,7 @@ const PpioPage: FC<{ Options: string[] }> = ({ Options }) => {
return
}
if (!ppioProvider) return
await checkProviderEnabled(ppioProvider, t)
if (isLoading) return
@@ -212,7 +215,7 @@ const PpioPage: FC<{ Options: string[] }> = ({ Options }) => {
return
}
if (!ppioProvider.apiKey) {
if (!ppioApiKey) {
window.modal.error({
content: t('error.no_api_key'),
centered: true
@@ -235,7 +238,7 @@ const PpioPage: FC<{ Options: string[] }> = ({ Options }) => {
setAbortController(controller)
try {
const service = new PpioService(ppioProvider.apiKey)
const service = new PpioService(ppioApiKey)
logger.info('Starting image generation', { model: painting.model, mode })

View File

@@ -14,8 +14,7 @@ import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { useProviderApiKeys, useProviders } from '@renderer/hooks/useProviders'
import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
import type { Painting } from '@renderer/types'
@@ -109,9 +108,11 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
const { siliconflow_paintings, addPainting, removePainting, updatePainting } = usePaintings()
const [painting, setPainting] = useState<Painting>(siliconflow_paintings[0] || DEFAULT_PAINTING)
const { theme } = useTheme()
const providers = useAllProviders()
const { providers } = useProviders()
const siliconFlowProvider = providers.find((p) => p.id === 'silicon')!
const siliconFlowProvider = providers.find((p) => p.id === 'silicon')
const { data: siliconKeyData } = useProviderApiKeys('silicon')
const siliconApiKey = siliconKeyData?.keys.find((k) => k.isEnabled)?.key ?? ''
const [currentImageIndex, setCurrentImageIndex] = useState(0)
const [isLoading, setIsLoading] = useState(false)
@@ -149,6 +150,7 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
}
const onGenerate = async () => {
if (!siliconFlowProvider) return
await checkProviderEnabled(siliconFlowProvider, t)
if (painting.files.length > 0) {
@@ -168,10 +170,7 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
updatePaintingState({ prompt })
const model = TEXT_TO_IMAGES_MODELS.find((m) => m.id === painting.model)
const provider = getProviderByModel(model)
if (!provider || !provider.apiKey) {
if (!siliconFlowProvider || !siliconApiKey) {
window.modal.error({
content: t('error.no_api_key'),
centered: true
@@ -191,7 +190,7 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
try {
const result = await window.api.ai.generateImage(
{
uniqueModelId: `${provider.id}::${painting.model}`,
uniqueModelId: `${siliconFlowProvider.id}::${painting.model}`,
prompt,
negativePrompt: painting.negativePrompt || undefined,
size: painting.imageSize || '1024x1024',
@@ -354,7 +353,11 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
<ContentContainer id="content-container">
<LeftContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<ProviderSelect provider={siliconFlowProvider} options={Options} onChange={handleProviderChange} />
<ProviderSelect
provider={siliconFlowProvider ?? { id: 'silicon' }}
options={Options}
onChange={handleProviderChange}
/>
<SettingTitle className="mt-4 mb-1">{t('common.model')}</SettingTitle>
<Select value={painting.model} options={modelOptions} onChange={onSelectModel} />
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle>

View File

@@ -8,7 +8,7 @@ import Scrollbar from '@renderer/components/Scrollbar'
import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useProviderApiKeys, useProviders } from '@renderer/hooks/useProviders'
import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
import type { TokenFluxPainting } from '@renderer/types'
@@ -45,7 +45,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
const [isTranslating, setIsTranslating] = useState(false)
const { t, i18n } = useTranslation()
const providers = useAllProviders()
const { providers } = useProviders()
const { addPainting, removePainting, updatePainting, tokenflux_paintings } = usePaintings()
const tokenFluxPaintings = tokenflux_paintings
const [painting, setPainting] = useState<TokenFluxPainting>(
@@ -56,11 +56,16 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
const location = useLocation()
const [autoTranslateWithSpace] = usePreference('chat.input.translate.auto_translate_with_space')
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
const tokenfluxProvider = providers.find((p) => p.id === 'tokenflux')!
const tokenfluxProvider = providers.find((p) => p.id === 'tokenflux')
const { data: tokenfluxKeyData } = useProviderApiKeys('tokenflux')
const tokenfluxApiKey = tokenfluxKeyData?.keys.find((k) => k.isEnabled)?.key ?? ''
const tokenfluxApiHost =
tokenfluxProvider?.endpointConfigs?.[tokenfluxProvider.defaultChatEndpoint ?? 'openai-chat-completions']?.baseUrl ??
''
const textareaRef = useRef<any>(null)
const tokenFluxService = useMemo(
() => new TokenFluxService(tokenfluxProvider.apiHost, tokenfluxProvider.apiKey),
[tokenfluxProvider]
() => new TokenFluxService(tokenfluxApiHost, tokenfluxApiKey),
[tokenfluxApiHost, tokenfluxApiKey]
)
useEffect(() => {
@@ -118,6 +123,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
}
const onGenerate = async () => {
if (!tokenfluxProvider) return
await checkProviderEnabled(tokenfluxProvider, t)
if (painting.files.length > 0) {
@@ -367,7 +373,11 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
</SettingHelpLink>
</ProviderTitleContainer>
<ProviderSelect provider={tokenfluxProvider} options={Options} onChange={handleProviderChange} />
<ProviderSelect
provider={tokenfluxProvider ?? { id: 'tokenflux' }}
options={Options}
onChange={handleProviderChange}
/>
{/* Model & Pricing Section */}
<SectionTitle

View File

@@ -6,7 +6,7 @@ import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navb
import Scrollbar from '@renderer/components/Scrollbar'
import { isMac } from '@renderer/config/constant'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useProviders } from '@renderer/hooks/useProviders'
import FileManager from '@renderer/services/FileManager'
import { getErrorMessage, uuid } from '@renderer/utils'
import { useLocation, useNavigate } from '@tanstack/react-router'
@@ -37,7 +37,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
const { zhipu_paintings, addPainting, removePainting, updatePainting } = usePaintings()
const [painting, setPainting] = useState<any>(zhipu_paintings?.[0] || DEFAULT_PAINTING)
const { t } = useTranslation()
const providers = useAllProviders()
const { providers } = useProviders()
// 确保painting使用智谱的cogview系列模型
useEffect(() => {

View File

@@ -2,13 +2,13 @@ import { resolveProviderIcon } from '@cherrystudio/ui/icons'
import { ProviderAvatarPrimitive } from '@renderer/components/ProviderAvatar'
import { getProviderDisplayName, useProviders } from '@renderer/hooks/useProviders'
import ImageStorage from '@renderer/services/ImageStorage'
import type { Provider } from '@types'
import { Select } from 'antd'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
type ProviderSelectProps = {
provider: Provider
/** Structural minimum: only `id` is read, so this accepts v1 or v2 Provider. */
provider: { id: string }
options: string[]
onChange: (value: string) => void
style?: React.CSSProperties

View File

@@ -1,16 +1,17 @@
import type { FileMetadata, Provider } from '@renderer/types'
import type { FileMetadata } from '@renderer/types'
import type { Provider } from '@shared/data/types/provider'
import type { TFunction } from 'i18next'
import { isEmpty } from 'lodash'
export function checkProviderEnabled(provider: Provider, t: TFunction): Promise<boolean> {
return new Promise((resolve, reject) => {
if (provider.enabled && !isEmpty(provider.apiKey)) {
const hasEnabledKey = provider.apiKeys.some((k) => k.isEnabled)
if (provider.isEnabled && hasEnabledKey) {
resolve(true)
return
}
window.modal.warning({
content: provider.apiKey ? t('error.no_api_key') : t('error.provider_disabled'),
content: hasEnabledKey ? t('error.no_api_key') : t('error.provider_disabled'),
centered: true,
closable: true,
okText: t('common.go_to_settings'),

View File

@@ -12,14 +12,13 @@ import {
import { usePreference } from '@data/hooks/usePreference'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { ModelSelector } from '@renderer/components/ModelSelector'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useDefaultModel } from '@renderer/hooks/useModels'
import AssistantSettingsPopup from '@renderer/pages/home/AssistantSettings'
import { TranslateSettingsPanelContent } from '@renderer/pages/translate/TranslateSettings'
import { TRANSLATE_PROMPT } from '@shared/config/prompts'
import type { Model as SharedModel } from '@shared/data/types/model'
import type { Model } from '@shared/data/types/model'
import { isNonChatModel } from '@shared/utils/model'
import { Languages, MessageSquareMore, PlusIcon, Rocket, Settings2 } from 'lucide-react'
import type { FC } from 'react'
@@ -49,28 +48,28 @@ const ModelSettings: FC<ModelSettingsProps> = ({
const [translateModelPrompt, setTranslateModelPrompt] = usePreference('feature.translate.model_prompt')
const [translateSettingsOpen, setTranslateSettingsOpen] = useState(false)
const modelFilter = useCallback((m: SharedModel) => !isNonChatModel(m), [])
const modelFilter = useCallback((m: Model) => !isNonChatModel(m), [])
const onSelectDefault = useCallback(
(selected: SharedModel | undefined) => {
(selected: Model | undefined) => {
if (!selected) return
void setDefaultModel(fromSharedModel(selected))
void setDefaultModel(selected)
},
[setDefaultModel]
)
const onSelectQuick = useCallback(
(selected: SharedModel | undefined) => {
(selected: Model | undefined) => {
if (!selected) return
void setQuickModel(fromSharedModel(selected))
void setQuickModel(selected)
},
[setQuickModel]
)
const onSelectTranslate = useCallback(
(selected: SharedModel | undefined) => {
(selected: Model | undefined) => {
if (!selected) return
void setTranslateModel(fromSharedModel(selected))
void setTranslateModel(selected)
},
[setTranslateModel]
)
@@ -83,12 +82,11 @@ const ModelSettings: FC<ModelSettingsProps> = ({
const groupStyle = compact ? { padding: 0, border: 'none', background: 'transparent' } : undefined
const triggerStyle = { width: compact ? '100%' : 360 }
const renderTrigger = (model: SharedModel | undefined) => {
const v1 = model ? fromSharedModel(model) : undefined
const renderTrigger = (model: Model | undefined) => {
return (
<Button variant="outline" className="justify-start" style={triggerStyle}>
{v1 ? <ModelAvatar model={v1} size={18} /> : <PlusIcon size={16} />}
<span className="truncate">{v1 ? v1.name : t('settings.models.empty')}</span>
{model ? <ModelAvatar model={model} size={18} /> : <PlusIcon size={16} />}
<span className="truncate">{model ? model.name : t('settings.models.empty')}</span>
</Button>
)
}

View File

@@ -307,7 +307,7 @@ export default function ProviderEditorDrawer({
<div className="space-y-2">
<label className="font-medium text-[13px] text-foreground/85">{t('settings.provider.add.type')}</label>
{isEditing ? (
<div className="flex w-full items-center justify-between rounded-md border border-border/40 bg-muted/20 px-2.5 py-1.5 text-xs text-foreground/70">
<div className="flex w-full items-center justify-between rounded-md border border-border/40 bg-muted/20 px-2.5 py-1.5 text-foreground/70 text-xs">
<span className="truncate">{selectedTemplate.label}</span>
</div>
) : (

View File

@@ -1,5 +1,4 @@
import IndicatorLight from '@renderer/components/IndicatorLight'
import { SelectModelPopup } from '@renderer/components/Popups/SelectModelPopup'
import CustomTag from '@renderer/components/Tags/CustomTag'
import { getProviderLabel } from '@renderer/i18n/label'
import NavigationService from '@renderer/services/NavigationService'
@@ -31,7 +30,8 @@ export const FreeTrialModelTag: FC<Props> = ({ modelId, providerId, showLabel =
const onNavigateProvider = (e: MouseEvent) => {
e.stopPropagation()
SelectModelPopup.hide()
// v1 SelectModelPopup was removed in the v2 ModelSelector migration; the
// inline v2 selector unmounts on route change, so no explicit hide needed.
void NavigationService.navigate?.({ to: '/settings/provider', search: { id: linkedProviderId } })
}

View File

@@ -97,11 +97,6 @@ export default function ProviderApiOptionsDrawer({ providerId, open, onClose }:
label: t('settings.provider.api.options.service_tier.label'),
help: t('settings.provider.api.options.service_tier.help')
},
{
key: 'enableThinking',
label: t('settings.provider.api.options.enable_thinking.label'),
help: t('settings.provider.api.options.enable_thinking.help')
},
{
key: 'verbosity',
label: t('settings.provider.api.options.verbosity.label'),

View File

@@ -111,8 +111,12 @@ export function isSupportVerbosityProvider(provider: Provider): boolean {
return provider.apiFeatures.verbosity
}
export function isSupportEnableThinkingProvider(provider: Provider): boolean {
return provider.apiFeatures.enableThinking
export function isSupportEnableThinkingProvider(_provider: Provider): boolean {
// `enableThinking` was removed from ApiFeatures on HEAD (commit 741d9eb24).
// There is no per-provider toggle in v2; thinking control is model-level.
// v1 default was "supported unless explicitly disabled" → keep true until
// provider-registry capability inference lands (PR #14011).
return true
}
export function isProviderSupportAuth(provider: Pick<Provider, 'id'>): boolean {

View File

@@ -2,7 +2,6 @@ import { InfoCircleOutlined } from '@ant-design/icons'
import { Button, InfoTooltip, RowFlex, Switch } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useDefaultModel } from '@renderer/hooks/useModels'
@@ -10,7 +9,6 @@ import { matchKeywordsInString } from '@renderer/utils'
import HomeWindow from '@renderer/windows/quickAssistant/home/HomeWindow'
import { Select } from 'antd'
import type { FC } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -31,11 +29,7 @@ const QuickAssistantSettings: FC = () => {
const { theme } = useTheme()
const { assistants } = useAssistants()
const { assistant: defaultAssistant } = useDefaultAssistant()
const { defaultModel: apiDefaultModel } = useDefaultModel()
const v1DefaultModel = useMemo(
() => (apiDefaultModel ? fromSharedModel(apiDefaultModel) : undefined),
[apiDefaultModel]
)
const { defaultModel } = useDefaultModel()
const handleEnableQuickAssistant = async (enable: boolean) => {
await setEnableQuickAssistant(enable)
@@ -128,7 +122,7 @@ const QuickAssistantSettings: FC = () => {
title: defaultAssistant.name,
label: (
<AssistantItem>
<ModelAvatar model={v1DefaultModel} size={18} />
<ModelAvatar model={defaultModel} size={18} />
<AssistantName>{defaultAssistant.name}</AssistantName>
<Spacer />
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
@@ -143,7 +137,7 @@ const QuickAssistantSettings: FC = () => {
title: a.name,
label: (
<AssistantItem>
<ModelAvatar model={v1DefaultModel} size={18} />
<ModelAvatar model={defaultModel} size={18} />
<AssistantName>{a.name}</AssistantName>
<Spacer />
</AssistantItem>

View File

@@ -1,7 +1,6 @@
import { Tooltip } from '@cherrystudio/ui'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import CopyButton from '@renderer/components/CopyButton'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useDefaultModel } from '@renderer/hooks/useModels'
import type { SelectionActionItem } from '@shared/data/preference/preferenceTypes'
@@ -30,7 +29,6 @@ const SelectionActionUserModal: FC<SelectionActionUserModalProps> = ({
const { assistants: userPredefinedAssistants } = useAssistants()
const { assistant: defaultAssistant } = useDefaultAssistant()
const { defaultModel: apiDefaultModel } = useDefaultModel()
const v1DefaultModel = apiDefaultModel ? fromSharedModel(apiDefaultModel) : undefined
const [formData, setFormData] = useState<Partial<SelectionActionItem>>({})
const [errors, setErrors] = useState<Partial<Record<keyof SelectionActionItem, string>>>({})
@@ -196,7 +194,7 @@ const SelectionActionUserModal: FC<SelectionActionUserModalProps> = ({
dropdownRender={(menu) => menu}>
<Select.Option key={defaultAssistant.id} value={defaultAssistant.id}>
<AssistantItem>
<ModelAvatar model={v1DefaultModel} size={18} />
<ModelAvatar model={apiDefaultModel} size={18} />
<AssistantName>{defaultAssistant.name}</AssistantName>
<Spacer />
<CurrentTag isCurrent={true}>{t('selection.settings.user_modal.assistant.default')}</CurrentTag>
@@ -207,7 +205,7 @@ const SelectionActionUserModal: FC<SelectionActionUserModalProps> = ({
.map((a) => (
<Select.Option key={a.id} value={a.id}>
<AssistantItem>
<ModelAvatar model={v1DefaultModel} size={18} />
<ModelAvatar model={apiDefaultModel} size={18} />
<AssistantName>{a.name}</AssistantName>
<Spacer />
</AssistantItem>

View File

@@ -2,7 +2,6 @@ import { useChat } from '@ai-sdk/react'
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import { isMac } from '@renderer/config/constant'
import { fromSharedModel } from '@renderer/config/models/_bridge'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAssistant, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useExecutionChats } from '@renderer/hooks/useExecutionChats'
@@ -69,11 +68,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
const { defaultModel: defaultApiModel } = useDefaultModel()
const { assistant: chosenAssistant, model: chosenApiModel } = useAssistant(quickAssistantId ?? '')
const currentAssistant = chosenAssistant ?? defaultAssistant
const currentApiModel = chosenApiModel ?? defaultApiModel
const currentModel = useMemo(
() => (currentApiModel ? fromSharedModel(currentApiModel) : undefined),
[currentApiModel]
)
const currentModel = chosenApiModel ?? defaultApiModel
// Lease a temporary topic for the quick-assistant conversation.
// Lifecycle is tied to this component; resetting the conversation drops and leases a new one.

View File

@@ -1,6 +1,7 @@
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { useTimer } from '@renderer/hooks/useTimer'
import type { Assistant, Model } from '@renderer/types'
import type { Assistant } from '@renderer/types'
import type { Model } from '@shared/data/types/model'
import { Input as AntdInput } from 'antd'
import type { InputRef } from 'rc-input/lib/interface'
import React, { useRef } from 'react'