This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
import { useMemo } from 'react'
import { useGetLanguage } from '@/context/i18n'
export const useDatasetApiAccessUrl = () => {
const locale = useGetLanguage()
const apiReferenceUrl = useMemo(() => {
if (locale === 'zh_Hans')
return 'https://docs.dify.ai/api-reference/%E6%95%B0%E6%8D%AE%E9%9B%86'
if (locale === 'ja_JP')
return 'https://docs.dify.ai/api-reference/%E3%83%87%E3%83%BC%E3%82%BF%E3%82%BB%E3%83%83%E3%83%88'
return 'https://docs.dify.ai/api-reference/datasets'
}, [locale])
return apiReferenceUrl
}

View File

@@ -0,0 +1,44 @@
import { useAsyncEffect } from 'ahooks'
import { appDefaultIconBackground } from '@/config'
import { searchEmoji } from '@/utils/emoji'
import type { AppIconType } from '@/types/app'
type UseAppFaviconOptions = {
enable?: boolean
icon_type?: AppIconType | null
icon?: string
icon_background?: string | null
icon_url?: string | null
}
export function useAppFavicon(options: UseAppFaviconOptions) {
const {
enable = true,
icon_type = 'emoji',
icon,
icon_background,
icon_url,
} = options
useAsyncEffect(async () => {
if (!enable || (icon_type === 'image' && !icon_url) || (icon_type === 'emoji' && !icon))
return
const isValidImageIcon = icon_type === 'image' && icon_url
const link: HTMLLinkElement = document.querySelector('link[rel*="icon"]') || document.createElement('link')
link.href = isValidImageIcon
? icon_url
: 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22>'
+ `<rect width=%22100%25%22 height=%22100%25%22 fill=%22${encodeURIComponent(icon_background || appDefaultIconBackground)}%22 rx=%2230%22 ry=%2230%22 />`
+ `<text x=%2212.5%22 y=%221em%22 font-size=%2275%22>${
icon ? await searchEmoji(icon) : '🤖'
}</text>`
+ '</svg>'
link.rel = 'shortcut icon'
link.type = 'image/svg'
document.getElementsByTagName('head')[0].appendChild(link)
}, [enable, icon, icon_background])
}

View File

@@ -0,0 +1,133 @@
/**
* Test suite for useBreakpoints hook
*
* This hook provides responsive breakpoint detection based on window width.
* It listens to window resize events and returns the current media type.
*
* Breakpoint definitions:
* - mobile: width <= 640px
* - tablet: 640px < width <= 768px
* - pc: width > 768px
*
* The hook automatically updates when the window is resized and cleans up
* event listeners on unmount to prevent memory leaks.
*/
import { act, renderHook } from '@testing-library/react'
import useBreakpoints, { MediaType } from './use-breakpoints'
describe('useBreakpoints', () => {
const originalInnerWidth = window.innerWidth
/**
* Helper function to simulate window resize events
* Updates window.innerWidth and dispatches a resize event
*/
const fireResize = (width: number) => {
window.innerWidth = width
act(() => {
window.dispatchEvent(new Event('resize'))
})
}
/**
* Restore the original innerWidth after all tests
* Ensures tests don't affect each other or the test environment
*/
afterAll(() => {
window.innerWidth = originalInnerWidth
})
/**
* Test mobile breakpoint detection
* Mobile devices have width <= 640px
*/
it('should return mobile for width <= 640px', () => {
// Mock window.innerWidth for mobile
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 640,
})
const { result } = renderHook(() => useBreakpoints())
expect(result.current).toBe(MediaType.mobile)
})
/**
* Test tablet breakpoint detection
* Tablet devices have width between 640px and 768px
*/
it('should return tablet for width > 640px and <= 768px', () => {
// Mock window.innerWidth for tablet
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 768,
})
const { result } = renderHook(() => useBreakpoints())
expect(result.current).toBe(MediaType.tablet)
})
/**
* Test desktop/PC breakpoint detection
* Desktop devices have width > 768px
*/
it('should return pc for width > 768px', () => {
// Mock window.innerWidth for pc
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1024,
})
const { result } = renderHook(() => useBreakpoints())
expect(result.current).toBe(MediaType.pc)
})
/**
* Test dynamic breakpoint updates on window resize
* The hook should react to window resize events and update the media type
*/
it('should update media type when window resizes', () => {
// Start with desktop
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1024,
})
const { result } = renderHook(() => useBreakpoints())
expect(result.current).toBe(MediaType.pc)
// Resize to tablet
fireResize(768)
expect(result.current).toBe(MediaType.tablet)
// Resize to mobile
fireResize(600)
expect(result.current).toBe(MediaType.mobile)
})
/**
* Test proper cleanup of event listeners
* Ensures no memory leaks by removing resize listeners on unmount
*/
it('should clean up event listeners on unmount', () => {
// Spy on addEventListener and removeEventListener
const addEventListenerSpy = jest.spyOn(window, 'addEventListener')
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener')
const { unmount } = renderHook(() => useBreakpoints())
// Unmount should trigger cleanup
unmount()
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
// Clean up spies
addEventListenerSpy.mockRestore()
removeEventListenerSpy.mockRestore()
})
})

View File

@@ -0,0 +1,29 @@
'use client'
import React from 'react'
export enum MediaType {
mobile = 'mobile',
tablet = 'tablet',
pc = 'pc',
}
const useBreakpoints = () => {
const [width, setWidth] = React.useState(globalThis.innerWidth)
const media = (() => {
if (width <= 640)
return MediaType.mobile
if (width <= 768)
return MediaType.tablet
return MediaType.pc
})()
React.useEffect(() => {
const handleWindowResize = () => setWidth(window.innerWidth)
window.addEventListener('resize', handleWindowResize)
return () => window.removeEventListener('resize', handleWindowResize)
}, [])
return media
}
export default useBreakpoints

View File

@@ -0,0 +1,111 @@
/**
* Test suite for useDocumentTitle hook
*
* This hook manages the browser document title with support for:
* - Custom branding (when enabled in system features)
* - Default "Dify" branding
* - Pending state handling (prevents title flicker during loading)
* - Page-specific titles with automatic suffix
*
* Title format: "[Page Title] - [Brand Name]"
* If no page title: "[Brand Name]"
*/
import { defaultSystemFeatures } from '@/types/feature'
import { act, renderHook } from '@testing-library/react'
import useDocumentTitle from './use-document-title'
import { useGlobalPublicStore } from '@/context/global-public-context'
jest.mock('@/service/common', () => ({
getSystemFeatures: jest.fn(() => ({ ...defaultSystemFeatures })),
}))
/**
* Test behavior when system features are still loading
* 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,
})
})
/**
* Test that title stays empty during loading even when a title is provided
*/
it('document title should be empty if set title', () => {
renderHook(() => useDocumentTitle('test'))
expect(document.title).toBe('')
})
/**
* Test that title stays empty during loading when no title is provided
*/
it('document title should be empty if not set title', () => {
renderHook(() => useDocumentTitle(''))
expect(document.title).toBe('')
})
})
/**
* Test default Dify branding behavior
* When custom branding is disabled, should use "Dify" as the brand name
*/
describe('use default branding', () => {
beforeEach(() => {
act(() => {
useGlobalPublicStore.setState({
isGlobalPending: false,
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } },
})
})
})
/**
* Test title format with page title and default branding
* Format: "[page] - Dify"
*/
it('document title should be test-Dify if set title', () => {
renderHook(() => useDocumentTitle('test'))
expect(document.title).toBe('test - Dify')
})
/**
* Test title with only default branding (no page title)
* Format: "Dify"
*/
it('document title should be Dify if not set title', () => {
renderHook(() => useDocumentTitle(''))
expect(document.title).toBe('Dify')
})
})
/**
* Test custom branding behavior
* When custom branding is enabled, should use the configured application_title
*/
describe('use specific branding', () => {
beforeEach(() => {
act(() => {
useGlobalPublicStore.setState({
isGlobalPending: false,
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: true, application_title: 'Test' } },
})
})
})
/**
* Test title format with page title and custom branding
* Format: "[page] - [Custom Brand]"
*/
it('document title should be test-Test if set title', () => {
renderHook(() => useDocumentTitle('test'))
expect(document.title).toBe('test - Test')
})
/**
* Test title with only custom branding (no page title)
* Format: "[Custom Brand]"
*/
it('document title should be Test if not set title', () => {
renderHook(() => useDocumentTitle(''))
expect(document.title).toBe('Test')
})
})

