mirror of
https://github.com/langgenius/dify.git
synced 2026-05-20 08:46:57 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f392b6950 | |||
| b0a3399774 |
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.14.1"
|
||||
version = "1.14.2"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
|
||||
2
api/uv.lock
generated
2
api/uv.lock
generated
@ -1323,7 +1323,7 @@ docs = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.14.1"
|
||||
version = "1.14.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
@ -220,7 +220,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@ -264,7 +264,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@ -290,7 +290,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@ -333,7 +333,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@ -366,7 +366,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.14.1
|
||||
image: langgenius/dify-web:1.14.2
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
|
||||
@ -226,7 +226,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@ -270,7 +270,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@ -296,7 +296,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@ -339,7 +339,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.14.1
|
||||
image: langgenius/dify-api:1.14.2
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@ -372,7 +372,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.14.1
|
||||
image: langgenius/dify-web:1.14.2
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
|
||||
@ -236,8 +236,8 @@ describe('Explore App List Flow', () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderAppList(true, onSuccess)
|
||||
|
||||
@ -247,7 +247,9 @@ describe('Apps', () => {
|
||||
})
|
||||
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
source: 'studio_template_list',
|
||||
appMode: AppModeEnum.CHAT,
|
||||
templateId: 'Alpha',
|
||||
})
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
|
||||
@ -127,7 +127,7 @@ const Apps = ({
|
||||
icon_background,
|
||||
description,
|
||||
})
|
||||
trackCreateApp({ appMode: mode })
|
||||
trackCreateApp({ source: 'studio_template_list', appMode: mode, templateId: currApp?.app_id })
|
||||
|
||||
setIsShowCreateModal(false)
|
||||
toast.success(t('newApp.appCreated', { ns: 'app' }))
|
||||
|
||||
@ -170,7 +170,7 @@ describe('CreateAppModal', () => {
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
}))
|
||||
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.ADVANCED_CHAT })
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_blank', appMode: AppModeEnum.ADVANCED_CHAT })
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
|
||||
@ -79,7 +79,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
mode: appMode,
|
||||
})
|
||||
|
||||
trackCreateApp({ appMode: app.mode })
|
||||
trackCreateApp({ source: 'studio_blank', appMode: app.mode })
|
||||
|
||||
toast.success(t('newApp.appCreated', { ns: 'app' }))
|
||||
onSuccess()
|
||||
|
||||
@ -197,7 +197,7 @@ describe('CreateFromDSLModal', () => {
|
||||
mode: DSLImportMode.YAML_URL,
|
||||
yaml_url: 'https://example.com/app.yml',
|
||||
})
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.CHAT })
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload', appMode: AppModeEnum.CHAT })
|
||||
expect(handleSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
expect(localStorage.getItem(NEED_REFRESH_APP_LIST_KEY)).toBe('1')
|
||||
@ -304,7 +304,7 @@ describe('CreateFromDSLModal', () => {
|
||||
expect(mockImportDSLConfirm).toHaveBeenCalledWith({
|
||||
import_id: 'import-3',
|
||||
})
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.WORKFLOW })
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload', appMode: AppModeEnum.WORKFLOW })
|
||||
})
|
||||
|
||||
it('should close the DSL mismatch modal when dialog requests close', async () => {
|
||||
|
||||
@ -110,7 +110,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
return
|
||||
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
trackCreateApp({ appMode: app_mode })
|
||||
trackCreateApp({ source: 'studio_upload', appMode: app_mode })
|
||||
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
@ -171,7 +171,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
const { status, app_id, app_mode } = response
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
trackCreateApp({ appMode: app_mode })
|
||||
trackCreateApp({ source: 'studio_upload', appMode: app_mode })
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
if (onClose)
|
||||
|
||||
@ -262,8 +262,8 @@ describe('Apps', () => {
|
||||
})
|
||||
|
||||
it('should track template preview creation after a successful import', async () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderWithClient(<Apps />)
|
||||
@ -275,7 +275,9 @@ describe('Apps', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAppDetail).toHaveBeenCalledWith('template-1')
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
source: 'studio_template_preview',
|
||||
appMode: AppModeEnum.CHAT,
|
||||
templateId: 'template-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -284,8 +286,8 @@ describe('Apps', () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.WORKFLOW })
|
||||
})
|
||||
|
||||
renderWithClient(<Apps />)
|
||||
@ -299,7 +301,9 @@ describe('Apps', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
appMode: AppModeEnum.CHAT,
|
||||
source: 'studio_template_preview',
|
||||
appMode: AppModeEnum.WORKFLOW,
|
||||
templateId: 'template-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -365,8 +369,8 @@ describe('Apps', () => {
|
||||
})
|
||||
|
||||
it('should import DSL from marketplace template on confirm', async () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
mockSearchParams = new URLSearchParams('template-id=tpl-42')
|
||||
renderWithClient(<Apps />)
|
||||
@ -378,14 +382,22 @@ describe('Apps', () => {
|
||||
{ mode: 'yaml-content', yaml_content: 'yaml-dsl-content' },
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
)
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
source: 'external',
|
||||
appMode: AppModeEnum.CHAT,
|
||||
templateId: 'tpl-42',
|
||||
})
|
||||
expect(mockReplace).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show DSL confirm modal when marketplace import is pending', async () => {
|
||||
it('should track marketplace template creation after confirming a pending import', async () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.WORKFLOW })
|
||||
})
|
||||
mockSearchParams = new URLSearchParams('template-id=tpl-42')
|
||||
renderWithClient(<Apps />)
|
||||
|
||||
@ -395,6 +407,16 @@ describe('Apps', () => {
|
||||
expect(screen.getByTestId('dsl-confirm-modal')).toBeInTheDocument()
|
||||
expect(mockReplace).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-dsl'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
source: 'external',
|
||||
appMode: AppModeEnum.WORKFLOW,
|
||||
templateId: 'tpl-42',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { CreateAppModalProps } from '../explore/create-app-modal'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEducationInit } from '@/app/education-apply/hooks'
|
||||
@ -31,6 +32,7 @@ const Apps = () => {
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
|
||||
const currentCreateAppModeRef = useRef<TryAppSelection['app']['app']['mode'] | null>(null)
|
||||
const currentCreateAppTrackingRef = useRef<Pick<TrackCreateAppParams, 'source' | 'templateId'> | null>(null)
|
||||
const currApp = currentTryAppParams?.app
|
||||
const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false)
|
||||
const hideTryAppPanel = useCallback(() => {
|
||||
@ -46,13 +48,24 @@ const Apps = () => {
|
||||
const [isShowCreateModal, setIsShowCreateModal] = useState(false)
|
||||
|
||||
const handleShowFromTryApp = useCallback(() => {
|
||||
currentCreateAppTrackingRef.current = {
|
||||
source: 'studio_template_preview',
|
||||
templateId: currentTryAppParams?.appId || currentTryAppParams?.app.app_id,
|
||||
}
|
||||
setIsShowCreateModal(true)
|
||||
}, [])
|
||||
const trackCurrentCreateApp = useCallback(() => {
|
||||
if (!currentCreateAppModeRef.current)
|
||||
}, [currentTryAppParams?.app.app_id, currentTryAppParams?.appId])
|
||||
const trackCurrentCreateApp = useCallback((appMode?: TryAppSelection['app']['app']['mode'] | null) => {
|
||||
const currentCreateAppTracking = currentCreateAppTrackingRef.current
|
||||
const resolvedAppMode = appMode ?? currentCreateAppModeRef.current
|
||||
if (!resolvedAppMode || !currentCreateAppTracking)
|
||||
return
|
||||
|
||||
trackCreateApp({ appMode: currentCreateAppModeRef.current })
|
||||
trackCreateApp({
|
||||
...currentCreateAppTracking,
|
||||
appMode: resolvedAppMode,
|
||||
})
|
||||
currentCreateAppTrackingRef.current = null
|
||||
currentCreateAppModeRef.current = null
|
||||
}, [])
|
||||
|
||||
const [controlRefreshList, setControlRefreshList] = useState(0)
|
||||
@ -81,19 +94,25 @@ const Apps = () => {
|
||||
|
||||
const onConfirmDSL = useCallback(async () => {
|
||||
await handleImportDSLConfirm({
|
||||
onSuccess: () => {
|
||||
trackCurrentCreateApp()
|
||||
onSuccess: (response) => {
|
||||
trackCurrentCreateApp(response.app_mode)
|
||||
onSuccess()
|
||||
},
|
||||
})
|
||||
}, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp])
|
||||
|
||||
const handleMarketplaceTemplateConfirm = useCallback(async (dslContent: string) => {
|
||||
currentCreateAppModeRef.current = null
|
||||
currentCreateAppTrackingRef.current = {
|
||||
source: 'external',
|
||||
templateId: templateId || undefined,
|
||||
}
|
||||
await handleImportDSL({
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: dslContent,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
onSuccess: (response) => {
|
||||
trackCurrentCreateApp(response.app_mode)
|
||||
handleCloseTemplateModal()
|
||||
onSuccess()
|
||||
},
|
||||
@ -102,7 +121,7 @@ const Apps = () => {
|
||||
setShowDSLConfirmModal(true)
|
||||
},
|
||||
})
|
||||
}, [handleImportDSL, handleCloseTemplateModal, onSuccess])
|
||||
}, [handleImportDSL, handleCloseTemplateModal, onSuccess, templateId, trackCurrentCreateApp])
|
||||
|
||||
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
name,
|
||||
@ -127,8 +146,8 @@ const Apps = () => {
|
||||
description,
|
||||
}
|
||||
await handleImportDSL(payload, {
|
||||
onSuccess: () => {
|
||||
trackCurrentCreateApp()
|
||||
onSuccess: (response) => {
|
||||
trackCurrentCreateApp(response.app_mode)
|
||||
setIsShowCreateModal(false)
|
||||
},
|
||||
onPending: () => {
|
||||
|
||||
@ -239,8 +239,8 @@ describe('AppList', () => {
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderAppList(true, onSuccess)
|
||||
@ -257,7 +257,9 @@ describe('AppList', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
source: 'explore_template_list',
|
||||
appMode: AppModeEnum.CHAT,
|
||||
templateId: 'app-1',
|
||||
})
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@ -351,8 +353,8 @@ describe('AppList', () => {
|
||||
allList: [createApp()],
|
||||
};
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderAppList(true)
|
||||
@ -417,8 +419,8 @@ describe('AppList', () => {
|
||||
allList: [createApp()],
|
||||
};
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderAppList(true)
|
||||
@ -429,7 +431,9 @@ describe('AppList', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({
|
||||
source: 'explore_template_preview',
|
||||
appMode: AppModeEnum.CHAT,
|
||||
templateId: 'app-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
@ -107,6 +108,7 @@ const Apps = ({
|
||||
|
||||
const [currentTryApp, setCurrentTryApp] = useState<TryAppSelection | undefined>(undefined)
|
||||
const currentCreateAppModeRef = useRef<App['app']['mode'] | null>(null)
|
||||
const currentCreateAppTrackingRef = useRef<Pick<TrackCreateAppParams, 'source' | 'templateId'> | null>(null)
|
||||
const isShowTryAppPanel = !!currentTryApp
|
||||
const hideTryAppPanel = useCallback(() => {
|
||||
setCurrentTryApp(undefined)
|
||||
@ -116,13 +118,24 @@ const Apps = ({
|
||||
}, [])
|
||||
const handleShowFromTryApp = useCallback(() => {
|
||||
setCurrApp(currentTryApp?.app || null)
|
||||
currentCreateAppTrackingRef.current = {
|
||||
source: 'explore_template_preview',
|
||||
templateId: currentTryApp?.appId || currentTryApp?.app.app_id,
|
||||
}
|
||||
setIsShowCreateModal(true)
|
||||
}, [currentTryApp?.app])
|
||||
const trackCurrentCreateApp = useCallback(() => {
|
||||
if (!currentCreateAppModeRef.current)
|
||||
}, [currentTryApp?.app, currentTryApp?.appId])
|
||||
const trackCurrentCreateApp = useCallback((appMode?: App['app']['mode'] | null) => {
|
||||
const currentCreateAppTracking = currentCreateAppTrackingRef.current
|
||||
const resolvedAppMode = appMode ?? currentCreateAppModeRef.current
|
||||
if (!resolvedAppMode || !currentCreateAppTracking)
|
||||
return
|
||||
|
||||
trackCreateApp({ appMode: currentCreateAppModeRef.current })
|
||||
trackCreateApp({
|
||||
...currentCreateAppTracking,
|
||||
appMode: resolvedAppMode,
|
||||
})
|
||||
currentCreateAppTrackingRef.current = null
|
||||
currentCreateAppModeRef.current = null
|
||||
}, [])
|
||||
|
||||
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
@ -148,8 +161,8 @@ const Apps = ({
|
||||
description,
|
||||
}
|
||||
await handleImportDSL(payload, {
|
||||
onSuccess: () => {
|
||||
trackCurrentCreateApp()
|
||||
onSuccess: (response) => {
|
||||
trackCurrentCreateApp(response.app_mode)
|
||||
setIsShowCreateModal(false)
|
||||
},
|
||||
onPending: () => {
|
||||
@ -160,8 +173,8 @@ const Apps = ({
|
||||
|
||||
const onConfirmDSL = useCallback(async () => {
|
||||
await handleImportDSLConfirm({
|
||||
onSuccess: () => {
|
||||
trackCurrentCreateApp()
|
||||
onSuccess: (response) => {
|
||||
trackCurrentCreateApp(response.app_mode)
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
@ -242,6 +255,10 @@ const Apps = ({
|
||||
app={app}
|
||||
canCreate={hasEditPermission}
|
||||
onCreate={() => {
|
||||
currentCreateAppTrackingRef.current = {
|
||||
source: 'explore_template_list',
|
||||
templateId: app.app_id,
|
||||
}
|
||||
setCurrApp(app)
|
||||
setIsShowCreateModal(true)
|
||||
}}
|
||||
|
||||
@ -32,7 +32,7 @@ type DSLPayload = {
|
||||
description?: string
|
||||
}
|
||||
type ResponseCallback = {
|
||||
onSuccess?: () => void
|
||||
onSuccess?: (payload: DSLImportResponse) => void
|
||||
onPending?: (payload: DSLImportResponse) => void
|
||||
onFailed?: () => void
|
||||
}
|
||||
@ -85,7 +85,7 @@ export const useImportDSL = () => {
|
||||
toast.success(message)
|
||||
else
|
||||
toast.warning(message, { description })
|
||||
onSuccess?.()
|
||||
onSuccess?.(response)
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
await handleCheckPluginDependencies(app_id)
|
||||
getRedirection(isCurrentWorkspaceEditor, { id: app_id, mode: app_mode }, push)
|
||||
@ -134,7 +134,7 @@ export const useImportDSL = () => {
|
||||
return
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
onSuccess?.()
|
||||
onSuccess?.(response)
|
||||
toast.success(t('newApp.appCreated', { ns: 'app' }))
|
||||
await handleCheckPluginDependencies(app_id)
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"type": "module",
|
||||
"version": "1.14.1",
|
||||
"version": "1.14.2",
|
||||
"private": true,
|
||||
"imports": {
|
||||
"#i18n": {
|
||||
|
||||
@ -55,11 +55,12 @@ describe('create-app-tracking', () => {
|
||||
})
|
||||
|
||||
describe('buildCreateAppEventPayload', () => {
|
||||
it('should build original payloads with normalized app mode and timestamp', () => {
|
||||
it('should build payloads with source, normalized app mode, and timestamp', () => {
|
||||
expect(buildCreateAppEventPayload({
|
||||
source: 'studio_blank',
|
||||
appMode: AppModeEnum.ADVANCED_CHAT,
|
||||
}, null, new Date(2026, 3, 13, 14, 5, 9))).toEqual({
|
||||
source: 'original',
|
||||
source: 'studio_blank',
|
||||
app_mode: 'chatflow',
|
||||
time: '04-13-14:05:09',
|
||||
})
|
||||
@ -67,9 +68,10 @@ describe('create-app-tracking', () => {
|
||||
|
||||
it('should map agent mode into the canonical app mode bucket', () => {
|
||||
expect(buildCreateAppEventPayload({
|
||||
source: 'studio_blank',
|
||||
appMode: AppModeEnum.AGENT_CHAT,
|
||||
}, null, new Date(2026, 3, 13, 9, 8, 7))).toEqual({
|
||||
source: 'original',
|
||||
source: 'studio_blank',
|
||||
app_mode: 'agent',
|
||||
time: '04-13-09:08:07',
|
||||
})
|
||||
@ -77,17 +79,19 @@ describe('create-app-tracking', () => {
|
||||
|
||||
it('should fold legacy non-agent modes into chatflow', () => {
|
||||
expect(buildCreateAppEventPayload({
|
||||
source: 'studio_blank',
|
||||
appMode: AppModeEnum.CHAT,
|
||||
}, null, new Date(2026, 3, 13, 8, 0, 1))).toEqual({
|
||||
source: 'original',
|
||||
source: 'studio_blank',
|
||||
app_mode: 'chatflow',
|
||||
time: '04-13-08:00:01',
|
||||
})
|
||||
|
||||
expect(buildCreateAppEventPayload({
|
||||
source: 'studio_blank',
|
||||
appMode: AppModeEnum.COMPLETION,
|
||||
}, null, new Date(2026, 3, 13, 8, 0, 2))).toEqual({
|
||||
source: 'original',
|
||||
source: 'studio_blank',
|
||||
app_mode: 'chatflow',
|
||||
time: '04-13-08:00:02',
|
||||
})
|
||||
@ -95,29 +99,56 @@ describe('create-app-tracking', () => {
|
||||
|
||||
it('should map workflow mode into the workflow bucket', () => {
|
||||
expect(buildCreateAppEventPayload({
|
||||
source: 'studio_blank',
|
||||
appMode: AppModeEnum.WORKFLOW,
|
||||
}, null, new Date(2026, 3, 13, 7, 6, 5))).toEqual({
|
||||
source: 'original',
|
||||
source: 'studio_blank',
|
||||
app_mode: 'workflow',
|
||||
time: '04-13-07:06:05',
|
||||
})
|
||||
})
|
||||
|
||||
it('should include template_id for template sources', () => {
|
||||
expect(buildCreateAppEventPayload({
|
||||
source: 'studio_template_list',
|
||||
appMode: AppModeEnum.CHAT,
|
||||
templateId: 'template-1',
|
||||
}, null, new Date(2026, 3, 13, 8, 0, 1))).toEqual({
|
||||
source: 'studio_template_list',
|
||||
app_mode: 'chatflow',
|
||||
time: '04-13-08:00:01',
|
||||
template_id: 'template-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should prefer external attribution when present', () => {
|
||||
expect(buildCreateAppEventPayload(
|
||||
{
|
||||
source: 'studio_template_list',
|
||||
appMode: AppModeEnum.WORKFLOW,
|
||||
templateId: 'template-1',
|
||||
},
|
||||
{
|
||||
utmSource: 'linkedin',
|
||||
utmCampaign: 'agent-launch',
|
||||
},
|
||||
new Date(2026, 3, 13, 7, 6, 5),
|
||||
)).toEqual({
|
||||
source: 'external',
|
||||
app_mode: 'workflow',
|
||||
time: '04-13-07:06:05',
|
||||
template_id: 'template-1',
|
||||
utm_source: 'linkedin',
|
||||
utm_campaign: 'agent-launch',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not build external payloads without attribution', () => {
|
||||
expect(buildCreateAppEventPayload({
|
||||
source: 'external',
|
||||
appMode: AppModeEnum.WORKFLOW,
|
||||
}, null, new Date(2026, 3, 13, 7, 6, 5))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackCreateApp', () => {
|
||||
@ -126,20 +157,24 @@ describe('create-app-tracking', () => {
|
||||
searchParams: new URLSearchParams('utm_source=newsletter&slug=how-to-build-rag-agent'),
|
||||
})
|
||||
|
||||
trackCreateApp({ appMode: AppModeEnum.WORKFLOW })
|
||||
trackCreateApp({ source: 'studio_template_list', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' })
|
||||
|
||||
expect(amplitude.trackEvent).toHaveBeenNthCalledWith(1, 'create_app', {
|
||||
source: 'external',
|
||||
app_mode: 'workflow',
|
||||
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
|
||||
template_id: 'template-1',
|
||||
utm_source: 'blog',
|
||||
utm_campaign: 'how-to-build-rag-agent',
|
||||
})
|
||||
|
||||
trackCreateApp({ appMode: AppModeEnum.WORKFLOW })
|
||||
trackCreateApp({ source: 'studio_template_list', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' })
|
||||
|
||||
expect(amplitude.trackEvent).toHaveBeenNthCalledWith(2, 'create_app', {
|
||||
source: 'original',
|
||||
source: 'studio_template_list',
|
||||
app_mode: 'workflow',
|
||||
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
|
||||
template_id: 'template-1',
|
||||
})
|
||||
})
|
||||
|
||||
@ -152,16 +187,19 @@ describe('create-app-tracking', () => {
|
||||
|
||||
window.history.replaceState({}, '', '/explore')
|
||||
|
||||
trackCreateApp({ appMode: AppModeEnum.CHAT })
|
||||
trackCreateApp({ source: 'explore_template_preview', appMode: AppModeEnum.CHAT, templateId: 'template-2' })
|
||||
|
||||
expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', {
|
||||
source: 'external',
|
||||
app_mode: 'chatflow',
|
||||
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
|
||||
template_id: 'template-2',
|
||||
utm_source: 'linkedin',
|
||||
utm_campaign: 'agent-launch',
|
||||
})
|
||||
})
|
||||
|
||||
it('should fall back to the original payload when window is unavailable', () => {
|
||||
it('should fall back to the provided source when window is unavailable', () => {
|
||||
const originalWindow = globalThis.window
|
||||
|
||||
try {
|
||||
@ -170,10 +208,10 @@ describe('create-app-tracking', () => {
|
||||
value: undefined,
|
||||
})
|
||||
|
||||
trackCreateApp({ appMode: AppModeEnum.AGENT_CHAT })
|
||||
trackCreateApp({ source: 'studio_blank', appMode: AppModeEnum.AGENT_CHAT })
|
||||
|
||||
expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', {
|
||||
source: 'original',
|
||||
source: 'studio_blank',
|
||||
app_mode: 'agent',
|
||||
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
|
||||
})
|
||||
@ -185,5 +223,29 @@ describe('create-app-tracking', () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should read, normalize, and consume snake_case sessionStorage attribution', () => {
|
||||
window.sessionStorage.setItem('create_app_external_attribution', JSON.stringify({
|
||||
utm_source: 'twitter',
|
||||
utm_campaign: 'launch-week',
|
||||
}))
|
||||
|
||||
trackCreateApp({ source: 'studio_blank', appMode: AppModeEnum.CHAT })
|
||||
|
||||
expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', {
|
||||
source: 'external',
|
||||
app_mode: 'chatflow',
|
||||
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
|
||||
utm_source: 'twitter/x',
|
||||
utm_campaign: 'launch-week',
|
||||
})
|
||||
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull()
|
||||
})
|
||||
|
||||
it('should not track external source without remembered attribution', () => {
|
||||
trackCreateApp({ source: 'external', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' })
|
||||
|
||||
expect(amplitude.trackEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,12 +6,13 @@ const CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY = 'create_app_external_attribu
|
||||
const CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS = ['utm_source', 'utm_campaign', 'slug'] as const
|
||||
|
||||
const EXTERNAL_UTM_SOURCE_MAP = {
|
||||
blog: 'blog',
|
||||
dify_blog: 'blog',
|
||||
linkedin: 'linkedin',
|
||||
newsletter: 'blog',
|
||||
twitter: 'twitter/x',
|
||||
x: 'twitter/x',
|
||||
'blog': 'blog',
|
||||
'dify_blog': 'blog',
|
||||
'linkedin': 'linkedin',
|
||||
'newsletter': 'blog',
|
||||
'twitter': 'twitter/x',
|
||||
'twitter/x': 'twitter/x',
|
||||
'x': 'twitter/x',
|
||||
} as const
|
||||
|
||||
type SearchParamReader = {
|
||||
@ -20,8 +21,19 @@ type SearchParamReader = {
|
||||
|
||||
type OriginalCreateAppMode = 'workflow' | 'chatflow' | 'agent'
|
||||
|
||||
type TrackCreateAppParams = {
|
||||
type CreateAppSource
|
||||
= | 'external'
|
||||
| 'explore_template_list'
|
||||
| 'explore_template_preview'
|
||||
| 'studio_blank'
|
||||
| 'studio_template_list'
|
||||
| 'studio_template_preview'
|
||||
| 'studio_upload'
|
||||
|
||||
export type TrackCreateAppParams = {
|
||||
source: CreateAppSource
|
||||
appMode: AppModeEnum
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
type ExternalCreateAppAttribution = {
|
||||
@ -173,7 +185,20 @@ export const extractExternalCreateAppAttribution = ({
|
||||
}
|
||||
|
||||
const readRememberedExternalCreateAppAttribution = (): ExternalCreateAppAttribution | null => {
|
||||
return parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)) as ExternalCreateAppAttribution | null
|
||||
const attribution = parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY))
|
||||
const utmSource = mapExternalUtmSource(
|
||||
getObjectStringValue(attribution?.utmSource) ?? getObjectStringValue(attribution?.utm_source),
|
||||
)
|
||||
|
||||
if (!utmSource)
|
||||
return null
|
||||
|
||||
const utmCampaign = getObjectStringValue(attribution?.utmCampaign) ?? getObjectStringValue(attribution?.utm_campaign)
|
||||
|
||||
return {
|
||||
utmSource,
|
||||
...(utmCampaign ? { utmCampaign } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const writeRememberedExternalCreateAppAttribution = (attribution: ExternalCreateAppAttribution) => {
|
||||
@ -214,18 +239,22 @@ export const buildCreateAppEventPayload = (
|
||||
externalAttribution?: ExternalCreateAppAttribution | null,
|
||||
currentTime = new Date(),
|
||||
) => {
|
||||
if (externalAttribution) {
|
||||
return {
|
||||
source: 'external',
|
||||
utm_source: externalAttribution.utmSource,
|
||||
...(externalAttribution.utmCampaign ? { utm_campaign: externalAttribution.utmCampaign } : {}),
|
||||
} satisfies Record<string, string>
|
||||
}
|
||||
const source = externalAttribution ? 'external' : params.source
|
||||
|
||||
if (source === 'external' && !externalAttribution)
|
||||
return null
|
||||
|
||||
return {
|
||||
source: 'original',
|
||||
source,
|
||||
app_mode: mapOriginalCreateAppMode(params.appMode),
|
||||
time: formatCreateAppTime(currentTime),
|
||||
...(params.templateId ? { template_id: params.templateId } : {}),
|
||||
...(externalAttribution
|
||||
? {
|
||||
utm_source: externalAttribution.utmSource,
|
||||
...(externalAttribution.utmCampaign ? { utm_campaign: externalAttribution.utmCampaign } : {}),
|
||||
}
|
||||
: {}),
|
||||
} satisfies Record<string, string>
|
||||
}
|
||||
|
||||
@ -233,6 +262,9 @@ export const trackCreateApp = (params: TrackCreateAppParams) => {
|
||||
const externalAttribution = resolveCurrentExternalCreateAppAttribution()
|
||||
const payload = buildCreateAppEventPayload(params, externalAttribution)
|
||||
|
||||
if (!payload)
|
||||
return
|
||||
|
||||
if (externalAttribution)
|
||||
clearRememberedExternalCreateAppAttribution()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user