feat: add amptitude to new agent (#39608)

Co-authored-by: 林玮 (Jade Lin) <linw1995@icloud.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Joel 2026-07-27 13:54:45 +08:00 committed by GitHub
parent 28d174603f
commit 52428df1bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 170 additions and 4 deletions

View File

@ -17,6 +17,12 @@ import { isValidGeneratedAnswer } from '../../utils'
import ChatWrapper from '../chat-wrapper'
import { useChatWithHistoryContext } from '../context'
const mockTrackEvent = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: mockTrackEvent,
}))
vi.mock('../../chat/hooks', () => ({
useChat: vi.fn(),
}))
@ -75,6 +81,7 @@ vi.mock('@/hooks/use-timestamp', () => ({
type ChatHookReturn = ReturnType<typeof useChat>
const mockAppData = {
mode: 'advanced-chat',
site: {
title: 'Test Chat',
chat_color_theme: 'blue',
@ -745,7 +752,34 @@ describe('ChatWrapper', () => {
expect(fetchChatList).toHaveBeenCalledWith('conversation-1', 'webApp', 'test-app-id')
})
it('should not fetch current conversation messages for non-new-agent chat', async () => {
it('should track the start action when a new agent web app sends a message', async () => {
const handleSend = vi.fn()
vi.mocked(useChat).mockReturnValue({
...defaultChatHookReturn,
handleSend,
chatList: [
{ id: '1', isOpeningStatement: true, content: 'Welcome', suggestedQuestions: ['Q1'] },
],
suggestedQuestions: ['Q1'],
} as unknown as ChatHookReturn)
vi.mocked(useChatWithHistoryContext).mockReturnValue({
...defaultContextValue,
currentConversationId: '',
isInstalledApp: false,
isNewAgent: true,
})
render(<ChatWrapper />)
fireEvent.click(await screen.findByText('Q1'))
expect(handleSend).toHaveBeenCalled()
expect(mockTrackEvent).toHaveBeenCalledWith('webapp_run', {
app_mode: 'agent-v2',
})
})
it('should track the site response mode without fetching messages for a regular web app', async () => {
const handleSend = vi.fn()
vi.mocked(useChat).mockReturnValue({
...defaultChatHookReturn,
@ -768,6 +802,9 @@ describe('ChatWrapper', () => {
const options = handleSend.mock.calls[0]![2]
expect(options.onGetConversationMessages).toBeUndefined()
expect(mockTrackEvent).toHaveBeenCalledWith('webapp_run', {
app_mode: 'advanced-chat',
})
})
it('should call fetchSuggestedQuestions in doSwitchSibling', async () => {

View File

@ -6,6 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import AnswerIcon from '@/app/components/base/answer-icon'
import AppIcon from '@/app/components/base/app-icon'
import InputsForm from '@/app/components/base/chat/chat-with-history/inputs-form'
@ -221,6 +222,9 @@ const ChatWrapper = () => {
onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted,
isPublicAPI: appSourceType === AppSourceType.webApp,
})
const appMode = isNewAgent ? 'agent-v2' : appData?.mode
if (appSourceType === AppSourceType.webApp && appMode)
trackEvent('webapp_run', { app_mode: appMode })
},
[
inputsForms,
@ -234,6 +238,7 @@ const ChatWrapper = () => {
isHistoryConversation,
handleNewConversationCompleted,
isNewAgent,
appData?.mode,
],
)

View File

@ -11,6 +11,12 @@ import { useChat } from '../../chat/hooks'
import ChatWrapper from '../chat-wrapper'
import { useEmbeddedChatbotContext } from '../context'
const mockTrackEvent = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: mockTrackEvent,
}))
vi.mock('../context', () => ({
useEmbeddedChatbotContext: vi.fn(),
}))
@ -132,6 +138,7 @@ const createContextValue = (
appMeta: { tool_icons: {} },
appData: {
app_id: 'app-1',
mode: 'chat',
can_replace_logo: true,
custom_config: {
remove_webapp_brand: false,
@ -533,6 +540,7 @@ describe('EmbeddedChatbot chat-wrapper', () => {
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resp-2', AppSourceType.tryApp, 'app-1')
expect(handleStop).toHaveBeenCalled()
expect(screen.queryByRole('img', { name: 'Alice' })).not.toBeInTheDocument()
expect(mockTrackEvent).not.toHaveBeenCalled()
cleanup()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(
@ -739,6 +747,9 @@ describe('EmbeddedChatbot chat-wrapper', () => {
fireEvent.click(screen.getByRole('button', { name: 'send through chat' }))
expect(handleSend).toHaveBeenCalled()
expect(mockTrackEvent).toHaveBeenCalledWith('webapp_run', {
app_mode: 'chat',
})
const options = handleSend.mock.calls[0]?.[2] as {
onConversationComplete?: (id: string) => void
}

View File

@ -6,6 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import AnswerIcon from '@/app/components/base/answer-icon'
import AppIcon from '@/app/components/base/app-icon'
import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions'
@ -207,6 +208,8 @@ const ChatWrapper = () => {
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: appSourceType === AppSourceType.webApp,
})
if (appSourceType === AppSourceType.webApp && appData?.mode)
trackEvent('webapp_run', { app_mode: appData.mode })
},
[
currentConversationId,
@ -217,6 +220,7 @@ const ChatWrapper = () => {
appSourceType,
appId,
handleNewConversationCompleted,
appData?.mode,
],
)

View File

@ -12,6 +12,8 @@ import { useResultSender } from '../use-result-sender'
const {
buildResultRequestDataMock,
createWorkflowStreamHandlersMock,
mockTrackEvent,
mockWebAppState,
sendCompletionMessageMock,
sendWorkflowMessageMock,
sleepMock,
@ -19,12 +21,27 @@ const {
} = vi.hoisted(() => ({
buildResultRequestDataMock: vi.fn(),
createWorkflowStreamHandlersMock: vi.fn(),
mockTrackEvent: vi.fn(),
mockWebAppState: {
appInfo: {
mode: 'completion',
},
},
sendCompletionMessageMock: vi.fn(),
sendWorkflowMessageMock: vi.fn(),
sleepMock: vi.fn(),
validateResultRequestMock: vi.fn(),
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: mockTrackEvent,
}))
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) =>
selector(mockWebAppState),
}))
vi.mock('@/service/share', async () => {
const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share')
return {
@ -226,6 +243,7 @@ const renderSender = ({
describe('useResultSender', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWebAppState.appInfo.mode = 'completion'
validateResultRequestMock.mockReturnValue({ canSend: true })
buildResultRequestDataMock.mockReturnValue({ inputs: { name: 'Alice' } })
createWorkflowStreamHandlersMock.mockReturnValue({ onWorkflowFinished: vi.fn() })
@ -273,6 +291,7 @@ describe('useResultSender', () => {
})
expect(buildResultRequestDataMock).not.toHaveBeenCalled()
expect(sendCompletionMessageMock).not.toHaveBeenCalled()
expect(mockTrackEvent).not.toHaveBeenCalled()
})
it('should send completion requests when controlSend changes and process callbacks', async () => {
@ -307,6 +326,9 @@ describe('useResultSender', () => {
expect(harness.runState.clearMoreLikeThis).toHaveBeenCalledTimes(1)
expect(onShowRes).toHaveBeenCalledTimes(1)
expect(onRunStart).toHaveBeenCalledTimes(1)
expect(mockTrackEvent).toHaveBeenCalledWith('webapp_run', {
app_mode: 'completion',
})
expect(sendCompletionMessageMock).toHaveBeenCalledWith(
{ inputs: { name: 'Alice' } },
expect.objectContaining({
@ -346,6 +368,7 @@ describe('useResultSender', () => {
it('should trigger workflow sends on retry and report workflow request failures', async () => {
const harness = createRunStateHarness()
mockWebAppState.appInfo.mode = 'workflow'
sendWorkflowMessageMock.mockRejectedValue(new Error('workflow failed'))
const { rerender, notify } = renderSender({
@ -385,6 +408,9 @@ describe('useResultSender', () => {
})
})
expect(harness.runState.clearMoreLikeThis).not.toHaveBeenCalled()
expect(mockTrackEvent).toHaveBeenCalledWith('webapp_run', {
app_mode: 'workflow',
})
})
it('should configure workflow handlers for installed apps as non-public', async () => {
@ -411,6 +437,7 @@ describe('useResultSender', () => {
AppSourceTypeEnum.installedApp,
'app-1',
)
expect(mockTrackEvent).not.toHaveBeenCalled()
})
it('should stringify non-Error workflow failures', async () => {

View File

@ -4,7 +4,9 @@ import type { ResultRunStateController } from './use-result-run-state'
import type { PromptConfig } from '@/models/debug'
import type { VisionFile, VisionSettings } from '@/types/app'
import { useCallback, useEffect, useRef } from 'react'
import { trackEvent } from '@/app/components/base/amplitude'
import { TEXT_GENERATION_TIMEOUT_MS } from '@/config'
import { useWebAppStore } from '@/context/web-app-context'
import { AppSourceType, sendCompletionMessage, sendWorkflowMessage } from '@/service/share'
import { sleep } from '@/utils'
import { buildResultRequestData, validateResultRequest } from '../result-request'
@ -58,6 +60,7 @@ export const useResultSender = ({
visionConfig,
}: UseResultSenderOptions) => {
const { clearMoreLikeThis } = runState
const appMode = useWebAppStore((state) => state.appInfo?.mode)
const handleSend = useCallback(async () => {
if (runState.isResponding) {
@ -96,6 +99,9 @@ export const useResultSender = ({
runState.setRespondingTrue()
if (appSourceType === AppSourceType.webApp && appMode)
trackEvent('webapp_run', { app_mode: appMode })
let isEnd = false
let isTimeout = false
let completionChunks: string[] = []
@ -196,6 +202,7 @@ export const useResultSender = ({
return true
}, [
appId,
appMode,
appSourceType,
completionFiles,
inputs,

View File

@ -68,6 +68,8 @@ const toastMock = vi.hoisted(() => ({
success: vi.fn(),
}))
const trackEventMock = vi.hoisted(() => vi.fn())
const modelHooksState = vi.hoisted(() => ({
defaultTextGenerationModel: {
provider: {
@ -194,6 +196,10 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMock,
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: trackEventMock,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
systemFeatures: {
@ -1252,6 +1258,7 @@ describe('AgentConfigurePage', () => {
'prompt:edited draft prompt',
)
expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled()
expect(trackEventMock).not.toHaveBeenCalled()
})
it('should stay in Preview when resetting the Build conversation fails', async () => {
@ -1374,6 +1381,7 @@ describe('AgentConfigurePage', () => {
expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent(
'draftType:draft',
)
expect(trackEventMock).not.toHaveBeenCalled()
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent(
'readonly:no',
)
@ -1904,7 +1912,7 @@ describe('AgentConfigurePage', () => {
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
})
it('should not checkout again when sending build chat from active build draft mode', async () => {
it('should track the run without checking out again in active build draft mode', async () => {
const queryClient = new QueryClient()
mocks.queryState.composer = {
data: {
@ -1955,6 +1963,7 @@ describe('AgentConfigurePage', () => {
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes')
})
expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled()
expect(trackEventMock).toHaveBeenCalledWith('agent_build_mode_run')
})
it('should show the working directory action after the first build reply completes', async () => {

View File

@ -18,6 +18,8 @@ const toastMock = vi.hoisted(() => ({
success: vi.fn(),
}))
const trackEventMock = vi.hoisted(() => vi.fn())
const composerPutMutationFn = vi.hoisted(() =>
vi.fn(
async (variables: {
@ -119,6 +121,10 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMock,
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: trackEventMock,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
agent: {
@ -159,9 +165,11 @@ vi.mock('@/service/client', () => ({
}))
function renderUseAgentConfigureSync({
agentName = 'Agent',
baseConfig,
currentModel,
}: {
agentName?: Parameters<typeof useAgentConfigureSync>[0]['agentName']
baseConfig?: Parameters<typeof useAgentConfigureSync>[0]['baseConfig']
currentModel?: Parameters<typeof useAgentConfigureSync>[0]['currentModel']
} = {}) {
@ -183,6 +191,7 @@ function renderUseAgentConfigureSync({
() =>
useAgentConfigureSync({
agentId: 'agent-1',
agentName,
baseConfig,
currentModel,
enabled: true,
@ -762,6 +771,12 @@ describe('useAgentConfigureSync', () => {
active_config_is_published: true,
name: 'Agent',
})
expect(trackEventMock).toHaveBeenCalledWith('app_published_time', {
action_mode: 'app',
app_id: 'agent-1',
app_name: 'Agent',
app_mode: 'agent-v2',
})
expect(toastMock.success).toHaveBeenCalledWith('common.api.actionSuccess')
})
@ -781,6 +796,7 @@ describe('useAgentConfigureSync', () => {
expect(composerPutMutationFn).not.toHaveBeenCalled()
expect(publishAgentMutationFn).not.toHaveBeenCalled()
expect(trackEventMock).not.toHaveBeenCalled()
expect(toastMock.error).toHaveBeenCalledWith('common.modelProvider.selectModel')
})

View File

@ -14,6 +14,7 @@ import { useAtomValue, useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import Loading from '@/app/components/base/loading'
import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions'
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
@ -328,6 +329,7 @@ function AgentConfigurePageComposerContent({
useAgentConfigureModelOptions()
const { draftSavedAt, isPublishing, publishDraft, saveDraft } = useAgentConfigureSync({
agentId,
agentName: agentQuery.data?.name,
baseConfig: agentSoulConfig,
currentModel,
enabled: composerQuery.isSuccess && !selectedVersionId && !buildDraft.isActive,
@ -564,11 +566,13 @@ function AgentConfigurePageComposerContent({
throw new Error('Agent model is required.')
}
return runBuildPreparation({
const preparedBuildDraft = await runBuildPreparation({
generation: buildCallbackGeneration,
markBuildChatStarted: true,
prepare: buildDraftActions.prepareBuildDraftBeforeRun,
})
trackEvent('agent_build_mode_run')
return preparedBuildDraft
}
: saveDraft
}

View File

@ -10,6 +10,7 @@ import isEqual from 'fast-deep-equal'
import { useSetAtom, useStore } from 'jotai'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback'
import { formStateToAgentSoulConfig } from '@/features/agent-v2/agent-composer/conversions'
import {
@ -29,11 +30,13 @@ const DRAFT_AUTOSAVE_WAIT = 5000
export function useAgentConfigureSync({
agentId,
agentName,
baseConfig,
currentModel,
enabled,
}: {
agentId: string
agentName?: string | null
baseConfig?: AgentSoulConfig
currentModel?: DefaultModel
enabled: boolean
@ -303,6 +306,12 @@ export function useAgentConfigureSync({
const publishedDraft = draft
setOriginalDraft(publishedDraft)
setPublishedDraft(publishedDraft)
trackEvent('app_published_time', {
action_mode: 'app',
app_id: agentId,
app_name: agentName,
app_mode: 'agent-v2',
})
toast.success(tCommon(($) => $['api.actionSuccess']))
} finally {
publishInFlightRef.current = false
@ -310,6 +319,7 @@ export function useAgentConfigureSync({
}
}, [
agentId,
agentName,
debouncedSaveDraft,
getKnowledgeValidationMessage,
publishAgent,

View File

@ -14,6 +14,8 @@ const toastMock = vi.hoisted(() => ({
const routerPushMock = vi.hoisted(() => vi.fn())
const trackCreateAppMock = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-query', () => ({
useMutation: () => ({
isPending: mutationMock.isPending,
@ -31,6 +33,10 @@ vi.mock('@/next/navigation', () => ({
}),
}))
vi.mock('@/utils/create-app-tracking', () => ({
trackCreateApp: trackCreateAppMock,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
agent: {
@ -106,6 +112,10 @@ describe('CreateAgentDialog', () => {
})
expect(toastMock.success).toHaveBeenCalledWith('agentV2.roster.createSuccess')
expect(trackCreateAppMock).toHaveBeenCalledWith({
source: 'studio_blank',
appMode: 'agent-v2',
})
expect(routerPushMock).toHaveBeenCalledWith('/agents/agent-1/configure')
})

View File

@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { trackCreateApp } from '@/utils/create-app-tracking'
import { getAgentDetailPath } from '../../agent-detail/routes'
import { defaultAgentIcon } from './agent-form'
import { AgentFormFields } from './agent-form-fields'
@ -76,6 +77,10 @@ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps
},
{
onSuccess: (createdAgent) => {
trackCreateApp({
source: 'studio_blank',
appMode: 'agent-v2',
})
toast.success(t(($) => $['roster.createSuccess']))
handleOpenChange(false)
router.push(getAgentDetailPath(createdAgent.id, 'configure'))

View File

@ -1,3 +1,4 @@
import type { AppMode } from '@dify/contracts/api/web/types.gen'
import type { Locale } from '@/i18n-config'
import type { AppIconType } from '@/types/app'
@ -36,6 +37,7 @@ export type AppMeta = {
export type CustomConfigValueType = string | number | boolean | null | undefined
export type AppData = {
app_id: string
mode?: AppMode
can_replace_logo?: boolean
custom_config: Record<string, CustomConfigValueType> | null
enable_site?: boolean

View File

@ -141,6 +141,23 @@ describe('create-app-tracking', () => {
})
})
it('should preserve agent v2 mode as its own app mode', () => {
expect(
buildCreateAppEventPayload(
{
source: 'studio_blank',
appMode: 'agent-v2',
},
null,
new Date(2026, 3, 13, 9, 8, 9),
),
).toEqual({
source: 'studio_blank',
app_mode: 'agent-v2',
time: '04-13-09:08:09',
})
})
it('should fold legacy non-agent modes into chatflow', () => {
expect(
buildCreateAppEventPayload(

View File

@ -18,7 +18,7 @@ type SearchParamReader = {
get: (name: string) => string | null
}
type OriginalCreateAppMode = 'workflow' | 'chatflow' | 'agent'
type OriginalCreateAppMode = 'workflow' | 'chatflow' | 'agent' | 'agent-v2'
type CreateAppSource =
| 'external'
@ -78,6 +78,8 @@ const formatCreateAppTime = (date: Date) => {
const mapOriginalCreateAppMode = (appMode: string): OriginalCreateAppMode => {
if (appMode === AppModeEnum.WORKFLOW) return 'workflow'
if (appMode === 'agent-v2') return 'agent-v2'
if (appMode === AppModeEnum.AGENT_CHAT || appMode === 'agent') return 'agent'
return 'chatflow'