View File

@@ -0,0 +1,44 @@
'use client'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useFavicon, useTitle } from 'ahooks'
import { basePath } from '@/utils/var'
import { useEffect } from 'react'
export default function useDocumentTitle(title: string) {
const isPending = useGlobalPublicStore(s => s.isGlobalPending)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const prefix = title ? `${title} - ` : ''
let titleStr = ''
let favicon = ''
if (isPending === false) {
if (systemFeatures.branding.enabled) {
titleStr = `${prefix}${systemFeatures.branding.application_title}`
favicon = systemFeatures.branding.favicon
}
else {
titleStr = `${prefix}Dify`
favicon = `${basePath}/favicon.ico`
}
}
useTitle(titleStr)
useEffect(() => {
let apple: HTMLLinkElement | null = null
if (systemFeatures.branding.favicon) {
document
.querySelectorAll(
'link[rel=\'icon\'], link[rel=\'shortcut icon\'], link[rel=\'apple-touch-icon\'], link[rel=\'mask-icon\']',
)
.forEach(n => n.parentNode?.removeChild(n))
apple = document.createElement('link')
apple.rel = 'apple-touch-icon'
apple.href = systemFeatures.branding.favicon
document.head.appendChild(apple)
}
return () => {
apple?.remove()
}
}, [systemFeatures.branding.favicon])
useFavicon(favicon)
}

View File

@@ -0,0 +1,376 @@
/**
* Test suite for useFormatTimeFromNow hook
*
* This hook provides internationalized relative time formatting (e.g., "2 hours ago", "3 days ago")
* using dayjs with the relativeTime plugin. It automatically uses the correct locale based on
* the user's i18n settings.
*
* Key features:
* - Supports 20+ locales with proper translations
* - Automatically syncs with user's interface language
* - Uses dayjs for consistent time calculations
* - Returns human-readable relative time strings
*/
import { renderHook } from '@testing-library/react'
import { useFormatTimeFromNow } from './use-format-time-from-now'
// Mock the i18n context
jest.mock('@/context/i18n', () => ({
useI18N: jest.fn(() => ({
locale: 'en-US',
})),
}))
// Import after mock to get the mocked version
import { useI18N } from '@/context/i18n'
describe('useFormatTimeFromNow', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('Basic functionality', () => {
/**
* Test that the hook returns a formatTimeFromNow function
* This is the primary interface of the hook
*/
it('should return formatTimeFromNow function', () => {
const { result } = renderHook(() => useFormatTimeFromNow())
expect(result.current).toHaveProperty('formatTimeFromNow')
expect(typeof result.current.formatTimeFromNow).toBe('function')
})
/**
* Test basic relative time formatting with English locale
* Should return human-readable relative time strings
*/
it('should format time from now in English', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Should contain "hour" or "hours" and "ago"
expect(formatted).toMatch(/hour|hours/)
expect(formatted).toMatch(/ago/)
})
/**
* Test that recent times are formatted as "a few seconds ago"
* Very recent timestamps should show seconds
*/
it('should format very recent times', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const fiveSecondsAgo = now - (5 * 1000)
const formatted = result.current.formatTimeFromNow(fiveSecondsAgo)
expect(formatted).toMatch(/second|seconds|few seconds/)
})
/**
* Test formatting of times in the past (days ago)
* Should handle day-level granularity
*/
it('should format times from days ago', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const threeDaysAgo = now - (3 * 24 * 60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(threeDaysAgo)
expect(formatted).toMatch(/day|days/)
expect(formatted).toMatch(/ago/)
})
/**
* Test formatting of future times
* dayjs fromNow also supports future times (e.g., "in 2 hours")
*/
it('should format future times', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const twoHoursFromNow = now + (2 * 60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(twoHoursFromNow)
expect(formatted).toMatch(/in/)
expect(formatted).toMatch(/hour|hours/)
})
})
describe('Locale support', () => {
/**
* Test Chinese (Simplified) locale formatting
* Should use Chinese characters for time units
*/
it('should format time in Chinese (Simplified)', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'zh-Hans' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Chinese should contain Chinese characters
expect(formatted).toMatch(/[\u4E00-\u9FA5]/)
})
/**
* Test Spanish locale formatting
* Should use Spanish words for relative time
*/
it('should format time in Spanish', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Spanish should contain "hace" (ago)
expect(formatted).toMatch(/hace/)
})
/**
* Test French locale formatting
* Should use French words for relative time
*/
it('should format time in French', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'fr-FR' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// French should contain "il y a" (ago)
expect(formatted).toMatch(/il y a/)
})
/**
* Test Japanese locale formatting
* Should use Japanese characters
*/
it('should format time in Japanese', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'ja-JP' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Japanese should contain Japanese characters
expect(formatted).toMatch(/[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/)
})
/**
* Test Portuguese (Brazil) locale formatting
* Should use pt-br locale mapping
*/
it('should format time in Portuguese (Brazil)', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'pt-BR' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Portuguese should contain "há" (ago)
expect(formatted).toMatch(/há/)
})
/**
* Test fallback to English for unsupported locales
* Unknown locales should default to English
*/
it('should fallback to English for unsupported locale', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'xx-XX' as any })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Should still return a valid string (in English)
expect(typeof formatted).toBe('string')
expect(formatted.length).toBeGreaterThan(0)
})
})
describe('Edge cases', () => {
/**
* Test handling of timestamp 0 (Unix epoch)
* Should format as a very old date
*/
it('should handle timestamp 0', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const formatted = result.current.formatTimeFromNow(0)
expect(typeof formatted).toBe('string')
expect(formatted.length).toBeGreaterThan(0)
expect(formatted).toMatch(/year|years/)
})
/**
* Test handling of very large timestamps
* Should handle dates far in the future
*/
it('should handle very large timestamps', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const farFuture = Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year from now
const formatted = result.current.formatTimeFromNow(farFuture)
expect(typeof formatted).toBe('string')
expect(formatted).toMatch(/in/)
})
/**
* Test that the function is memoized based on locale
* Changing locale should update the function
*/
it('should update when locale changes', () => {
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
// First render with English
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
rerender()
const englishResult = result.current.formatTimeFromNow(oneHourAgo)
// Second render with Spanish
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
rerender()
const spanishResult = result.current.formatTimeFromNow(oneHourAgo)
// Results should be different
expect(englishResult).not.toBe(spanishResult)
})
})
describe('Time granularity', () => {
/**
* Test different time granularities (seconds, minutes, hours, days, months, years)
* dayjs should automatically choose the appropriate unit
*/
it('should use appropriate time units for different durations', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result } = renderHook(() => useFormatTimeFromNow())
const now = Date.now()
// Seconds
const seconds = result.current.formatTimeFromNow(now - 30 * 1000)
expect(seconds).toMatch(/second/)
// Minutes
const minutes = result.current.formatTimeFromNow(now - 5 * 60 * 1000)
expect(minutes).toMatch(/minute/)
// Hours
const hours = result.current.formatTimeFromNow(now - 3 * 60 * 60 * 1000)
expect(hours).toMatch(/hour/)
// Days
const days = result.current.formatTimeFromNow(now - 5 * 24 * 60 * 60 * 1000)
expect(days).toMatch(/day/)
// Months
const months = result.current.formatTimeFromNow(now - 60 * 24 * 60 * 60 * 1000)
expect(months).toMatch(/month/)
})
})
describe('Locale mapping', () => {
/**
* Test that all supported locales in the localeMap are handled correctly
* This ensures the mapping from app locales to dayjs locales works
*/
it('should handle all mapped locales', () => {
const locales = [
'en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR',
'de-DE', 'ja-JP', 'ko-KR', 'ru-RU', 'it-IT', 'th-TH',
'id-ID', 'uk-UA', 'vi-VN', 'ro-RO', 'pl-PL', 'hi-IN',
'tr-TR', 'fa-IR', 'sl-SI',
]
const now = Date.now()
const oneHourAgo = now - (60 * 60 * 1000)
locales.forEach((locale) => {
;(useI18N as jest.Mock).mockReturnValue({ locale })
const { result } = renderHook(() => useFormatTimeFromNow())
const formatted = result.current.formatTimeFromNow(oneHourAgo)
// Should return a non-empty string for each locale
expect(typeof formatted).toBe('string')
expect(formatted.length).toBeGreaterThan(0)
})
})
})
describe('Performance', () => {
/**
* Test that the hook doesn't create new functions on every render
* The formatTimeFromNow function should be memoized with useCallback
*/
it('should memoize formatTimeFromNow function', () => {
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
const firstFunction = result.current.formatTimeFromNow
rerender()
const secondFunction = result.current.formatTimeFromNow
// Same locale should return the same function reference
expect(firstFunction).toBe(secondFunction)
})
/**
* Test that changing locale creates a new function
* This ensures the memoization dependency on locale works correctly
*/
it('should create new function when locale changes', () => {
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
rerender()
const englishFunction = result.current.formatTimeFromNow
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
rerender()
const spanishFunction = result.current.formatTimeFromNow
// Different locale should return different function reference
expect(englishFunction).not.toBe(spanishFunction)
})
})
})

