feat: support NewAPI as a generic provider type (#10696)

* feat: add support for New API providerType

* feat: support New API as a generic painting provider

* refactor: update styling in painting pages to use Tailwind classes

- Replaced inline styles with Tailwind CSS classes for margin adjustments in AihubmixPage, DmxapiPage, SiliconPage, TokenFluxPage, and ZhipuPage.
- Enhanced consistency and maintainability of the codebase by standardizing styling approach across components.
- Minor refactor in ProviderSelect component to support className prop for better styling flexibility.
This commit is contained in:
Calcium-Ion 2025-10-16 13:07:28 +08:00 committed by kangfenmao
parent 726b2570e2
commit 0502ff48f1
13 changed files with 204 additions and 253 deletions

View File

@ -62,13 +62,14 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
// return createVertexProvider(provider) // return createVertexProvider(provider)
// } // }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (isSystemProvider(provider)) { if (isSystemProvider(provider)) {
if (provider.id === 'aihubmix') { if (provider.id === 'aihubmix') {
return aihubmixProviderCreator(model, provider) return aihubmixProviderCreator(model, provider)
} }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (provider.id === 'vertexai') { if (provider.id === 'vertexai') {
return vertexAnthropicProviderCreator(model, provider) return vertexAnthropicProviderCreator(model, provider)
} }

View File

@ -283,7 +283,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
'new-api': { 'new-api': {
id: 'new-api', id: 'new-api',
name: 'New API', name: 'New API',
type: 'openai', type: 'new-api',
apiKey: '', apiKey: '',
apiHost: 'http://localhost:3000', apiHost: 'http://localhost:3000',
models: SYSTEM_MODELS['new-api'], models: SYSTEM_MODELS['new-api'],
@ -1422,5 +1422,5 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
} }
export const isNewApiProvider = (provider: Provider) => { export const isNewApiProvider = (provider: Provider) => {
return ['new-api', 'cherryin'].includes(provider.id) return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
} }

View File

@ -14,7 +14,6 @@ import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider' import { useAllProviders } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime' import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService' import { translateText } from '@renderer/services/TranslateService'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
@ -35,6 +34,7 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings' import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard' import Artboard from './components/Artboard'
import PaintingsList from './components/PaintingsList' import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { type ConfigItem, createModeConfigs, DEFAULT_PAINTING } from './config/aihubmixConfig' import { type ConfigItem, createModeConfigs, DEFAULT_PAINTING } from './config/aihubmixConfig'
import { checkProviderEnabled } from './utils' import { checkProviderEnabled } from './utils'
@ -76,20 +76,6 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { theme } = useTheme() const { theme } = useTheme()
const providers = useAllProviders() const providers = useAllProviders()
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
if (provider) {
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { generating } = useRuntime() const { generating } = useRuntime()
const navigate = useNavigate() const navigate = useNavigate()
@ -849,17 +835,12 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
/> />
</SettingHelpLink> </SettingHelpLink>
</ProviderTitleContainer> </ProviderTitleContainer>
<ProviderSelect
<Select value={providerOptions[1].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}> provider={aihubmixProvider}
{providerOptions.map((provider) => ( options={Options}
<Select.Option value={provider.value} key={provider.value}> onChange={handleProviderChange}
<SelectOptionContainer> className={'mb-4'}
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} /> />
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
{/* 使用JSON配置渲染设置项 */} {/* 使用JSON配置渲染设置项 */}
{modeConfigs[mode].filter((item) => (item.condition ? item.condition(painting) : true)).map(renderConfigItem)} {modeConfigs[mode].filter((item) => (item.condition ? item.condition(painting) : true)).map(renderConfigItem)}
@ -1034,12 +1015,6 @@ const ModeSegmentedContainer = styled.div`
padding-top: 24px; padding-top: 24px;
` `
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
// 添加新的样式组件 // 添加新的样式组件
const ProviderTitleContainer = styled.div` const ProviderTitleContainer = styled.div`
display: flex; display: flex;

View File

@ -8,7 +8,6 @@ import { getProviderLogo } from '@renderer/config/providers'
import { usePaintings } from '@renderer/hooks/usePaintings' import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider' import { useAllProviders } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime' import { useRuntime } from '@renderer/hooks/useRuntime'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime' import { setGenerating } from '@renderer/store/runtime'
@ -29,6 +28,7 @@ import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard' import Artboard from './components/Artboard'
import ImageUploader from './components/ImageUploader' import ImageUploader from './components/ImageUploader'
import PaintingsList from './components/PaintingsList' import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { import {
COURSE_URL, COURSE_URL,
DEFAULT_PAINTING, DEFAULT_PAINTING,
@ -46,20 +46,6 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
const [painting, setPainting] = useState<DmxapiPainting>(dmxapi_paintings?.[0] || DEFAULT_PAINTING) const [painting, setPainting] = useState<DmxapiPainting>(dmxapi_paintings?.[0] || DEFAULT_PAINTING)
const { t } = useTranslation() const { t } = useTranslation()
const providers = useAllProviders() const providers = useAllProviders()
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
if (provider) {
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const dmxapiProvider = providers.find((p) => p.id === 'dmxapi')! const dmxapiProvider = providers.find((p) => p.id === 'dmxapi')!
@ -785,9 +771,9 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
return ( return (
<Container> <Container>
<Navbar> <Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('paintings.title')}</NavbarCenter> <NavbarCenter className="border-r-0">{t('paintings.title')}</NavbarCenter>
{isMac && ( {isMac && (
<NavbarRight style={{ justifyContent: 'flex-end' }}> <NavbarRight className="justify-end">
<Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={createNewPainting}> <Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={createNewPainting}>
{t('paintings.button.new.image')} {t('paintings.button.new.image')}
</Button> </Button>
@ -797,7 +783,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
<ContentContainer id="content-container"> <ContentContainer id="content-container">
<LeftContainer> <LeftContainer>
<ProviderTitleContainer> <ProviderTitleContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle> <SettingTitle className="mb-1">{t('common.provider')}</SettingTitle>
<div> <div>
<SettingHelpLink target="_blank" href={COURSE_URL}> <SettingHelpLink target="_blank" href={COURSE_URL}>
{t('paintings.paint_course')} {t('paintings.paint_course')}
@ -805,28 +791,19 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
<SettingHelpLink target="_blank" href={TOP_UP_URL}> <SettingHelpLink target="_blank" href={TOP_UP_URL}>
{t('paintings.top_up')} {t('paintings.top_up')}
</SettingHelpLink> </SettingHelpLink>
<ProviderLogo <ProviderLogo shape="square" src={getProviderLogo(dmxapiProvider.id)} size={16} className="ml-1" />
shape="square"
src={getProviderLogo(dmxapiProvider.id)}
size={16}
style={{ marginLeft: 5 }}
/>
</div> </div>
</ProviderTitleContainer> </ProviderTitleContainer>
<Select value={providerOptions[3].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}> <ProviderSelect
{providerOptions.map((provider) => ( provider={dmxapiProvider}
<Select.Option value={provider.value} key={provider.value}> options={Options}
<SelectOptionContainer> onChange={handleProviderChange}
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} /> className="mb-4"
{provider.label} />
</SelectOptionContainer>
</Select.Option>
))}
</Select>
{painting.generationMode && {painting.generationMode &&
[generationModeType.EDIT, generationModeType.MERGE].includes(painting.generationMode) && ( [generationModeType.EDIT, generationModeType.MERGE].includes(painting.generationMode) && (
<> <>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}></SettingTitle> <SettingTitle className="mt-4 mb-1"></SettingTitle>
<ImageUploader <ImageUploader
fileMap={fileMap} fileMap={fileMap}
maxImages={painting.generationMode === generationModeType.EDIT ? 1 : 3} maxImages={painting.generationMode === generationModeType.EDIT ? 1 : 3}
@ -838,13 +815,13 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</> </>
)} )}
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}> <SettingTitle className="mt-4 mb-1">
{t('common.model')} <SettingPrice>{painting.priceModel !== '0' ? painting.priceModel : ''}</SettingPrice> {t('common.model')} <SettingPrice>{painting.priceModel !== '0' ? painting.priceModel : ''}</SettingPrice>
</SettingTitle> </SettingTitle>
<Select <Select
value={painting.model} value={painting.model}
onChange={onSelectModel} onChange={onSelectModel}
style={{ width: '100%' }} className="w-full"
loading={isLoadingModels} loading={isLoadingModels}
placeholder={isLoadingModels ? t('common.loading') : t('paintings.select_model')}> placeholder={isLoadingModels ? t('common.loading') : t('paintings.select_model')}>
{Object.entries(modelOptions).map(([provider, models]) => { {Object.entries(modelOptions).map(([provider, models]) => {
@ -861,11 +838,11 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
})} })}
</Select> </Select>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle> <SettingTitle className="mt-4 mb-1">{t('paintings.image.size')}</SettingTitle>
<Select <Select
value={isCustomSize ? 'custom' : painting.image_size} value={isCustomSize ? 'custom' : painting.image_size}
onChange={(value) => onSelectImageSize(value)} onChange={(value) => onSelectImageSize(value)}
style={{ width: '100%' }}> className="w-full">
{(() => { {(() => {
const currentModel = allModels.find((m) => m.id === painting.model) const currentModel = allModels.find((m) => m.id === painting.model)
const modelImageSizes = currentModel?.image_sizes || [] const modelImageSizes = currentModel?.image_sizes || []
@ -874,7 +851,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
return modelImageSizes.map((size) => { return modelImageSizes.map((size) => {
return ( return (
<Select.Option key={size.value} value={size.value}> <Select.Option key={size.value} value={size.value}>
<HStack style={{ alignItems: 'center', gap: 8 }}> <HStack className="items-center gap-2">
<span>{size.label}</span> <span>{size.label}</span>
</HStack> </HStack>
</Select.Option> </Select.Option>
@ -884,7 +861,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
{/* 检查当前模型是否支持自定义尺寸 */} {/* 检查当前模型是否支持自定义尺寸 */}
{allModels.find((m) => m.id === painting.model)?.is_custom_size && ( {allModels.find((m) => m.id === painting.model)?.is_custom_size && (
<Select.Option value="custom" key="custom"> <Select.Option value="custom" key="custom">
<HStack style={{ alignItems: 'center', gap: 8 }}> <HStack className="items-center gap-2">
<span>{t('paintings.custom_size')}</span> <span>{t('paintings.custom_size')}</span>
</HStack> </HStack>
</Select.Option> </Select.Option>
@ -893,7 +870,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
{/* 自定义尺寸输入框 */} {/* 自定义尺寸输入框 */}
{isCustomSize && allModels.find((m) => m.id === painting.model)?.is_custom_size && ( {isCustomSize && allModels.find((m) => m.id === painting.model)?.is_custom_size && (
<div style={{ marginTop: 10 }}> <div className="mt-2.5">
<HStack style={{ gap: 8, alignItems: 'center' }}> <HStack style={{ gap: 8, alignItems: 'center' }}>
<InputNumber <InputNumber
placeholder="W" placeholder="W"
@ -921,7 +898,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
{painting.generationMode === generationModeType.GENERATION && ( {painting.generationMode === generationModeType.GENERATION && (
<> <>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}> <SettingTitle className="mt-4 mb-1">
{t('paintings.seed')} {t('paintings.seed')}
<Tooltip title={t('paintings.seed_desc_tip')}> <Tooltip title={t('paintings.seed_desc_tip')}>
<InfoIcon /> <InfoIcon />
@ -941,7 +918,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</> </>
)} )}
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.style_type')}</SettingTitle> <SettingTitle className="mt-4 mb-1">{t('paintings.style_type')}</SettingTitle>
<SliderContainer> <SliderContainer>
<RadioTextBox> <RadioTextBox>
{STYLE_TYPE_OPTIONS.map((ele) => ( {STYLE_TYPE_OPTIONS.map((ele) => (
@ -955,7 +932,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</RadioTextBox> </RadioTextBox>
</SliderContainer> </SliderContainer>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}> <SettingTitle className="mt-4 mb-1">
{t('paintings.auto_create_paint')} {t('paintings.auto_create_paint')}
<Tooltip title={t('paintings.auto_create_paint_tip')}> <Tooltip title={t('paintings.auto_create_paint_tip')}>
<InfoIcon /> <InfoIcon />
@ -1032,11 +1009,6 @@ const ProviderTitleContainer = styled.div`
const ProviderLogo = styled(Avatar)` const ProviderLogo = styled(Avatar)`
border: 0.5px solid var(--color-border); border: 0.5px solid var(--color-border);
` `
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const ContentContainer = styled.div` const ContentContainer = styled.div`
display: flex; display: flex;

View File

@ -6,7 +6,7 @@ import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navb
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import TranslateButton from '@renderer/components/TranslateButton' import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import { getProviderLogo } from '@renderer/config/providers' import { getProviderLogo, isNewApiProvider, PROVIDER_URLS } from '@renderer/config/providers'
import { LanguagesEnum } from '@renderer/config/translate' import { LanguagesEnum } from '@renderer/config/translate'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings' import { usePaintings } from '@renderer/hooks/usePaintings'
@ -17,8 +17,7 @@ import {
getPaintingsBackgroundOptionsLabel, getPaintingsBackgroundOptionsLabel,
getPaintingsImageSizeOptionsLabel, getPaintingsImageSizeOptionsLabel,
getPaintingsModerationOptionsLabel, getPaintingsModerationOptionsLabel,
getPaintingsQualityOptionsLabel, getPaintingsQualityOptionsLabel
getProviderLabel
} from '@renderer/i18n/label' } from '@renderer/i18n/label'
import PaintingsList from '@renderer/pages/paintings/components/PaintingsList' import PaintingsList from '@renderer/pages/paintings/components/PaintingsList'
import { DEFAULT_PAINTING, MODELS, SUPPORTED_MODELS } from '@renderer/pages/paintings/config/NewApiConfig' import { DEFAULT_PAINTING, MODELS, SUPPORTED_MODELS } from '@renderer/pages/paintings/config/NewApiConfig'
@ -40,6 +39,7 @@ import styled from 'styled-components'
import SendMessageButton from '../home/Inputbar/SendMessageButton' import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings' import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard' import Artboard from './components/Artboard'
import ProviderSelect from './components/ProviderSelect'
import { checkProviderEnabled } from './utils' import { checkProviderEnabled } from './utils'
const logger = loggerService.withContext('NewApiPage') const logger = loggerService.withContext('NewApiPage')
@ -55,8 +55,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
} }
}, [openai_image_generate, openai_image_edit]) }, [openai_image_generate, openai_image_edit])
const filteredPaintings = useMemo(() => newApiPaintings[mode] || [], [newApiPaintings, mode]) // moved below after newApiProvider is defined
const [painting, setPainting] = useState<PaintingAction>(filteredPaintings[0] || DEFAULT_PAINTING)
const [currentImageIndex, setCurrentImageIndex] = useState(0) const [currentImageIndex, setCurrentImageIndex] = useState(0)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [abortController, setAbortController] = useState<AbortController | null>(null) const [abortController, setAbortController] = useState<AbortController | null>(null)
@ -67,27 +66,22 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { theme } = useTheme() const { theme } = useTheme()
const providers = useAllProviders() const providers = useAllProviders()
const providerOptions = Options.map((option) => { const location = useLocation()
const provider = providers.find((p) => p.id === option) const routeName = location.pathname.split('/').pop() || 'new-api'
if (provider) { const newApiProviders = providers.filter((p) => isNewApiProvider(p))
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { generating } = useRuntime() const { generating } = useRuntime()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const { autoTranslateWithSpace } = useSettings() const { autoTranslateWithSpace } = useSettings()
const spaceClickTimer = useRef<NodeJS.Timeout>(null) const spaceClickTimer = useRef<NodeJS.Timeout>(null)
const newApiProvider = providers.find((p) => p.id === 'new-api')! const newApiProvider = newApiProviders.find((p) => p.id === routeName) || newApiProviders[0]
const filteredPaintings = useMemo(
() => (newApiPaintings[mode] || []).filter((p) => p.providerId === newApiProvider.id),
[newApiPaintings, mode, newApiProvider.id]
)
const [painting, setPainting] = useState<PaintingAction>({ ...DEFAULT_PAINTING, providerId: newApiProvider.id })
const modeOptions = [ const modeOptions = [
{ label: t('paintings.mode.generate'), value: 'openai_image_generate' }, { label: t('paintings.mode.generate'), value: 'openai_image_generate' },
@ -102,7 +96,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
}, [editImageFiles]) }, [editImageFiles])
const updatePaintingState = (updates: Partial<PaintingAction>) => { const updatePaintingState = (updates: Partial<PaintingAction>) => {
const updatedPainting = { ...painting, ...updates } const updatedPainting = { ...painting, providerId: newApiProvider.id, ...updates }
setPainting(updatedPainting) setPainting(updatedPainting)
updatePainting(mode, updatedPainting) updatePainting(mode, updatedPainting)
} }
@ -138,9 +132,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
return { return {
...DEFAULT_PAINTING, ...DEFAULT_PAINTING,
model: painting.model || modelOptions[0]?.value || '', model: painting.model || modelOptions[0]?.value || '',
id: uuid() id: uuid(),
providerId: newApiProvider.id
} }
}, [modelOptions, painting.model]) }, [modelOptions, painting.model, newApiProvider.id])
const selectedModelConfig = useMemo( const selectedModelConfig = useMemo(
() => MODELS.find((m) => m.name === painting.model) || MODELS[0], () => MODELS.find((m) => m.name === painting.model) || MODELS[0],
@ -444,11 +439,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
// 处理模式切换 // 处理模式切换
const handleModeChange = (value: string) => { const handleModeChange = (value: string) => {
setMode(value as keyof PaintingsState) setMode(value as keyof PaintingsState)
if (newApiPaintings[value as keyof PaintingsState] && newApiPaintings[value as keyof PaintingsState].length > 0) { const list = (newApiPaintings[value as keyof PaintingsState] || []).filter(
setPainting(newApiPaintings[value as keyof PaintingsState][0]) (p) => p.providerId === newApiProvider.id
} else { )
setPainting(DEFAULT_PAINTING) setPainting(list[0] || { ...DEFAULT_PAINTING, providerId: newApiProvider.id })
}
} }
// 渲染配置项的函数 // 渲染配置项的函数
@ -473,8 +467,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const newPainting = getNewPainting() const newPainting = getNewPainting()
addPainting(mode, newPainting) addPainting(mode, newPainting)
setPainting(newPainting) setPainting(newPainting)
} else {
setPainting(filteredPaintings[0])
} }
}, [filteredPaintings, mode, addPainting, painting, getNewPainting]) }, [filteredPaintings, mode, addPainting, getNewPainting])
useEffect(() => { useEffect(() => {
const timer = spaceClickTimer.current const timer = spaceClickTimer.current
@ -501,7 +497,9 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
<LeftContainer> <LeftContainer>
<ProviderTitleContainer> <ProviderTitleContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle> <SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<SettingHelpLink target="_blank" href={'https://docs.newapi.pro/apps/cherry-studio/'}> <SettingHelpLink
target="_blank"
href={PROVIDER_URLS[newApiProvider.id]?.websites?.docs || 'https://docs.newapi.pro/apps/cherry-studio/'}>
{t('paintings.learn_more')} {t('paintings.learn_more')}
<ProviderLogo <ProviderLogo
shape="square" shape="square"
@ -512,19 +510,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
</SettingHelpLink> </SettingHelpLink>
</ProviderTitleContainer> </ProviderTitleContainer>
<Select <ProviderSelect provider={newApiProvider} options={Options} onChange={handleProviderChange} />
value={providerOptions.find((p) => p.value === 'new-api')?.value}
onChange={handleProviderChange}
style={{ width: '100%' }}>
{providerOptions.map((provider) => (
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
{/* 当没有可用的 Image Generation 模型时,提示用户先去新增 */} {/* 当没有可用的 Image Generation 模型时,提示用户先去新增 */}
{modelOptions.length === 0 && ( {modelOptions.length === 0 && (
@ -792,20 +778,12 @@ const ProviderLogo = styled(Avatar)`
border: 0.5px solid var(--color-border); border: 0.5px solid var(--color-border);
` `
// 添加新的样式组件
const ModeSegmentedContainer = styled.div` const ModeSegmentedContainer = styled.div`
display: flex; display: flex;
justify-content: center; justify-content: center;
padding-top: 24px; padding-top: 24px;
` `
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
// 添加新的样式组件
const ProviderTitleContainer = styled.div` const ProviderTitleContainer = styled.div`
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@ -1,8 +1,10 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { isNewApiProvider } from '@renderer/config/providers'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setDefaultPaintingProvider } from '@renderer/store/settings' import { setDefaultPaintingProvider } from '@renderer/store/settings'
import { PaintingProvider } from '@renderer/types' import { PaintingProvider, SystemProviderId } from '@renderer/types'
import { FC, useEffect } from 'react' import { FC, useEffect, useMemo } from 'react'
import { Route, Routes, useParams } from 'react-router-dom' import { Route, Routes, useParams } from 'react-router-dom'
import AihubmixPage from './AihubmixPage' import AihubmixPage from './AihubmixPage'
@ -14,19 +16,23 @@ import ZhipuPage from './ZhipuPage'
const logger = loggerService.withContext('PaintingsRoutePage') const logger = loggerService.withContext('PaintingsRoutePage')
const Options = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux', 'new-api'] const BASE_OPTIONS: SystemProviderId[] = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux']
const PaintingsRoutePage: FC = () => { const PaintingsRoutePage: FC = () => {
const params = useParams() const params = useParams()
const provider = params['*'] const provider = params['*']
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const providers = useAllProviders()
const Options = useMemo(() => {
return [...BASE_OPTIONS, ...providers.filter((p) => isNewApiProvider(p)).map((p) => p.id)]
}, [providers])
useEffect(() => { useEffect(() => {
logger.debug(`defaultPaintingProvider: ${provider}`) logger.debug(`defaultPaintingProvider: ${provider}`)
if (provider && Options.includes(provider)) { if (provider && Options.includes(provider)) {
dispatch(setDefaultPaintingProvider(provider as PaintingProvider)) dispatch(setDefaultPaintingProvider(provider as PaintingProvider))
} }
}, [provider, dispatch]) }, [provider, dispatch, Options])
return ( return (
<Routes> <Routes>
@ -36,7 +42,12 @@ const PaintingsRoutePage: FC = () => {
<Route path="/silicon" element={<SiliconPage Options={Options} />} /> <Route path="/silicon" element={<SiliconPage Options={Options} />} />
<Route path="/dmxapi" element={<DmxapiPage Options={Options} />} /> <Route path="/dmxapi" element={<DmxapiPage Options={Options} />} />
<Route path="/tokenflux" element={<TokenFluxPage Options={Options} />} /> <Route path="/tokenflux" element={<TokenFluxPage Options={Options} />} />
<Route path="/new-api" element={<NewApiPage Options={Options} />} /> {/* new-api family providers are mounted dynamically below */}
{providers
.filter((p) => isNewApiProvider(p))
.map((p) => (
<Route key={p.id} path={`/${p.id}`} element={<NewApiPage Options={Options} />} />
))}
</Routes> </Routes>
) )
} }

View File

@ -12,14 +12,12 @@ import { HStack, VStack } from '@renderer/components/Layout'
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import TranslateButton from '@renderer/components/TranslateButton' import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import { getProviderLogo } from '@renderer/config/providers'
import { LanguagesEnum } from '@renderer/config/translate' import { LanguagesEnum } from '@renderer/config/translate'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings' import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider' import { useAllProviders } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime' import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { getProviderLabel } from '@renderer/i18n/label'
import { getProviderByModel } from '@renderer/services/AssistantService' import { getProviderByModel } from '@renderer/services/AssistantService'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService' import { translateText } from '@renderer/services/TranslateService'
@ -27,7 +25,7 @@ import { useAppDispatch } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime' import { setGenerating } from '@renderer/store/runtime'
import type { FileMetadata, Painting } from '@renderer/types' import type { FileMetadata, Painting } from '@renderer/types'
import { getErrorMessage, uuid } from '@renderer/utils' import { getErrorMessage, uuid } from '@renderer/utils'
import { Avatar, Button, Input, InputNumber, Radio, Select, Slider, Switch, Tooltip } from 'antd' import { Button, Input, InputNumber, Radio, Select, Slider, Switch, Tooltip } from 'antd'
import TextArea from 'antd/es/input/TextArea' import TextArea from 'antd/es/input/TextArea'
import { Info } from 'lucide-react' import { Info } from 'lucide-react'
import type { FC } from 'react' import type { FC } from 'react'
@ -40,6 +38,7 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingTitle } from '../settings' import { SettingTitle } from '../settings'
import Artboard from './components/Artboard' import Artboard from './components/Artboard'
import PaintingsList from './components/PaintingsList' import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { checkProviderEnabled } from './utils' import { checkProviderEnabled } from './utils'
export const TEXT_TO_IMAGES_MODELS = [ export const TEXT_TO_IMAGES_MODELS = [
@ -115,22 +114,8 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
const [painting, setPainting] = useState<Painting>(siliconflow_paintings[0] || DEFAULT_PAINTING) const [painting, setPainting] = useState<Painting>(siliconflow_paintings[0] || DEFAULT_PAINTING)
const { theme } = useTheme() const { theme } = useTheme()
const providers = useAllProviders() const providers = useAllProviders()
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
if (provider) {
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const siliconflowProvider = providers.find((p) => p.id === 'silicon') const siliconFlowProvider = providers.find((p) => p.id === 'silicon')!
const [currentImageIndex, setCurrentImageIndex] = useState(0) const [currentImageIndex, setCurrentImageIndex] = useState(0)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@ -170,7 +155,7 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
} }
const onGenerate = async () => { const onGenerate = async () => {
await checkProviderEnabled(siliconflowProvider!, t) await checkProviderEnabled(siliconFlowProvider!, t)
if (painting.files.length > 0) { if (painting.files.length > 0) {
const confirmed = await window.modal.confirm({ const confirmed = await window.modal.confirm({
@ -389,17 +374,8 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
<ContentContainer id="content-container"> <ContentContainer id="content-container">
<LeftContainer> <LeftContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle> <SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<Select value={providerOptions[2].value} onChange={handleProviderChange}> <ProviderSelect provider={siliconFlowProvider} options={Options} onChange={handleProviderChange} />
{providerOptions.map((provider) => ( <SettingTitle className="mt-4 mb-1">{t('common.model')}</SettingTitle>
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle>
<Select value={painting.model} options={modelOptions} onChange={onSelectModel} /> <Select value={painting.model} options={modelOptions} onChange={onSelectModel} />
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle> <SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle>
<Radio.Group <Radio.Group
@ -672,14 +648,4 @@ const StyledInputNumber = styled(InputNumber)`
width: 70px; width: 70px;
` `
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const ProviderLogo = styled(Avatar)`
flex-shrink: 0;
`
export default SiliconPage export default SiliconPage

View File

@ -10,7 +10,6 @@ import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider' import { useAllProviders } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime' import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService' import { translateText } from '@renderer/services/TranslateService'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
@ -31,6 +30,7 @@ import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard' import Artboard from './components/Artboard'
import { DynamicFormRender } from './components/DynamicFormRender' import { DynamicFormRender } from './components/DynamicFormRender'
import PaintingsList from './components/PaintingsList' import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { DEFAULT_TOKENFLUX_PAINTING, type TokenFluxModel } from './config/tokenFluxConfig' import { DEFAULT_TOKENFLUX_PAINTING, type TokenFluxModel } from './config/tokenFluxConfig'
import { checkProviderEnabled } from './utils' import { checkProviderEnabled } from './utils'
import TokenFluxService from './utils/TokenFluxService' import TokenFluxService from './utils/TokenFluxService'
@ -55,21 +55,6 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
tokenFluxPaintings[0] || { ...DEFAULT_TOKENFLUX_PAINTING, id: uuid() } tokenFluxPaintings[0] || { ...DEFAULT_TOKENFLUX_PAINTING, id: uuid() }
) )
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
if (provider) {
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { generating } = useRuntime() const { generating } = useRuntime()
const navigate = useNavigate() const navigate = useNavigate()
@ -387,19 +372,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
</SettingHelpLink> </SettingHelpLink>
</ProviderTitleContainer> </ProviderTitleContainer>
<Select <ProviderSelect provider={tokenfluxProvider} options={Options} onChange={handleProviderChange} />
value={providerOptions.find((p) => p.value === 'tokenflux')?.value}
onChange={handleProviderChange}
style={{ width: '100%' }}>
{providerOptions.map((provider) => (
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
{/* Model & Pricing Section */} {/* Model & Pricing Section */}
<SectionTitle <SectionTitle
@ -775,11 +748,4 @@ const ProviderTitleContainer = styled.div`
align-items: center; align-items: center;
margin-bottom: 5px; margin-bottom: 5px;
` `
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
export default TokenFluxPage export default TokenFluxPage

View File

@ -8,7 +8,6 @@ import { getProviderLogo } from '@renderer/config/providers'
import { usePaintings } from '@renderer/hooks/usePaintings' import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider' import { useAllProviders } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime' import { useRuntime } from '@renderer/hooks/useRuntime'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime' import { setGenerating } from '@renderer/store/runtime'
@ -24,6 +23,7 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings' import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard' import Artboard from './components/Artboard'
import PaintingsList from './components/PaintingsList' import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { import {
COURSE_URL, COURSE_URL,
DEFAULT_PAINTING, DEFAULT_PAINTING,
@ -50,21 +50,6 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [painting?.id]) // 只在painting的id改变时执行避免无限循环 }, [painting?.id]) // 只在painting的id改变时执行避免无限循环
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
if (provider) {
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const zhipuProvider = providers.find((p) => p.id === 'zhipu')! const zhipuProvider = providers.find((p) => p.id === 'zhipu')!
const [currentImageIndex, setCurrentImageIndex] = useState(0) const [currentImageIndex, setCurrentImageIndex] = useState(0)
@ -370,16 +355,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
/> />
</div> </div>
</ProviderTitleContainer> </ProviderTitleContainer>
<Select value={providerOptions[0].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}> <ProviderSelect provider={zhipuProvider} options={Options} onChange={handleProviderChange} className="mb-4" />
{providerOptions.map((provider) => (
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle> <SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle>
<Select <Select
@ -563,12 +539,6 @@ const ProviderTitleContainer = styled.div`
margin-bottom: 10px; margin-bottom: 10px;
` `
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const ProviderLogo = styled(Avatar)` const ProviderLogo = styled(Avatar)`
border-radius: 4px; border-radius: 4px;
` `

View File

@ -0,0 +1,99 @@
import { Select, SelectItem } from '@heroui/react'
import { ProviderAvatarPrimitive } from '@renderer/components/ProviderAvatar'
import { getProviderLogo } from '@renderer/config/providers'
import ImageStorage from '@renderer/services/ImageStorage'
import { getProviderNameById } from '@renderer/services/ProviderService'
import { Provider } from '@types'
import React, { FC, useEffect, useState } from 'react'
type ProviderSelectProps = {
provider: Provider
options: string[]
onChange: (value: string) => void
style?: React.CSSProperties
className?: string
}
const ProviderSelect: FC<ProviderSelectProps> = ({ provider, options, onChange, style, className }) => {
const [customLogos, setCustomLogos] = useState<Record<string, string>>({})
useEffect(() => {
const loadLogos = async () => {
const logos: Record<string, string> = {}
for (const providerId of options) {
try {
const logoData = await ImageStorage.get(`provider-${providerId}`)
if (logoData) {
logos[providerId] = logoData
}
} catch (error) {
// Ignore errors for providers without custom logos
}
}
setCustomLogos(logos)
}
loadLogos()
}, [options])
const getProviderLogoSrc = (providerId: string) => {
const systemLogo = getProviderLogo(providerId)
if (systemLogo) {
return systemLogo
}
return customLogos[providerId]
}
const providerOptions = options.map((option) => {
return {
label: getProviderNameById(option),
value: option
}
})
return (
<Select
selectedKeys={[provider.id]}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string
onChange(selectedKey)
}}
style={style}
className={`w-full ${className || ''}`}
renderValue={(items) => {
return items.map((item) => (
<div key={item.key} className="flex items-center gap-2">
<div className="flex h-4 w-4 items-center justify-center">
<ProviderAvatarPrimitive
providerId={item.key as string}
providerName={item.textValue || ''}
logoSrc={getProviderLogoSrc(item.key as string)}
size={16}
/>
</div>
<span>{item.textValue}</span>
</div>
))
}}>
{providerOptions.map((providerOption) => (
<SelectItem
key={providerOption.value}
textValue={providerOption.label}
startContent={
<div className="flex h-4 w-4 items-center justify-center">
<ProviderAvatarPrimitive
providerId={providerOption.value}
providerName={providerOption.label}
logoSrc={getProviderLogoSrc(providerOption.value)}
size={16}
/>
</div>
}>
{providerOption.label}
</SelectItem>
))}
</Select>
)
}
export default ProviderSelect

View File

@ -252,7 +252,8 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
{ label: 'OpenAI-Response', value: 'openai-response' }, { label: 'OpenAI-Response', value: 'openai-response' },
{ label: 'Gemini', value: 'gemini' }, { label: 'Gemini', value: 'gemini' },
{ label: 'Anthropic', value: 'anthropic' }, { label: 'Anthropic', value: 'anthropic' },
{ label: 'Azure OpenAI', value: 'azure-openai' } { label: 'Azure OpenAI', value: 'azure-openai' },
{ label: 'New API', value: 'new-api' }
]} ]}
/> />
</Form.Item> </Form.Item>

View File

@ -12,6 +12,15 @@ export function getProviderName(model?: Model) {
return getFancyProviderName(provider) return getFancyProviderName(provider)
} }
export function getProviderNameById(pid: string) {
const provider = getStoreProviders().find((p) => p.id === pid)
if (provider) {
return getFancyProviderName(provider)
} else {
return 'Unknown Provider'
}
}
export function getProviderByModel(model?: Model) { export function getProviderByModel(model?: Model) {
const id = model?.provider const id = model?.provider
const provider = getStoreProviders().find((p) => p.id === id) const provider = getStoreProviders().find((p) => p.id === id)

View File

@ -370,6 +370,7 @@ export type ProviderType =
| 'mistral' | 'mistral'
| 'aws-bedrock' | 'aws-bedrock'
| 'vertex-anthropic' | 'vertex-anthropic'
| 'new-api'
export type ModelType = 'text' | 'vision' | 'embedding' | 'reasoning' | 'function_calling' | 'web_search' | 'rerank' export type ModelType = 'text' | 'vision' | 'embedding' | 'reasoning' | 'function_calling' | 'web_search' | 'rerank'
@ -418,6 +419,8 @@ export type PaintingParams = {
id: string id: string
urls: string[] urls: string[]
files: FileMetadata[] files: FileMetadata[]
// provider that this painting belongs to (for new-api family separation)
providerId?: string
} }
export type PaintingProvider = 'zhipu' | 'aihubmix' | 'silicon' | 'dmxapi' | 'new-api' export type PaintingProvider = 'zhipu' | 'aihubmix' | 'silicon' | 'dmxapi' | 'new-api'