Compare commits

...
29 changed files with 278 additions and 75 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ jobs:
uses: actions/setup-node@v6
if: steps.changed-files.outputs.any_changed == 'true'
with:
node-version: 22
node-version: 24
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
+2 -6
View File
@@ -16,10 +16,6 @@ jobs:
name: unit test for Node.js SDK
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16, 18, 20, 22]
defaults:
run:
working-directory: sdks/nodejs-client
@@ -29,10 +25,10 @@ jobs:
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
node-version: 24
cache: ''
cache-dependency-path: 'pnpm-lock.yaml'
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version: 24
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
+3 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime
from typing import TypeAlias
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -20,8 +21,8 @@ class SimpleFeedback(ResponseModel):
class RetrieverResource(ResponseModel):
id: str
message_id: str
id: str = Field(default_factory=lambda: str(uuid4()))
message_id: str = Field(default_factory=lambda: str(uuid4()))
position: int
dataset_id: str | None = None
dataset_name: str | None = None
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.11.2"
version = "1.11.4"
requires-python = ">=3.11,<3.13"
dependencies = [
Generated
+1 -1
View File
@@ -1368,7 +1368,7 @@ wheels = [
[[package]]
name = "dify-api"
version = "1.11.2"
version = "1.11.4"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },
+4 -4
View File
@@ -21,7 +21,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.11.2
image: langgenius/dify-api:1.11.4
restart: always
environment:
# Use the shared environment variables.
@@ -63,7 +63,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.11.2
image: langgenius/dify-api:1.11.4
restart: always
environment:
# Use the shared environment variables.
@@ -102,7 +102,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.11.2
image: langgenius/dify-api:1.11.4
restart: always
environment:
# Use the shared environment variables.
@@ -132,7 +132,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.11.2
image: langgenius/dify-web:1.11.4
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
+4 -4
View File
@@ -704,7 +704,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.11.2
image: langgenius/dify-api:1.11.4
restart: always
environment:
# Use the shared environment variables.
@@ -746,7 +746,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.11.2
image: langgenius/dify-api:1.11.4
restart: always
environment:
# Use the shared environment variables.
@@ -785,7 +785,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.11.2
image: langgenius/dify-api:1.11.4
restart: always
environment:
# Use the shared environment variables.
@@ -815,7 +815,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.11.2
image: langgenius/dify-web:1.11.4
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
+1 -1
View File
@@ -1 +1 @@
22.21.1
24
+1 -1
View File
@@ -1,5 +1,5 @@
# base image
FROM node:22.21.1-alpine3.23 AS base
FROM node:24-alpine AS base
LABEL maintainer="takatost@gmail.com"
# if you located in China, you can use aliyun mirror to speed up
+2 -2
View File
@@ -8,8 +8,8 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next
Before starting the web frontend service, please make sure the following environment is ready.
- [Node.js](https://nodejs.org) >= v22.11.x
- [pnpm](https://pnpm.io) v10.x
- [Node.js](https://nodejs.org)
- [pnpm](https://pnpm.io)
> [!TIP]
> It is recommended to install and enable Corepack to manage package manager versions automatically:
@@ -53,6 +53,7 @@ vi.mock('@/context/global-public-context', () => {
)
return {
useGlobalPublicStore,
useIsSystemFeaturesPending: () => false,
}
})
@@ -66,7 +66,9 @@ export default function CheckCode() {
setIsLoading(true)
const ret = await webAppEmailLoginWithCode({ email, code: encryptVerificationCode(code), token })
if (ret.result === 'success') {
setWebAppAccessToken(ret.data.access_token)
if (ret?.data?.access_token) {
setWebAppAccessToken(ret.data.access_token)
}
const { access_token } = await fetchAccessToken({
appCode: appCode!,
userId: embeddedUserId || undefined,
@@ -82,7 +82,9 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
body: loginData,
})
if (res.result === 'success') {
setWebAppAccessToken(res.data.access_token)
if (res?.data?.access_token) {
setWebAppAccessToken(res.data.access_token)
}
const { access_token } = await fetchAccessToken({
appCode: appCode!,
+3 -10
View File
@@ -9,8 +9,8 @@ import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { fetchSetupStatus } from '@/service/common'
import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
import { trackEvent } from './base/amplitude'
@@ -33,15 +33,8 @@ export const AppInitializer = ({
const isSetupFinished = useCallback(async () => {
try {
if (localStorage.getItem('setup_status') === 'finished')
return true
const setUpStatus = await fetchSetupStatus()
if (setUpStatus.step !== 'finished') {
localStorage.removeItem('setup_status')
return false
}
localStorage.setItem('setup_status', 'finished')
return true
const setUpStatus = await fetchSetupStatusWithCache()
return setUpStatus.step === 'finished'
}
catch (error) {
console.error(error)
@@ -125,7 +125,6 @@ const resetAccessControlStore = () => {
const resetGlobalStore = () => {
useGlobalPublicStore.setState({
systemFeatures: defaultSystemFeatures,
isGlobalPending: false,
})
}
@@ -362,6 +362,18 @@ describe('PreviewDocumentPicker', () => {
expect(screen.getByText('--')).toBeInTheDocument()
})
it('should render when value prop is omitted (optional)', () => {
const files = createMockDocumentList(2)
const onChange = vi.fn()
// Do not pass `value` at all to verify optional behavior
render(<PreviewDocumentPicker files={files} onChange={onChange} />)
// Renders placeholder for missing name
expect(screen.getByText('--')).toBeInTheDocument()
// Portal wrapper renders
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should handle empty files array', () => {
renderComponent({ files: [] })
@@ -18,7 +18,7 @@ import DocumentList from './document-list'
type Props = {
className?: string
value: DocumentItem
value?: DocumentItem
files: DocumentItem[]
onChange: (value: DocumentItem) => void
}
@@ -30,7 +30,8 @@ const PreviewDocumentPicker: FC<Props> = ({
onChange,
}) => {
const { t } = useTranslation()
const { name, extension } = value
const name = value?.name || ''
const extension = value?.extension
const [open, {
set: setOpen,
+8
View File
@@ -19,6 +19,14 @@ vi.mock('@/service/common', () => ({
getSystemFeatures: vi.fn(),
}))
vi.mock('@/context/global-public-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/global-public-context')>()
return {
...actual,
useIsSystemFeaturesPending: () => false,
}
})
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
const mockFetchInitValidateStatus = vi.mocked(fetchInitValidateStatus)
const mockSetup = vi.mocked(setup)
@@ -65,8 +65,10 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis
body: loginData,
})
if (res.result === 'success') {
// Track login success event
setWebAppAccessToken(res.data.access_token)
if (res?.data?.access_token) {
// Track login success event
setWebAppAccessToken(res.data.access_token)
}
trackEvent('user_login_success', {
method: 'email_password',
is_invite: isInvite,
+36 -17
View File
@@ -2,42 +2,61 @@
import type { FC, PropsWithChildren } from 'react'
import type { SystemFeatures } from '@/types/feature'
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { create } from 'zustand'
import Loading from '@/app/components/base/loading'
import { getSystemFeatures } from '@/service/common'
import { defaultSystemFeatures } from '@/types/feature'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
type GlobalPublicStore = {
isGlobalPending: boolean
setIsGlobalPending: (isPending: boolean) => void
systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void
}
export const useGlobalPublicStore = create<GlobalPublicStore>(set => ({
isGlobalPending: true,
setIsGlobalPending: (isPending: boolean) => set(() => ({ isGlobalPending: isPending })),
systemFeatures: defaultSystemFeatures,
setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })),
}))
const systemFeaturesQueryKey = ['systemFeatures'] as const
const setupStatusQueryKey = ['setupStatus'] as const
async function fetchSystemFeatures() {
const data = await getSystemFeatures()
const { setSystemFeatures } = useGlobalPublicStore.getState()
setSystemFeatures({ ...defaultSystemFeatures, ...data })
return data
}
export function useSystemFeaturesQuery() {
return useQuery({
queryKey: systemFeaturesQueryKey,
queryFn: fetchSystemFeatures,
})
}
export function useIsSystemFeaturesPending() {
const { isPending } = useSystemFeaturesQuery()
return isPending
}
export function useSetupStatusQuery() {
return useQuery({
queryKey: setupStatusQueryKey,
queryFn: fetchSetupStatusWithCache,
staleTime: Infinity,
})
}
const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({
children,
}) => {
const { isPending, data } = useQuery({
queryKey: ['systemFeatures'],
queryFn: getSystemFeatures,
})
const { setSystemFeatures, setIsGlobalPending: setIsPending } = useGlobalPublicStore()
useEffect(() => {
if (data)
setSystemFeatures({ ...defaultSystemFeatures, ...data })
}, [data, setSystemFeatures])
// Fetch systemFeatures and setupStatus in parallel to reduce waterfall.
// setupStatus is prefetched here and cached in localStorage for AppInitializer.
const { isPending } = useSystemFeaturesQuery()
useEffect(() => {
setIsPending(isPending)
}, [isPending, setIsPending])
// Prefetch setupStatus for AppInitializer (result not needed here)
useSetupStatusQuery()
if (isPending)
return <div className="flex h-screen w-screen items-center justify-center"><Loading /></div>
+2 -2
View File
@@ -10,7 +10,7 @@ import { getProcessedSystemVariablesFromUrlParams } from '@/app/components/base/
import Loading from '@/app/components/base/loading'
import { AccessMode } from '@/models/access-control'
import { useGetWebAppAccessModeByCode } from '@/service/use-share'
import { useGlobalPublicStore } from './global-public-context'
import { useIsSystemFeaturesPending } from './global-public-context'
type WebAppStore = {
shareCode: string | null
@@ -65,7 +65,7 @@ const getShareCodeFromPathname = (pathname: string): string | null => {
}
const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending)
const isGlobalPending = useIsSystemFeaturesPending()
const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode)
const updateShareCode = useWebAppStore(state => state.updateShareCode)
const updateEmbeddedUserId = useWebAppStore(state => state.updateEmbeddedUserId)
+17 -7
View File
@@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGlobalPublicStore, useIsSystemFeaturesPending } from '@/context/global-public-context'
/**
* Test suite for useDocumentTitle hook
*
@@ -15,6 +15,14 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { defaultSystemFeatures } from '@/types/feature'
import useDocumentTitle from './use-document-title'
vi.mock('@/context/global-public-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/global-public-context')>()
return {
...actual,
useIsSystemFeaturesPending: vi.fn(() => false),
}
})
vi.mock('@/service/common', () => ({
getSystemFeatures: vi.fn(() => ({ ...defaultSystemFeatures })),
}))
@@ -24,10 +32,12 @@ vi.mock('@/service/common', () => ({
* Title should remain empty to prevent flicker
*/
describe('title should be empty if systemFeatures is pending', () => {
act(() => {
useGlobalPublicStore.setState({
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } },
isGlobalPending: true,
beforeEach(() => {
vi.mocked(useIsSystemFeaturesPending).mockReturnValue(true)
act(() => {
useGlobalPublicStore.setState({
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } },
})
})
})
/**
@@ -52,9 +62,9 @@ describe('title should be empty if systemFeatures is pending', () => {
*/
describe('use default branding', () => {
beforeEach(() => {
vi.mocked(useIsSystemFeaturesPending).mockReturnValue(false)
act(() => {
useGlobalPublicStore.setState({
isGlobalPending: false,
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } },
})
})
@@ -84,9 +94,9 @@ describe('use default branding', () => {
*/
describe('use specific branding', () => {
beforeEach(() => {
vi.mocked(useIsSystemFeaturesPending).mockReturnValue(false)
act(() => {
useGlobalPublicStore.setState({
isGlobalPending: false,
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: true, application_title: 'Test' } },
})
})
+2 -2
View File
@@ -1,11 +1,11 @@
'use client'
import { useFavicon, useTitle } from 'ahooks'
import { useEffect } from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGlobalPublicStore, useIsSystemFeaturesPending } from '@/context/global-public-context'
import { basePath } from '@/utils/var'
export default function useDocumentTitle(title: string) {
const isPending = useGlobalPublicStore(s => s.isGlobalPending)
const isPending = useIsSystemFeaturesPending()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const prefix = title ? `${title} - ` : ''
let titleStr = ''
+1 -4
View File
@@ -1,7 +1,7 @@
{
"name": "dify-web",
"type": "module",
"version": "1.11.2",
"version": "1.11.4",
"private": true,
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a",
"imports": {
@@ -10,9 +10,6 @@
"default": "./i18n-config/lib.client.ts"
}
},
"engines": {
"node": ">=22.12.0"
},
"browserslist": [
"last 1 Chrome version",
"last 1 Firefox version",
+1 -1
View File
@@ -39,7 +39,7 @@ import { del, get, patch, post, put } from './base'
type LoginSuccess = {
result: 'success'
data: { access_token: string }
data?: { access_token?: string }
}
type LoginFail = {
result: 'fail'
+139
View File
@@ -0,0 +1,139 @@
import type { SetupStatusResponse } from '@/models/common'
import { fetchSetupStatus } from '@/service/common'
import { fetchSetupStatusWithCache } from './setup-status'
vi.mock('@/service/common', () => ({
fetchSetupStatus: vi.fn(),
}))
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
describe('setup-status utilities', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
describe('fetchSetupStatusWithCache', () => {
describe('when cache exists', () => {
it('should return cached finished status without API call', async () => {
localStorage.setItem('setup_status', 'finished')
const result = await fetchSetupStatusWithCache()
expect(result).toEqual({ step: 'finished' })
expect(mockFetchSetupStatus).not.toHaveBeenCalled()
})
it('should not modify localStorage when returning cached value', async () => {
localStorage.setItem('setup_status', 'finished')
await fetchSetupStatusWithCache()
expect(localStorage.getItem('setup_status')).toBe('finished')
})
})
describe('when cache does not exist', () => {
it('should call API and cache finished status', async () => {
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBe('finished')
})
it('should call API and remove cache when not finished', async () => {
const apiResponse: SetupStatusResponse = { step: 'not_started' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBeNull()
})
it('should clear stale cache when API returns not_started', async () => {
localStorage.setItem('setup_status', 'some_invalid_value')
const apiResponse: SetupStatusResponse = { step: 'not_started' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(result).toEqual(apiResponse)
expect(localStorage.getItem('setup_status')).toBeNull()
})
})
describe('cache edge cases', () => {
it('should call API when cache value is empty string', async () => {
localStorage.setItem('setup_status', '')
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
})
it('should call API when cache value is not "finished"', async () => {
localStorage.setItem('setup_status', 'not_started')
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
})
it('should call API when localStorage key does not exist', async () => {
const apiResponse: SetupStatusResponse = { step: 'finished' }
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
expect(result).toEqual(apiResponse)
})
})
describe('API response handling', () => {
it('should preserve setup_at from API response', async () => {
const setupDate = new Date('2024-01-01')
const apiResponse: SetupStatusResponse = {
step: 'finished',
setup_at: setupDate,
}
mockFetchSetupStatus.mockResolvedValue(apiResponse)
const result = await fetchSetupStatusWithCache()
expect(result).toEqual(apiResponse)
expect(result.setup_at).toEqual(setupDate)
})
it('should propagate API errors', async () => {
const apiError = new Error('Network error')
mockFetchSetupStatus.mockRejectedValue(apiError)
await expect(fetchSetupStatusWithCache()).rejects.toThrow('Network error')
})
it('should not update cache when API call fails', async () => {
mockFetchSetupStatus.mockRejectedValue(new Error('API error'))
await expect(fetchSetupStatusWithCache()).rejects.toThrow()
expect(localStorage.getItem('setup_status')).toBeNull()
})
})
})
})
+21
View File
@@ -0,0 +1,21 @@
import type { SetupStatusResponse } from '@/models/common'
import { fetchSetupStatus } from '@/service/common'
const SETUP_STATUS_KEY = 'setup_status'
const isSetupStatusCached = (): boolean =>
localStorage.getItem(SETUP_STATUS_KEY) === 'finished'
export const fetchSetupStatusWithCache = async (): Promise<SetupStatusResponse> => {
if (isSetupStatusCached())
return { step: 'finished' }
const status = await fetchSetupStatus()
if (status.step === 'finished')
localStorage.setItem(SETUP_STATUS_KEY, 'finished')
else
localStorage.removeItem(SETUP_STATUS_KEY)
return status
}