View File

@@ -0,0 +1,61 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useCallback } from 'react'
import { useI18N } from '@/context/i18n'
import type { Locale } from '@/i18n-config'
import 'dayjs/locale/de'
import 'dayjs/locale/es'
import 'dayjs/locale/fa'
import 'dayjs/locale/fr'
import 'dayjs/locale/hi'
import 'dayjs/locale/id'
import 'dayjs/locale/it'
import 'dayjs/locale/ja'
import 'dayjs/locale/ko'
import 'dayjs/locale/pl'
import 'dayjs/locale/pt-br'
import 'dayjs/locale/ro'
import 'dayjs/locale/ru'
import 'dayjs/locale/sl'
import 'dayjs/locale/th'
import 'dayjs/locale/tr'
import 'dayjs/locale/uk'
import 'dayjs/locale/vi'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/zh-tw'
dayjs.extend(relativeTime)
const localeMap: Record<Locale, string> = {
'en-US': 'en',
'zh-Hans': 'zh-cn',
'zh-Hant': 'zh-tw',
'pt-BR': 'pt-br',
'es-ES': 'es',
'fr-FR': 'fr',
'de-DE': 'de',
'ja-JP': 'ja',
'ko-KR': 'ko',
'ru-RU': 'ru',
'it-IT': 'it',
'th-TH': 'th',
'id-ID': 'id',
'uk-UA': 'uk',
'vi-VN': 'vi',
'ro-RO': 'ro',
'pl-PL': 'pl',
'hi-IN': 'hi',
'tr-TR': 'tr',
'fa-IR': 'fa',
'sl-SI': 'sl',
}
export const useFormatTimeFromNow = () => {
const { locale } = useI18N()
const formatTimeFromNow = useCallback((time: number) => {
const dayjsLocale = localeMap[locale] ?? 'en'
return dayjs(time).locale(dayjsLocale).fromNow()
}, [locale])
return { formatTimeFromNow }
}

View File

@@ -0,0 +1,9 @@
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { renderI18nObject } from '@/i18n-config'
export const useRenderI18nObject = () => {
const language = useLanguage()
return (obj: Record<string, string>) => {
return renderI18nObject(obj, language)
}
}

View File

