mirror of
https://github.com/langgenius/dify.git
synced 2026-04-20 02:37:20 +08:00
fix(web): harden try-app requirements icon resolution
This commit is contained in:
@ -1,5 +1,7 @@
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppInfo from '../index'
|
||||
|
||||
@ -9,6 +11,21 @@ vi.mock('../use-get-requirements', () => ({
|
||||
default: (...args: unknown[]) => mockUseGetRequirements(...args),
|
||||
}))
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({
|
||||
src,
|
||||
alt,
|
||||
unoptimized: _unoptimized,
|
||||
...rest
|
||||
}: {
|
||||
src: string
|
||||
alt: string
|
||||
unoptimized?: boolean
|
||||
} & ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
React.createElement('img', { src, alt, ...rest })
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({
|
||||
id: 'test-app-id',
|
||||
name: 'Test App Name',
|
||||
@ -312,7 +329,7 @@ describe('AppInfo', () => {
|
||||
expect(screen.queryByText('explore.tryApp.requirements')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders requirement icons with correct background image', () => {
|
||||
it('renders requirement icons with correct image src', () => {
|
||||
mockUseGetRequirements.mockReturnValue({
|
||||
requirements: [
|
||||
{ name: 'Test Tool', iconUrl: 'https://example.com/test-icon.png' },
|
||||
@ -330,9 +347,36 @@ describe('AppInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const iconElement = container.querySelector('[style*="background-image"]')
|
||||
const iconElement = container.querySelector('img[src="https://example.com/test-icon.png"]')
|
||||
expect(iconElement).toBeInTheDocument()
|
||||
expect(iconElement).toHaveStyle({ backgroundImage: 'url(https://example.com/test-icon.png)' })
|
||||
})
|
||||
|
||||
it('falls back to default icon when requirement image fails to load', () => {
|
||||
mockUseGetRequirements.mockReturnValue({
|
||||
requirements: [
|
||||
{ name: 'Broken Tool', iconUrl: 'https://example.com/broken-icon.png' },
|
||||
],
|
||||
})
|
||||
|
||||
const appDetail = createMockAppDetail('chat')
|
||||
const mockOnCreate = vi.fn()
|
||||
|
||||
render(
|
||||
<AppInfo
|
||||
appId="test-app-id"
|
||||
appDetail={appDetail}
|
||||
onCreate={mockOnCreate}
|
||||
/>,
|
||||
)
|
||||
|
||||
const requirementRow = screen.getByText('Broken Tool').parentElement as HTMLElement
|
||||
const iconImage = requirementRow.querySelector('img') as HTMLImageElement
|
||||
expect(iconImage).toBeInTheDocument()
|
||||
|
||||
fireEvent.error(iconImage)
|
||||
|
||||
expect(requirementRow.querySelector('img')).not.toBeInTheDocument()
|
||||
expect(requirementRow.querySelector('.i-custom-public-other-default-tool-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -400,6 +400,61 @@ describe('useGetRequirements', () => {
|
||||
|
||||
expect(result.current.requirements[0].iconUrl).toBe('https://marketplace.api/plugins/org/plugin/icon')
|
||||
})
|
||||
|
||||
it('maps google model provider to gemini plugin icon URL', () => {
|
||||
mockUseGetTryAppFlowPreview.mockReturnValue({ data: null })
|
||||
|
||||
const appDetail = createMockAppDetail('chat', {
|
||||
model_config: {
|
||||
model: {
|
||||
provider: 'langgenius/google/google',
|
||||
name: 'gemini-2.0',
|
||||
mode: 'chat',
|
||||
},
|
||||
dataset_configs: { datasets: { datasets: [] } },
|
||||
agent_mode: { tools: [] },
|
||||
user_input_form: [],
|
||||
},
|
||||
} as unknown as Partial<TryAppInfo>)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGetRequirements({ appDetail, appId: 'test-app-id' }),
|
||||
)
|
||||
|
||||
expect(result.current.requirements[0].iconUrl).toBe('https://marketplace.api/plugins/langgenius/gemini/icon')
|
||||
})
|
||||
|
||||
it('maps special builtin tool providers to *_tool plugin icon URL', () => {
|
||||
mockUseGetTryAppFlowPreview.mockReturnValue({ data: null })
|
||||
|
||||
const appDetail = createMockAppDetail('agent-chat', {
|
||||
model_config: {
|
||||
model: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
name: 'gpt-4',
|
||||
mode: 'chat',
|
||||
},
|
||||
dataset_configs: { datasets: { datasets: [] } },
|
||||
agent_mode: {
|
||||
tools: [
|
||||
{
|
||||
enabled: true,
|
||||
provider_id: 'langgenius/jina/jina',
|
||||
tool_label: 'Jina Search',
|
||||
},
|
||||
],
|
||||
},
|
||||
user_input_form: [],
|
||||
},
|
||||
} as unknown as Partial<TryAppInfo>)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGetRequirements({ appDetail, appId: 'test-app-id' }),
|
||||
)
|
||||
|
||||
const toolRequirement = result.current.requirements.find(item => item.name === 'Jina Search')
|
||||
expect(toolRequirement?.iconUrl).toBe('https://marketplace.api/plugins/langgenius/jina_tool/icon')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hook calls', () => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AppTypeIcon } from '@/app/components/app/type-selector'
|
||||
@ -19,6 +19,37 @@ type Props = {
|
||||
}
|
||||
|
||||
const headerClassName = 'system-sm-semibold-uppercase text-text-secondary mb-3'
|
||||
const requirementIconSize = 20
|
||||
|
||||
type RequirementIconProps = {
|
||||
iconUrl: string
|
||||
}
|
||||
|
||||
const RequirementIcon: FC<RequirementIconProps> = ({ iconUrl }) => {
|
||||
const [failedSource, setFailedSource] = React.useState<string | null>(null)
|
||||
const hasLoadError = !iconUrl || failedSource === iconUrl
|
||||
|
||||
if (hasLoadError) {
|
||||
return (
|
||||
<div className="flex size-5 items-center justify-center overflow-hidden rounded-[6px] border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
|
||||
<div className="i-custom-public-other-default-tool-icon size-3 text-text-tertiary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
className="size-5 rounded-md object-cover shadow-xs"
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={requirementIconSize}
|
||||
height={requirementIconSize}
|
||||
unoptimized
|
||||
onError={() => setFailedSource(iconUrl)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AppInfo: FC<Props> = ({
|
||||
appId,
|
||||
@ -62,17 +93,17 @@ const AppInfo: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
{appDetail.description && (
|
||||
<div className="system-sm-regular mt-[14px] shrink-0 text-text-secondary">{appDetail.description}</div>
|
||||
<div className="mt-[14px] shrink-0 text-text-secondary system-sm-regular">{appDetail.description}</div>
|
||||
)}
|
||||
<Button variant="primary" className="mt-3 flex w-full max-w-full" onClick={onCreate}>
|
||||
<RiAddLine className="mr-1 size-4 shrink-0" />
|
||||
<span className="i-ri-add-line mr-1 size-4 shrink-0" />
|
||||
<span className="truncate">{t('tryApp.createFromSampleApp', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
|
||||
{category && (
|
||||
<div className="mt-6 shrink-0">
|
||||
<div className={headerClassName}>{t('tryApp.category', { ns: 'explore' })}</div>
|
||||
<div className="system-md-regular text-text-secondary">{category}</div>
|
||||
<div className="text-text-secondary system-md-regular">{category}</div>
|
||||
</div>
|
||||
)}
|
||||
{requirements.length > 0 && (
|
||||
@ -81,8 +112,8 @@ const AppInfo: FC<Props> = ({
|
||||
<div className="space-y-0.5">
|
||||
{requirements.map(item => (
|
||||
<div className="flex items-center space-x-2 py-1" key={item.name}>
|
||||
<div className="size-5 rounded-md bg-cover shadow-xs" style={{ backgroundImage: `url(${item.iconUrl})` }} />
|
||||
<div className="system-md-regular w-0 grow truncate text-text-secondary">{item.name}</div>
|
||||
<RequirementIcon iconUrl={item.iconUrl} />
|
||||
<div className="w-0 grow truncate text-text-secondary system-md-regular">{item.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -16,8 +16,56 @@ type RequirementItem = {
|
||||
name: string
|
||||
iconUrl: string
|
||||
}
|
||||
const getIconUrl = (provider: string, tool: string) => {
|
||||
return `${MARKETPLACE_API_PREFIX}/plugins/${provider}/${tool}/icon`
|
||||
|
||||
type ProviderType = 'model' | 'tool'
|
||||
|
||||
type ProviderInfo = {
|
||||
organization: string
|
||||
providerName: string
|
||||
}
|
||||
|
||||
const PROVIDER_PLUGIN_ALIASES: Record<ProviderType, Record<string, string>> = {
|
||||
model: {
|
||||
google: 'gemini',
|
||||
},
|
||||
tool: {
|
||||
stepfun: 'stepfun_tool',
|
||||
jina: 'jina_tool',
|
||||
siliconflow: 'siliconflow_tool',
|
||||
gitee_ai: 'gitee_ai_tool',
|
||||
},
|
||||
}
|
||||
|
||||
const parseProviderId = (providerId: string): ProviderInfo | null => {
|
||||
const segments = providerId.split('/').filter(Boolean)
|
||||
if (!segments.length)
|
||||
return null
|
||||
|
||||
if (segments.length === 1) {
|
||||
return {
|
||||
organization: 'langgenius',
|
||||
providerName: segments[0],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
organization: segments[0],
|
||||
providerName: segments[1],
|
||||
}
|
||||
}
|
||||
|
||||
const getPluginName = (providerName: string, type: ProviderType) => {
|
||||
return PROVIDER_PLUGIN_ALIASES[type][providerName] || providerName
|
||||
}
|
||||
|
||||
const getIconUrl = (providerId: string, type: ProviderType) => {
|
||||
const parsed = parseProviderId(providerId)
|
||||
if (!parsed)
|
||||
return ''
|
||||
|
||||
const organization = encodeURIComponent(parsed.organization)
|
||||
const pluginName = encodeURIComponent(getPluginName(parsed.providerName, type))
|
||||
return `${MARKETPLACE_API_PREFIX}/plugins/${organization}/${pluginName}/icon`
|
||||
}
|
||||
|
||||
const useGetRequirements = ({ appDetail, appId }: Params) => {
|
||||
@ -28,20 +76,19 @@ const useGetRequirements = ({ appDetail, appId }: Params) => {
|
||||
|
||||
const requirements: RequirementItem[] = []
|
||||
if (isBasic) {
|
||||
const modelProviderAndName = appDetail.model_config.model.provider.split('/')
|
||||
const modelProvider = appDetail.model_config.model.provider
|
||||
const name = appDetail.model_config.model.provider.split('/').pop() || ''
|
||||
requirements.push({
|
||||
name,
|
||||
iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]),
|
||||
iconUrl: getIconUrl(modelProvider, 'model'),
|
||||
})
|
||||
}
|
||||
if (isAgent) {
|
||||
requirements.push(...appDetail.model_config.agent_mode.tools.filter(data => (data as AgentTool).enabled).map((data) => {
|
||||
const tool = data as AgentTool
|
||||
const modelProviderAndName = tool.provider_id.split('/')
|
||||
return {
|
||||
name: tool.tool_label,
|
||||
iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]),
|
||||
iconUrl: getIconUrl(tool.provider_id, 'tool'),
|
||||
}
|
||||
}))
|
||||
}
|
||||
@ -50,20 +97,18 @@ const useGetRequirements = ({ appDetail, appId }: Params) => {
|
||||
const llmNodes = nodes.filter(node => node.data.type === BlockEnum.LLM)
|
||||
requirements.push(...llmNodes.map((node) => {
|
||||
const data = node.data as LLMNodeType
|
||||
const modelProviderAndName = data.model.provider.split('/')
|
||||
return {
|
||||
name: data.model.name,
|
||||
iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]),
|
||||
iconUrl: getIconUrl(data.model.provider, 'model'),
|
||||
}
|
||||
}))
|
||||
|
||||
const toolNodes = nodes.filter(node => node.data.type === BlockEnum.Tool)
|
||||
requirements.push(...toolNodes.map((node) => {
|
||||
const data = node.data as ToolNodeType
|
||||
const toolProviderAndName = data.provider_id.split('/')
|
||||
return {
|
||||
name: data.tool_label,
|
||||
iconUrl: getIconUrl(toolProviderAndName[0], toolProviderAndName[1]),
|
||||
iconUrl: getIconUrl(data.provider_id, 'tool'),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@ -4088,11 +4088,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/explore/try-app/app-info/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/explore/try-app/app/chat.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
|
||||
Reference in New Issue
Block a user