mirror of
https://github.com/langgenius/dify.git
synced 2026-03-06 07:35:14 +00:00
feat: add supports for "Open in Dify" from template details page in m… (#32852)
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import type { MarketplaceTemplate } from '@/service/marketplace-templates'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { MARKETPLACE_API_PREFIX, MARKETPLACE_URL_PREFIX } from '@/config'
|
||||
import {
|
||||
fetchMarketplaceTemplateDSL,
|
||||
useMarketplaceTemplateDetail,
|
||||
} from '@/service/marketplace-templates'
|
||||
|
||||
type ImportFromMarketplaceTemplateModalProps = {
|
||||
templateId: string
|
||||
onConfirm: (yamlContent: string, template: MarketplaceTemplate) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ImportFromMarketplaceTemplateModal = ({
|
||||
templateId,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ImportFromMarketplaceTemplateModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
|
||||
const { data, isLoading, isError } = useMarketplaceTemplateDetail(templateId)
|
||||
const template = data?.data ?? null
|
||||
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!template || isImporting)
|
||||
return
|
||||
setIsImporting(true)
|
||||
try {
|
||||
const yamlContent = await fetchMarketplaceTemplateDSL(templateId)
|
||||
onConfirm(yamlContent, template)
|
||||
}
|
||||
catch {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('marketplace.template.importFailed', { ns: 'app' }),
|
||||
})
|
||||
setIsImporting(false)
|
||||
}
|
||||
}, [template, templateId, isImporting, onConfirm, notify, t])
|
||||
|
||||
const templateUrl = MARKETPLACE_URL_PREFIX
|
||||
? `${MARKETPLACE_URL_PREFIX}/templates/${encodeURIComponent(templateId)}`
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="w-[520px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl"
|
||||
isShow
|
||||
onClose={onClose}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between pb-3 pl-6 pr-5 pt-6">
|
||||
<div className="text-text-primary title-2xl-semi-bold">
|
||||
{t('marketplace.template.modalTitle', { ns: 'app' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span className="i-ri-close-line h-[18px] w-[18px] text-text-tertiary" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-4">
|
||||
{isLoading && (
|
||||
<div className="flex h-[200px] items-center justify-center">
|
||||
<span className="i-ri-loader-2-line h-6 w-6 animate-spin text-text-tertiary" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && !isLoading && (
|
||||
<div className="flex h-[200px] flex-col items-center justify-center gap-2">
|
||||
<div className="text-text-tertiary system-md-regular">
|
||||
{t('marketplace.template.fetchFailed', { ns: 'app' })}
|
||||
</div>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('newApp.Cancel', { ns: 'app' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{template && !isLoading && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Template info */}
|
||||
<div className="flex items-start gap-3 rounded-xl bg-background-section-burn p-4">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={template.icon_file_key ? 'image' : 'emoji'}
|
||||
icon={template.icon || '🤖'}
|
||||
background={template.icon_background || '#FFEAD5'}
|
||||
imageUrl={template.icon_file_key
|
||||
? `${MARKETPLACE_API_PREFIX}/templates/${encodeURIComponent(templateId)}/icon`
|
||||
: undefined}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="truncate text-text-primary system-md-semibold">
|
||||
{template.template_name}
|
||||
</div>
|
||||
<div className="text-text-tertiary system-xs-regular">
|
||||
{t('marketplace.template.publishedBy', { ns: 'app', publisher: template.publisher_unique_handle })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
{template.overview && (
|
||||
<div>
|
||||
<div className="mb-1 text-text-secondary system-sm-semibold">
|
||||
{t('marketplace.template.overview', { ns: 'app' })}
|
||||
</div>
|
||||
<div className="line-clamp-4 text-text-tertiary system-sm-regular">
|
||||
{template.overview}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage count */}
|
||||
{template.usage_count !== null && template.usage_count > 0 && (
|
||||
<div className="text-text-quaternary system-xs-regular">
|
||||
{t('marketplace.template.usageCount', { ns: 'app', count: template.usage_count })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Marketplace link */}
|
||||
{templateUrl && (
|
||||
<a
|
||||
href={templateUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-text-accent system-xs-regular"
|
||||
>
|
||||
{t('marketplace.template.viewOnMarketplace', { ns: 'app' })}
|
||||
<span className="i-ri-external-link-line h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{template && !isLoading && (
|
||||
<div className="flex items-center justify-end gap-3 border-t border-divider-subtle px-6 py-4">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('newApp.Cancel', { ns: 'app' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={isImporting}
|
||||
>
|
||||
{isImporting && <span className="i-ri-loader-2-line mr-1 h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{t('marketplace.template.importConfirm', { ns: 'app' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImportFromMarketplaceTemplateModal
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client'
|
||||
import type { CreateAppModalProps } from '../explore/create-app-modal'
|
||||
import type { CurrentTryAppParams } from '@/context/explore-context'
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { MarketplaceTemplate } from '@/service/marketplace-templates'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEducationInit } from '@/app/education-apply/hooks'
|
||||
import AppListContext from '@/context/app-list-context'
|
||||
@@ -14,8 +17,15 @@ import CreateAppModal from '../explore/create-app-modal'
|
||||
import TryApp from '../explore/try-app'
|
||||
import List from './list'
|
||||
|
||||
const ImportFromMarketplaceTemplateModal = dynamic(
|
||||
() => import('./import-from-marketplace-template-modal'),
|
||||
{ ssr: false },
|
||||
)
|
||||
|
||||
const Apps = () => {
|
||||
const { t } = useTranslation()
|
||||
const searchParams = useSearchParams()
|
||||
const { replace } = useRouter()
|
||||
|
||||
useDocumentTitle(t('menus.apps', { ns: 'common' }))
|
||||
useEducationInit()
|
||||
@@ -92,6 +102,43 @@ const Apps = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Marketplace template import via URL param
|
||||
const marketplaceTemplateId = searchParams.get('template-id') || undefined
|
||||
const dismissedTemplateIdRef = useRef<string | undefined>(undefined)
|
||||
const showMarketplaceModal = !!marketplaceTemplateId && dismissedTemplateIdRef.current !== marketplaceTemplateId
|
||||
|
||||
const handleCloseMarketplaceModal = useCallback(() => {
|
||||
dismissedTemplateIdRef.current = marketplaceTemplateId
|
||||
// Remove template-id from URL without full navigation
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('template-id')
|
||||
const newQuery = params.toString()
|
||||
replace(newQuery ? `/apps?${newQuery}` : '/apps')
|
||||
}, [searchParams, replace, marketplaceTemplateId])
|
||||
|
||||
const handleMarketplaceTemplateConfirm = useCallback(async (
|
||||
yamlContent: string,
|
||||
template: MarketplaceTemplate,
|
||||
) => {
|
||||
const payload = {
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: yamlContent,
|
||||
name: template.template_name,
|
||||
icon: template.icon || undefined,
|
||||
icon_background: template.icon_background || undefined,
|
||||
}
|
||||
await handleImportDSL(payload, {
|
||||
onSuccess: () => {
|
||||
handleCloseMarketplaceModal()
|
||||
onSuccess()
|
||||
},
|
||||
onPending: () => {
|
||||
handleCloseMarketplaceModal()
|
||||
setShowDSLConfirmModal(true)
|
||||
},
|
||||
})
|
||||
}, [handleImportDSL, onSuccess, handleCloseMarketplaceModal])
|
||||
|
||||
return (
|
||||
<AppListContext.Provider value={{
|
||||
currentApp: currentTryAppParams,
|
||||
@@ -137,6 +184,14 @@ const Apps = () => {
|
||||
onHide={() => setIsShowCreateModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showMarketplaceModal && marketplaceTemplateId && (
|
||||
<ImportFromMarketplaceTemplateModal
|
||||
templateId={marketplaceTemplateId}
|
||||
onConfirm={handleMarketplaceTemplateConfirm}
|
||||
onClose={handleCloseMarketplaceModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AppListContext.Provider>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, PluginsSearchParams } from '@/app/components/plugins/marketplace/types'
|
||||
import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
|
||||
import type { MarketplaceTemplate } from '@/service/marketplace-templates'
|
||||
import { type } from '@orpc/contract'
|
||||
import { base } from './base'
|
||||
|
||||
@@ -54,3 +55,15 @@ export const searchAdvancedContract = base
|
||||
body: Omit<PluginsSearchParams, 'type'>
|
||||
}>())
|
||||
.output(type<{ data: PluginsFromMarketplaceResponse }>())
|
||||
|
||||
export const templateDetailContract = base
|
||||
.route({
|
||||
path: '/templates/{templateId}',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(type<{
|
||||
params: {
|
||||
templateId: string
|
||||
}
|
||||
}>())
|
||||
.output(type<{ data: MarketplaceTemplate }>())
|
||||
|
||||
@@ -53,12 +53,13 @@ import {
|
||||
workflowDraftUpdateFeaturesContract,
|
||||
} from './console/workflow'
|
||||
import { workflowCommentContracts } from './console/workflow-comment'
|
||||
import { collectionPluginsContract, collectionsContract, searchAdvancedContract } from './marketplace'
|
||||
import { collectionPluginsContract, collectionsContract, searchAdvancedContract, templateDetailContract } from './marketplace'
|
||||
|
||||
export const marketplaceRouterContract = {
|
||||
collections: collectionsContract,
|
||||
collectionPlugins: collectionPluginsContract,
|
||||
searchAdvanced: searchAdvancedContract,
|
||||
templateDetail: templateDetailContract,
|
||||
}
|
||||
|
||||
export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract>
|
||||
|
||||
@@ -123,6 +123,15 @@
|
||||
"importFromDSLUrl": "From URL",
|
||||
"importFromDSLUrlPlaceholder": "Paste DSL link here",
|
||||
"join": "Join the community",
|
||||
"marketplace.template.categories": "Categories",
|
||||
"marketplace.template.fetchFailed": "Failed to load template information",
|
||||
"marketplace.template.importConfirm": "Import Template",
|
||||
"marketplace.template.importFailed": "Failed to import template",
|
||||
"marketplace.template.modalTitle": "Import from Marketplace",
|
||||
"marketplace.template.overview": "Overview",
|
||||
"marketplace.template.publishedBy": "By {{publisher}}",
|
||||
"marketplace.template.usageCount": "Used {{count}} times",
|
||||
"marketplace.template.viewOnMarketplace": "View on Marketplace",
|
||||
"maxActiveRequests": "Max concurrent requests",
|
||||
"maxActiveRequestsPlaceholder": "Enter 0 for unlimited",
|
||||
"maxActiveRequestsTip": "Maximum number of concurrent active requests per app (0 for unlimited)",
|
||||
|
||||
@@ -123,6 +123,15 @@
|
||||
"importFromDSLUrl": "URL",
|
||||
"importFromDSLUrlPlaceholder": "输入 DSL 文件的 URL",
|
||||
"join": "参与社区",
|
||||
"marketplace.template.categories": "分类",
|
||||
"marketplace.template.fetchFailed": "获取模板信息失败",
|
||||
"marketplace.template.importConfirm": "导入模板",
|
||||
"marketplace.template.importFailed": "导入模板失败",
|
||||
"marketplace.template.modalTitle": "从模板市场导入",
|
||||
"marketplace.template.overview": "概览",
|
||||
"marketplace.template.publishedBy": "发布者:{{publisher}}",
|
||||
"marketplace.template.usageCount": "已使用 {{count}} 次",
|
||||
"marketplace.template.viewOnMarketplace": "在模板市场中查看",
|
||||
"maxActiveRequests": "最大活跃请求数",
|
||||
"maxActiveRequestsPlaceholder": "0 表示不限制",
|
||||
"maxActiveRequestsTip": "当前应用的最大活跃请求数(0 表示不限制)",
|
||||
|
||||
48
web/service/marketplace-templates.ts
Normal file
48
web/service/marketplace-templates.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import { marketplaceClient, marketplaceQuery } from '@/service/client'
|
||||
|
||||
export type MarketplaceTemplate = {
|
||||
id: string
|
||||
publisher_type: 'individual' | 'organization'
|
||||
publisher_unique_handle: string
|
||||
template_name: string
|
||||
icon: string
|
||||
icon_background: string
|
||||
icon_file_key: string
|
||||
kind: 'classic' | 'sandboxed'
|
||||
categories: string[]
|
||||
deps_plugins: string[]
|
||||
preferred_languages: string[]
|
||||
overview: string
|
||||
readme: string
|
||||
partner_link: string
|
||||
version: string
|
||||
status: string
|
||||
usage_count: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const useMarketplaceTemplateDetail = (templateId: string) => {
|
||||
return useQuery({
|
||||
queryKey: marketplaceQuery.templateDetail.queryKey({
|
||||
input: { params: { templateId } },
|
||||
}),
|
||||
queryFn: () => marketplaceClient.templateDetail({ params: { templateId } }),
|
||||
enabled: !!templateId,
|
||||
})
|
||||
}
|
||||
|
||||
export const fetchMarketplaceTemplateDSL = async (
|
||||
templateId: string,
|
||||
): Promise<string> => {
|
||||
const res = await fetch(
|
||||
`${MARKETPLACE_API_PREFIX}/templates/${encodeURIComponent(templateId)}/dsl`,
|
||||
{ credentials: 'omit' },
|
||||
)
|
||||
if (!res.ok)
|
||||
throw new Error(`Failed to fetch template DSL: ${res.status}`)
|
||||
|
||||
return await res.text()
|
||||
}
|
||||
Reference in New Issue
Block a user