@@ -0,0 +1,163 @@
import {
useCallback,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
import type {
DSLImportMode,
DSLImportResponse,
} from '@/models/app'
import { DSLImportStatus } from '@/models/app'
import {
importDSL,
importDSLConfirm,
} from '@/service/apps'
import type { AppIconType } from '@/types/app'
import { useToastContext } from '@/app/components/base/toast'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { getRedirection } from '@/utils/app-redirection'
import { useSelector } from '@/context/app-context'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
type DSLPayload = {
mode: DSLImportMode
yaml_content?: string
yaml_url?: string
name?: string
icon_type?: AppIconType
icon?: string
icon_background?: string
description?: string
}
type ResponseCallback = {
onSuccess?: () => void
onPending?: (payload: DSLImportResponse) => void
onFailed?: () => void
}
export const useImportDSL = () => {
const { t } = useTranslation()
const { notify } = useToastContext()
const [isFetching, setIsFetching] = useState(false)
const { handleCheckPluginDependencies } = usePluginDependencies()
const isCurrentWorkspaceEditor = useSelector(s => s.isCurrentWorkspaceEditor)
const { push } = useRouter()
const [versions, setVersions] = useState<{ importedVersion: string; systemVersion: string }>()
const importIdRef = useRef<string>('')
const handleImportDSL = useCallback(async (
payload: DSLPayload,
{
onSuccess,
onPending,
onFailed,
}: ResponseCallback,
) => {
if (isFetching)
return
setIsFetching(true)
try {
const response = await importDSL(payload)
if (!response)
return
const {
id,
status,
app_id,
app_mode,
imported_dsl_version,
current_dsl_version,
} = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
if (!app_id)
return
notify({
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
message: t(status === DSLImportStatus.COMPLETED ? 'app.newApp.appCreated' : 'app.newApp.caution'),
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('app.newApp.appCreateDSLWarning'),
})
onSuccess?.()
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
await handleCheckPluginDependencies(app_id)
getRedirection(isCurrentWorkspaceEditor, { id: app_id, mode: app_mode }, push)
}
else if (status === DSLImportStatus.PENDING) {
setVersions({
importedVersion: imported_dsl_version ?? '',
systemVersion: current_dsl_version ?? '',
})
importIdRef.current = id
onPending?.(response)
}
else {
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
onFailed?.()
}
}
catch {
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
onFailed?.()
}
finally {
setIsFetching(false)
}
}, [t, notify, handleCheckPluginDependencies, isCurrentWorkspaceEditor, push, isFetching])
const handleImportDSLConfirm = useCallback(async (
{
onSuccess,
onFailed,
}: Pick<ResponseCallback, 'onSuccess' | 'onFailed'>,
) => {
if (isFetching)
return
setIsFetching(true)
if (!importIdRef.current)
return
try {
const response = await importDSLConfirm({
import_id: importIdRef.current,
})
const { status, app_id, app_mode } = response
if (!app_id)
return
if (status === DSLImportStatus.COMPLETED) {
onSuccess?.()
notify({
type: 'success',
message: t('app.newApp.appCreated'),
})
await handleCheckPluginDependencies(app_id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
}
else if (status === DSLImportStatus.FAILED) {
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
onFailed?.()
}
}
catch {
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
onFailed?.()
}
finally {
setIsFetching(false)
}
}, [t, notify, handleCheckPluginDependencies, isCurrentWorkspaceEditor, push, isFetching])
return {
handleImportDSL,
handleImportDSLConfirm,
versions,
isFetching,
}
}

View File

@@ -0,0 +1,32 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
export const useKnowledge = () => {
const { t } = useTranslation()
const formatIndexingTechnique = useCallback((indexingTechnique: string) => {
return t(`dataset.indexingTechnique.${indexingTechnique}`)
}, [t])
const formatIndexingMethod = useCallback((indexingMethod: string, isEco?: boolean) => {
if (isEco)
return t('dataset.indexingMethod.invertedIndex')
return t(`dataset.indexingMethod.${indexingMethod}`)
}, [t])
const formatIndexingTechniqueAndMethod = useCallback((indexingTechnique: string, indexingMethod: string) => {
let result = formatIndexingTechnique(indexingTechnique)
if (indexingMethod)
result += ` · ${formatIndexingMethod(indexingMethod, indexingTechnique === 'economy')}`
return result
}, [formatIndexingTechnique, formatIndexingMethod])
return {
formatIndexingTechnique,
formatIndexingMethod,
formatIndexingTechniqueAndMethod,
}
}

View File

@@ -0,0 +1,403 @@
'use client'
import { useTranslation } from 'react-i18next'
import { formatFileSize, formatNumber, formatTime } from '@/utils/format'
import { ChunkingMode, type DocType } from '@/models/datasets'
import useTimestamp from '@/hooks/use-timestamp'
export type inputType = 'input' | 'select' | 'textarea'
export type metadataType = DocType | 'originInfo' | 'technicalParameters'
type MetadataMap
= Record<
metadataType,
{
text: string
allowEdit?: boolean
icon?: React.ReactNode
iconName?: string
subFieldsMap: Record<
string,
{
label: string
inputType?: inputType
field?: string
render?: (value: any, total?: number) => React.ReactNode | string
}
>
}
>
const fieldPrefix = 'datasetDocuments.metadata.field'
export const useMetadataMap = (): MetadataMap => {
const { t } = useTranslation()
const { formatTime: formatTimestamp } = useTimestamp()
return {
book: {
text: t('datasetDocuments.metadata.type.book'),
iconName: 'bookOpen',
subFieldsMap: {
title: { label: t(`${fieldPrefix}.book.title`) },
language: {
label: t(`${fieldPrefix}.book.language`),
inputType: 'select',
},
author: { label: t(`${fieldPrefix}.book.author`) },
publisher: { label: t(`${fieldPrefix}.book.publisher`) },
publication_date: { label: t(`${fieldPrefix}.book.publicationDate`) },
isbn: { label: t(`${fieldPrefix}.book.ISBN`) },
category: {
label: t(`${fieldPrefix}.book.category`),
inputType: 'select',
},
},
},
web_page: {
text: t('datasetDocuments.metadata.type.webPage'),
iconName: 'globe',
subFieldsMap: {
'title': { label: t(`${fieldPrefix}.webPage.title`) },
'url': { label: t(`${fieldPrefix}.webPage.url`) },
'language': {
label: t(`${fieldPrefix}.webPage.language`),
inputType: 'select',
},
'author/publisher': { label: t(`${fieldPrefix}.webPage.authorPublisher`) },
'publish_date': { label: t(`${fieldPrefix}.webPage.publishDate`) },
'topic/keywords': { label: t(`${fieldPrefix}.webPage.topicKeywords`) },
'description': { label: t(`${fieldPrefix}.webPage.description`) },
},
},
paper: {
text: t('datasetDocuments.metadata.type.paper'),
iconName: 'graduationHat',
subFieldsMap: {
'title': { label: t(`${fieldPrefix}.paper.title`) },
'language': {
label: t(`${fieldPrefix}.paper.language`),
inputType: 'select',
},
'author': { label: t(`${fieldPrefix}.paper.author`) },
'publish_date': { label: t(`${fieldPrefix}.paper.publishDate`) },
'journal/conference_name': {
label: t(`${fieldPrefix}.paper.journalConferenceName`),
},
'volume/issue/page_numbers': { label: t(`${fieldPrefix}.paper.volumeIssuePage`) },
'doi': { label: t(`${fieldPrefix}.paper.DOI`) },
'topic/keywords': { label: t(`${fieldPrefix}.paper.topicKeywords`) },
'abstract': {
label: t(`${fieldPrefix}.paper.abstract`),
inputType: 'textarea',
},
},
},
social_media_post: {
text: t('datasetDocuments.metadata.type.socialMediaPost'),
iconName: 'atSign',
subFieldsMap: {
'platform': { label: t(`${fieldPrefix}.socialMediaPost.platform`) },
'author/username': {
label: t(`${fieldPrefix}.socialMediaPost.authorUsername`),
},
'publish_date': { label: t(`${fieldPrefix}.socialMediaPost.publishDate`) },
'post_url': { label: t(`${fieldPrefix}.socialMediaPost.postURL`) },
'topics/tags': { label: t(`${fieldPrefix}.socialMediaPost.topicsTags`) },
},
},
personal_document: {
text: t('datasetDocuments.metadata.type.personalDocument'),
iconName: 'file',
subFieldsMap: {
'title': { label: t(`${fieldPrefix}.personalDocument.title`) },
'author': { label: t(`${fieldPrefix}.personalDocument.author`) },
'creation_date': {
label: t(`${fieldPrefix}.personalDocument.creationDate`),
},
'last_modified_date': {
label: t(`${fieldPrefix}.personalDocument.lastModifiedDate`),
},
'document_type': {
label: t(`${fieldPrefix}.personalDocument.documentType`),
inputType: 'select',
},
'tags/category': {
label: t(`${fieldPrefix}.personalDocument.tagsCategory`),
},
},
},
business_document: {
text: t('datasetDocuments.metadata.type.businessDocument'),
iconName: 'briefcase',
subFieldsMap: {
'title': { label: t(`${fieldPrefix}.businessDocument.title`) },
'author': { label: t(`${fieldPrefix}.businessDocument.author`) },
'creation_date': {
label: t(`${fieldPrefix}.businessDocument.creationDate`),
},
'last_modified_date': {
label: t(`${fieldPrefix}.businessDocument.lastModifiedDate`),
},
'document_type': {
label: t(`${fieldPrefix}.businessDocument.documentType`),
inputType: 'select',
},
'department/team': {
label: t(`${fieldPrefix}.businessDocument.departmentTeam`),
},
},
},
im_chat_log: {
text: t('datasetDocuments.metadata.type.IMChat'),
iconName: 'messageTextCircle',
subFieldsMap: {
'chat_platform': { label: t(`${fieldPrefix}.IMChat.chatPlatform`) },
'chat_participants/group_name': {
label: t(`${fieldPrefix}.IMChat.chatPartiesGroupName`),
},
'start_date': { label: t(`${fieldPrefix}.IMChat.startDate`) },
'end_date': { label: t(`${fieldPrefix}.IMChat.endDate`) },
'participants': { label: t(`${fieldPrefix}.IMChat.participants`) },
'topicKeywords': {
label: t(`${fieldPrefix}.IMChat.topicKeywords`),
inputType: 'textarea',
},
'fileType': { label: t(`${fieldPrefix}.IMChat.fileType`) },
},
},
wikipedia_entry: {
text: t('datasetDocuments.metadata.type.wikipediaEntry'),
allowEdit: false,
subFieldsMap: {
'title': { label: t(`${fieldPrefix}.wikipediaEntry.title`) },
'language': {
label: t(`${fieldPrefix}.wikipediaEntry.language`),
inputType: 'select',
},
'web_page_url': { label: t(`${fieldPrefix}.wikipediaEntry.webpageURL`) },
'editor/contributor': {
label: t(`${fieldPrefix}.wikipediaEntry.editorContributor`),
},
'last_edit_date': {
label: t(`${fieldPrefix}.wikipediaEntry.lastEditDate`),
},
'summary/introduction': {
label: t(`${fieldPrefix}.wikipediaEntry.summaryIntroduction`),
inputType: 'textarea',
},
},
},
synced_from_notion: {
text: t('datasetDocuments.metadata.type.notion'),
allowEdit: false,
subFieldsMap: {
'title': { label: t(`${fieldPrefix}.notion.title`) },
'language': { label: t(`${fieldPrefix}.notion.lang`), inputType: 'select' },
'author/creator': { label: t(`${fieldPrefix}.notion.author`) },
'creation_date': { label: t(`${fieldPrefix}.notion.createdTime`) },
'last_modified_date': {
label: t(`${fieldPrefix}.notion.lastModifiedTime`),
},
'notion_page_link': { label: t(`${fieldPrefix}.notion.url`) },
'category/tags': { label: t(`${fieldPrefix}.notion.tag`) },
'description': { label: t(`${fieldPrefix}.notion.desc`) },
},
},
synced_from_github: {
text: t('datasetDocuments.metadata.type.github'),
allowEdit: false,
subFieldsMap: {
'repository_name': { label: t(`${fieldPrefix}.github.repoName`) },
'repository_description': { label: t(`${fieldPrefix}.github.repoDesc`) },
'repository_owner/organization': { label: t(`${fieldPrefix}.github.repoOwner`) },
'code_filename': { label: t(`${fieldPrefix}.github.fileName`) },
'code_file_path': { label: t(`${fieldPrefix}.github.filePath`) },
'programming_language': { label: t(`${fieldPrefix}.github.programmingLang`) },
'github_link': { label: t(`${fieldPrefix}.github.url`) },
'open_source_license': { label: t(`${fieldPrefix}.github.license`) },
'commit_date': { label: t(`${fieldPrefix}.github.lastCommitTime`) },
'commit_author': {
label: t(`${fieldPrefix}.github.lastCommitAuthor`),
},
},
},
originInfo: {
text: '',
allowEdit: false,
subFieldsMap: {
'name': { label: t(`${fieldPrefix}.originInfo.originalFilename`) },
'data_source_info.upload_file.size': {
label: t(`${fieldPrefix}.originInfo.originalFileSize`),
render: value => formatFileSize(value),
},
'created_at': {
label: t(`${fieldPrefix}.originInfo.uploadDate`),
render: value => formatTimestamp(value, t('datasetDocuments.metadata.dateTimeFormat') as string),
},
'completed_at': {
label: t(`${fieldPrefix}.originInfo.lastUpdateDate`),
render: value => formatTimestamp(value, t('datasetDocuments.metadata.dateTimeFormat') as string),
},
'data_source_type': {
label: t(`${fieldPrefix}.originInfo.source`),
render: value => t(`datasetDocuments.metadata.source.${value === 'notion_import' ? 'notion' : value}`),
},
},
},
technicalParameters: {
text: t('datasetDocuments.metadata.type.technicalParameters'),
allowEdit: false,
subFieldsMap: {
'doc_form': {
label: t(`${fieldPrefix}.technicalParameters.segmentSpecification`),
render: (value) => {
if (value === ChunkingMode.text)
return t('dataset.chunkingMode.general')
if (value === ChunkingMode.qa)
return t('dataset.chunkingMode.qa')
if (value === ChunkingMode.parentChild)
return t('dataset.chunkingMode.parentChild')
return '--'
},
},
'dataset_process_rule.rules.segmentation.max_tokens': {
label: t(`${fieldPrefix}.technicalParameters.segmentLength`),
render: value => formatNumber(value),
},
'average_segment_length': {
label: t(`${fieldPrefix}.technicalParameters.avgParagraphLength`),
render: value => `${formatNumber(value)} characters`,
},
'segment_count': {
label: t(`${fieldPrefix}.technicalParameters.paragraphs`),
render: value => `${formatNumber(value)} paragraphs`,
},
'hit_count': {
label: t(`${fieldPrefix}.technicalParameters.hitCount`),
render: (value, total) => {
const v = value || 0
return `${!total ? 0 : ((v / total) * 100).toFixed(2)}% (${v}/${total})`
},
},
'indexing_latency': {
label: t(`${fieldPrefix}.technicalParameters.embeddingTime`),
render: value => formatTime(value),
},
'tokens': {
label: t(`${fieldPrefix}.technicalParameters.embeddedSpend`),
render: value => `${formatNumber(value)} tokens`,
},
},
},
}
}
const langPrefix = 'datasetDocuments.metadata.languageMap.'
export const useLanguages = () => {
const { t } = useTranslation()
return {
zh: t(`${langPrefix}zh`),
en: t(`${langPrefix}en`),
es: t(`${langPrefix}es`),
fr: t(`${langPrefix}fr`),
de: t(`${langPrefix}de`),
ja: t(`${langPrefix}ja`),
ko: t(`${langPrefix}ko`),
ru: t(`${langPrefix}ru`),
ar: t(`${langPrefix}ar`),
pt: t(`${langPrefix}pt`),
it: t(`${langPrefix}it`),
nl: t(`${langPrefix}nl`),
pl: t(`${langPrefix}pl`),
sv: t(`${langPrefix}sv`),
tr: t(`${langPrefix}tr`),
he: t(`${langPrefix}he`),
hi: t(`${langPrefix}hi`),
da: t(`${langPrefix}da`),
fi: t(`${langPrefix}fi`),
no: t(`${langPrefix}no`),
hu: t(`${langPrefix}hu`),
el: t(`${langPrefix}el`),
cs: t(`${langPrefix}cs`),
th: t(`${langPrefix}th`),
id: t(`${langPrefix}id`),
ro: t(`${langPrefix}ro`),
}
}
const bookCategoryPrefix = 'datasetDocuments.metadata.categoryMap.book.'
export const useBookCategories = () => {
const { t } = useTranslation()
return {
fiction: t(`${bookCategoryPrefix}fiction`),
biography: t(`${bookCategoryPrefix}biography`),
history: t(`${bookCategoryPrefix}history`),
science: t(`${bookCategoryPrefix}science`),
technology: t(`${bookCategoryPrefix}technology`),
education: t(`${bookCategoryPrefix}education`),
philosophy: t(`${bookCategoryPrefix}philosophy`),
religion: t(`${bookCategoryPrefix}religion`),
socialSciences: t(`${bookCategoryPrefix}socialSciences`),
art: t(`${bookCategoryPrefix}art`),
travel: t(`${bookCategoryPrefix}travel`),
health: t(`${bookCategoryPrefix}health`),
selfHelp: t(`${bookCategoryPrefix}selfHelp`),
businessEconomics: t(`${bookCategoryPrefix}businessEconomics`),
cooking: t(`${bookCategoryPrefix}cooking`),
childrenYoungAdults: t(`${bookCategoryPrefix}childrenYoungAdults`),
comicsGraphicNovels: t(`${bookCategoryPrefix}comicsGraphicNovels`),
poetry: t(`${bookCategoryPrefix}poetry`),
drama: t(`${bookCategoryPrefix}drama`),
other: t(`${bookCategoryPrefix}other`),
}
}
const personalDocCategoryPrefix
= 'datasetDocuments.metadata.categoryMap.personalDoc.'
export const usePersonalDocCategories = () => {
const { t } = useTranslation()
return {
notes: t(`${personalDocCategoryPrefix}notes`),
blogDraft: t(`${personalDocCategoryPrefix}blogDraft`),
diary: t(`${personalDocCategoryPrefix}diary`),
researchReport: t(`${personalDocCategoryPrefix}researchReport`),
bookExcerpt: t(`${personalDocCategoryPrefix}bookExcerpt`),
schedule: t(`${personalDocCategoryPrefix}schedule`),
list: t(`${personalDocCategoryPrefix}list`),
projectOverview: t(`${personalDocCategoryPrefix}projectOverview`),
photoCollection: t(`${personalDocCategoryPrefix}photoCollection`),
creativeWriting: t(`${personalDocCategoryPrefix}creativeWriting`),
codeSnippet: t(`${personalDocCategoryPrefix}codeSnippet`),
designDraft: t(`${personalDocCategoryPrefix}designDraft`),
personalResume: t(`${personalDocCategoryPrefix}personalResume`),
other: t(`${personalDocCategoryPrefix}other`),
}
}
const businessDocCategoryPrefix
= 'datasetDocuments.metadata.categoryMap.businessDoc.'
export const useBusinessDocCategories = () => {
const { t } = useTranslation()
return {
meetingMinutes: t(`${businessDocCategoryPrefix}meetingMinutes`),
researchReport: t(`${businessDocCategoryPrefix}researchReport`),
proposal: t(`${businessDocCategoryPrefix}proposal`),
employeeHandbook: t(`${businessDocCategoryPrefix}employeeHandbook`),
trainingMaterials: t(`${businessDocCategoryPrefix}trainingMaterials`),
requirementsDocument: t(`${businessDocCategoryPrefix}requirementsDocument`),
designDocument: t(`${businessDocCategoryPrefix}designDocument`),
productSpecification: t(`${businessDocCategoryPrefix}productSpecification`),
financialReport: t(`${businessDocCategoryPrefix}financialReport`),
marketAnalysis: t(`${businessDocCategoryPrefix}marketAnalysis`),
projectPlan: t(`${businessDocCategoryPrefix}projectPlan`),
teamStructure: t(`${businessDocCategoryPrefix}teamStructure`),
policiesProcedures: t(`${businessDocCategoryPrefix}policiesProcedures`),
contractsAgreements: t(`${businessDocCategoryPrefix}contractsAgreements`),
emailCorrespondence: t(`${businessDocCategoryPrefix}emailCorrespondence`),
other: t(`${businessDocCategoryPrefix}other`),
}
}

View File

@@ -0,0 +1,74 @@
import type { Emitter, EventType, Handler, WildcardHandler } from 'mitt'
import create from 'mitt'
import { useEffect, useRef } from 'react'
const merge = <T extends Record<string, any>>(
...args: Array<T | undefined>
): T => {
return Object.assign({}, ...args)
}
export type _Events = Record<EventType, unknown>
export type UseSubscribeOption = {
/**
* Whether the subscription is enabled.
* @default true
*/
enabled: boolean;
}
export type ExtendedOn<Events extends _Events> = {
<Key extends keyof Events>(
type: Key,
handler: Handler<Events[Key]>,
options?: UseSubscribeOption,
): void;
(
type: '*',
handler: WildcardHandler<Events>,
option?: UseSubscribeOption,
): void;
}
export type UseMittReturn<Events extends _Events> = {
useSubscribe: ExtendedOn<Events>;
emit: Emitter<Events>['emit'];
}
const defaultSubscribeOption: UseSubscribeOption = {
enabled: true,
}
function useMitt<Events extends _Events>(
mitt?: Emitter<Events>,
): UseMittReturn<Events> {
const emitterRef = useRef<Emitter<Events> | undefined>(undefined)
if (!emitterRef.current)
emitterRef.current = mitt ?? create<Events>()
if (mitt && emitterRef.current !== mitt) {
emitterRef.current.off('*')
emitterRef.current = mitt
}
const emitter = emitterRef.current
const useSubscribe: ExtendedOn<Events> = (
type: string,
handler: any,
option?: UseSubscribeOption,
) => {
const { enabled } = merge(defaultSubscribeOption, option)
useEffect(() => {
if (enabled) {
emitter.on(type, handler)
return () => emitter.off(type, handler)
}
})
}
return {
emit: emitter.emit,
useSubscribe,
}
}
export { useMitt }

View File

@@ -0,0 +1,49 @@
import { useEffect, useRef, useState } from 'react'
import type { ModerationService } from '@/models/common'
function splitStringByLength(inputString: string, chunkLength: number) {
const resultArray = []
for (let i = 0; i < inputString.length; i += chunkLength)
resultArray.push(inputString.substring(i, i + chunkLength))
return resultArray
}
export const useModerate = (
content: string,
stop: boolean,
moderationService: (text: string) => ReturnType<ModerationService>,
separateLength = 50,
) => {
const moderatedContentMap = useRef<Map<number, string>>(new Map())
const moderatingIndex = useRef<number[]>([])
const [contentArr, setContentArr] = useState<string[]>([])
const handleModerate = () => {
const stringArr = splitStringByLength(content, separateLength)
const lastIndex = stringArr.length - 1
stringArr.forEach((item, index) => {
if (!(index in moderatingIndex.current) && !moderatedContentMap.current.get(index)) {
if (index === lastIndex && !stop)
return
moderatingIndex.current.push(index)
moderationService(item).then((res) => {
if (res.flagged) {
moderatedContentMap.current.set(index, res.text)
setContentArr([...stringArr.slice(0, index), res.text, ...stringArr.slice(index + 1)])
}
})
}
})
setContentArr(stringArr)
}
useEffect(() => {
if (content)
handleModerate()
}, [content, stop])
return contentArr.map((item, index) => moderatedContentMap.current.get(index) || item).join('')
}

View File

@@ -0,0 +1,70 @@
'use client'
import { useEffect } from 'react'
import { validateRedirectUrl } from '@/utils/urlValidation'
export const useOAuthCallback = () => {
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search)
const subscriptionId = urlParams.get('subscription_id')
const error = urlParams.get('error')
const errorDescription = urlParams.get('error_description')
if (window.opener) {
if (subscriptionId) {
window.opener.postMessage({
type: 'oauth_callback',
success: true,
subscriptionId,
}, '*')
}
else if (error) {
window.opener.postMessage({
type: 'oauth_callback',
success: false,
error,
errorDescription,
}, '*')
}
else {
window.opener.postMessage({
type: 'oauth_callback',
}, '*')
}
window.close()
}
}, [])
}
export const openOAuthPopup = (url: string, callback: (data?: any) => void) => {
const width = 600
const height = 600
const left = window.screenX + (window.outerWidth - width) / 2
const top = window.screenY + (window.outerHeight - height) / 2
validateRedirectUrl(url)
const popup = window.open(
url,
'OAuth',
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes`,
)
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'oauth_callback') {
window.removeEventListener('message', handleMessage)
callback(event.data)
}
}
window.addEventListener('message', handleMessage)
// Fallback for window close detection
const checkClosed = setInterval(() => {
if (popup?.closed) {
clearInterval(checkClosed)
window.removeEventListener('message', handleMessage)
callback()
}
}, 1000)
return popup
}

119
dify/web/hooks/use-pay.tsx Normal file
View File

@@ -0,0 +1,119 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import {
fetchDataSourceNotionBinding,
} from '@/service/common'
import type { IConfirm } from '@/app/components/base/confirm'
import Confirm from '@/app/components/base/confirm'
export type ConfirmType = Pick<IConfirm, 'type' | 'title' | 'content'>
export const useAnthropicCheckPay = () => {
const { t } = useTranslation()
const [confirm, setConfirm] = useState<ConfirmType | null>(null)
const searchParams = useSearchParams()
const providerName = searchParams.get('provider_name')
const paymentResult = searchParams.get('payment_result')
useEffect(() => {
if (providerName === 'anthropic' && (paymentResult === 'succeeded' || paymentResult === 'cancelled')) {
setConfirm({
type: paymentResult === 'succeeded' ? 'info' : 'warning',
title: paymentResult === 'succeeded' ? t('common.actionMsg.paySucceeded') : t('common.actionMsg.payCancelled'),
})
}
}, [providerName, paymentResult, t])
return confirm
}
export const useBillingPay = () => {
const { t } = useTranslation()
const [confirm, setConfirm] = useState<ConfirmType | null>(null)
const searchParams = useSearchParams()
const paymentType = searchParams.get('payment_type')
const paymentResult = searchParams.get('payment_result')
useEffect(() => {
if (paymentType === 'billing' && (paymentResult === 'succeeded' || paymentResult === 'cancelled')) {
setConfirm({
type: paymentResult === 'succeeded' ? 'info' : 'warning',
title: paymentResult === 'succeeded' ? t('common.actionMsg.paySucceeded') : t('common.actionMsg.payCancelled'),
})
}
}, [paymentType, paymentResult, t])
return confirm
}
export const useCheckNotion = () => {
const router = useRouter()
const [confirm, setConfirm] = useState<ConfirmType | null>(null)
const [canBinding, setCanBinding] = useState(false)
const searchParams = useSearchParams()
const type = searchParams.get('type')
const notionCode = searchParams.get('code')
const notionError = searchParams.get('error')
const { data } = useSWR(
(canBinding && notionCode)
? `/oauth/data-source/binding/notion?code=${notionCode}`
: null,
fetchDataSourceNotionBinding,
)
useEffect(() => {
if (data)
router.replace('/')
}, [data, router])
useEffect(() => {
if (type === 'notion') {
if (notionError) {
setConfirm({
type: 'warning',
title: notionError,
})
}
else if (notionCode) {
setCanBinding(true)
}
}
}, [type, notionCode, notionError])
return confirm
}
export const CheckModal = () => {
const router = useRouter()
const { t } = useTranslation()
const [showPayStatusModal, setShowPayStatusModal] = useState(true)
const anthropicConfirmInfo = useAnthropicCheckPay()
const notionConfirmInfo = useCheckNotion()
const billingConfirmInfo = useBillingPay()
const handleCancelShowPayStatusModal = useCallback(() => {
setShowPayStatusModal(false)
router.replace('/')
}, [router])
const confirmInfo = anthropicConfirmInfo || notionConfirmInfo || billingConfirmInfo
if (!confirmInfo || !showPayStatusModal)
return null
return (
<Confirm
isShow
onCancel={handleCancelShowPayStatusModal}
onConfirm={handleCancelShowPayStatusModal}
showCancel={false}
type={confirmInfo.type === 'info' ? 'info' : 'warning' }
title={confirmInfo.title}
content={(confirmInfo as unknown as { desc: string }).desc || ''}
confirmText={(confirmInfo.type === 'info' && t('common.operation.ok')) || ''}
/>
)
}

View File

@@ -0,0 +1,543 @@
/**
* Test suite for useTabSearchParams hook
*
* This hook manages tab state through URL search parameters, enabling:
* - Bookmarkable tab states (users can share URLs with specific tabs active)
* - Browser history integration (back/forward buttons work with tabs)
* - Configurable routing behavior (push vs replace)
* - Optional search parameter syncing (can disable URL updates)
*
* The hook syncs a local tab state with URL search parameters, making tab
* navigation persistent and shareable across sessions.
*/
import { act, renderHook } from '@testing-library/react'
import { useTabSearchParams } from './use-tab-searchparams'
// Mock Next.js navigation hooks
const mockPush = jest.fn()
const mockReplace = jest.fn()
const mockPathname = '/test-path'
const mockSearchParams = new URLSearchParams()
jest.mock('next/navigation', () => ({
usePathname: jest.fn(() => mockPathname),
useRouter: jest.fn(() => ({
push: mockPush,
replace: mockReplace,
})),
useSearchParams: jest.fn(() => mockSearchParams),
}))
// Import after mocks
import { usePathname } from 'next/navigation'
describe('useTabSearchParams', () => {
beforeEach(() => {
jest.clearAllMocks()
mockSearchParams.delete('category')
mockSearchParams.delete('tab')
})
describe('Basic functionality', () => {
/**
* Test that the hook returns a tuple with activeTab and setActiveTab
* This is the primary interface matching React's useState pattern
*/
it('should return activeTab and setActiveTab function', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
const [activeTab, setActiveTab] = result.current
expect(typeof activeTab).toBe('string')
expect(typeof setActiveTab).toBe('function')
})
/**
* Test that the hook initializes with the default tab
* When no search param is present, should use defaultTab
*/
it('should initialize with default tab when no search param exists', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
const [activeTab] = result.current
expect(activeTab).toBe('overview')
})
/**
* Test that the hook reads from URL search parameters
* When a search param exists, it should take precedence over defaultTab
*/
it('should initialize with search param value when present', () => {
mockSearchParams.set('category', 'settings')
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
const [activeTab] = result.current
expect(activeTab).toBe('settings')
})
/**
* Test that setActiveTab updates the local state
* The active tab should change when setActiveTab is called
*/
it('should update active tab when setActiveTab is called', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
const [activeTab] = result.current
expect(activeTab).toBe('settings')
})
})
describe('Routing behavior', () => {
/**
* Test default push routing behavior
* By default, tab changes should use router.push (adds to history)
*/
it('should use push routing by default', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings')
expect(mockReplace).not.toHaveBeenCalled()
})
/**
* Test replace routing behavior
* When routingBehavior is 'replace', should use router.replace (no history)
*/
it('should use replace routing when specified', () => {
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
routingBehavior: 'replace',
}),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
expect(mockReplace).toHaveBeenCalledWith('/test-path?category=settings')
expect(mockPush).not.toHaveBeenCalled()
})
/**
* Test that URL encoding is applied to tab values
* Special characters in tab names should be properly encoded
*/
it('should encode special characters in tab values', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings & config')
})
expect(mockPush).toHaveBeenCalledWith(
'/test-path?category=settings%20%26%20config',
)
})
/**
* Test that URL decoding is applied when reading from search params
* Encoded values in the URL should be properly decoded
*/
it('should decode encoded values from search params', () => {
mockSearchParams.set('category', 'settings%20%26%20config')
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
const [activeTab] = result.current
expect(activeTab).toBe('settings & config')
})
})
describe('Custom search parameter name', () => {
/**
* Test using a custom search parameter name
* Should support different param names instead of default 'category'
*/
it('should use custom search param name', () => {
mockSearchParams.set('tab', 'profile')
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
searchParamName: 'tab',
}),
)
const [activeTab] = result.current
expect(activeTab).toBe('profile')
})
/**
* Test that setActiveTab uses the custom param name in the URL
*/
it('should update URL with custom param name', () => {
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
searchParamName: 'tab',
}),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('profile')
})
expect(mockPush).toHaveBeenCalledWith('/test-path?tab=profile')
})
})
describe('Disabled search params mode', () => {
/**
* Test that disableSearchParams prevents URL updates
* When disabled, tab state should be local only
*/
it('should not update URL when disableSearchParams is true', () => {
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
disableSearchParams: true,
}),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
expect(mockPush).not.toHaveBeenCalled()
expect(mockReplace).not.toHaveBeenCalled()
})
/**
* Test that local state still updates when search params are disabled
* The tab state should work even without URL syncing
*/
it('should still update local state when search params disabled', () => {
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
disableSearchParams: true,
}),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
const [activeTab] = result.current
expect(activeTab).toBe('settings')
})
/**
* Test that disabled mode always uses defaultTab
* Search params should be ignored when disabled
*/
it('should use defaultTab when search params disabled even if URL has value', () => {
mockSearchParams.set('category', 'settings')
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
disableSearchParams: true,
}),
)
const [activeTab] = result.current
expect(activeTab).toBe('overview')
})
})
describe('Edge cases', () => {
/**
* Test handling of empty string tab values
* Empty strings should be handled gracefully
*/
it('should handle empty string tab values', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('')
})
const [activeTab] = result.current
expect(activeTab).toBe('')
expect(mockPush).toHaveBeenCalledWith('/test-path?category=')
})
/**
* Test that special characters in tab names are properly encoded
* This ensures URLs remain valid even with unusual tab names
*/
it('should handle tabs with various special characters', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
// Test tab with slashes
act(() => result.current[1]('tab/with/slashes'))
expect(result.current[0]).toBe('tab/with/slashes')
// Test tab with question marks
act(() => result.current[1]('tab?with?questions'))
expect(result.current[0]).toBe('tab?with?questions')
// Test tab with hash symbols
act(() => result.current[1]('tab#with#hash'))
expect(result.current[0]).toBe('tab#with#hash')
// Test tab with equals signs
act(() => result.current[1]('tab=with=equals'))
expect(result.current[0]).toBe('tab=with=equals')
})
/**
* Test fallback when pathname is not available
* Should use window.location.pathname as fallback
*/
it('should fallback to window.location.pathname when hook pathname is null', () => {
;(usePathname as jest.Mock).mockReturnValue(null)
// Mock window.location.pathname
Object.defineProperty(window, 'location', {
value: { pathname: '/fallback-path' },
writable: true,
})
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
expect(mockPush).toHaveBeenCalledWith('/fallback-path?category=settings')
// Restore mock
;(usePathname as jest.Mock).mockReturnValue(mockPathname)
})
})
describe('Multiple instances', () => {
/**
* Test that multiple instances with different param names work independently
* Different hooks should not interfere with each other
*/
it('should support multiple independent tab states', () => {
mockSearchParams.set('category', 'overview')
mockSearchParams.set('subtab', 'details')
const { result: result1 } = renderHook(() =>
useTabSearchParams({
defaultTab: 'home',
searchParamName: 'category',
}),
)
const { result: result2 } = renderHook(() =>
useTabSearchParams({
defaultTab: 'info',
searchParamName: 'subtab',
}),
)
const [activeTab1] = result1.current
const [activeTab2] = result2.current
expect(activeTab1).toBe('overview')
expect(activeTab2).toBe('details')
})
})
describe('Integration scenarios', () => {
/**
* Test typical usage in a tabbed interface
* Simulates real-world tab switching behavior
*/
it('should handle sequential tab changes', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
// Change to settings tab
act(() => {
const [, setActiveTab] = result.current
setActiveTab('settings')
})
expect(result.current[0]).toBe('settings')
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings')
// Change to profile tab
act(() => {
const [, setActiveTab] = result.current
setActiveTab('profile')
})
expect(result.current[0]).toBe('profile')
expect(mockPush).toHaveBeenCalledWith('/test-path?category=profile')
// Verify push was called twice
expect(mockPush).toHaveBeenCalledTimes(2)
})
/**
* Test that the hook works with complex pathnames
* Should handle nested routes and existing query params
*/
it('should work with complex pathnames', () => {
;(usePathname as jest.Mock).mockReturnValue('/app/123/settings')
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('advanced')
})
expect(mockPush).toHaveBeenCalledWith('/app/123/settings?category=advanced')
// Restore mock
;(usePathname as jest.Mock).mockReturnValue(mockPathname)
})
})
describe('Type safety', () => {
/**
* Test that the return type is a const tuple
* TypeScript should infer [string, (tab: string) => void] as const
*/
it('should return a const tuple type', () => {
const { result } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
// The result should be a tuple with exactly 2 elements
expect(result.current).toHaveLength(2)
expect(typeof result.current[0]).toBe('string')
expect(typeof result.current[1]).toBe('function')
})
})
describe('Performance', () => {
/**
* Test that the hook creates a new function on each render
* Note: The current implementation doesn't use useCallback,
* so setActiveTab is recreated on each render. This could lead to
* unnecessary re-renders in child components that depend on this function.
* TODO: Consider memoizing setActiveTab with useCallback for better performance.
*/
it('should create new setActiveTab function on each render', () => {
const { result, rerender } = renderHook(() =>
useTabSearchParams({ defaultTab: 'overview' }),
)
const [, firstSetActiveTab] = result.current
rerender()
const [, secondSetActiveTab] = result.current
// Function reference changes on re-render (not memoized)
expect(firstSetActiveTab).not.toBe(secondSetActiveTab)
// But both functions should work correctly
expect(typeof firstSetActiveTab).toBe('function')
expect(typeof secondSetActiveTab).toBe('function')
})
})
describe('Browser history integration', () => {
/**
* Test that push behavior adds to browser history
* This enables back/forward navigation through tabs
*/
it('should add to history with push behavior', () => {
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
routingBehavior: 'push',
}),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('tab1')
})
act(() => {
const [, setActiveTab] = result.current
setActiveTab('tab2')
})
act(() => {
const [, setActiveTab] = result.current
setActiveTab('tab3')
})
// Each tab change should create a history entry
expect(mockPush).toHaveBeenCalledTimes(3)
})
/**
* Test that replace behavior doesn't add to history
* This prevents cluttering browser history with tab changes
*/
it('should not add to history with replace behavior', () => {
const { result } = renderHook(() =>
useTabSearchParams({
defaultTab: 'overview',
routingBehavior: 'replace',
}),
)
act(() => {
const [, setActiveTab] = result.current
setActiveTab('tab1')
})
act(() => {
const [, setActiveTab] = result.current
setActiveTab('tab2')
})
// Should use replace instead of push
expect(mockReplace).toHaveBeenCalledTimes(2)
expect(mockPush).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,47 @@
'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
type UseTabSearchParamsOptions = {
defaultTab: string
routingBehavior?: 'push' | 'replace'
searchParamName?: string
disableSearchParams?: boolean
}
/**
* Custom hook to manage tab state via URL search parameters in a Next.js application.
* This hook allows for syncing the active tab with the browser's URL, enabling bookmarking and sharing of URLs with a specific tab activated.
*
* @param {UseTabSearchParamsOptions} options Configuration options for the hook:
* - `defaultTab`: The tab to default to when no tab is specified in the URL.
* - `routingBehavior`: Optional. Determines how changes to the active tab update the browser's history ('push' or 'replace'). Default is 'push'.
* - `searchParamName`: Optional. The name of the search parameter that holds the tab state in the URL. Default is 'category'.
* @returns A tuple where the first element is the active tab and the second element is a function to set the active tab.
*/
export const useTabSearchParams = ({
defaultTab,
routingBehavior = 'push',
searchParamName = 'category',
disableSearchParams = false,
}: UseTabSearchParamsOptions) => {
const pathnameFromHook = usePathname()
const router = useRouter()
const pathName = pathnameFromHook || window?.location?.pathname
const searchParams = useSearchParams()
const searchParamValue = searchParams.has(searchParamName) ? decodeURIComponent(searchParams.get(searchParamName)!) : defaultTab
const [activeTab, setTab] = useState<string>(
!disableSearchParams
? searchParamValue
: defaultTab,
)
const setActiveTab = (newActiveTab: string) => {
setTab(newActiveTab)
if (disableSearchParams)
return
router[`${routingBehavior}`](`${pathName}?${searchParamName}=${encodeURIComponent(newActiveTab)}`)
}
return [activeTab, setActiveTab] as const
}

View File

@@ -0,0 +1,13 @@
import { Theme } from '@/types/app'
import { useTheme as useBaseTheme } from 'next-themes'
const useTheme = () => {
const { theme, resolvedTheme, ...rest } = useBaseTheme()
return {
// only returns 'light' or 'dark' theme
theme: theme === Theme.system ? resolvedTheme as Theme : theme as Theme,
...rest,
}
}
export default useTheme

View File

@@ -0,0 +1,65 @@
import { renderHook } from '@testing-library/react'
import useTimestamp from './use-timestamp'
jest.mock('@/context/app-context', () => ({
useAppContext: jest.fn(() => ({
userProfile: {
id: '8b18e24b-1ac8-4262-aa5c-e9aa95c76846',
name: 'test',
avatar: null,
avatar_url: null,
email: 'test@dify.ai',
is_password_set: false,
interface_language: 'zh-Hans',
interface_theme: 'light',
timezone: 'Asia/Shanghai',
last_login_at: 1744188761,
last_login_ip: '127.0.0.1',
created_at: 1728444483,
},
})),
}))
describe('useTimestamp', () => {
describe('formatTime', () => {
it('should format unix timestamp correctly', () => {
const { result } = renderHook(() => useTimestamp())
const timestamp = 1704132000
expect(result.current.formatTime(timestamp, 'YYYY-MM-DD HH:mm:ss'))
.toBe('2024-01-02 02:00:00')
})
it('should format with different patterns', () => {
const { result } = renderHook(() => useTimestamp())
const timestamp = 1704132000
expect(result.current.formatTime(timestamp, 'MM/DD/YYYY'))
.toBe('01/02/2024')
expect(result.current.formatTime(timestamp, 'HH:mm'))
.toBe('02:00')
})
})
describe('formatDate', () => {
it('should format date string correctly', () => {
const { result } = renderHook(() => useTimestamp())
const dateString = '2024-01-01T12:00:00Z'
expect(result.current.formatDate(dateString, 'YYYY-MM-DD HH:mm:ss'))
.toBe('2024-01-01 20:00:00')
})
it('should format with different patterns', () => {
const { result } = renderHook(() => useTimestamp())
const dateString = '2024-01-01T12:00:00Z'
expect(result.current.formatDate(dateString, 'MM/DD/YYYY'))
.toBe('01/01/2024')
expect(result.current.formatDate(dateString, 'HH:mm'))
.toBe('20:00')
})
})
})

View File

@@ -0,0 +1,25 @@
'use client'
import { useCallback } from 'react'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import { useAppContext } from '@/context/app-context'
dayjs.extend(utc)
dayjs.extend(timezone)
const useTimestamp = () => {
const { userProfile: { timezone } } = useAppContext()
const formatTime = useCallback((value: number, format: string) => {
return dayjs.unix(value).tz(timezone).format(format)
}, [timezone])
const formatDate = useCallback((value: string, format: string) => {
return dayjs(value).tz(timezone).format(format)
}, [timezone])
return { formatTime, formatDate }
}
export default useTimestamp