mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-04 05:00:00 +08:00
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:
@@ -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
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as ApiKeyListPopup } from './popup'
|
||||
export * from './types'
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
`
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user