From b2cbeeae929fc1a593a04994d032dfb2b4a7fbd5 Mon Sep 17 00:00:00 2001 From: xuwei95 <1013104194@qq.com> Date: Thu, 8 Jan 2026 17:23:27 +0800 Subject: [PATCH 01/22] fix(web): restrict postMessage targetOrigin from wildcard to specific origins (#30690) Co-authored-by: XW --- .../base/chat/embedded-chatbot/header/index.tsx | 4 +++- web/hooks/use-oauth.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/web/app/components/base/chat/embedded-chatbot/header/index.tsx b/web/app/components/base/chat/embedded-chatbot/header/index.tsx index 869f88efb6..95ba6d212d 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/index.tsx @@ -66,7 +66,9 @@ const Header: FC = ({ const listener = (event: MessageEvent) => handleMessageReceived(event) window.addEventListener('message', listener) - window.parent.postMessage({ type: 'dify-chatbot-iframe-ready' }, '*') + // Security: Use document.referrer to get parent origin + const targetOrigin = document.referrer ? new URL(document.referrer).origin : '*' + window.parent.postMessage({ type: 'dify-chatbot-iframe-ready' }, targetOrigin) return () => window.removeEventListener('message', listener) }, [isIframe, handleMessageReceived]) diff --git a/web/hooks/use-oauth.ts b/web/hooks/use-oauth.ts index 34ed8bafb0..8fb2707804 100644 --- a/web/hooks/use-oauth.ts +++ b/web/hooks/use-oauth.ts @@ -10,12 +10,15 @@ export const useOAuthCallback = () => { const errorDescription = urlParams.get('error_description') if (window.opener) { + // Use window.opener.origin instead of '*' for security + const targetOrigin = window.opener?.origin || '*' + if (subscriptionId) { window.opener.postMessage({ type: 'oauth_callback', success: true, subscriptionId, - }, '*') + }, targetOrigin) } else if (error) { window.opener.postMessage({ @@ -23,12 +26,12 @@ export const useOAuthCallback = () => { success: false, error, errorDescription, - }, '*') + }, targetOrigin) } else { window.opener.postMessage({ type: 'oauth_callback', - }, '*') + }, targetOrigin) } window.close() } From 91d44719f455f2988f3ebdd18768c283805706da Mon Sep 17 00:00:00 2001 From: MkDev11 Date: Thu, 8 Jan 2026 02:05:32 -0800 Subject: [PATCH 02/22] fix(web): resolve chat message loading race conditions and infinite loops (#30695) Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- web/app/components/app/log/list.spec.tsx | 228 ++++++++++++++++++++ web/app/components/app/log/list.tsx | 252 +++++++++-------------- 2 files changed, 322 insertions(+), 158 deletions(-) create mode 100644 web/app/components/app/log/list.spec.tsx diff --git a/web/app/components/app/log/list.spec.tsx b/web/app/components/app/log/list.spec.tsx new file mode 100644 index 0000000000..81901c6cad --- /dev/null +++ b/web/app/components/app/log/list.spec.tsx @@ -0,0 +1,228 @@ +/** + * Tests for race condition prevention logic in chat message loading. + * These tests verify the core algorithms used in fetchData and loadMoreMessages + * to prevent race conditions, infinite loops, and stale state issues. + * See GitHub issue #30259 for context. + */ + +// Test the race condition prevention logic in isolation +describe('Chat Message Loading Race Condition Prevention', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('Request Deduplication', () => { + it('should deduplicate messages with same IDs when merging responses', async () => { + // Simulate the deduplication logic used in setAllChatItems + const existingItems = [ + { id: 'msg-1', isAnswer: false }, + { id: 'msg-2', isAnswer: true }, + ] + const newItems = [ + { id: 'msg-2', isAnswer: true }, // duplicate + { id: 'msg-3', isAnswer: false }, // new + ] + + const existingIds = new Set(existingItems.map(item => item.id)) + const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id)) + const mergedItems = [...uniqueNewItems, ...existingItems] + + expect(uniqueNewItems).toHaveLength(1) + expect(uniqueNewItems[0].id).toBe('msg-3') + expect(mergedItems).toHaveLength(3) + }) + }) + + describe('Retry Counter Logic', () => { + const MAX_RETRY_COUNT = 3 + + it('should increment retry counter when no unique items found', () => { + const state = { retryCount: 0 } + const prevItemsLength = 5 + + // Simulate the retry logic from loadMoreMessages + const uniqueNewItemsLength = 0 + + if (uniqueNewItemsLength === 0) { + if (state.retryCount < MAX_RETRY_COUNT && prevItemsLength > 1) { + state.retryCount++ + } + else { + state.retryCount = 0 + } + } + + expect(state.retryCount).toBe(1) + }) + + it('should reset retry counter after MAX_RETRY_COUNT attempts', () => { + const state = { retryCount: MAX_RETRY_COUNT } + const prevItemsLength = 5 + const uniqueNewItemsLength = 0 + + if (uniqueNewItemsLength === 0) { + if (state.retryCount < MAX_RETRY_COUNT && prevItemsLength > 1) { + state.retryCount++ + } + else { + state.retryCount = 0 + } + } + + expect(state.retryCount).toBe(0) + }) + + it('should reset retry counter when unique items are found', () => { + const state = { retryCount: 2 } + + // Simulate finding unique items (length > 0) + const processRetry = (uniqueCount: number) => { + if (uniqueCount === 0) { + state.retryCount++ + } + else { + state.retryCount = 0 + } + } + + processRetry(3) // Found 3 unique items + + expect(state.retryCount).toBe(0) + }) + }) + + describe('Throttling Logic', () => { + const SCROLL_DEBOUNCE_MS = 200 + + it('should throttle requests within debounce window', () => { + const state = { lastLoadTime: 0 } + const results: boolean[] = [] + + const tryRequest = (now: number): boolean => { + if (now - state.lastLoadTime >= SCROLL_DEBOUNCE_MS) { + state.lastLoadTime = now + return true + } + return false + } + + // First request - should pass + results.push(tryRequest(1000)) + // Second request within debounce - should be blocked + results.push(tryRequest(1100)) + // Third request after debounce - should pass + results.push(tryRequest(1300)) + + expect(results).toEqual([true, false, true]) + }) + }) + + describe('AbortController Cancellation', () => { + it('should abort previous request when new request starts', () => { + const state: { controller: AbortController | null } = { controller: null } + const abortedSignals: boolean[] = [] + + // First request + const controller1 = new AbortController() + state.controller = controller1 + + // Second request - should abort first + if (state.controller) { + state.controller.abort() + abortedSignals.push(state.controller.signal.aborted) + } + const controller2 = new AbortController() + state.controller = controller2 + + expect(abortedSignals).toEqual([true]) + expect(controller1.signal.aborted).toBe(true) + expect(controller2.signal.aborted).toBe(false) + }) + }) + + describe('Stale Response Detection', () => { + it('should ignore responses from outdated requests', () => { + const state = { requestId: 0 } + const processedResponses: number[] = [] + + // Simulate concurrent requests - each gets its own captured ID + const request1Id = ++state.requestId + const request2Id = ++state.requestId + + // Request 2 completes first (current requestId is 2) + if (request2Id === state.requestId) { + processedResponses.push(request2Id) + } + + // Request 1 completes later (stale - requestId is still 2) + if (request1Id === state.requestId) { + processedResponses.push(request1Id) + } + + expect(processedResponses).toEqual([2]) + expect(processedResponses).not.toContain(1) + }) + }) + + describe('Pagination Anchor Management', () => { + it('should track oldest answer ID for pagination', () => { + let oldestAnswerIdRef: string | undefined + + const chatItems = [ + { id: 'question-1', isAnswer: false }, + { id: 'answer-1', isAnswer: true }, + { id: 'question-2', isAnswer: false }, + { id: 'answer-2', isAnswer: true }, + ] + + // Update pagination anchor with oldest answer ID + const answerItems = chatItems.filter(item => item.isAnswer) + const oldestAnswer = answerItems[answerItems.length - 1] + if (oldestAnswer?.id) { + oldestAnswerIdRef = oldestAnswer.id + } + + expect(oldestAnswerIdRef).toBe('answer-2') + }) + + it('should use pagination anchor in subsequent requests', () => { + const oldestAnswerIdRef = 'answer-123' + const params: { conversation_id: string, limit: number, first_id?: string } = { + conversation_id: 'conv-1', + limit: 10, + } + + if (oldestAnswerIdRef) { + params.first_id = oldestAnswerIdRef + } + + expect(params.first_id).toBe('answer-123') + }) + }) +}) + +describe('Functional State Update Pattern', () => { + it('should use functional update to avoid stale closures', () => { + // Simulate the functional update pattern used in setAllChatItems + let state = [{ id: '1' }, { id: '2' }] + + const newItems = [{ id: '3' }, { id: '2' }] // id '2' is duplicate + + // Functional update pattern + const updater = (prevItems: { id: string }[]) => { + const existingIds = new Set(prevItems.map(item => item.id)) + const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id)) + return [...uniqueNewItems, ...prevItems] + } + + state = updater(state) + + expect(state).toHaveLength(3) + expect(state.map(i => i.id)).toEqual(['3', '1', '2']) + }) +}) diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index a17177bf7e..410953ccf7 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -209,7 +209,6 @@ type IDetailPanel = { function DetailPanel({ detail, onFeedback }: IDetailPanel) { const MIN_ITEMS_FOR_SCROLL_LOADING = 8 - const SCROLL_THRESHOLD_PX = 50 const SCROLL_DEBOUNCE_MS = 200 const { userProfile: { timezone } } = useAppContext() const { formatTime } = useTimestamp() @@ -228,69 +227,103 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { const [hasMore, setHasMore] = useState(true) const [varValues, setVarValues] = useState>({}) const isLoadingRef = useRef(false) + const abortControllerRef = useRef(null) + const requestIdRef = useRef(0) + const lastLoadTimeRef = useRef(0) + const retryCountRef = useRef(0) + const oldestAnswerIdRef = useRef(undefined) + const MAX_RETRY_COUNT = 3 const [allChatItems, setAllChatItems] = useState([]) const [chatItemTree, setChatItemTree] = useState([]) const [threadChatItems, setThreadChatItems] = useState([]) const fetchData = useCallback(async () => { - if (isLoadingRef.current) + if (isLoadingRef.current || !hasMore) return + // Cancel any in-flight request + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + + const controller = new AbortController() + abortControllerRef.current = controller + const currentRequestId = ++requestIdRef.current + try { isLoadingRef.current = true - if (!hasMore) - return - const params: ChatMessagesRequest = { conversation_id: detail.id, limit: 10, } - // Use the oldest answer item ID for pagination - const answerItems = allChatItems.filter(item => item.isAnswer) - const oldestAnswerItem = answerItems[answerItems.length - 1] - if (oldestAnswerItem?.id) - params.first_id = oldestAnswerItem.id + // Use ref for pagination anchor to avoid stale closure issues + if (oldestAnswerIdRef.current) + params.first_id = oldestAnswerIdRef.current + const messageRes = await fetchChatMessages({ url: `/apps/${appDetail?.id}/chat-messages`, params, }) + + // Ignore stale responses + if (currentRequestId !== requestIdRef.current || controller.signal.aborted) + return if (messageRes.data.length > 0) { const varValues = messageRes.data.at(-1)!.inputs setVarValues(varValues) } setHasMore(messageRes.has_more) - const newAllChatItems = [ - ...getFormattedChatList(messageRes.data, detail.id, timezone!, t('dateTimeFormat', { ns: 'appLog' }) as string), - ...allChatItems, - ] - setAllChatItems(newAllChatItems) + const newItems = getFormattedChatList(messageRes.data, detail.id, timezone!, t('dateTimeFormat', { ns: 'appLog' }) as string) - let tree = buildChatItemTree(newAllChatItems) - if (messageRes.has_more === false && detail?.model_config?.configs?.introduction) { - tree = [{ - id: 'introduction', - isAnswer: true, - isOpeningStatement: true, - content: detail?.model_config?.configs?.introduction ?? 'hello', - feedbackDisabled: true, - children: tree, - }] - } - setChatItemTree(tree) - - const lastMessageId = newAllChatItems.length > 0 ? newAllChatItems[newAllChatItems.length - 1].id : undefined - setThreadChatItems(getThreadMessages(tree, lastMessageId)) + // Use functional update to avoid stale state issues + setAllChatItems((prevItems: IChatItem[]) => { + const existingIds = new Set(prevItems.map(item => item.id)) + const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id)) + return [...uniqueNewItems, ...prevItems] + }) } - catch (err) { + catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') + return console.error('fetchData execution failed:', err) } finally { isLoadingRef.current = false + if (abortControllerRef.current === controller) + abortControllerRef.current = null } - }, [allChatItems, detail.id, hasMore, timezone, t, appDetail, detail?.model_config?.configs?.introduction]) + }, [detail.id, hasMore, timezone, t, appDetail, detail?.model_config?.configs?.introduction]) + + // Derive chatItemTree, threadChatItems, and oldestAnswerIdRef from allChatItems + useEffect(() => { + if (allChatItems.length === 0) + return + + let tree = buildChatItemTree(allChatItems) + if (!hasMore && detail?.model_config?.configs?.introduction) { + tree = [{ + id: 'introduction', + isAnswer: true, + isOpeningStatement: true, + content: detail?.model_config?.configs?.introduction ?? 'hello', + feedbackDisabled: true, + children: tree, + }] + } + setChatItemTree(tree) + + const lastMessageId = allChatItems.length > 0 ? allChatItems[allChatItems.length - 1].id : undefined + setThreadChatItems(getThreadMessages(tree, lastMessageId)) + + // Update pagination anchor ref with the oldest answer ID + const answerItems = allChatItems.filter(item => item.isAnswer) + const oldestAnswer = answerItems[answerItems.length - 1] + if (oldestAnswer?.id) + oldestAnswerIdRef.current = oldestAnswer.id + }, [allChatItems, hasMore, detail?.model_config?.configs?.introduction]) const switchSibling = useCallback((siblingMessageId: string) => { const newThreadChatItems = getThreadMessages(chatItemTree, siblingMessageId) @@ -397,6 +430,12 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { if (isLoading || !hasMore || !appDetail?.id || !detail.id) return + // Throttle using ref to persist across re-renders + const now = Date.now() + if (now - lastLoadTimeRef.current < SCROLL_DEBOUNCE_MS) + return + lastLoadTimeRef.current = now + setIsLoading(true) try { @@ -405,15 +444,9 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { limit: 10, } - // Use the earliest response item as the first_id - const answerItems = allChatItems.filter(item => item.isAnswer) - const oldestAnswerItem = answerItems[answerItems.length - 1] - if (oldestAnswerItem?.id) { - params.first_id = oldestAnswerItem.id - } - else if (allChatItems.length > 0 && allChatItems[0]?.id) { - const firstId = allChatItems[0].id.replace('question-', '').replace('answer-', '') - params.first_id = firstId + // Use ref for pagination anchor to avoid stale closure issues + if (oldestAnswerIdRef.current) { + params.first_id = oldestAnswerIdRef.current } const messageRes = await fetchChatMessages({ @@ -423,6 +456,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { if (!messageRes.data || messageRes.data.length === 0) { setHasMore(false) + retryCountRef.current = 0 return } @@ -440,91 +474,36 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { t('dateTimeFormat', { ns: 'appLog' }) as string, ) - // Check for duplicate messages - const existingIds = new Set(allChatItems.map(item => item.id)) - const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id)) + // Use functional update to get latest state and avoid stale closures + setAllChatItems((prevItems: IChatItem[]) => { + const existingIds = new Set(prevItems.map(item => item.id)) + const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id)) - if (uniqueNewItems.length === 0) { - if (allChatItems.length > 1) { - const nextId = allChatItems[1].id.replace('question-', '').replace('answer-', '') - - const retryParams = { - ...params, - first_id: nextId, + // If no unique items and we haven't exceeded retry limit, signal retry needed + if (uniqueNewItems.length === 0) { + if (retryCountRef.current < MAX_RETRY_COUNT && prevItems.length > 1) { + retryCountRef.current++ + return prevItems } - - const retryRes = await fetchChatMessages({ - url: `/apps/${appDetail.id}/chat-messages`, - params: retryParams, - }) - - if (retryRes.data && retryRes.data.length > 0) { - const retryItems = getFormattedChatList( - retryRes.data, - detail.id, - timezone!, - t('dateTimeFormat', { ns: 'appLog' }) as string, - ) - - const retryUniqueItems = retryItems.filter(item => !existingIds.has(item.id)) - if (retryUniqueItems.length > 0) { - const newAllChatItems = [ - ...retryUniqueItems, - ...allChatItems, - ] - - setAllChatItems(newAllChatItems) - - let tree = buildChatItemTree(newAllChatItems) - if (retryRes.has_more === false && detail?.model_config?.configs?.introduction) { - tree = [{ - id: 'introduction', - isAnswer: true, - isOpeningStatement: true, - content: detail?.model_config?.configs?.introduction ?? 'hello', - feedbackDisabled: true, - children: tree, - }] - } - setChatItemTree(tree) - setHasMore(retryRes.has_more) - setThreadChatItems(getThreadMessages(tree, newAllChatItems.at(-1)?.id)) - return - } + else { + retryCountRef.current = 0 + return prevItems } } - } - const newAllChatItems = [ - ...uniqueNewItems, - ...allChatItems, - ] - - setAllChatItems(newAllChatItems) - - let tree = buildChatItemTree(newAllChatItems) - if (messageRes.has_more === false && detail?.model_config?.configs?.introduction) { - tree = [{ - id: 'introduction', - isAnswer: true, - isOpeningStatement: true, - content: detail?.model_config?.configs?.introduction ?? 'hello', - feedbackDisabled: true, - children: tree, - }] - } - setChatItemTree(tree) - - setThreadChatItems(getThreadMessages(tree, newAllChatItems.at(-1)?.id)) + retryCountRef.current = 0 + return [...uniqueNewItems, ...prevItems] + }) } catch (error) { console.error(error) setHasMore(false) + retryCountRef.current = 0 } finally { setIsLoading(false) } - }, [allChatItems, detail.id, hasMore, isLoading, timezone, t, appDetail]) + }, [detail.id, hasMore, isLoading, timezone, t, appDetail, detail?.model_config?.configs?.introduction]) useEffect(() => { const scrollableDiv = document.getElementById('scrollableDiv') @@ -556,24 +535,11 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { if (!scrollContainer) return - let lastLoadTime = 0 - const throttleDelay = 200 - const handleScroll = () => { const currentScrollTop = scrollContainer!.scrollTop - const scrollHeight = scrollContainer!.scrollHeight - const clientHeight = scrollContainer!.clientHeight + const isNearTop = currentScrollTop < 30 - const distanceFromTop = currentScrollTop - const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight - - const now = Date.now() - - const isNearTop = distanceFromTop < 30 - // eslint-disable-next-line sonarjs/no-unused-vars - const _distanceFromBottom = distanceFromBottom < 30 - if (isNearTop && hasMore && !isLoading && (now - lastLoadTime > throttleDelay)) { - lastLoadTime = now + if (isNearTop && hasMore && !isLoading) { loadMoreMessages() } } @@ -619,36 +585,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { return () => cancelAnimationFrame(raf) }, []) - // Add scroll listener to ensure loading is triggered - useEffect(() => { - if (threadChatItems.length >= MIN_ITEMS_FOR_SCROLL_LOADING && hasMore) { - const scrollableDiv = document.getElementById('scrollableDiv') - - if (scrollableDiv) { - let loadingTimeout: NodeJS.Timeout | null = null - - const handleScroll = () => { - const { scrollTop } = scrollableDiv - - // Trigger loading when scrolling near the top - if (scrollTop < SCROLL_THRESHOLD_PX && !isLoadingRef.current) { - if (loadingTimeout) - clearTimeout(loadingTimeout) - - loadingTimeout = setTimeout(fetchData, SCROLL_DEBOUNCE_MS) // 200ms debounce - } - } - - scrollableDiv.addEventListener('scroll', handleScroll) - return () => { - scrollableDiv.removeEventListener('scroll', handleScroll) - if (loadingTimeout) - clearTimeout(loadingTimeout) - } - } - } - }, [threadChatItems.length, hasMore, fetchData]) - return (
{/* Panel Header */} From 7774a1312e37b4536d031e6179fea7e919a60314 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 8 Jan 2026 21:28:49 +0800 Subject: [PATCH 03/22] fix(ci): use repository_dispatch for i18n sync workflow (#30744) --- .github/workflows/translate-i18n-claude.yml | 47 +++++++++------ .github/workflows/trigger-i18n-sync.yml | 66 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/trigger-i18n-sync.yml diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index 0e05913576..003e7ffc6e 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -1,10 +1,12 @@ name: Translate i18n Files with Claude Code +# Note: claude-code-action doesn't support push events directly. +# Push events are handled by trigger-i18n-sync.yml which sends repository_dispatch. +# See: https://github.com/langgenius/dify/issues/30743 + on: - push: - branches: [main] - paths: - - 'web/i18n/en-US/*.json' + repository_dispatch: + types: [i18n-sync] workflow_dispatch: inputs: files: @@ -87,26 +89,35 @@ jobs: echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT fi fi - else - # Push trigger - detect changed files from the push - BEFORE_SHA="${{ github.event.before }}" - # Handle edge case: first push or force push may have null/zero SHA - if [ -z "$BEFORE_SHA" ] || [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then - # Fallback to comparing with parent commit - BEFORE_SHA="HEAD~1" + elif [ "${{ github.event_name }}" == "repository_dispatch" ]; then + # Triggered by push via trigger-i18n-sync.yml workflow + # Validate required payload fields + if [ -z "${{ github.event.client_payload.changed_files }}" ]; then + echo "Error: repository_dispatch payload missing required 'changed_files' field" >&2 + exit 1 fi - changed=$(git diff --name-only "$BEFORE_SHA" ${{ github.sha }} -- 'web/i18n/en-US/*.json' 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/.json$//' | tr '\n' ' ' || echo "") - echo "CHANGED_FILES=$changed" >> $GITHUB_OUTPUT + echo "CHANGED_FILES=${{ github.event.client_payload.changed_files }}" >> $GITHUB_OUTPUT echo "TARGET_LANGS=" >> $GITHUB_OUTPUT - echo "SYNC_MODE=incremental" >> $GITHUB_OUTPUT + echo "SYNC_MODE=${{ github.event.client_payload.sync_mode || 'incremental' }}" >> $GITHUB_OUTPUT - # Generate detailed diff for the push - git diff "$BEFORE_SHA"..${{ github.sha }} -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || echo "" > /tmp/i18n-diff.txt - if [ -s /tmp/i18n-diff.txt ]; then - echo "DIFF_AVAILABLE=true" >> $GITHUB_OUTPUT + # Decode the base64-encoded diff from the trigger workflow + if [ -n "${{ github.event.client_payload.diff_base64 }}" ]; then + if ! echo "${{ github.event.client_payload.diff_base64 }}" | base64 -d > /tmp/i18n-diff.txt 2>&1; then + echo "Warning: Failed to decode base64 diff payload" >&2 + echo "" > /tmp/i18n-diff.txt + echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT + elif [ -s /tmp/i18n-diff.txt ]; then + echo "DIFF_AVAILABLE=true" >> $GITHUB_OUTPUT + else + echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT + fi else + echo "" > /tmp/i18n-diff.txt echo "DIFF_AVAILABLE=false" >> $GITHUB_OUTPUT fi + else + echo "Unsupported event type: ${{ github.event_name }}" + exit 1 fi # Truncate diff if too large (keep first 50KB) diff --git a/.github/workflows/trigger-i18n-sync.yml b/.github/workflows/trigger-i18n-sync.yml new file mode 100644 index 0000000000..de093c9235 --- /dev/null +++ b/.github/workflows/trigger-i18n-sync.yml @@ -0,0 +1,66 @@ +name: Trigger i18n Sync on Push + +# This workflow bridges the push event to repository_dispatch +# because claude-code-action doesn't support push events directly. +# See: https://github.com/langgenius/dify/issues/30743 + +on: + push: + branches: [main] + paths: + - 'web/i18n/en-US/*.json' + +permissions: + contents: write + +jobs: + trigger: + if: github.repository == 'langgenius/dify' + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect changed files and generate diff + id: detect + run: | + BEFORE_SHA="${{ github.event.before }}" + # Handle edge case: force push may have null/zero SHA + if [ -z "$BEFORE_SHA" ] || [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then + BEFORE_SHA="HEAD~1" + fi + + # Detect changed i18n files + changed=$(git diff --name-only "$BEFORE_SHA" "${{ github.sha }}" -- 'web/i18n/en-US/*.json' 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/.json$//' | tr '\n' ' ' || echo "") + echo "changed_files=$changed" >> $GITHUB_OUTPUT + + # Generate diff for context + git diff "$BEFORE_SHA" "${{ github.sha }}" -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || echo "" > /tmp/i18n-diff.txt + + # Truncate if too large (keep first 50KB to match receiving workflow) + head -c 50000 /tmp/i18n-diff.txt > /tmp/i18n-diff-truncated.txt + mv /tmp/i18n-diff-truncated.txt /tmp/i18n-diff.txt + + # Base64 encode the diff for safe JSON transport (portable, single-line) + diff_base64=$(base64 < /tmp/i18n-diff.txt | tr -d '\n') + echo "diff_base64=$diff_base64" >> $GITHUB_OUTPUT + + if [ -n "$changed" ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "Detected changed files: $changed" + else + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "No i18n changes detected" + fi + + - name: Trigger i18n sync workflow + if: steps.detect.outputs.has_changes == 'true' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + event-type: i18n-sync + client-payload: '{"changed_files": "${{ steps.detect.outputs.changed_files }}", "diff_base64": "${{ steps.detect.outputs.diff_base64 }}", "sync_mode": "incremental", "trigger_sha": "${{ github.sha }}"}' From 5ad238579938f6d904edaced2e36c3d39962fa8d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:53:04 +0800 Subject: [PATCH 04/22] chore(i18n): sync translations with en-US (#30750) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- web/i18n/ar-TN/common.json | 4 ++++ web/i18n/de-DE/common.json | 4 ++++ web/i18n/es-ES/common.json | 4 ++++ web/i18n/fa-IR/common.json | 4 ++++ web/i18n/fr-FR/common.json | 4 ++++ web/i18n/hi-IN/common.json | 4 ++++ web/i18n/id-ID/common.json | 4 ++++ web/i18n/it-IT/common.json | 4 ++++ web/i18n/ko-KR/common.json | 4 ++++ web/i18n/pl-PL/common.json | 4 ++++ web/i18n/pt-BR/common.json | 4 ++++ web/i18n/ro-RO/common.json | 4 ++++ web/i18n/ru-RU/common.json | 4 ++++ web/i18n/sl-SI/common.json | 4 ++++ web/i18n/th-TH/common.json | 4 ++++ web/i18n/tr-TR/common.json | 4 ++++ web/i18n/uk-UA/common.json | 4 ++++ web/i18n/vi-VN/common.json | 4 ++++ web/i18n/zh-Hant/common.json | 4 ++++ 19 files changed, 76 insertions(+) diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index d015f1ae0b..998466c649 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "أوقات الاتصال", "modelProvider.card.buyQuota": "شراء حصة", "modelProvider.card.callTimes": "أوقات الاتصال", + "modelProvider.card.modelAPI": "نماذج {{modelName}} تستخدم مفتاح API.", + "modelProvider.card.modelNotSupported": "نماذج {{modelName}} غير مثبتة.", + "modelProvider.card.modelSupported": "نماذج {{modelName}} تستخدم هذا الحصة.", "modelProvider.card.onTrial": "في التجربة", "modelProvider.card.paid": "مدفوع", "modelProvider.card.priorityUse": "أولوية الاستخدام", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "الرموز المجانية المتاحة المتبقية", "modelProvider.rerankModel.key": "نموذج إعادة الترتيب", "modelProvider.rerankModel.tip": "سيعيد نموذج إعادة الترتيب ترتيب قائمة المستندات المرشحة بناءً على المطابقة الدلالية مع استعلام المستخدم، مما يحسن نتائج الترتيب الدلالي", + "modelProvider.resetDate": "إعادة التعيين في {{date}}", "modelProvider.searchModel": "نموذج البحث", "modelProvider.selectModel": "اختر نموذجك", "modelProvider.selector.emptySetting": "يرجى الانتقال إلى الإعدادات للتكوين", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index f54f6a939f..bd2d083fb0 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Anrufzeiten", "modelProvider.card.buyQuota": "Kontingent kaufen", "modelProvider.card.callTimes": "Anrufzeiten", + "modelProvider.card.modelAPI": "{{modelName}}-Modelle verwenden den API-Schlüssel.", + "modelProvider.card.modelNotSupported": "{{modelName}}-Modelle sind nicht installiert.", + "modelProvider.card.modelSupported": "{{modelName}}-Modelle verwenden dieses Kontingent.", "modelProvider.card.onTrial": "In Probe", "modelProvider.card.paid": "Bezahlt", "modelProvider.card.priorityUse": "Priorisierte Nutzung", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Verbleibende verfügbare kostenlose Token", "modelProvider.rerankModel.key": "Rerank-Modell", "modelProvider.rerankModel.tip": "Rerank-Modell wird die Kandidatendokumentenliste basierend auf der semantischen Übereinstimmung mit der Benutzeranfrage neu ordnen und die Ergebnisse der semantischen Rangordnung verbessern", + "modelProvider.resetDate": "Zurücksetzen am {{date}}", "modelProvider.searchModel": "Suchmodell", "modelProvider.selectModel": "Wählen Sie Ihr Modell", "modelProvider.selector.emptySetting": "Bitte gehen Sie zu den Einstellungen, um zu konfigurieren", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index ec08f11ed7..8175f97946 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Tiempos de llamada", "modelProvider.card.buyQuota": "Comprar Cuota", "modelProvider.card.callTimes": "Tiempos de llamada", + "modelProvider.card.modelAPI": "Los modelos {{modelName}} están usando la clave API.", + "modelProvider.card.modelNotSupported": "Los modelos {{modelName}} no están instalados.", + "modelProvider.card.modelSupported": "Los modelos {{modelName}} están usando esta cuota.", "modelProvider.card.onTrial": "En prueba", "modelProvider.card.paid": "Pagado", "modelProvider.card.priorityUse": "Uso prioritario", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Tokens gratuitos restantes disponibles", "modelProvider.rerankModel.key": "Modelo de Reordenar", "modelProvider.rerankModel.tip": "El modelo de reordenar reordenará la lista de documentos candidatos basada en la coincidencia semántica con la consulta del usuario, mejorando los resultados de clasificación semántica", + "modelProvider.resetDate": "Restablecer el {{date}}", "modelProvider.searchModel": "Modelo de búsqueda", "modelProvider.selectModel": "Selecciona tu modelo", "modelProvider.selector.emptySetting": "Por favor ve a configuraciones para configurar", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index 78f9b9e388..90ca2fbce3 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "تعداد فراخوانی", "modelProvider.card.buyQuota": "خرید سهمیه", "modelProvider.card.callTimes": "تعداد فراخوانی", + "modelProvider.card.modelAPI": "مدل‌های {{modelName}} از کلید API استفاده می‌کنند.", + "modelProvider.card.modelNotSupported": "مدل‌های {{modelName}} نصب نشده‌اند.", + "modelProvider.card.modelSupported": "مدل‌های {{modelName}} از این سهمیه استفاده می‌کنند.", "modelProvider.card.onTrial": "در حال آزمایش", "modelProvider.card.paid": "پرداخت شده", "modelProvider.card.priorityUse": "استفاده با اولویت", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "توکن‌های رایگان باقی‌مانده در دسترس", "modelProvider.rerankModel.key": "مدل رتبه‌بندی مجدد", "modelProvider.rerankModel.tip": "مدل رتبه‌بندی مجدد، لیست اسناد کاندید را بر اساس تطابق معنایی با پرسش کاربر مرتب می‌کند و نتایج رتبه‌بندی معنایی را بهبود می‌بخشد", + "modelProvider.resetDate": "بازنشانی در {{date}}", "modelProvider.searchModel": "جستجوی مدل", "modelProvider.selectModel": "مدل خود را انتخاب کنید", "modelProvider.selector.emptySetting": "لطفاً به تنظیمات بروید تا پیکربندی کنید", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index 7cc1af2d80..d2b4c70d7c 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Temps d'appel", "modelProvider.card.buyQuota": "Acheter Quota", "modelProvider.card.callTimes": "Temps d'appel", + "modelProvider.card.modelAPI": "Les modèles {{modelName}} utilisent la clé API.", + "modelProvider.card.modelNotSupported": "Les modèles {{modelName}} ne sont pas installés.", + "modelProvider.card.modelSupported": "Les modèles {{modelName}} utilisent ce quota.", "modelProvider.card.onTrial": "En Essai", "modelProvider.card.paid": "Payé", "modelProvider.card.priorityUse": "Utilisation prioritaire", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Tokens gratuits restants disponibles", "modelProvider.rerankModel.key": "Modèle de Réorganisation", "modelProvider.rerankModel.tip": "Le modèle de réorganisation réorganisera la liste des documents candidats en fonction de la correspondance sémantique avec la requête de l'utilisateur, améliorant ainsi les résultats du classement sémantique.", + "modelProvider.resetDate": "Réinitialiser le {{date}}", "modelProvider.searchModel": "Modèle de recherche", "modelProvider.selectModel": "Sélectionnez votre modèle", "modelProvider.selector.emptySetting": "Veuillez aller dans les paramètres pour configurer", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index 4670d5a545..c7b2402f81 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "कॉल समय", "modelProvider.card.buyQuota": "कोटा खरीदें", "modelProvider.card.callTimes": "कॉल समय", + "modelProvider.card.modelAPI": "{{modelName}} मॉडल API कुंजी का उपयोग कर रहे हैं।", + "modelProvider.card.modelNotSupported": "{{modelName}} मॉडल इंस्टॉल नहीं हैं।", + "modelProvider.card.modelSupported": "{{modelName}} मॉडल इस कोटा का उपयोग कर रहे हैं।", "modelProvider.card.onTrial": "परीक्षण पर", "modelProvider.card.paid": "भुगतान किया हुआ", "modelProvider.card.priorityUse": "प्राथमिकता उपयोग", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "बचे हुए उपलब्ध मुफ्त टोकन", "modelProvider.rerankModel.key": "रीरैंक मॉडल", "modelProvider.rerankModel.tip": "रीरैंक मॉडल उपयोगकर्ता प्रश्न के साथ सांविधिक मेल के आधार पर उम्मीदवार दस्तावेज़ सूची को पुनः क्रमित करेगा, सांविधिक रैंकिंग के परिणामों में सुधार करेगा।", + "modelProvider.resetDate": "{{date}} को रीसेट करें", "modelProvider.searchModel": "खोज मॉडल", "modelProvider.selectModel": "अपने मॉडल का चयन करें", "modelProvider.selector.emptySetting": "कॉन्फ़िगर करने के लिए कृपया सेटिंग्स पर जाएं", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index ede4d3ae44..541ee74b10 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Waktu panggilan", "modelProvider.card.buyQuota": "Beli Kuota", "modelProvider.card.callTimes": "Waktu panggilan", + "modelProvider.card.modelAPI": "Model {{modelName}} menggunakan Kunci API.", + "modelProvider.card.modelNotSupported": "Model {{modelName}} tidak terpasang.", + "modelProvider.card.modelSupported": "Model {{modelName}} menggunakan kuota ini.", "modelProvider.card.onTrial": "Sedang Diadili", "modelProvider.card.paid": "Dibayar", "modelProvider.card.priorityUse": "Penggunaan prioritas", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Token gratis yang masih tersedia", "modelProvider.rerankModel.key": "Peringkat ulang Model", "modelProvider.rerankModel.tip": "Model rerank akan menyusun ulang daftar dokumen kandidat berdasarkan kecocokan semantik dengan kueri pengguna, meningkatkan hasil peringkat semantik", + "modelProvider.resetDate": "Setel ulang pada {{date}}", "modelProvider.searchModel": "Model pencarian", "modelProvider.selectModel": "Pilih model Anda", "modelProvider.selector.emptySetting": "Silakan buka pengaturan untuk mengonfigurasi", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index 737ef923b1..49e14591a7 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Numero di chiamate", "modelProvider.card.buyQuota": "Acquista Quota", "modelProvider.card.callTimes": "Numero di chiamate", + "modelProvider.card.modelAPI": "I modelli {{modelName}} stanno utilizzando la chiave API.", + "modelProvider.card.modelNotSupported": "I modelli {{modelName}} non sono installati.", + "modelProvider.card.modelSupported": "I modelli {{modelName}} stanno utilizzando questa quota.", "modelProvider.card.onTrial": "In Prova", "modelProvider.card.paid": "Pagato", "modelProvider.card.priorityUse": "Uso prioritario", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Token gratuiti rimanenti disponibili", "modelProvider.rerankModel.key": "Modello di Rerank", "modelProvider.rerankModel.tip": "Il modello di rerank riordinerà la lista dei documenti candidati basandosi sulla corrispondenza semantica con la query dell'utente, migliorando i risultati del ranking semantico", + "modelProvider.resetDate": "Ripristina il {{date}}", "modelProvider.searchModel": "Modello di ricerca", "modelProvider.selectModel": "Seleziona il tuo modello", "modelProvider.selector.emptySetting": "Per favore vai alle impostazioni per configurare", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index 5640cb353d..a8ae974530 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "호출 횟수", "modelProvider.card.buyQuota": "Buy Quota", "modelProvider.card.callTimes": "호출 횟수", + "modelProvider.card.modelAPI": "{{modelName}} 모델이 API 키를 사용하고 있습니다.", + "modelProvider.card.modelNotSupported": "{{modelName}} 모델이 설치되지 않았습니다.", + "modelProvider.card.modelSupported": "{{modelName}} 모델이 이 할당량을 사용하고 있습니다.", "modelProvider.card.onTrial": "트라이얼 중", "modelProvider.card.paid": "유료", "modelProvider.card.priorityUse": "우선 사용", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "남은 무료 토큰 사용 가능", "modelProvider.rerankModel.key": "재랭크 모델", "modelProvider.rerankModel.tip": "재랭크 모델은 사용자 쿼리와의 의미적 일치를 기반으로 후보 문서 목록을 재배열하여 의미적 순위를 향상시킵니다.", + "modelProvider.resetDate": "{{date}}에 재설정", "modelProvider.searchModel": "검색 모델", "modelProvider.selectModel": "모델 선택", "modelProvider.selector.emptySetting": "설정으로 이동하여 구성하세요", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index ae654e04ac..963ecf865d 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Czasy wywołań", "modelProvider.card.buyQuota": "Kup limit", "modelProvider.card.callTimes": "Czasy wywołań", + "modelProvider.card.modelAPI": "Modele {{modelName}} używają klucza API.", + "modelProvider.card.modelNotSupported": "Modele {{modelName}} nie są zainstalowane.", + "modelProvider.card.modelSupported": "Modele {{modelName}} używają tego limitu.", "modelProvider.card.onTrial": "Na próbę", "modelProvider.card.paid": "Płatny", "modelProvider.card.priorityUse": "Używanie z priorytetem", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Pozostałe dostępne darmowe tokeny", "modelProvider.rerankModel.key": "Model ponownego rankingu", "modelProvider.rerankModel.tip": "Model ponownego rankingu zmieni kolejność listy dokumentów kandydatów na podstawie semantycznego dopasowania z zapytaniem użytkownika, poprawiając wyniki rankingu semantycznego", + "modelProvider.resetDate": "Reset {{date}}", "modelProvider.searchModel": "Model wyszukiwania", "modelProvider.selectModel": "Wybierz swój model", "modelProvider.selector.emptySetting": "Przejdź do ustawień, aby skonfigurować", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index 2e7f49de7e..7efc250349 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Chamadas", "modelProvider.card.buyQuota": "Comprar Quota", "modelProvider.card.callTimes": "Chamadas", + "modelProvider.card.modelAPI": "Os modelos {{modelName}} estão usando a Chave API.", + "modelProvider.card.modelNotSupported": "Os modelos {{modelName}} não estão instalados.", + "modelProvider.card.modelSupported": "Os modelos {{modelName}} estão usando esta cota.", "modelProvider.card.onTrial": "Em Teste", "modelProvider.card.paid": "Pago", "modelProvider.card.priorityUse": "Uso prioritário", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Tokens gratuitos disponíveis restantes", "modelProvider.rerankModel.key": "Modelo de Reordenação", "modelProvider.rerankModel.tip": "O modelo de reordenaenação reorganizará a lista de documentos candidatos com base na correspondência semântica com a consulta do usuário, melhorando os resultados da classificação semântica", + "modelProvider.resetDate": "Redefinir em {{date}}", "modelProvider.searchModel": "Modelo de pesquisa", "modelProvider.selectModel": "Selecione seu modelo", "modelProvider.selector.emptySetting": "Por favor, vá para configurações para configurar", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index c21e755b3c..bafe3542dc 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Apeluri", "modelProvider.card.buyQuota": "Cumpără cotă", "modelProvider.card.callTimes": "Apeluri", + "modelProvider.card.modelAPI": "Modelele {{modelName}} folosesc cheia API.", + "modelProvider.card.modelNotSupported": "Modelele {{modelName}} nu sunt instalate.", + "modelProvider.card.modelSupported": "Modelele {{modelName}} folosesc această cotă.", "modelProvider.card.onTrial": "În probă", "modelProvider.card.paid": "Plătit", "modelProvider.card.priorityUse": "Utilizare prioritară", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Jetoane gratuite disponibile rămase", "modelProvider.rerankModel.key": "Model de reordonare", "modelProvider.rerankModel.tip": "Modelul de reordonare va reordona lista de documente candidate pe baza potrivirii semantice cu interogarea utilizatorului, îmbunătățind rezultatele clasificării semantice", + "modelProvider.resetDate": "Resetare la {{date}}", "modelProvider.searchModel": "Model de căutare", "modelProvider.selectModel": "Selectați modelul dvs.", "modelProvider.selector.emptySetting": "Vă rugăm să mergeți la setări pentru a configura", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index e763a7ec2a..0210db777f 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Количество вызовов", "modelProvider.card.buyQuota": "Купить квоту", "modelProvider.card.callTimes": "Количество вызовов", + "modelProvider.card.modelAPI": "Модели {{modelName}} используют API-ключ.", + "modelProvider.card.modelNotSupported": "Модели {{modelName}} не установлены.", + "modelProvider.card.modelSupported": "Модели {{modelName}} используют эту квоту.", "modelProvider.card.onTrial": "Пробная версия", "modelProvider.card.paid": "Платный", "modelProvider.card.priorityUse": "Приоритетное использование", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Оставшиеся доступные бесплатные токены", "modelProvider.rerankModel.key": "Модель повторного ранжирования", "modelProvider.rerankModel.tip": "Модель повторного ранжирования изменит порядок списка документов-кандидатов на основе семантического соответствия запросу пользователя, улучшая результаты семантического ранжирования", + "modelProvider.resetDate": "Сброс {{date}}", "modelProvider.searchModel": "Поиск модели", "modelProvider.selectModel": "Выберите свою модель", "modelProvider.selector.emptySetting": "Пожалуйста, перейдите в настройки для настройки", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index d092fe10c8..c33686ac03 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Število klicev", "modelProvider.card.buyQuota": "Kupi kvoto", "modelProvider.card.callTimes": "Časi klicev", + "modelProvider.card.modelAPI": "Modeli {{modelName}} uporabljajo API ključ.", + "modelProvider.card.modelNotSupported": "Modeli {{modelName}} niso nameščeni.", + "modelProvider.card.modelSupported": "Modeli {{modelName}} uporabljajo to kvoto.", "modelProvider.card.onTrial": "Na preizkusu", "modelProvider.card.paid": "Plačano", "modelProvider.card.priorityUse": "Prednostna uporaba", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Preostali razpoložljivi brezplačni žetoni", "modelProvider.rerankModel.key": "Model za prerazvrstitev", "modelProvider.rerankModel.tip": "Model za prerazvrstitev bo prerazporedil seznam kandidatskih dokumentov na podlagi semantične ujemanja z uporabniško poizvedbo, s čimer se izboljšajo rezultati semantičnega razvrščanja.", + "modelProvider.resetDate": "Ponastavi {{date}}", "modelProvider.searchModel": "Model iskanja", "modelProvider.selectModel": "Izberite svoj model", "modelProvider.selector.emptySetting": "Prosimo, pojdite v nastavitve za konfiguracijo", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index 9a38f7f683..2a6b575618 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "เวลาโทร", "modelProvider.card.buyQuota": "ซื้อโควต้า", "modelProvider.card.callTimes": "เวลาโทร", + "modelProvider.card.modelAPI": "โมเดล {{modelName}} กำลังใช้คีย์ API", + "modelProvider.card.modelNotSupported": "โมเดล {{modelName}} ไม่ได้ติดตั้ง", + "modelProvider.card.modelSupported": "โมเดล {{modelName}} กำลังใช้โควต้านี้", "modelProvider.card.onTrial": "ทดลองใช้", "modelProvider.card.paid": "จ่าย", "modelProvider.card.priorityUse": "ลําดับความสําคัญในการใช้งาน", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "โทเค็นฟรีที่เหลืออยู่", "modelProvider.rerankModel.key": "จัดอันดับโมเดลใหม่", "modelProvider.rerankModel.tip": "โมเดล Rerank จะจัดลําดับรายการเอกสารผู้สมัครใหม่ตามการจับคู่ความหมายกับการสืบค้นของผู้ใช้ ซึ่งช่วยปรับปรุงผลลัพธ์ของการจัดอันดับความหมาย", + "modelProvider.resetDate": "รีเซ็ตเมื่อ {{date}}", "modelProvider.searchModel": "ค้นหารุ่น", "modelProvider.selectModel": "เลือกรุ่นของคุณ", "modelProvider.selector.emptySetting": "โปรดไปที่การตั้งค่าเพื่อกําหนดค่า", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index 0ee51e161c..c45b453180 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Çağrı Süreleri", "modelProvider.card.buyQuota": "Kota Satın Al", "modelProvider.card.callTimes": "Çağrı Süreleri", + "modelProvider.card.modelAPI": "{{modelName}} modelleri API Anahtarını kullanıyor.", + "modelProvider.card.modelNotSupported": "{{modelName}} modelleri kurulu değil.", + "modelProvider.card.modelSupported": "{{modelName}} modelleri bu kotayı kullanıyor.", "modelProvider.card.onTrial": "Deneme Sürümünde", "modelProvider.card.paid": "Ücretli", "modelProvider.card.priorityUse": "Öncelikli Kullan", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Kalan kullanılabilir ücretsiz tokenler", "modelProvider.rerankModel.key": "Yeniden Sıralama Modeli", "modelProvider.rerankModel.tip": "Yeniden sıralama modeli, kullanıcı sorgusuyla anlam eşleştirmesine dayalı olarak aday belge listesini yeniden sıralayacak ve anlam sıralama sonuçlarını iyileştirecektir.", + "modelProvider.resetDate": "{{date}} tarihinde sıfırla", "modelProvider.searchModel": "Model ara", "modelProvider.selectModel": "Modelinizi seçin", "modelProvider.selector.emptySetting": "Lütfen ayarlara gidip yapılandırın", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index ddec8637e1..e9e810da45 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Кількість викликів", "modelProvider.card.buyQuota": "Придбати квоту", "modelProvider.card.callTimes": "Кількість викликів", + "modelProvider.card.modelAPI": "Моделі {{modelName}} використовують API-ключ.", + "modelProvider.card.modelNotSupported": "Моделі {{modelName}} не встановлено.", + "modelProvider.card.modelSupported": "Моделі {{modelName}} використовують цю квоту.", "modelProvider.card.onTrial": "У пробному періоді", "modelProvider.card.paid": "Оплачено", "modelProvider.card.priorityUse": "Пріоритетне використання", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Залишилося доступних безкоштовних токенів", "modelProvider.rerankModel.key": "Модель повторного ранжування", "modelProvider.rerankModel.tip": "Модель повторного ранжування змінить порядок списку документів-кандидатів на основі семантичної відповідності запиту користувача, покращуючи результати семантичного ранжування.", + "modelProvider.resetDate": "Скидання {{date}}", "modelProvider.searchModel": "Пошукова модель", "modelProvider.selectModel": "Виберіть свою модель", "modelProvider.selector.emptySetting": "Перейдіть до налаштувань, щоб налаштувати", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index f8fa9c07d5..1fec0e10e2 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Số lần gọi", "modelProvider.card.buyQuota": "Mua Quota", "modelProvider.card.callTimes": "Số lần gọi", + "modelProvider.card.modelAPI": "Các mô hình {{modelName}} đang sử dụng Khóa API.", + "modelProvider.card.modelNotSupported": "Các mô hình {{modelName}} chưa được cài đặt.", + "modelProvider.card.modelSupported": "Các mô hình {{modelName}} đang sử dụng hạn mức này.", "modelProvider.card.onTrial": "Thử nghiệm", "modelProvider.card.paid": "Đã thanh toán", "modelProvider.card.priorityUse": "Ưu tiên sử dụng", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Số lượng mã thông báo miễn phí còn lại", "modelProvider.rerankModel.key": "Mô hình Sắp xếp lại", "modelProvider.rerankModel.tip": "Mô hình sắp xếp lại sẽ sắp xếp lại danh sách tài liệu ứng cử viên dựa trên sự phù hợp ngữ nghĩa với truy vấn của người dùng, cải thiện kết quả của việc xếp hạng ngữ nghĩa", + "modelProvider.resetDate": "Đặt lại vào {{date}}", "modelProvider.searchModel": "Mô hình tìm kiếm", "modelProvider.selectModel": "Chọn mô hình của bạn", "modelProvider.selector.emptySetting": "Vui lòng vào cài đặt để cấu hình", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index 8fe3e5bd07..52be863c6d 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "呼叫次數", "modelProvider.card.buyQuota": "購買額度", "modelProvider.card.callTimes": "呼叫次數", + "modelProvider.card.modelAPI": "{{modelName}} 模型正在使用 API Key。", + "modelProvider.card.modelNotSupported": "{{modelName}} 模型未安裝。", + "modelProvider.card.modelSupported": "{{modelName}} 模型正在使用此配額。", "modelProvider.card.onTrial": "試用中", "modelProvider.card.paid": "已購買", "modelProvider.card.priorityUse": "優先使用", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "剩餘免費額度", "modelProvider.rerankModel.key": "Rerank 模型", "modelProvider.rerankModel.tip": "重排序模型將根據候選文件列表與使用者問題語義匹配度進行重新排序,從而改進語義排序的結果", + "modelProvider.resetDate": "於 {{date}} 重置", "modelProvider.searchModel": "搜尋模型", "modelProvider.selectModel": "選擇您的模型", "modelProvider.selector.emptySetting": "請前往設定進行配置", From 9848823dcd1679c375171e474ab1f0dac85dd318 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Fri, 9 Jan 2026 10:21:18 +0800 Subject: [PATCH 05/22] feat: implement step two of dataset creation with comprehensive UI components and hooks (#30681) Co-authored-by: CodingOnStar --- .../components/general-chunking-options.tsx | 199 ++ .../create/step-two/components/index.ts | 5 + .../components/indexing-mode-section.tsx | 253 ++ .../step-two/{ => components}/inputs.tsx | 0 .../step-two/{ => components}/option-card.tsx | 0 .../components/parent-child-options.tsx | 191 ++ .../step-two/components/preview-panel.tsx | 171 ++ .../step-two/components/step-two-footer.tsx | 58 + .../create/step-two/{ => hooks}/escape.ts | 0 .../datasets/create/step-two/hooks/index.ts | 14 + .../create/step-two/{ => hooks}/unescape.ts | 0 .../step-two/hooks/use-document-creation.ts | 279 +++ .../step-two/hooks/use-indexing-config.ts | 143 ++ .../step-two/hooks/use-indexing-estimate.ts | 123 + .../step-two/hooks/use-preview-state.ts | 127 + .../step-two/hooks/use-segmentation-state.ts | 222 ++ .../datasets/create/step-two/index.spec.tsx | 2197 +++++++++++++++++ .../datasets/create/step-two/index.tsx | 1366 ++-------- .../datasets/create/step-two/types.ts | 28 + 19 files changed, 4209 insertions(+), 1167 deletions(-) create mode 100644 web/app/components/datasets/create/step-two/components/general-chunking-options.tsx create mode 100644 web/app/components/datasets/create/step-two/components/index.ts create mode 100644 web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx rename web/app/components/datasets/create/step-two/{ => components}/inputs.tsx (100%) rename web/app/components/datasets/create/step-two/{ => components}/option-card.tsx (100%) create mode 100644 web/app/components/datasets/create/step-two/components/parent-child-options.tsx create mode 100644 web/app/components/datasets/create/step-two/components/preview-panel.tsx create mode 100644 web/app/components/datasets/create/step-two/components/step-two-footer.tsx rename web/app/components/datasets/create/step-two/{ => hooks}/escape.ts (100%) create mode 100644 web/app/components/datasets/create/step-two/hooks/index.ts rename web/app/components/datasets/create/step-two/{ => hooks}/unescape.ts (100%) create mode 100644 web/app/components/datasets/create/step-two/hooks/use-document-creation.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/use-indexing-config.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/use-preview-state.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts create mode 100644 web/app/components/datasets/create/step-two/index.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/types.ts diff --git a/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx new file mode 100644 index 0000000000..5140c902f5 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx @@ -0,0 +1,199 @@ +'use client' + +import type { FC } from 'react' +import type { PreProcessingRule } from '@/models/datasets' +import { + RiAlertFill, + RiSearchEyeLine, +} from '@remixicon/react' +import Image from 'next/image' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import Checkbox from '@/app/components/base/checkbox' +import Divider from '@/app/components/base/divider' +import Tooltip from '@/app/components/base/tooltip' +import { IS_CE_EDITION } from '@/config' +import { ChunkingMode } from '@/models/datasets' +import SettingCog from '../../assets/setting-gear-mod.svg' +import s from '../index.module.css' +import LanguageSelect from '../language-select' +import { DelimiterInput, MaxLengthInput, OverlapInput } from './inputs' +import { OptionCard } from './option-card' + +type TextLabelProps = { + children: React.ReactNode +} + +const TextLabel: FC = ({ children }) => { + return +} + +type GeneralChunkingOptionsProps = { + // State + segmentIdentifier: string + maxChunkLength: number + overlap: number + rules: PreProcessingRule[] + currentDocForm: ChunkingMode + docLanguage: string + // Flags + isActive: boolean + isInUpload: boolean + isNotUploadInEmptyDataset: boolean + hasCurrentDatasetDocForm: boolean + // Actions + onSegmentIdentifierChange: (value: string) => void + onMaxChunkLengthChange: (value: number) => void + onOverlapChange: (value: number) => void + onRuleToggle: (id: string) => void + onDocFormChange: (form: ChunkingMode) => void + onDocLanguageChange: (lang: string) => void + onPreview: () => void + onReset: () => void + // Locale + locale: string +} + +export const GeneralChunkingOptions: FC = ({ + segmentIdentifier, + maxChunkLength, + overlap, + rules, + currentDocForm, + docLanguage, + isActive, + isInUpload, + isNotUploadInEmptyDataset, + hasCurrentDatasetDocForm, + onSegmentIdentifierChange, + onMaxChunkLengthChange, + onOverlapChange, + onRuleToggle, + onDocFormChange, + onDocLanguageChange, + onPreview, + onReset, + locale, +}) => { + const { t } = useTranslation() + + const getRuleName = (key: string): string => { + const ruleNameMap: Record = { + remove_extra_spaces: t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' }), + remove_urls_emails: t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' }), + remove_stopwords: t('stepTwo.removeStopwords', { ns: 'datasetCreation' }), + } + return ruleNameMap[key] ?? key + } + + return ( + } + activeHeaderClassName="bg-dataset-option-card-blue-gradient" + description={t('stepTwo.generalTip', { ns: 'datasetCreation' })} + isActive={isActive} + onSwitched={() => onDocFormChange(ChunkingMode.text)} + actions={( + <> + + + + )} + noHighlight={isInUpload && isNotUploadInEmptyDataset} + > +
+
+ onSegmentIdentifierChange(e.target.value)} + /> + + +
+
+
+
+ {t('stepTwo.rules', { ns: 'datasetCreation' })} +
+ +
+
+ {rules.map(rule => ( +
onRuleToggle(rule.id)} + > + + +
+ ))} + {IS_CE_EDITION && ( + <> + +
+
{ + if (hasCurrentDatasetDocForm) + return + if (currentDocForm === ChunkingMode.qa) + onDocFormChange(ChunkingMode.text) + else + onDocFormChange(ChunkingMode.qa) + }} + > + + +
+ + +
+ {currentDocForm === ChunkingMode.qa && ( +
+ + + {t('stepTwo.QATip', { ns: 'datasetCreation' })} + +
+ )} + + )} +
+
+
+
+ ) +} diff --git a/web/app/components/datasets/create/step-two/components/index.ts b/web/app/components/datasets/create/step-two/components/index.ts new file mode 100644 index 0000000000..d5382e0c4b --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/index.ts @@ -0,0 +1,5 @@ +export { GeneralChunkingOptions } from './general-chunking-options' +export { IndexingModeSection } from './indexing-mode-section' +export { ParentChildOptions } from './parent-child-options' +export { PreviewPanel } from './preview-panel' +export { StepTwoFooter } from './step-two-footer' diff --git a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx new file mode 100644 index 0000000000..ee49f42903 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx @@ -0,0 +1,253 @@ +'use client' + +import type { FC } from 'react' +import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { RetrievalConfig } from '@/types/app' +import Image from 'next/image' +import Link from 'next/link' +import { useTranslation } from 'react-i18next' +import Badge from '@/app/components/base/badge' +import Button from '@/app/components/base/button' +import CustomDialog from '@/app/components/base/dialog' +import Divider from '@/app/components/base/divider' +import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' +import Tooltip from '@/app/components/base/tooltip' +import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config' +import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config' +import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { useDocLink } from '@/context/i18n' +import { ChunkingMode } from '@/models/datasets' +import { cn } from '@/utils/classnames' +import { indexMethodIcon } from '../../icons' +import { IndexingType } from '../hooks' +import s from '../index.module.css' +import { OptionCard } from './option-card' + +type IndexingModeSectionProps = { + // State + indexType: IndexingType + hasSetIndexType: boolean + docForm: ChunkingMode + embeddingModel: DefaultModel + embeddingModelList?: Model[] + retrievalConfig: RetrievalConfig + showMultiModalTip: boolean + // Flags + isModelAndRetrievalConfigDisabled: boolean + datasetId?: string + // Modal state + isQAConfirmDialogOpen: boolean + // Actions + onIndexTypeChange: (type: IndexingType) => void + onEmbeddingModelChange: (model: DefaultModel) => void + onRetrievalConfigChange: (config: RetrievalConfig) => void + onQAConfirmDialogClose: () => void + onQAConfirmDialogConfirm: () => void +} + +export const IndexingModeSection: FC = ({ + indexType, + hasSetIndexType, + docForm, + embeddingModel, + embeddingModelList, + retrievalConfig, + showMultiModalTip, + isModelAndRetrievalConfigDisabled, + datasetId, + isQAConfirmDialogOpen, + onIndexTypeChange, + onEmbeddingModelChange, + onRetrievalConfigChange, + onQAConfirmDialogClose, + onQAConfirmDialogConfirm, +}) => { + const { t } = useTranslation() + const docLink = useDocLink() + + const getIndexingTechnique = () => indexType + + return ( + <> + {/* Index Mode */} +
+ {t('stepTwo.indexMode', { ns: 'datasetCreation' })} +
+
+ {/* Qualified option */} + {(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.QUALIFIED)) && ( + + {t('stepTwo.qualified', { ns: 'datasetCreation' })} + + {t('stepTwo.recommend', { ns: 'datasetCreation' })} + + + {!hasSetIndexType && } + +
+ )} + description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })} + icon={} + isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED} + disabled={hasSetIndexType} + onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)} + /> + )} + + {/* Economical option */} + {(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.ECONOMICAL)) && ( + <> + +
+

+ {t('stepTwo.qaSwitchHighQualityTipTitle', { ns: 'datasetCreation' })} +

+

+ {t('stepTwo.qaSwitchHighQualityTipContent', { ns: 'datasetCreation' })} +

+
+
+ + +
+
+ + {docForm === ChunkingMode.qa + ? t('stepTwo.notAvailableForQA', { ns: 'datasetCreation' }) + : t('stepTwo.notAvailableForParentChild', { ns: 'datasetCreation' })} +
+ )} + noDecoration + position="top" + asChild={false} + triggerClassName="flex-1 self-stretch" + > + } + isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL} + disabled={hasSetIndexType || docForm !== ChunkingMode.text} + onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)} + /> + + + )} + + + {/* High quality tip */} + {!hasSetIndexType && indexType === IndexingType.QUALIFIED && ( +
+
+
+ +
+ + {t('stepTwo.highQualityTip', { ns: 'datasetCreation' })} + +
+ )} + + {/* Economical index setting tip */} + {hasSetIndexType && indexType === IndexingType.ECONOMICAL && ( +
+ {t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} + + {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} + +
+ )} + + {/* Embedding model */} + {indexType === IndexingType.QUALIFIED && ( +
+
+ {t('form.embeddingModel', { ns: 'datasetSettings' })} +
+ + {isModelAndRetrievalConfigDisabled && ( +
+ {t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} + + {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} + +
+ )} +
+ )} + + + + {/* Retrieval Method Config */} +
+ {!isModelAndRetrievalConfigDisabled + ? ( +
+
+ {t('form.retrievalSetting.title', { ns: 'datasetSettings' })} +
+
+ + {t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })} + + {t('form.retrievalSetting.longDescription', { ns: 'datasetSettings' })} +
+
+ ) + : ( +
+
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
+
+ )} + +
+ {getIndexingTechnique() === IndexingType.QUALIFIED + ? ( + + ) + : ( + + )} +
+
+ + ) +} diff --git a/web/app/components/datasets/create/step-two/inputs.tsx b/web/app/components/datasets/create/step-two/components/inputs.tsx similarity index 100% rename from web/app/components/datasets/create/step-two/inputs.tsx rename to web/app/components/datasets/create/step-two/components/inputs.tsx diff --git a/web/app/components/datasets/create/step-two/option-card.tsx b/web/app/components/datasets/create/step-two/components/option-card.tsx similarity index 100% rename from web/app/components/datasets/create/step-two/option-card.tsx rename to web/app/components/datasets/create/step-two/components/option-card.tsx diff --git a/web/app/components/datasets/create/step-two/components/parent-child-options.tsx b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx new file mode 100644 index 0000000000..e46aa5817b --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx @@ -0,0 +1,191 @@ +'use client' + +import type { FC } from 'react' +import type { ParentChildConfig } from '../hooks' +import type { ParentMode, PreProcessingRule } from '@/models/datasets' +import { RiSearchEyeLine } from '@remixicon/react' +import Image from 'next/image' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import Checkbox from '@/app/components/base/checkbox' +import Divider from '@/app/components/base/divider' +import { ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge' +import RadioCard from '@/app/components/base/radio-card' +import { ChunkingMode } from '@/models/datasets' +import FileList from '../../assets/file-list-3-fill.svg' +import Note from '../../assets/note-mod.svg' +import BlueEffect from '../../assets/option-card-effect-blue.svg' +import s from '../index.module.css' +import { DelimiterInput, MaxLengthInput } from './inputs' +import { OptionCard } from './option-card' + +type TextLabelProps = { + children: React.ReactNode +} + +const TextLabel: FC = ({ children }) => { + return +} + +type ParentChildOptionsProps = { + // State + parentChildConfig: ParentChildConfig + rules: PreProcessingRule[] + currentDocForm: ChunkingMode + // Flags + isActive: boolean + isInUpload: boolean + isNotUploadInEmptyDataset: boolean + // Actions + onDocFormChange: (form: ChunkingMode) => void + onChunkForContextChange: (mode: ParentMode) => void + onParentDelimiterChange: (value: string) => void + onParentMaxLengthChange: (value: number) => void + onChildDelimiterChange: (value: string) => void + onChildMaxLengthChange: (value: number) => void + onRuleToggle: (id: string) => void + onPreview: () => void + onReset: () => void +} + +export const ParentChildOptions: FC = ({ + parentChildConfig, + rules, + currentDocForm: _currentDocForm, + isActive, + isInUpload, + isNotUploadInEmptyDataset, + onDocFormChange, + onChunkForContextChange, + onParentDelimiterChange, + onParentMaxLengthChange, + onChildDelimiterChange, + onChildMaxLengthChange, + onRuleToggle, + onPreview, + onReset, +}) => { + const { t } = useTranslation() + + const getRuleName = (key: string): string => { + const ruleNameMap: Record = { + remove_extra_spaces: t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' }), + remove_urls_emails: t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' }), + remove_stopwords: t('stepTwo.removeStopwords', { ns: 'datasetCreation' }), + } + return ruleNameMap[key] ?? key + } + + return ( + } + effectImg={BlueEffect.src} + className="text-util-colors-blue-light-blue-light-500" + activeHeaderClassName="bg-dataset-option-card-blue-gradient" + description={t('stepTwo.parentChildTip', { ns: 'datasetCreation' })} + isActive={isActive} + onSwitched={() => onDocFormChange(ChunkingMode.parentChild)} + actions={( + <> + + + + )} + noHighlight={isInUpload && isNotUploadInEmptyDataset} + > +
+ {/* Parent chunk for context */} +
+
+
+ {t('stepTwo.parentChunkForContext', { ns: 'datasetCreation' })} +
+ +
+ } + title={t('stepTwo.paragraph', { ns: 'datasetCreation' })} + description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })} + isChosen={parentChildConfig.chunkForContext === 'paragraph'} + onChosen={() => onChunkForContextChange('paragraph')} + chosenConfig={( +
+ onParentDelimiterChange(e.target.value)} + /> + +
+ )} + /> + } + title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })} + description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })} + onChosen={() => onChunkForContextChange('full-doc')} + isChosen={parentChildConfig.chunkForContext === 'full-doc'} + /> +
+ + {/* Child chunk for retrieval */} +
+
+
+ {t('stepTwo.childChunkForRetrieval', { ns: 'datasetCreation' })} +
+ +
+
+ onChildDelimiterChange(e.target.value)} + /> + +
+
+ + {/* Rules */} +
+
+
+ {t('stepTwo.rules', { ns: 'datasetCreation' })} +
+ +
+
+ {rules.map(rule => ( +
onRuleToggle(rule.id)} + > + + +
+ ))} +
+
+
+
+ ) +} diff --git a/web/app/components/datasets/create/step-two/components/preview-panel.tsx b/web/app/components/datasets/create/step-two/components/preview-panel.tsx new file mode 100644 index 0000000000..4f25cee5bd --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/preview-panel.tsx @@ -0,0 +1,171 @@ +'use client' + +import type { FC } from 'react' +import type { ParentChildConfig } from '../hooks' +import type { DataSourceType, FileIndexingEstimateResponse } from '@/models/datasets' +import { RiSearchEyeLine } from '@remixicon/react' +import { noop } from 'es-toolkit/function' +import { useTranslation } from 'react-i18next' +import Badge from '@/app/components/base/badge' +import FloatRightContainer from '@/app/components/base/float-right-container' +import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' +import { FULL_DOC_PREVIEW_LENGTH } from '@/config' +import { ChunkingMode } from '@/models/datasets' +import { cn } from '@/utils/classnames' +import { ChunkContainer, QAPreview } from '../../../chunk' +import PreviewDocumentPicker from '../../../common/document-picker/preview-document-picker' +import { PreviewSlice } from '../../../formatted-text/flavours/preview-slice' +import { FormattedText } from '../../../formatted-text/formatted' +import PreviewContainer from '../../../preview/container' +import { PreviewHeader } from '../../../preview/header' + +type PreviewPanelProps = { + // State + isMobile: boolean + dataSourceType: DataSourceType + currentDocForm: ChunkingMode + estimate?: FileIndexingEstimateResponse + parentChildConfig: ParentChildConfig + isSetting?: boolean + // Picker + pickerFiles: Array<{ id: string, name: string, extension: string }> + pickerValue: { id: string, name: string, extension: string } + // Mutation state + isIdle: boolean + isPending: boolean + // Actions + onPickerChange: (selected: { id: string, name: string }) => void +} + +export const PreviewPanel: FC = ({ + isMobile, + dataSourceType: _dataSourceType, + currentDocForm, + estimate, + parentChildConfig, + isSetting, + pickerFiles, + pickerValue, + isIdle, + isPending, + onPickerChange, +}) => { + const { t } = useTranslation() + + return ( + + +
+ >} + onChange={onPickerChange} + value={isSetting ? pickerFiles[0] : pickerValue} + /> + {currentDocForm !== ChunkingMode.qa && ( + + )} +
+ + )} + className={cn('relative flex h-full w-1/2 shrink-0 p-4 pr-0', isMobile && 'w-full max-w-[524px]')} + mainClassName="space-y-6" + > + {/* QA Preview */} + {currentDocForm === ChunkingMode.qa && estimate?.qa_preview && ( + estimate.qa_preview.map((item, index) => ( + + + + )) + )} + + {/* Text Preview */} + {currentDocForm === ChunkingMode.text && estimate?.preview && ( + estimate.preview.map((item, index) => ( + + {item.content} + + )) + )} + + {/* Parent-Child Preview */} + {currentDocForm === ChunkingMode.parentChild && estimate?.preview && ( + estimate.preview.map((item, index) => { + const indexForLabel = index + 1 + const childChunks = parentChildConfig.chunkForContext === 'full-doc' + ? item.child_chunks.slice(0, FULL_DOC_PREVIEW_LENGTH) + : item.child_chunks + return ( + + + {childChunks.map((child, childIndex) => { + const childIndexForLabel = childIndex + 1 + return ( + + ) + })} + + + ) + }) + )} + + {/* Idle State */} + {isIdle && ( +
+
+ +

+ {t('stepTwo.previewChunkTip', { ns: 'datasetCreation' })} +

+
+
+ )} + + {/* Loading State */} + {isPending && ( +
+ {Array.from({ length: 10 }, (_, i) => ( + + + + + + + + + + + ))} +
+ )} +
+
+ ) +} diff --git a/web/app/components/datasets/create/step-two/components/step-two-footer.tsx b/web/app/components/datasets/create/step-two/components/step-two-footer.tsx new file mode 100644 index 0000000000..a22be64a75 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/step-two-footer.tsx @@ -0,0 +1,58 @@ +'use client' + +import type { FC } from 'react' +import { RiArrowLeftLine } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' + +type StepTwoFooterProps = { + isSetting?: boolean + isCreating: boolean + onPrevious: () => void + onCreate: () => void + onCancel?: () => void +} + +export const StepTwoFooter: FC = ({ + isSetting, + isCreating, + onPrevious, + onCreate, + onCancel, +}) => { + const { t } = useTranslation() + + if (!isSetting) { + return ( +
+ + +
+ ) + } + + return ( +
+ + +
+ ) +} diff --git a/web/app/components/datasets/create/step-two/escape.ts b/web/app/components/datasets/create/step-two/hooks/escape.ts similarity index 100% rename from web/app/components/datasets/create/step-two/escape.ts rename to web/app/components/datasets/create/step-two/hooks/escape.ts diff --git a/web/app/components/datasets/create/step-two/hooks/index.ts b/web/app/components/datasets/create/step-two/hooks/index.ts new file mode 100644 index 0000000000..f16daaaea5 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/index.ts @@ -0,0 +1,14 @@ +export { useDocumentCreation } from './use-document-creation' +export type { DocumentCreation, ValidationParams } from './use-document-creation' + +export { IndexingType, useIndexingConfig } from './use-indexing-config' +export type { IndexingConfig } from './use-indexing-config' + +export { useIndexingEstimate } from './use-indexing-estimate' +export type { IndexingEstimate } from './use-indexing-estimate' + +export { usePreviewState } from './use-preview-state' +export type { PreviewState } from './use-preview-state' + +export { DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP, DEFAULT_SEGMENT_IDENTIFIER, defaultParentChildConfig, MAXIMUM_CHUNK_TOKEN_LENGTH, useSegmentationState } from './use-segmentation-state' +export type { ParentChildConfig, SegmentationState } from './use-segmentation-state' diff --git a/web/app/components/datasets/create/step-two/unescape.ts b/web/app/components/datasets/create/step-two/hooks/unescape.ts similarity index 100% rename from web/app/components/datasets/create/step-two/unescape.ts rename to web/app/components/datasets/create/step-two/hooks/unescape.ts diff --git a/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts b/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts new file mode 100644 index 0000000000..fd132b38ef --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts @@ -0,0 +1,279 @@ +import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { NotionPage } from '@/models/common' +import type { + ChunkingMode, + CrawlOptions, + CrawlResultItem, + CreateDocumentReq, + createDocumentResponse, + CustomFile, + FullDocumentDetail, + ProcessRule, +} from '@/models/datasets' +import type { RetrievalConfig, RETRIEVE_METHOD } from '@/types/app' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { trackEvent } from '@/app/components/base/amplitude' +import Toast from '@/app/components/base/toast' +import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model' +import { DataSourceProvider } from '@/models/common' +import { + DataSourceType, +} from '@/models/datasets' +import { getNotionInfo, getWebsiteInfo, useCreateDocument, useCreateFirstDocument } from '@/service/knowledge/use-create-dataset' +import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' +import { IndexingType } from './use-indexing-config' +import { MAXIMUM_CHUNK_TOKEN_LENGTH } from './use-segmentation-state' + +export type UseDocumentCreationOptions = { + datasetId?: string + isSetting?: boolean + documentDetail?: FullDocumentDetail + dataSourceType: DataSourceType + files: CustomFile[] + notionPages: NotionPage[] + notionCredentialId: string + websitePages: CrawlResultItem[] + crawlOptions?: CrawlOptions + websiteCrawlProvider?: DataSourceProvider + websiteCrawlJobId?: string + // Callbacks + onStepChange?: (delta: number) => void + updateIndexingTypeCache?: (type: string) => void + updateResultCache?: (res: createDocumentResponse) => void + updateRetrievalMethodCache?: (method: RETRIEVE_METHOD | '') => void + onSave?: () => void + mutateDatasetRes?: () => void +} + +export type ValidationParams = { + segmentationType: string + maxChunkLength: number + limitMaxChunkLength: number + overlap: number + indexType: IndexingType + embeddingModel: DefaultModel + rerankModelList: Model[] + retrievalConfig: RetrievalConfig +} + +export const useDocumentCreation = (options: UseDocumentCreationOptions) => { + const { t } = useTranslation() + const { + datasetId, + isSetting, + documentDetail, + dataSourceType, + files, + notionPages, + notionCredentialId, + websitePages, + crawlOptions, + websiteCrawlProvider = DataSourceProvider.jinaReader, + websiteCrawlJobId = '', + onStepChange, + updateIndexingTypeCache, + updateResultCache, + updateRetrievalMethodCache, + onSave, + mutateDatasetRes, + } = options + + const createFirstDocumentMutation = useCreateFirstDocument() + const createDocumentMutation = useCreateDocument(datasetId!) + const invalidDatasetList = useInvalidDatasetList() + + const isCreating = createFirstDocumentMutation.isPending || createDocumentMutation.isPending + + // Validate creation params + const validateParams = useCallback((params: ValidationParams): boolean => { + const { + segmentationType, + maxChunkLength, + limitMaxChunkLength, + overlap, + indexType, + embeddingModel, + rerankModelList, + retrievalConfig, + } = params + + if (segmentationType === 'general' && overlap > maxChunkLength) { + Toast.notify({ type: 'error', message: t('stepTwo.overlapCheck', { ns: 'datasetCreation' }) }) + return false + } + + if (segmentationType === 'general' && maxChunkLength > limitMaxChunkLength) { + Toast.notify({ + type: 'error', + message: t('stepTwo.maxLengthCheck', { ns: 'datasetCreation', limit: limitMaxChunkLength }), + }) + return false + } + + if (!isSetting) { + if (indexType === IndexingType.QUALIFIED && (!embeddingModel.model || !embeddingModel.provider)) { + Toast.notify({ + type: 'error', + message: t('datasetConfig.embeddingModelRequired', { ns: 'appDebug' }), + }) + return false + } + + if (!isReRankModelSelected({ + rerankModelList, + retrievalConfig, + indexMethod: indexType, + })) { + Toast.notify({ type: 'error', message: t('datasetConfig.rerankModelRequired', { ns: 'appDebug' }) }) + return false + } + } + + return true + }, [t, isSetting]) + + // Build creation params + const buildCreationParams = useCallback(( + currentDocForm: ChunkingMode, + docLanguage: string, + processRule: ProcessRule, + retrievalConfig: RetrievalConfig, + embeddingModel: DefaultModel, + indexingTechnique: string, + ): CreateDocumentReq | null => { + if (isSetting) { + return { + original_document_id: documentDetail?.id, + doc_form: currentDocForm, + doc_language: docLanguage, + process_rule: processRule, + retrieval_model: retrievalConfig, + embedding_model: embeddingModel.model, + embedding_model_provider: embeddingModel.provider, + indexing_technique: indexingTechnique, + } as CreateDocumentReq + } + + const params: CreateDocumentReq = { + data_source: { + type: dataSourceType, + info_list: { + data_source_type: dataSourceType, + }, + }, + indexing_technique: indexingTechnique, + process_rule: processRule, + doc_form: currentDocForm, + doc_language: docLanguage, + retrieval_model: retrievalConfig, + embedding_model: embeddingModel.model, + embedding_model_provider: embeddingModel.provider, + } as CreateDocumentReq + + // Add data source specific info + if (dataSourceType === DataSourceType.FILE) { + params.data_source!.info_list.file_info_list = { + file_ids: files.map(file => file.id || '').filter(Boolean), + } + } + if (dataSourceType === DataSourceType.NOTION) + params.data_source!.info_list.notion_info_list = getNotionInfo(notionPages, notionCredentialId) + + if (dataSourceType === DataSourceType.WEB) { + params.data_source!.info_list.website_info_list = getWebsiteInfo({ + websiteCrawlProvider, + websiteCrawlJobId, + websitePages, + crawlOptions, + }) + } + + return params + }, [ + isSetting, + documentDetail, + dataSourceType, + files, + notionPages, + notionCredentialId, + websitePages, + websiteCrawlProvider, + websiteCrawlJobId, + crawlOptions, + ]) + + // Execute creation + const executeCreation = useCallback(async ( + params: CreateDocumentReq, + indexType: IndexingType, + retrievalConfig: RetrievalConfig, + ) => { + if (!datasetId) { + await createFirstDocumentMutation.mutateAsync(params, { + onSuccess(data) { + updateIndexingTypeCache?.(indexType) + updateResultCache?.(data) + updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) + }, + }) + } + else { + await createDocumentMutation.mutateAsync(params, { + onSuccess(data) { + updateIndexingTypeCache?.(indexType) + updateResultCache?.(data) + updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) + }, + }) + } + + mutateDatasetRes?.() + invalidDatasetList() + + trackEvent('create_datasets', { + data_source_type: dataSourceType, + indexing_technique: indexType, + }) + + onStepChange?.(+1) + + if (isSetting) + onSave?.() + }, [ + datasetId, + createFirstDocumentMutation, + createDocumentMutation, + updateIndexingTypeCache, + updateResultCache, + updateRetrievalMethodCache, + mutateDatasetRes, + invalidDatasetList, + dataSourceType, + onStepChange, + isSetting, + onSave, + ]) + + // Validate preview params + const validatePreviewParams = useCallback((maxChunkLength: number): boolean => { + if (maxChunkLength > MAXIMUM_CHUNK_TOKEN_LENGTH) { + Toast.notify({ + type: 'error', + message: t('stepTwo.maxLengthCheck', { ns: 'datasetCreation', limit: MAXIMUM_CHUNK_TOKEN_LENGTH }), + }) + return false + } + return true + }, [t]) + + return { + isCreating, + validateParams, + buildCreationParams, + executeCreation, + validatePreviewParams, + } +} + +export type DocumentCreation = ReturnType diff --git a/web/app/components/datasets/create/step-two/hooks/use-indexing-config.ts b/web/app/components/datasets/create/step-two/hooks/use-indexing-config.ts new file mode 100644 index 0000000000..97fc9c260f --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/use-indexing-config.ts @@ -0,0 +1,143 @@ +import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { RetrievalConfig } from '@/types/app' +import { useEffect, useMemo, useState } from 'react' +import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useDefaultModel, useModelList, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { RETRIEVE_METHOD } from '@/types/app' + +export enum IndexingType { + QUALIFIED = 'high_quality', + ECONOMICAL = 'economy', +} + +const DEFAULT_RETRIEVAL_CONFIG: RetrievalConfig = { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, +} + +export type UseIndexingConfigOptions = { + initialIndexType?: IndexingType + initialEmbeddingModel?: DefaultModel + initialRetrievalConfig?: RetrievalConfig + isAPIKeySet: boolean + hasSetIndexType: boolean +} + +export const useIndexingConfig = (options: UseIndexingConfigOptions) => { + const { + initialIndexType, + initialEmbeddingModel, + initialRetrievalConfig, + isAPIKeySet, + hasSetIndexType, + } = options + + // Rerank model + const { + modelList: rerankModelList, + defaultModel: rerankDefaultModel, + currentModel: isRerankDefaultModelValid, + } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) + + // Embedding model list + const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) + const { data: defaultEmbeddingModel } = useDefaultModel(ModelTypeEnum.textEmbedding) + + // Index type state + const [indexType, setIndexType] = useState(() => { + if (initialIndexType) + return initialIndexType + return isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL + }) + + // Embedding model state + const [embeddingModel, setEmbeddingModel] = useState( + initialEmbeddingModel ?? { + provider: defaultEmbeddingModel?.provider.provider || '', + model: defaultEmbeddingModel?.model || '', + }, + ) + + // Retrieval config state + const [retrievalConfig, setRetrievalConfig] = useState( + initialRetrievalConfig ?? DEFAULT_RETRIEVAL_CONFIG, + ) + + // Sync retrieval config with rerank model when available + useEffect(() => { + if (initialRetrievalConfig) + return + + setRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: !!isRerankDefaultModelValid, + reranking_model: { + reranking_provider_name: isRerankDefaultModelValid ? rerankDefaultModel?.provider.provider ?? '' : '', + reranking_model_name: isRerankDefaultModelValid ? rerankDefaultModel?.model ?? '' : '', + }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }) + }, [rerankDefaultModel, isRerankDefaultModelValid, initialRetrievalConfig]) + + // Sync index type with props + useEffect(() => { + if (initialIndexType) + setIndexType(initialIndexType) + else + setIndexType(isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL) + }, [isAPIKeySet, initialIndexType]) + + // Show multimodal tip + const showMultiModalTip = useMemo(() => { + return checkShowMultiModalTip({ + embeddingModel, + rerankingEnable: retrievalConfig.reranking_enable, + rerankModel: { + rerankingProviderName: retrievalConfig.reranking_model.reranking_provider_name, + rerankingModelName: retrievalConfig.reranking_model.reranking_model_name, + }, + indexMethod: indexType, + embeddingModelList, + rerankModelList, + }) + }, [embeddingModel, retrievalConfig, indexType, embeddingModelList, rerankModelList]) + + // Get effective indexing technique + const getIndexingTechnique = () => initialIndexType || indexType + + return { + // Index type + indexType, + setIndexType, + hasSetIndexType, + getIndexingTechnique, + + // Embedding model + embeddingModel, + setEmbeddingModel, + embeddingModelList, + defaultEmbeddingModel, + + // Retrieval config + retrievalConfig, + setRetrievalConfig, + rerankModelList, + rerankDefaultModel, + isRerankDefaultModelValid, + + // Computed + showMultiModalTip, + } +} + +export type IndexingConfig = ReturnType diff --git a/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts b/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts new file mode 100644 index 0000000000..cc5a2bcf33 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts @@ -0,0 +1,123 @@ +import type { IndexingType } from './use-indexing-config' +import type { NotionPage } from '@/models/common' +import type { ChunkingMode, CrawlOptions, CrawlResultItem, CustomFile, ProcessRule } from '@/models/datasets' +import { useCallback } from 'react' +import { DataSourceProvider } from '@/models/common' +import { DataSourceType } from '@/models/datasets' +import { + useFetchFileIndexingEstimateForFile, + useFetchFileIndexingEstimateForNotion, + useFetchFileIndexingEstimateForWeb, +} from '@/service/knowledge/use-create-dataset' + +export type UseIndexingEstimateOptions = { + dataSourceType: DataSourceType + datasetId?: string + // Document settings + currentDocForm: ChunkingMode + docLanguage: string + // File data source + files: CustomFile[] + previewFileName?: string + // Notion data source + previewNotionPage: NotionPage + notionCredentialId: string + // Website data source + previewWebsitePage: CrawlResultItem + crawlOptions?: CrawlOptions + websiteCrawlProvider?: DataSourceProvider + websiteCrawlJobId?: string + // Processing + indexingTechnique: IndexingType + processRule: ProcessRule +} + +export const useIndexingEstimate = (options: UseIndexingEstimateOptions) => { + const { + dataSourceType, + datasetId, + currentDocForm, + docLanguage, + files, + previewFileName, + previewNotionPage, + notionCredentialId, + previewWebsitePage, + crawlOptions, + websiteCrawlProvider, + websiteCrawlJobId, + indexingTechnique, + processRule, + } = options + + // File indexing estimate + const fileQuery = useFetchFileIndexingEstimateForFile({ + docForm: currentDocForm, + docLanguage, + dataSourceType: DataSourceType.FILE, + files: previewFileName + ? [files.find(file => file.name === previewFileName)!] + : files, + indexingTechnique, + processRule, + dataset_id: datasetId!, + }) + + // Notion indexing estimate + const notionQuery = useFetchFileIndexingEstimateForNotion({ + docForm: currentDocForm, + docLanguage, + dataSourceType: DataSourceType.NOTION, + notionPages: [previewNotionPage], + indexingTechnique, + processRule, + dataset_id: datasetId || '', + credential_id: notionCredentialId, + }) + + // Website indexing estimate + const websiteQuery = useFetchFileIndexingEstimateForWeb({ + docForm: currentDocForm, + docLanguage, + dataSourceType: DataSourceType.WEB, + websitePages: [previewWebsitePage], + crawlOptions, + websiteCrawlProvider: websiteCrawlProvider ?? DataSourceProvider.jinaReader, + websiteCrawlJobId: websiteCrawlJobId ?? '', + indexingTechnique, + processRule, + dataset_id: datasetId || '', + }) + + // Get current mutation based on data source type + const getCurrentMutation = useCallback(() => { + if (dataSourceType === DataSourceType.FILE) + return fileQuery + if (dataSourceType === DataSourceType.NOTION) + return notionQuery + return websiteQuery + }, [dataSourceType, fileQuery, notionQuery, websiteQuery]) + + const currentMutation = getCurrentMutation() + + // Trigger estimate fetch + const fetchEstimate = useCallback(() => { + if (dataSourceType === DataSourceType.FILE) + fileQuery.mutate() + else if (dataSourceType === DataSourceType.NOTION) + notionQuery.mutate() + else + websiteQuery.mutate() + }, [dataSourceType, fileQuery, notionQuery, websiteQuery]) + + return { + currentMutation, + estimate: currentMutation.data, + isIdle: currentMutation.isIdle, + isPending: currentMutation.isPending, + fetchEstimate, + reset: currentMutation.reset, + } +} + +export type IndexingEstimate = ReturnType diff --git a/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts b/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts new file mode 100644 index 0000000000..94171c5947 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts @@ -0,0 +1,127 @@ +import type { NotionPage } from '@/models/common' +import type { CrawlResultItem, CustomFile, DocumentItem, FullDocumentDetail } from '@/models/datasets' +import { useCallback, useState } from 'react' +import { DataSourceType } from '@/models/datasets' + +export type UsePreviewStateOptions = { + dataSourceType: DataSourceType + files: CustomFile[] + notionPages: NotionPage[] + websitePages: CrawlResultItem[] + documentDetail?: FullDocumentDetail + datasetId?: string +} + +export const usePreviewState = (options: UsePreviewStateOptions) => { + const { + dataSourceType, + files, + notionPages, + websitePages, + documentDetail, + datasetId, + } = options + + // File preview state + const [previewFile, setPreviewFile] = useState( + (datasetId && documentDetail) + ? documentDetail.file + : files[0], + ) + + // Notion page preview state + const [previewNotionPage, setPreviewNotionPage] = useState( + (datasetId && documentDetail) + ? documentDetail.notion_page + : notionPages[0], + ) + + // Website page preview state + const [previewWebsitePage, setPreviewWebsitePage] = useState( + (datasetId && documentDetail) + ? documentDetail.website_page + : websitePages[0], + ) + + // Get preview items for document picker based on data source type + const getPreviewPickerItems = useCallback(() => { + if (dataSourceType === DataSourceType.FILE) { + return files as Array> + } + if (dataSourceType === DataSourceType.NOTION) { + return notionPages.map(page => ({ + id: page.page_id, + name: page.page_name, + extension: 'md', + })) + } + if (dataSourceType === DataSourceType.WEB) { + return websitePages.map(page => ({ + id: page.source_url, + name: page.title, + extension: 'md', + })) + } + return [] + }, [dataSourceType, files, notionPages, websitePages]) + + // Get current preview value for picker + const getPreviewPickerValue = useCallback(() => { + if (dataSourceType === DataSourceType.FILE) { + return previewFile as Required + } + if (dataSourceType === DataSourceType.NOTION) { + return { + id: previewNotionPage?.page_id || '', + name: previewNotionPage?.page_name || '', + extension: 'md', + } + } + if (dataSourceType === DataSourceType.WEB) { + return { + id: previewWebsitePage?.source_url || '', + name: previewWebsitePage?.title || '', + extension: 'md', + } + } + return { id: '', name: '', extension: '' } + }, [dataSourceType, previewFile, previewNotionPage, previewWebsitePage]) + + // Handle preview change + const handlePreviewChange = useCallback((selected: { id: string, name: string }) => { + if (dataSourceType === DataSourceType.FILE) { + setPreviewFile(selected as DocumentItem) + } + else if (dataSourceType === DataSourceType.NOTION) { + const selectedPage = notionPages.find(page => page.page_id === selected.id) + if (selectedPage) + setPreviewNotionPage(selectedPage) + } + else if (dataSourceType === DataSourceType.WEB) { + const selectedPage = websitePages.find(page => page.source_url === selected.id) + if (selectedPage) + setPreviewWebsitePage(selectedPage) + } + }, [dataSourceType, notionPages, websitePages]) + + return { + // File preview + previewFile, + setPreviewFile, + + // Notion preview + previewNotionPage, + setPreviewNotionPage, + + // Website preview + previewWebsitePage, + setPreviewWebsitePage, + + // Picker helpers + getPreviewPickerItems, + getPreviewPickerValue, + handlePreviewChange, + } +} + +export type PreviewState = ReturnType diff --git a/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts b/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts new file mode 100644 index 0000000000..69cc089b4f --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts @@ -0,0 +1,222 @@ +import type { ParentMode, PreProcessingRule, ProcessRule, Rules } from '@/models/datasets' +import { useCallback, useState } from 'react' +import { ChunkingMode, ProcessMode } from '@/models/datasets' +import escape from './escape' +import unescape from './unescape' + +// Constants +export const DEFAULT_SEGMENT_IDENTIFIER = '\\n\\n' +export const DEFAULT_MAXIMUM_CHUNK_LENGTH = 1024 +export const DEFAULT_OVERLAP = 50 +export const MAXIMUM_CHUNK_TOKEN_LENGTH = Number.parseInt( + globalThis.document?.body?.getAttribute('data-public-indexing-max-segmentation-tokens-length') || '4000', + 10, +) + +export type ParentChildConfig = { + chunkForContext: ParentMode + parent: { + delimiter: string + maxLength: number + } + child: { + delimiter: string + maxLength: number + } +} + +export const defaultParentChildConfig: ParentChildConfig = { + chunkForContext: 'paragraph', + parent: { + delimiter: '\\n\\n', + maxLength: 1024, + }, + child: { + delimiter: '\\n', + maxLength: 512, + }, +} + +export type UseSegmentationStateOptions = { + initialSegmentationType?: ProcessMode +} + +export const useSegmentationState = (options: UseSegmentationStateOptions = {}) => { + const { initialSegmentationType } = options + + // Segmentation type (general or parent-child) + const [segmentationType, setSegmentationType] = useState( + initialSegmentationType ?? ProcessMode.general, + ) + + // General chunking settings + const [segmentIdentifier, doSetSegmentIdentifier] = useState(DEFAULT_SEGMENT_IDENTIFIER) + const [maxChunkLength, setMaxChunkLength] = useState(DEFAULT_MAXIMUM_CHUNK_LENGTH) + const [limitMaxChunkLength, setLimitMaxChunkLength] = useState(MAXIMUM_CHUNK_TOKEN_LENGTH) + const [overlap, setOverlap] = useState(DEFAULT_OVERLAP) + + // Pre-processing rules + const [rules, setRules] = useState([]) + const [defaultConfig, setDefaultConfig] = useState() + + // Parent-child config + const [parentChildConfig, setParentChildConfig] = useState(defaultParentChildConfig) + + // Escaped segment identifier setter + const setSegmentIdentifier = useCallback((value: string, canEmpty?: boolean) => { + if (value) { + doSetSegmentIdentifier(escape(value)) + } + else { + doSetSegmentIdentifier(canEmpty ? '' : DEFAULT_SEGMENT_IDENTIFIER) + } + }, []) + + // Rule toggle handler + const toggleRule = useCallback((id: string) => { + setRules(prev => prev.map(rule => + rule.id === id ? { ...rule, enabled: !rule.enabled } : rule, + )) + }, []) + + // Reset to defaults + const resetToDefaults = useCallback(() => { + if (defaultConfig) { + setSegmentIdentifier(defaultConfig.segmentation.separator) + setMaxChunkLength(defaultConfig.segmentation.max_tokens) + setOverlap(defaultConfig.segmentation.chunk_overlap!) + setRules(defaultConfig.pre_processing_rules) + } + setParentChildConfig(defaultParentChildConfig) + }, [defaultConfig, setSegmentIdentifier]) + + // Apply config from document detail + const applyConfigFromRules = useCallback((rulesConfig: Rules, isHierarchical: boolean) => { + const separator = rulesConfig.segmentation.separator + const max = rulesConfig.segmentation.max_tokens + const chunkOverlap = rulesConfig.segmentation.chunk_overlap + + setSegmentIdentifier(separator) + setMaxChunkLength(max) + setOverlap(chunkOverlap!) + setRules(rulesConfig.pre_processing_rules) + setDefaultConfig(rulesConfig) + + if (isHierarchical) { + setParentChildConfig({ + chunkForContext: rulesConfig.parent_mode || 'paragraph', + parent: { + delimiter: escape(rulesConfig.segmentation.separator), + maxLength: rulesConfig.segmentation.max_tokens, + }, + child: { + delimiter: escape(rulesConfig.subchunk_segmentation!.separator), + maxLength: rulesConfig.subchunk_segmentation!.max_tokens, + }, + }) + } + }, [setSegmentIdentifier]) + + // Get process rule for API + const getProcessRule = useCallback((docForm: ChunkingMode): ProcessRule => { + if (docForm === ChunkingMode.parentChild) { + return { + rules: { + pre_processing_rules: rules, + segmentation: { + separator: unescape(parentChildConfig.parent.delimiter), + max_tokens: parentChildConfig.parent.maxLength, + }, + parent_mode: parentChildConfig.chunkForContext, + subchunk_segmentation: { + separator: unescape(parentChildConfig.child.delimiter), + max_tokens: parentChildConfig.child.maxLength, + }, + }, + mode: 'hierarchical', + } as ProcessRule + } + + return { + rules: { + pre_processing_rules: rules, + segmentation: { + separator: unescape(segmentIdentifier), + max_tokens: maxChunkLength, + chunk_overlap: overlap, + }, + }, + mode: segmentationType, + } as ProcessRule + }, [rules, parentChildConfig, segmentIdentifier, maxChunkLength, overlap, segmentationType]) + + // Update parent config field + const updateParentConfig = useCallback((field: 'delimiter' | 'maxLength', value: string | number) => { + setParentChildConfig((prev) => { + let newValue: string | number + if (field === 'delimiter') + newValue = value ? escape(value as string) : '' + else + newValue = value + return { + ...prev, + parent: { ...prev.parent, [field]: newValue }, + } + }) + }, []) + + // Update child config field + const updateChildConfig = useCallback((field: 'delimiter' | 'maxLength', value: string | number) => { + setParentChildConfig((prev) => { + let newValue: string | number + if (field === 'delimiter') + newValue = value ? escape(value as string) : '' + else + newValue = value + return { + ...prev, + child: { ...prev.child, [field]: newValue }, + } + }) + }, []) + + // Set chunk for context mode + const setChunkForContext = useCallback((mode: ParentMode) => { + setParentChildConfig(prev => ({ ...prev, chunkForContext: mode })) + }, []) + + return { + // General chunking state + segmentationType, + setSegmentationType, + segmentIdentifier, + setSegmentIdentifier, + maxChunkLength, + setMaxChunkLength, + limitMaxChunkLength, + setLimitMaxChunkLength, + overlap, + setOverlap, + + // Rules + rules, + setRules, + defaultConfig, + setDefaultConfig, + toggleRule, + + // Parent-child config + parentChildConfig, + setParentChildConfig, + updateParentConfig, + updateChildConfig, + setChunkForContext, + + // Actions + resetToDefaults, + applyConfigFromRules, + getProcessRule, + } +} + +export type SegmentationState = ReturnType diff --git a/web/app/components/datasets/create/step-two/index.spec.tsx b/web/app/components/datasets/create/step-two/index.spec.tsx new file mode 100644 index 0000000000..7145920f60 --- /dev/null +++ b/web/app/components/datasets/create/step-two/index.spec.tsx @@ -0,0 +1,2197 @@ +import type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { DataSourceProvider, NotionPage } from '@/models/common' +import type { + CrawlOptions, + CrawlResultItem, + CustomFile, + FileIndexingEstimateResponse, + FullDocumentDetail, + PreProcessingRule, + Rules, +} from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import { PreviewPanel } from './components/preview-panel' +import { StepTwoFooter } from './components/step-two-footer' +import { + DEFAULT_MAXIMUM_CHUNK_LENGTH, + DEFAULT_OVERLAP, + DEFAULT_SEGMENT_IDENTIFIER, + defaultParentChildConfig, + IndexingType, + useDocumentCreation, + useIndexingConfig, + useIndexingEstimate, + usePreviewState, + useSegmentationState, +} from './hooks' +import escape from './hooks/escape' +import unescape from './hooks/unescape' + +// ============================================ +// Mock external dependencies +// ============================================ + +// Mock dataset detail context +const mockDataset = { + id: 'test-dataset-id', + doc_form: ChunkingMode.text, + data_source_type: DataSourceType.FILE, + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + } as RetrievalConfig, +} + +let mockCurrentDataset: typeof mockDataset | null = null +const mockMutateDatasetRes = vi.fn() + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset: typeof mockDataset | null, mutateDatasetRes: () => void }) => unknown) => + selector({ dataset: mockCurrentDataset, mutateDatasetRes: mockMutateDatasetRes }), +})) + +// Note: @/context/i18n is globally mocked in vitest.setup.ts, no need to mock here +// Note: @/hooks/use-breakpoints uses real import + +// Mock model hooks +const mockEmbeddingModelList = [ + { provider: 'openai', model: 'text-embedding-ada-002' }, + { provider: 'cohere', model: 'embed-english-v3.0' }, +] +const mockDefaultEmbeddingModel = { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' } +// Model[] type structure for rerank model list (simplified mock) +const mockRerankModelList: Model[] = [{ + provider: 'cohere', + icon_small: { en_US: 'cohere-icon', zh_Hans: 'cohere-icon' }, + label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, + models: [{ + model: 'rerank-english-v3.0', + label: { en_US: 'Rerank English v3.0', zh_Hans: 'Rerank English v3.0' }, + model_type: ModelTypeEnum.rerank, + features: [], + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + }], + status: ModelStatusEnum.active, +}] +const mockRerankDefaultModel = { provider: { provider: 'cohere' }, model: 'rerank-english-v3.0' } +let mockIsRerankDefaultModelValid = true + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ + modelList: mockRerankModelList, + defaultModel: mockRerankDefaultModel, + currentModel: mockIsRerankDefaultModelValid, + }), + useModelList: () => ({ data: mockEmbeddingModelList }), + useDefaultModel: () => ({ data: mockDefaultEmbeddingModel }), +})) + +// Mock service hooks +const mockFetchDefaultProcessRuleMutate = vi.fn() +vi.mock('@/service/knowledge/use-create-dataset', () => ({ + useFetchDefaultProcessRule: ({ onSuccess }: { onSuccess: (data: { rules: Rules, limits: { indexing_max_segmentation_tokens_length: number } }) => void }) => ({ + mutate: (url: string) => { + mockFetchDefaultProcessRuleMutate(url) + onSuccess({ + rules: { + segmentation: { separator: '\\n', max_tokens: 500, chunk_overlap: 50 }, + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ], + parent_mode: 'paragraph', + subchunk_segmentation: { separator: '\\n', max_tokens: 256 }, + }, + limits: { indexing_max_segmentation_tokens_length: 4000 }, + }) + }, + isPending: false, + }), + useFetchFileIndexingEstimateForFile: () => ({ + mutate: vi.fn(), + data: undefined, + isIdle: true, + isPending: false, + reset: vi.fn(), + }), + useFetchFileIndexingEstimateForNotion: () => ({ + mutate: vi.fn(), + data: undefined, + isIdle: true, + isPending: false, + reset: vi.fn(), + }), + useFetchFileIndexingEstimateForWeb: () => ({ + mutate: vi.fn(), + data: undefined, + isIdle: true, + isPending: false, + reset: vi.fn(), + }), + useCreateFirstDocument: () => ({ + mutateAsync: vi.fn().mockImplementation(async (params: unknown, options?: { onSuccess?: (data: unknown) => void }) => { + const data = { dataset: { id: 'new-dataset-id' } } + options?.onSuccess?.(data) + return data + }), + isPending: false, + }), + useCreateDocument: () => ({ + mutateAsync: vi.fn().mockImplementation(async (params: unknown, options?: { onSuccess?: (data: unknown) => void }) => { + const data = { document: { id: 'new-doc-id' } } + options?.onSuccess?.(data) + return data + }), + isPending: false, + }), + getNotionInfo: vi.fn().mockReturnValue([{ workspace_id: 'ws-1', pages: [{ page_id: 'page-1' }] }]), + getWebsiteInfo: vi.fn().mockReturnValue({ provider: 'jinaReader', job_id: 'job-123', urls: ['https://test.com'] }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useInvalidDatasetList: () => vi.fn(), +})) + +// Mock amplitude tracking (external service) +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) + +// Note: @/app/components/base/toast - uses real import (base component) +// Note: @/app/components/datasets/common/check-rerank-model - uses real import +// Note: @/app/components/base/float-right-container - uses real import (base component) + +// Mock checkShowMultiModalTip - requires complex model list structure +vi.mock('@/app/components/datasets/settings/utils', () => ({ + checkShowMultiModalTip: () => false, +})) + +// ============================================ +// Test data factories +// ============================================ + +const createMockFile = (overrides?: Partial): CustomFile => ({ + id: 'file-1', + name: 'test-file.pdf', + extension: 'pdf', + size: 1024, + type: 'application/pdf', + lastModified: Date.now(), + ...overrides, +} as CustomFile) + +const createMockNotionPage = (overrides?: Partial): NotionPage => ({ + page_id: 'notion-page-1', + page_name: 'Test Notion Page', + page_icon: null, + type: 'page', + ...overrides, +} as NotionPage) + +const createMockWebsitePage = (overrides?: Partial): CrawlResultItem => ({ + source_url: 'https://example.com/page1', + title: 'Test Website Page', + description: 'Test description', + markdown: '# Test Content', + ...overrides, +} as CrawlResultItem) + +const createMockDocumentDetail = (overrides?: Partial): FullDocumentDetail => ({ + id: 'doc-1', + doc_form: ChunkingMode.text, + doc_language: 'English', + file: { id: 'file-1', name: 'test.pdf', extension: 'pdf' }, + notion_page: createMockNotionPage(), + website_page: createMockWebsitePage(), + dataset_process_rule: { + mode: ProcessMode.general, + rules: { + segmentation: { separator: '\\n\\n', max_tokens: 1024, chunk_overlap: 50 }, + pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }], + }, + }, + ...overrides, +} as FullDocumentDetail) + +const createMockRules = (overrides?: Partial): Rules => ({ + segmentation: { separator: '\\n\\n', max_tokens: 1024, chunk_overlap: 50 }, + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ], + parent_mode: 'paragraph', + subchunk_segmentation: { separator: '\\n', max_tokens: 512 }, + ...overrides, +}) + +const createMockEstimate = (overrides?: Partial): FileIndexingEstimateResponse => ({ + total_segments: 10, + total_nodes: 10, + tokens: 5000, + total_price: 0.01, + currency: 'USD', + qa_preview: [{ question: 'Q1', answer: 'A1' }], + preview: [{ content: 'Chunk 1 content', child_chunks: ['Child 1', 'Child 2'] }], + ...overrides, +}) + +// ============================================ +// Utility Functions Tests (escape/unescape) +// ============================================ + +describe('escape utility', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Tests for escape function + describe('escape function', () => { + it('should return empty string for null/undefined input', () => { + expect(escape(null as unknown as string)).toBe('') + expect(escape(undefined as unknown as string)).toBe('') + expect(escape('')).toBe('') + }) + + it('should escape newline characters', () => { + expect(escape('\n')).toBe('\\n') + expect(escape('\r')).toBe('\\r') + expect(escape('\n\r')).toBe('\\n\\r') + }) + + it('should escape tab characters', () => { + expect(escape('\t')).toBe('\\t') + }) + + it('should escape other special characters', () => { + expect(escape('\0')).toBe('\\0') + expect(escape('\b')).toBe('\\b') + expect(escape('\f')).toBe('\\f') + expect(escape('\v')).toBe('\\v') + }) + + it('should escape single quotes', () => { + expect(escape('\'')).toBe('\\\'') + }) + + it('should handle mixed content', () => { + expect(escape('Hello\nWorld\t!')).toBe('Hello\\nWorld\\t!') + }) + + it('should not escape regular characters', () => { + expect(escape('Hello World')).toBe('Hello World') + expect(escape('abc123')).toBe('abc123') + }) + + it('should return empty string for non-string input', () => { + expect(escape(123 as unknown as string)).toBe('') + expect(escape({} as unknown as string)).toBe('') + }) + }) +}) + +describe('unescape utility', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Tests for unescape function + describe('unescape function', () => { + it('should unescape newline characters', () => { + expect(unescape('\\n')).toBe('\n') + expect(unescape('\\r')).toBe('\r') + }) + + it('should unescape tab characters', () => { + expect(unescape('\\t')).toBe('\t') + }) + + it('should unescape other special characters', () => { + expect(unescape('\\0')).toBe('\0') + expect(unescape('\\b')).toBe('\b') + expect(unescape('\\f')).toBe('\f') + expect(unescape('\\v')).toBe('\v') + }) + + it('should unescape single and double quotes', () => { + expect(unescape('\\\'')).toBe('\'') + expect(unescape('\\"')).toBe('"') + }) + + it('should unescape backslash', () => { + expect(unescape('\\\\')).toBe('\\') + }) + + it('should unescape hex sequences', () => { + expect(unescape('\\x41')).toBe('A') // 0x41 = 65 = 'A' + expect(unescape('\\x5A')).toBe('Z') // 0x5A = 90 = 'Z' + }) + + it('should unescape short hex (2-digit) sequences', () => { + // Short hex format: \xNN (2 hexadecimal digits) + expect(unescape('\\xA5')).toBe('¥') // Yen sign + expect(unescape('\\x7F')).toBe('\x7F') // Delete character + expect(unescape('\\x00')).toBe('\x00') // Null character via hex + }) + + it('should unescape octal sequences', () => { + expect(unescape('\\101')).toBe('A') // Octal 101 = 65 = 'A' + expect(unescape('\\132')).toBe('Z') // Octal 132 = 90 = 'Z' + expect(unescape('\\7')).toBe('\x07') // Single digit octal + }) + + it('should unescape unicode sequences', () => { + expect(unescape('\\u0041')).toBe('A') + expect(unescape('\\u{41}')).toBe('A') + }) + + it('should unescape Python-style unicode', () => { + expect(unescape('\\U00000041')).toBe('A') + }) + + it('should handle mixed content', () => { + expect(unescape('Hello\\nWorld\\t!')).toBe('Hello\nWorld\t!') + }) + + it('should not modify regular text', () => { + expect(unescape('Hello World')).toBe('Hello World') + }) + }) +}) + +// ============================================ +// useSegmentationState Hook Tests +// ============================================ + +describe('useSegmentationState', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Tests for initial state + describe('Initial State', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => useSegmentationState()) + + expect(result.current.segmentationType).toBe(ProcessMode.general) + expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER) + expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH) + expect(result.current.overlap).toBe(DEFAULT_OVERLAP) + expect(result.current.rules).toEqual([]) + expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig) + }) + + it('should initialize with custom segmentation type', () => { + const { result } = renderHook(() => + useSegmentationState({ initialSegmentationType: ProcessMode.parentChild }), + ) + + expect(result.current.segmentationType).toBe(ProcessMode.parentChild) + }) + }) + + // Tests for state setters + describe('State Management', () => { + it('should update segmentation type', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentationType(ProcessMode.parentChild) + }) + + expect(result.current.segmentationType).toBe(ProcessMode.parentChild) + }) + + it('should update max chunk length', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setMaxChunkLength(2048) + }) + + expect(result.current.maxChunkLength).toBe(2048) + }) + + it('should update overlap', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setOverlap(100) + }) + + expect(result.current.overlap).toBe(100) + }) + + it('should update rules', () => { + const { result } = renderHook(() => useSegmentationState()) + const newRules: PreProcessingRule[] = [{ id: 'test', enabled: true }] + + act(() => { + result.current.setRules(newRules) + }) + + expect(result.current.rules).toEqual(newRules) + }) + }) + + // Tests for setSegmentIdentifier with escape + describe('setSegmentIdentifier', () => { + it('should escape special characters', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('\n\n') + }) + + expect(result.current.segmentIdentifier).toBe('\\n\\n') + }) + + it('should use default when empty and canEmpty is false', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('') + }) + + expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER) + }) + + it('should allow empty when canEmpty is true', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('', true) + }) + + expect(result.current.segmentIdentifier).toBe('') + }) + }) + + // Tests for toggleRule + describe('toggleRule', () => { + it('should toggle rule enabled state', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setRules([ + { id: 'rule1', enabled: true }, + { id: 'rule2', enabled: false }, + ]) + }) + + act(() => { + result.current.toggleRule('rule1') + }) + + expect(result.current.rules.find(r => r.id === 'rule1')?.enabled).toBe(false) + expect(result.current.rules.find(r => r.id === 'rule2')?.enabled).toBe(false) + }) + + it('should not affect other rules', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setRules([ + { id: 'rule1', enabled: true }, + { id: 'rule2', enabled: false }, + ]) + }) + + act(() => { + result.current.toggleRule('rule2') + }) + + expect(result.current.rules.find(r => r.id === 'rule1')?.enabled).toBe(true) + expect(result.current.rules.find(r => r.id === 'rule2')?.enabled).toBe(true) + }) + }) + + // Tests for parent-child config + describe('Parent-Child Configuration', () => { + it('should update parent config delimiter with truthy value', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('delimiter', '\n\n\n') + }) + + expect(result.current.parentChildConfig.parent.delimiter).toBe('\\n\\n\\n') + }) + + it('should update parent config delimiter with empty value', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('delimiter', '') + }) + + expect(result.current.parentChildConfig.parent.delimiter).toBe('') + }) + + it('should update parent config maxLength', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('maxLength', 2048) + }) + + expect(result.current.parentChildConfig.parent.maxLength).toBe(2048) + }) + + it('should update child config delimiter with truthy value', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateChildConfig('delimiter', '\n') + }) + + expect(result.current.parentChildConfig.child.delimiter).toBe('\\n') + }) + + it('should update child config delimiter with empty value', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateChildConfig('delimiter', '') + }) + + expect(result.current.parentChildConfig.child.delimiter).toBe('') + }) + + it('should update child config maxLength', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateChildConfig('maxLength', 256) + }) + + expect(result.current.parentChildConfig.child.maxLength).toBe(256) + }) + + it('should set chunk for context mode', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setChunkForContext('full-doc') + }) + + expect(result.current.parentChildConfig.chunkForContext).toBe('full-doc') + }) + }) + + // Tests for resetToDefaults + describe('resetToDefaults', () => { + it('should reset to default config when available', () => { + const { result } = renderHook(() => useSegmentationState()) + + // Set non-default values and default config + act(() => { + result.current.setMaxChunkLength(2048) + result.current.setOverlap(100) + result.current.setDefaultConfig(createMockRules()) + }) + + // Reset - should use default config values + act(() => { + result.current.resetToDefaults() + }) + + expect(result.current.maxChunkLength).toBe(1024) + expect(result.current.overlap).toBe(50) + expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig) + }) + + it('should only reset parentChildConfig when no default config', () => { + const { result } = renderHook(() => useSegmentationState()) + + // Set non-default values without setting defaultConfig + act(() => { + result.current.setMaxChunkLength(2048) + result.current.setOverlap(100) + result.current.setChunkForContext('full-doc') + }) + + // Reset - should only reset parentChildConfig since no default config + act(() => { + result.current.resetToDefaults() + }) + + // Values stay the same since no defaultConfig + expect(result.current.maxChunkLength).toBe(2048) + expect(result.current.overlap).toBe(100) + // But parentChildConfig is always reset + expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig) + }) + }) + + // Tests for applyConfigFromRules + describe('applyConfigFromRules', () => { + it('should apply general config from rules', () => { + const { result } = renderHook(() => useSegmentationState()) + const rules = createMockRules({ + segmentation: { separator: '---', max_tokens: 512, chunk_overlap: 25 }, + }) + + act(() => { + result.current.applyConfigFromRules(rules, false) + }) + + expect(result.current.maxChunkLength).toBe(512) + expect(result.current.overlap).toBe(25) + }) + + it('should apply hierarchical config from rules', () => { + const { result } = renderHook(() => useSegmentationState()) + const rules = createMockRules({ + parent_mode: 'paragraph', + subchunk_segmentation: { separator: '\n', max_tokens: 256 }, + }) + + act(() => { + result.current.applyConfigFromRules(rules, true) + }) + + expect(result.current.parentChildConfig.chunkForContext).toBe('paragraph') + expect(result.current.parentChildConfig.child.maxLength).toBe(256) + }) + + it('should apply full hierarchical parent-child config from rules', () => { + const { result } = renderHook(() => useSegmentationState()) + const rules = createMockRules({ + segmentation: { separator: '\n\n', max_tokens: 1024, chunk_overlap: 50 }, + parent_mode: 'full-doc', + subchunk_segmentation: { separator: '\n', max_tokens: 128 }, + }) + + act(() => { + result.current.applyConfigFromRules(rules, true) + }) + + // Should set parent config from segmentation + expect(result.current.parentChildConfig.parent.delimiter).toBe('\\n\\n') + expect(result.current.parentChildConfig.parent.maxLength).toBe(1024) + // Should set child config from subchunk_segmentation + expect(result.current.parentChildConfig.child.delimiter).toBe('\\n') + expect(result.current.parentChildConfig.child.maxLength).toBe(128) + // Should set chunkForContext + expect(result.current.parentChildConfig.chunkForContext).toBe('full-doc') + }) + }) + + // Tests for getProcessRule + describe('getProcessRule', () => { + it('should return general process rule', () => { + const { result } = renderHook(() => useSegmentationState()) + + const processRule = result.current.getProcessRule(ChunkingMode.text) + + expect(processRule.mode).toBe(ProcessMode.general) + expect(processRule.rules.segmentation.max_tokens).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH) + }) + + it('should return hierarchical process rule for parent-child', () => { + const { result } = renderHook(() => useSegmentationState()) + + const processRule = result.current.getProcessRule(ChunkingMode.parentChild) + + expect(processRule.mode).toBe('hierarchical') + expect(processRule.rules.parent_mode).toBe('paragraph') + expect(processRule.rules.subchunk_segmentation).toBeDefined() + }) + }) +}) + +// ============================================ +// useIndexingConfig Hook Tests +// ============================================ + +describe('useIndexingConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsRerankDefaultModelValid = true + }) + + // Tests for initial state + // Note: Hook has useEffect that syncs state, so we test the state after effects settle + describe('Initial State', () => { + it('should initialize with QUALIFIED when API key is set', async () => { + const { result } = renderHook(() => + useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), + ) + + // After effects settle, indexType should be QUALIFIED + await vi.waitFor(() => { + expect(result.current.indexType).toBe(IndexingType.QUALIFIED) + }) + }) + + it('should initialize with ECONOMICAL when API key is not set', async () => { + const { result } = renderHook(() => + useIndexingConfig({ isAPIKeySet: false, hasSetIndexType: false }), + ) + + await vi.waitFor(() => { + expect(result.current.indexType).toBe(IndexingType.ECONOMICAL) + }) + }) + + it('should use initial index type when provided', async () => { + const { result } = renderHook(() => + useIndexingConfig({ + isAPIKeySet: false, + hasSetIndexType: true, + initialIndexType: IndexingType.QUALIFIED, + }), + ) + + await vi.waitFor(() => { + expect(result.current.indexType).toBe(IndexingType.QUALIFIED) + }) + }) + }) + + // Tests for state setters + describe('State Management', () => { + it('should update index type', async () => { + const { result } = renderHook(() => + useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), + ) + + // Wait for initial effects to settle + await vi.waitFor(() => { + expect(result.current.indexType).toBeDefined() + }) + + act(() => { + result.current.setIndexType(IndexingType.ECONOMICAL) + }) + + expect(result.current.indexType).toBe(IndexingType.ECONOMICAL) + }) + + it('should update embedding model', async () => { + const { result } = renderHook(() => + useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), + ) + + await vi.waitFor(() => { + expect(result.current.embeddingModel).toBeDefined() + }) + + act(() => { + result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' }) + }) + + expect(result.current.embeddingModel).toEqual({ provider: 'cohere', model: 'embed-v3' }) + }) + + it('should update retrieval config', async () => { + const { result } = renderHook(() => + useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), + ) + + await vi.waitFor(() => { + expect(result.current.retrievalConfig).toBeDefined() + }) + + const newConfig: RetrievalConfig = { + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + reranking_model: { reranking_provider_name: 'cohere', reranking_model_name: 'rerank-v3' }, + top_k: 5, + score_threshold_enabled: true, + score_threshold: 0.7, + } + + act(() => { + result.current.setRetrievalConfig(newConfig) + }) + + expect(result.current.retrievalConfig).toEqual(newConfig) + }) + }) + + // Tests for getIndexingTechnique + describe('getIndexingTechnique', () => { + it('should return initial type when set', async () => { + const { result } = renderHook(() => + useIndexingConfig({ + isAPIKeySet: true, + hasSetIndexType: true, + initialIndexType: IndexingType.ECONOMICAL, + }), + ) + + await vi.waitFor(() => { + expect(result.current.getIndexingTechnique()).toBe(IndexingType.ECONOMICAL) + }) + }) + + it('should return current type when no initial type', async () => { + const { result } = renderHook(() => + useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), + ) + + await vi.waitFor(() => { + expect(result.current.indexType).toBeDefined() + }) + + act(() => { + result.current.setIndexType(IndexingType.ECONOMICAL) + }) + + expect(result.current.getIndexingTechnique()).toBe(IndexingType.ECONOMICAL) + }) + }) + + // Tests for initialRetrievalConfig handling + describe('initialRetrievalConfig', () => { + it('should skip retrieval config sync when initialRetrievalConfig is provided', async () => { + const customRetrievalConfig: RetrievalConfig = { + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + reranking_model: { reranking_provider_name: 'custom', reranking_model_name: 'custom-model' }, + top_k: 10, + score_threshold_enabled: true, + score_threshold: 0.8, + } + + const { result } = renderHook(() => + useIndexingConfig({ + isAPIKeySet: true, + hasSetIndexType: false, + initialRetrievalConfig: customRetrievalConfig, + }), + ) + + await vi.waitFor(() => { + expect(result.current.retrievalConfig).toBeDefined() + }) + + // Should use the provided initial config, not the default synced one + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.hybrid) + expect(result.current.retrievalConfig.top_k).toBe(10) + }) + }) +}) + +// ============================================ +// usePreviewState Hook Tests +// ============================================ + +describe('usePreviewState', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultOptions = { + dataSourceType: DataSourceType.FILE, + files: [createMockFile()], + notionPages: [createMockNotionPage()], + websitePages: [createMockWebsitePage()], + } + + // Tests for initial state + describe('Initial State', () => { + it('should initialize with first file for FILE data source', () => { + const { result } = renderHook(() => usePreviewState(defaultOptions)) + + expect(result.current.previewFile).toEqual(defaultOptions.files[0]) + }) + + it('should initialize with first notion page for NOTION data source', () => { + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.NOTION }), + ) + + expect(result.current.previewNotionPage).toEqual(defaultOptions.notionPages[0]) + }) + + it('should initialize with document detail when provided', () => { + const documentDetail = createMockDocumentDetail() + const { result } = renderHook(() => + usePreviewState({ + ...defaultOptions, + documentDetail, + datasetId: 'test-id', + }), + ) + + expect(result.current.previewFile).toEqual(documentDetail.file) + }) + }) + + // Tests for getPreviewPickerItems + describe('getPreviewPickerItems', () => { + it('should return files for FILE data source', () => { + const { result } = renderHook(() => usePreviewState(defaultOptions)) + + const items = result.current.getPreviewPickerItems() + expect(items).toEqual(defaultOptions.files) + }) + + it('should return mapped notion pages for NOTION data source', () => { + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.NOTION }), + ) + + const items = result.current.getPreviewPickerItems() + expect(items[0]).toEqual({ + id: 'notion-page-1', + name: 'Test Notion Page', + extension: 'md', + }) + }) + + it('should return mapped website pages for WEB data source', () => { + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.WEB }), + ) + + const items = result.current.getPreviewPickerItems() + expect(items[0]).toEqual({ + id: 'https://example.com/page1', + name: 'Test Website Page', + extension: 'md', + }) + }) + + it('should return empty array for unknown data source', () => { + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: 'unknown' as DataSourceType }), + ) + + const items = result.current.getPreviewPickerItems() + expect(items).toEqual([]) + }) + }) + + // Tests for getPreviewPickerValue + describe('getPreviewPickerValue', () => { + it('should return file value for FILE data source', () => { + const { result } = renderHook(() => usePreviewState(defaultOptions)) + + const value = result.current.getPreviewPickerValue() + expect(value).toEqual(defaultOptions.files[0]) + }) + + it('should return mapped notion page value for NOTION data source', () => { + const notionPage = createMockNotionPage({ page_id: 'page-123', page_name: 'My Page' }) + const { result } = renderHook(() => + usePreviewState({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + notionPages: [notionPage], + }), + ) + + const value = result.current.getPreviewPickerValue() + expect(value).toEqual({ + id: 'page-123', + name: 'My Page', + extension: 'md', + }) + }) + + it('should return mapped website page value for WEB data source', () => { + const websitePage = createMockWebsitePage({ source_url: 'https://test.com', title: 'Test Title' }) + const { result } = renderHook(() => + usePreviewState({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + websitePages: [websitePage], + }), + ) + + const value = result.current.getPreviewPickerValue() + expect(value).toEqual({ + id: 'https://test.com', + name: 'Test Title', + extension: 'md', + }) + }) + + it('should return empty value for unknown data source', () => { + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: 'unknown' as DataSourceType }), + ) + + const value = result.current.getPreviewPickerValue() + expect(value).toEqual({ id: '', name: '', extension: '' }) + }) + + it('should handle undefined notion page gracefully', () => { + const { result } = renderHook(() => + usePreviewState({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + notionPages: [], + }), + ) + + const value = result.current.getPreviewPickerValue() + expect(value).toEqual({ + id: '', + name: '', + extension: 'md', + }) + }) + + it('should handle undefined website page gracefully', () => { + const { result } = renderHook(() => + usePreviewState({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + websitePages: [], + }), + ) + + const value = result.current.getPreviewPickerValue() + expect(value).toEqual({ + id: '', + name: '', + extension: 'md', + }) + }) + }) + + // Tests for handlePreviewChange + describe('handlePreviewChange', () => { + it('should update preview file for FILE data source', () => { + const files = [createMockFile(), createMockFile({ id: 'file-2', name: 'second.pdf' })] + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, files }), + ) + + act(() => { + result.current.handlePreviewChange({ id: 'file-2', name: 'second.pdf' }) + }) + + expect(result.current.previewFile).toEqual({ id: 'file-2', name: 'second.pdf' }) + }) + + it('should update preview notion page for NOTION data source', () => { + const notionPages = [ + createMockNotionPage(), + createMockNotionPage({ page_id: 'notion-page-2', page_name: 'Second Page' }), + ] + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.NOTION, notionPages }), + ) + + act(() => { + result.current.handlePreviewChange({ id: 'notion-page-2', name: 'Second Page' }) + }) + + expect(result.current.previewNotionPage?.page_id).toBe('notion-page-2') + }) + + it('should update preview website page for WEB data source', () => { + const websitePages = [ + createMockWebsitePage(), + createMockWebsitePage({ source_url: 'https://example.com/page2', title: 'Second Page' }), + ] + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.WEB, websitePages }), + ) + + act(() => { + result.current.handlePreviewChange({ id: 'https://example.com/page2', name: 'Second Page' }) + }) + + expect(result.current.previewWebsitePage?.source_url).toBe('https://example.com/page2') + }) + }) +}) + +// ============================================ +// useDocumentCreation Hook Tests +// ============================================ + +describe('useDocumentCreation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultOptions = { + dataSourceType: DataSourceType.FILE, + files: [createMockFile()], + notionPages: [] as NotionPage[], + notionCredentialId: '', + websitePages: [] as CrawlResultItem[], + } + + // Tests for validateParams + describe('validateParams', () => { + it('should return false when overlap exceeds max chunk length', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 100, + limitMaxChunkLength: 4000, + overlap: 200, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }) + + expect(isValid).toBe(false) + }) + + it('should return false when max chunk length exceeds limit', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 5000, + limitMaxChunkLength: 4000, + overlap: 50, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }) + + expect(isValid).toBe(false) + }) + + it('should return true for valid params', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 1000, + limitMaxChunkLength: 4000, + overlap: 50, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }) + + expect(isValid).toBe(true) + }) + }) + + // Tests for buildCreationParams + describe('buildCreationParams', () => { + it('should build params for file upload', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).toBeDefined() + expect(params?.doc_form).toBe(ChunkingMode.text) + expect(params?.doc_language).toBe('English') + expect(params?.data_source?.type).toBe(DataSourceType.FILE) + }) + + it('should build params for setting mode', () => { + const documentDetail = createMockDocumentDetail() + const { result } = renderHook(() => + useDocumentCreation({ + ...defaultOptions, + isSetting: true, + documentDetail, + }), + ) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params?.original_document_id).toBe(documentDetail.id) + }) + + it('should build params for notion_import data source', () => { + const { result } = renderHook(() => + useDocumentCreation({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + notionPages: [createMockNotionPage()], + notionCredentialId: 'notion-cred-123', + }), + ) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).toBeDefined() + expect(params?.data_source?.type).toBe(DataSourceType.NOTION) + expect(params?.data_source?.info_list.notion_info_list).toBeDefined() + }) + + it('should build params for website_crawl data source', () => { + const { result } = renderHook(() => + useDocumentCreation({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + websitePages: [createMockWebsitePage()], + websiteCrawlProvider: 'jinaReader' as DataSourceProvider, + websiteCrawlJobId: 'job-123', + crawlOptions: { max_depth: 2 } as CrawlOptions, + }), + ) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).toBeDefined() + expect(params?.data_source?.type).toBe(DataSourceType.WEB) + expect(params?.data_source?.info_list.website_info_list).toBeDefined() + }) + }) + + // Tests for validateParams edge cases + describe('validateParams - additional cases', () => { + it('should return false when embedding model is missing for QUALIFIED index type', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 500, + limitMaxChunkLength: 4000, + overlap: 50, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: '', model: '' }, + rerankModelList: mockRerankModelList, + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }) + + expect(isValid).toBe(false) + }) + + it('should return false when rerank model is required but not selected', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + // isReRankModelSelected returns false when: + // - indexMethod === 'high_quality' (IndexingType.QUALIFIED) + // - reranking_enable === true + // - rerankModelSelected === false (model not found in list) + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 500, + limitMaxChunkLength: 4000, + overlap: 50, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], // Empty list means model won't be found + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: true, // Reranking enabled + reranking_model: { + reranking_provider_name: 'nonexistent', + reranking_model_name: 'nonexistent-model', + }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }) + + expect(isValid).toBe(false) + }) + }) + + // Tests for executeCreation + describe('executeCreation', () => { + it('should call createFirstDocumentMutation when datasetId is not provided', async () => { + const mockOnStepChange = vi.fn() + const mockUpdateIndexingTypeCache = vi.fn() + const mockUpdateResultCache = vi.fn() + const mockUpdateRetrievalMethodCache = vi.fn() + const mockOnSave = vi.fn() + + const { result } = renderHook(() => + useDocumentCreation({ + ...defaultOptions, + datasetId: undefined, + onStepChange: mockOnStepChange, + updateIndexingTypeCache: mockUpdateIndexingTypeCache, + updateResultCache: mockUpdateResultCache, + updateRetrievalMethodCache: mockUpdateRetrievalMethodCache, + onSave: mockOnSave, + }), + ) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + await act(async () => { + await result.current.executeCreation(params!, IndexingType.QUALIFIED, { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }) + }) + + expect(mockOnStepChange).toHaveBeenCalledWith(1) + }) + + it('should call createDocumentMutation when datasetId is provided', async () => { + const mockOnStepChange = vi.fn() + const { result } = renderHook(() => + useDocumentCreation({ + ...defaultOptions, + datasetId: 'existing-dataset-id', + onStepChange: mockOnStepChange, + }), + ) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + await act(async () => { + await result.current.executeCreation(params!, IndexingType.QUALIFIED, { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }) + }) + + expect(mockOnStepChange).toHaveBeenCalledWith(1) + }) + + it('should call onSave when in setting mode', async () => { + const mockOnSave = vi.fn() + const documentDetail = createMockDocumentDetail() + const { result } = renderHook(() => + useDocumentCreation({ + ...defaultOptions, + datasetId: 'existing-dataset-id', + isSetting: true, + documentDetail, + onSave: mockOnSave, + }), + ) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + await act(async () => { + await result.current.executeCreation(params!, IndexingType.QUALIFIED, { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }) + }) + + expect(mockOnSave).toHaveBeenCalled() + }) + }) + + // Tests for validatePreviewParams + describe('validatePreviewParams', () => { + it('should return true for valid max chunk length', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const isValid = result.current.validatePreviewParams(1000) + expect(isValid).toBe(true) + }) + + it('should return false when max chunk length exceeds maximum', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const isValid = result.current.validatePreviewParams(10000) + expect(isValid).toBe(false) + }) + }) +}) + +// ============================================ +// useIndexingEstimate Hook Tests +// ============================================ + +describe('useIndexingEstimate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultOptions = { + dataSourceType: DataSourceType.FILE, + currentDocForm: ChunkingMode.text, + docLanguage: 'English', + files: [createMockFile()], + previewNotionPage: createMockNotionPage(), + notionCredentialId: '', + previewWebsitePage: createMockWebsitePage(), + indexingTechnique: IndexingType.QUALIFIED, + processRule: { mode: ProcessMode.general, rules: createMockRules() }, + } + + // Tests for initial state + describe('Initial State', () => { + it('should initialize with idle state', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + + expect(result.current.isIdle).toBe(true) + expect(result.current.isPending).toBe(false) + expect(result.current.estimate).toBeUndefined() + }) + }) + + // Tests for fetchEstimate + describe('fetchEstimate', () => { + it('should have fetchEstimate function', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + + expect(typeof result.current.fetchEstimate).toBe('function') + }) + + it('should have reset function', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + + expect(typeof result.current.reset).toBe('function') + }) + + it('should call fetchEstimate for FILE data source', () => { + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.FILE, + previewFileName: 'test-file.pdf', + }), + ) + + act(() => { + result.current.fetchEstimate() + }) + + // fetchEstimate should be callable without error + expect(result.current.fetchEstimate).toBeDefined() + }) + + it('should call fetchEstimate for NOTION data source', () => { + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + previewNotionPage: createMockNotionPage(), + notionCredentialId: 'cred-123', + }), + ) + + act(() => { + result.current.fetchEstimate() + }) + + expect(result.current.fetchEstimate).toBeDefined() + }) + + it('should call fetchEstimate for WEB data source', () => { + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + previewWebsitePage: createMockWebsitePage(), + websiteCrawlProvider: 'jinaReader' as DataSourceProvider, + websiteCrawlJobId: 'job-123', + crawlOptions: { max_depth: 2 } as CrawlOptions, + }), + ) + + act(() => { + result.current.fetchEstimate() + }) + + expect(result.current.fetchEstimate).toBeDefined() + }) + }) + + // Tests for getCurrentMutation based on data source type + describe('Data Source Selection', () => { + it('should use file query for FILE data source', () => { + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.FILE, + }), + ) + + expect(result.current.currentMutation).toBeDefined() + expect(result.current.isIdle).toBe(true) + }) + + it('should use notion query for NOTION data source', () => { + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + }), + ) + + expect(result.current.currentMutation).toBeDefined() + expect(result.current.isIdle).toBe(true) + }) + + it('should use website query for WEB data source', () => { + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + websiteCrawlProvider: 'jinaReader' as DataSourceProvider, + websiteCrawlJobId: 'job-123', + }), + ) + + expect(result.current.currentMutation).toBeDefined() + expect(result.current.isIdle).toBe(true) + }) + }) +}) + +// ============================================ +// StepTwoFooter Component Tests +// ============================================ + +describe('StepTwoFooter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultProps = { + isSetting: false, + isCreating: false, + onPrevious: vi.fn(), + onCreate: vi.fn(), + onCancel: vi.fn(), + } + + // Tests for rendering + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + // Should render Previous and Next buttons with correct text + expect(screen.getByText(/previousStep/i)).toBeInTheDocument() + expect(screen.getByText(/nextStep/i)).toBeInTheDocument() + }) + + it('should render Previous and Next buttons when not in setting mode', () => { + render() + + expect(screen.getByText(/previousStep/i)).toBeInTheDocument() + expect(screen.getByText(/nextStep/i)).toBeInTheDocument() + }) + + it('should render Save and Cancel buttons when in setting mode', () => { + render() + + expect(screen.getByText(/save/i)).toBeInTheDocument() + expect(screen.getByText(/cancel/i)).toBeInTheDocument() + }) + }) + + // Tests for user interactions + describe('User Interactions', () => { + it('should call onPrevious when Previous button is clicked', () => { + const onPrevious = vi.fn() + render() + + fireEvent.click(screen.getByText(/previousStep/i)) + + expect(onPrevious).toHaveBeenCalledTimes(1) + }) + + it('should call onCreate when Next/Save button is clicked', () => { + const onCreate = vi.fn() + render() + + fireEvent.click(screen.getByText(/nextStep/i)) + + expect(onCreate).toHaveBeenCalledTimes(1) + }) + + it('should call onCancel when Cancel button is clicked in setting mode', () => { + const onCancel = vi.fn() + render() + + fireEvent.click(screen.getByText(/cancel/i)) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + }) + + // Tests for loading state + describe('Loading State', () => { + it('should show loading state on Next button when creating', () => { + render() + + const nextButton = screen.getByText(/nextStep/i).closest('button') + // Button has disabled:btn-disabled class which handles the loading state + expect(nextButton).toHaveClass('disabled:btn-disabled') + }) + + it('should show loading state on Save button when creating in setting mode', () => { + render() + + const saveButton = screen.getByText(/save/i).closest('button') + // Button has disabled:btn-disabled class which handles the loading state + expect(saveButton).toHaveClass('disabled:btn-disabled') + }) + }) +}) + +// ============================================ +// PreviewPanel Component Tests +// ============================================ + +describe('PreviewPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultProps = { + isMobile: false, + dataSourceType: DataSourceType.FILE, + currentDocForm: ChunkingMode.text, + estimate: undefined as FileIndexingEstimateResponse | undefined, + parentChildConfig: defaultParentChildConfig, + isSetting: false, + pickerFiles: [{ id: 'file-1', name: 'test.pdf', extension: 'pdf' }], + pickerValue: { id: 'file-1', name: 'test.pdf', extension: 'pdf' }, + isIdle: true, + isPending: false, + onPickerChange: vi.fn(), + } + + // Tests for rendering + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + // Check for the preview header title text + expect(screen.getByText('datasetCreation.stepTwo.preview')).toBeInTheDocument() + }) + + it('should render idle state when isIdle is true', () => { + render() + + expect(screen.getByText(/previewChunkTip/i)).toBeInTheDocument() + }) + + it('should render loading skeleton when isPending is true', () => { + render() + + // Should show skeleton containers + expect(screen.queryByText(/previewChunkTip/i)).not.toBeInTheDocument() + }) + }) + + // Tests for different doc forms + describe('Preview Content', () => { + it('should render text preview when docForm is text', () => { + const estimate = createMockEstimate() + render( + , + ) + + expect(screen.getByText('Chunk 1 content')).toBeInTheDocument() + }) + + it('should render QA preview when docForm is qa', () => { + const estimate = createMockEstimate() + render( + , + ) + + expect(screen.getByText('Q1')).toBeInTheDocument() + expect(screen.getByText('A1')).toBeInTheDocument() + }) + + it('should show chunk count badge for non-QA doc form', () => { + const estimate = createMockEstimate({ total_segments: 25 }) + render( + , + ) + + expect(screen.getByText(/25/)).toBeInTheDocument() + }) + + it('should render parent-child preview when docForm is parentChild', () => { + const estimate = createMockEstimate({ + preview: [ + { content: 'Parent chunk content', child_chunks: ['Child 1', 'Child 2', 'Child 3'] }, + ], + }) + render( + , + ) + + // Should render parent chunk label + expect(screen.getByText('Chunk-1')).toBeInTheDocument() + // Should render child chunks + expect(screen.getByText('Child 1')).toBeInTheDocument() + expect(screen.getByText('Child 2')).toBeInTheDocument() + expect(screen.getByText('Child 3')).toBeInTheDocument() + }) + + it('should limit child chunks when chunkForContext is full-doc', () => { + // FULL_DOC_PREVIEW_LENGTH is 50, so we need more than 50 chunks to test the limit + const manyChildChunks = Array.from({ length: 60 }, (_, i) => `ChildChunk${i + 1}`) + const estimate = createMockEstimate({ + preview: [{ content: 'Parent content', child_chunks: manyChildChunks }], + }) + render( + , + ) + + // Should render parent chunk + expect(screen.getByText('Chunk-1')).toBeInTheDocument() + // full-doc mode limits to FULL_DOC_PREVIEW_LENGTH (50) + expect(screen.getByText('ChildChunk1')).toBeInTheDocument() + expect(screen.getByText('ChildChunk50')).toBeInTheDocument() + // Should not render beyond the limit + expect(screen.queryByText('ChildChunk51')).not.toBeInTheDocument() + }) + + it('should render multiple parent chunks in parent-child mode', () => { + const estimate = createMockEstimate({ + preview: [ + { content: 'Parent 1', child_chunks: ['P1-C1'] }, + { content: 'Parent 2', child_chunks: ['P2-C1'] }, + ], + }) + render( + , + ) + + expect(screen.getByText('Chunk-1')).toBeInTheDocument() + expect(screen.getByText('Chunk-2')).toBeInTheDocument() + expect(screen.getByText('P1-C1')).toBeInTheDocument() + expect(screen.getByText('P2-C1')).toBeInTheDocument() + }) + }) + + // Tests for picker + describe('Document Picker', () => { + it('should call onPickerChange when document is selected', () => { + const onPickerChange = vi.fn() + render() + + // The picker interaction would be tested through the actual component + expect(onPickerChange).not.toHaveBeenCalled() + }) + }) +}) + +// ============================================ +// Edge Cases Tests +// ============================================ + +describe('Edge Cases', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Empty/Null Values', () => { + it('should handle empty files array in usePreviewState', () => { + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.FILE, + files: [], + notionPages: [], + websitePages: [], + }), + ) + + expect(result.current.previewFile).toBeUndefined() + }) + + it('should handle empty notion pages array', () => { + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages: [], + websitePages: [], + }), + ) + + expect(result.current.previewNotionPage).toBeUndefined() + }) + + it('should handle empty website pages array', () => { + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.WEB, + files: [], + notionPages: [], + websitePages: [], + }), + ) + + expect(result.current.previewWebsitePage).toBeUndefined() + }) + }) + + describe('Boundary Conditions', () => { + it('should handle very large chunk length', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setMaxChunkLength(999999) + }) + + expect(result.current.maxChunkLength).toBe(999999) + }) + + it('should handle zero overlap', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setOverlap(0) + }) + + expect(result.current.overlap).toBe(0) + }) + + it('should handle special characters in segment identifier', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('<<>>') + }) + + expect(result.current.segmentIdentifier).toBe('<<>>') + }) + }) + + describe('Callback Stability', () => { + it('should maintain stable setSegmentIdentifier reference', () => { + const { result, rerender } = renderHook(() => useSegmentationState()) + const initialSetter = result.current.setSegmentIdentifier + + rerender() + + expect(result.current.setSegmentIdentifier).toBe(initialSetter) + }) + + it('should maintain stable toggleRule reference', () => { + const { result, rerender } = renderHook(() => useSegmentationState()) + const initialToggle = result.current.toggleRule + + rerender() + + expect(result.current.toggleRule).toBe(initialToggle) + }) + + it('should maintain stable getProcessRule reference', () => { + const { result, rerender } = renderHook(() => useSegmentationState()) + + // Update some state to trigger re-render + act(() => { + result.current.setMaxChunkLength(2048) + }) + + rerender() + + // getProcessRule depends on state, so it may change but should remain a function + expect(typeof result.current.getProcessRule).toBe('function') + }) + }) +}) + +// ============================================ +// Integration Scenarios +// ============================================ + +describe('Integration Scenarios', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCurrentDataset = null + }) + + describe('Document Creation Flow', () => { + it('should build and validate params for file upload workflow', () => { + const files = [createMockFile()] + + const { result: segResult } = renderHook(() => useSegmentationState()) + const { result: creationResult } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + // Build params + const params = creationResult.current.buildCreationParams( + ChunkingMode.text, + 'English', + segResult.current.getProcessRule(ChunkingMode.text), + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).toBeDefined() + expect(params?.data_source?.info_list.file_info_list?.file_ids).toContain('file-1') + }) + + it('should handle parent-child document form', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentationType(ProcessMode.parentChild) + result.current.setChunkForContext('full-doc') + result.current.updateParentConfig('maxLength', 2048) + result.current.updateChildConfig('maxLength', 512) + }) + + const processRule = result.current.getProcessRule(ChunkingMode.parentChild) + + expect(processRule.mode).toBe('hierarchical') + expect(processRule.rules.parent_mode).toBe('full-doc') + expect(processRule.rules.segmentation.max_tokens).toBe(2048) + expect(processRule.rules.subchunk_segmentation?.max_tokens).toBe(512) + }) + }) + + describe('Preview Flow', () => { + it('should handle preview file change flow', () => { + const files = [ + createMockFile({ id: 'file-1', name: 'first.pdf' }), + createMockFile({ id: 'file-2', name: 'second.pdf' }), + ] + + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + }), + ) + + // Initial state + expect(result.current.getPreviewPickerValue().name).toBe('first.pdf') + + // Change preview + act(() => { + result.current.handlePreviewChange({ id: 'file-2', name: 'second.pdf' }) + }) + + expect(result.current.previewFile).toEqual({ id: 'file-2', name: 'second.pdf' }) + }) + }) + + describe('Escape/Unescape Round Trip', () => { + it('should preserve original string through escape/unescape', () => { + const original = '\n\n' + const escaped = escape(original) + const unescaped = unescape(escaped) + + expect(unescaped).toBe(original) + }) + + it('should handle complex strings without backslashes', () => { + // This string contains control characters but no literal backslashes. + const original = 'Hello\nWorld\t!\r\n' + const escaped = escape(original) + const unescaped = unescape(escaped) + expect(unescaped).toBe(original) + }) + + it('should document behavior for strings with existing backslashes', () => { + // When the original string already contains backslash sequences, + // escape/unescape are not perfectly symmetric because escape() + // does not escape backslashes. + const original = 'Hello\\nWorld' + const escaped = escape(original) + const unescaped = unescape(escaped) + // The unescaped value interprets "\n" as a newline, so it differs from the original. + expect(unescaped).toBe('Hello\nWorld') + expect(unescaped).not.toBe(original) + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/index.tsx b/web/app/components/datasets/create/step-two/index.tsx index 51b5c15178..b4d2c5f6e9 100644 --- a/web/app/components/datasets/create/step-two/index.tsx +++ b/web/app/components/datasets/create/step-two/index.tsx @@ -1,137 +1,30 @@ 'use client' -import type { FC, PropsWithChildren } from 'react' -import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { NotionPage } from '@/models/common' -import type { CrawlOptions, CrawlResultItem, CreateDocumentReq, createDocumentResponse, CustomFile, DocumentItem, FullDocumentDetail, ParentMode, PreProcessingRule, ProcessRule, Rules } from '@/models/datasets' -import type { RetrievalConfig } from '@/types/app' -import { - RiAlertFill, - RiArrowLeftLine, - RiSearchEyeLine, -} from '@remixicon/react' -import { noop } from 'es-toolkit/function' -import Image from 'next/image' -import Link from 'next/link' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { trackEvent } from '@/app/components/base/amplitude' -import Badge from '@/app/components/base/badge' -import Button from '@/app/components/base/button' -import Checkbox from '@/app/components/base/checkbox' -import CustomDialog from '@/app/components/base/dialog' -import Divider from '@/app/components/base/divider' -import FloatRightContainer from '@/app/components/base/float-right-container' -import { ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge' -import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' -import RadioCard from '@/app/components/base/radio-card' -import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' -import Toast from '@/app/components/base/toast' -import Tooltip from '@/app/components/base/tooltip' -import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model' -import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config' -import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config' -import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useDefaultModel, useModelList, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' -import { FULL_DOC_PREVIEW_LENGTH, IS_CE_EDITION } from '@/config' +import type { FC } from 'react' +import type { StepTwoProps } from './types' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Divider from '@/app/components/base/divider' +import Toast from '@/app/components/base/toast' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { useDocLink, useLocale } from '@/context/i18n' +import { useLocale } from '@/context/i18n' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { LanguagesSupported } from '@/i18n-config/language' import { DataSourceProvider } from '@/models/common' -import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets' -import { getNotionInfo, getWebsiteInfo, useCreateDocument, useCreateFirstDocument, useFetchDefaultProcessRule, useFetchFileIndexingEstimateForFile, useFetchFileIndexingEstimateForNotion, useFetchFileIndexingEstimateForWeb } from '@/service/knowledge/use-create-dataset' -import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' -import { RETRIEVE_METHOD } from '@/types/app' +import { ChunkingMode, ProcessMode } from '@/models/datasets' +import { useFetchDefaultProcessRule } from '@/service/knowledge/use-create-dataset' import { cn } from '@/utils/classnames' -import { ChunkContainer, QAPreview } from '../../chunk' -import PreviewDocumentPicker from '../../common/document-picker/preview-document-picker' -import { PreviewSlice } from '../../formatted-text/flavours/preview-slice' -import { FormattedText } from '../../formatted-text/formatted' -import PreviewContainer from '../../preview/container' -import { PreviewHeader } from '../../preview/header' -import { checkShowMultiModalTip } from '../../settings/utils' -import FileList from '../assets/file-list-3-fill.svg' -import Note from '../assets/note-mod.svg' -import BlueEffect from '../assets/option-card-effect-blue.svg' -import SettingCog from '../assets/setting-gear-mod.svg' -import { indexMethodIcon } from '../icons' -import escape from './escape' -import s from './index.module.css' -import { DelimiterInput, MaxLengthInput, OverlapInput } from './inputs' -import LanguageSelect from './language-select' -import { OptionCard } from './option-card' -import unescape from './unescape' +import { GeneralChunkingOptions, IndexingModeSection, ParentChildOptions, PreviewPanel, StepTwoFooter } from './components' +import { IndexingType, MAXIMUM_CHUNK_TOKEN_LENGTH, useDocumentCreation, useIndexingConfig, useIndexingEstimate, usePreviewState, useSegmentationState } from './hooks' -const TextLabel: FC = (props) => { - return -} +export { IndexingType } -type StepTwoProps = { - isSetting?: boolean - documentDetail?: FullDocumentDetail - isAPIKeySet: boolean - onSetting: () => void - datasetId?: string - indexingType?: IndexingType - retrievalMethod?: string - dataSourceType: DataSourceType - files: CustomFile[] - notionPages?: NotionPage[] - notionCredentialId: string - websitePages?: CrawlResultItem[] - crawlOptions?: CrawlOptions - websiteCrawlProvider?: DataSourceProvider - websiteCrawlJobId?: string - onStepChange?: (delta: number) => void - updateIndexingTypeCache?: (type: string) => void - updateRetrievalMethodCache?: (method: RETRIEVE_METHOD | '') => void - updateResultCache?: (res: createDocumentResponse) => void - onSave?: () => void - onCancel?: () => void -} - -export enum IndexingType { - QUALIFIED = 'high_quality', - ECONOMICAL = 'economy', -} - -const DEFAULT_SEGMENT_IDENTIFIER = '\\n\\n' -const DEFAULT_MAXIMUM_CHUNK_LENGTH = 1024 -const DEFAULT_OVERLAP = 50 -const MAXIMUM_CHUNK_TOKEN_LENGTH = Number.parseInt(globalThis.document?.body?.getAttribute('data-public-indexing-max-segmentation-tokens-length') || '4000', 10) - -type ParentChildConfig = { - chunkForContext: ParentMode - parent: { - delimiter: string - maxLength: number - } - child: { - delimiter: string - maxLength: number - } -} - -const defaultParentChildConfig: ParentChildConfig = { - chunkForContext: 'paragraph', - parent: { - delimiter: '\\n\\n', - maxLength: 1024, - }, - child: { - delimiter: '\\n', - maxLength: 512, - }, -} - -const StepTwo = ({ +const StepTwo: FC = ({ isSetting, documentDetail, isAPIKeySet, datasetId, - indexingType, + indexingType: propsIndexingType, dataSourceType: inCreatePageDataSourceType, files, notionPages = [], @@ -146,1099 +39,238 @@ const StepTwo = ({ onSave, onCancel, updateRetrievalMethodCache, -}: StepTwoProps) => { +}) => { const { t } = useTranslation() - const docLink = useDocLink() const locale = useLocale() - const media = useBreakpoints() - const isMobile = media === MediaType.mobile - - const currentDataset = useDatasetDetailContextWithSelector(state => state.dataset) - const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes) + const isMobile = useBreakpoints() === MediaType.mobile + const currentDataset = useDatasetDetailContextWithSelector(s => s.dataset) + const mutateDatasetRes = useDatasetDetailContextWithSelector(s => s.mutateDatasetRes) + // Computed flags const isInUpload = Boolean(currentDataset) const isUploadInEmptyDataset = isInUpload && !currentDataset?.doc_form const isNotUploadInEmptyDataset = !isUploadInEmptyDataset const isInInit = !isInUpload && !isSetting - const isInCreatePage = !datasetId || (datasetId && !currentDataset?.data_source_type) - const dataSourceType = isInCreatePage ? inCreatePageDataSourceType : currentDataset?.data_source_type - const [segmentationType, setSegmentationType] = useState( - currentDataset?.doc_form === ChunkingMode.parentChild ? ProcessMode.parentChild : ProcessMode.general, - ) - const [segmentIdentifier, doSetSegmentIdentifier] = useState(DEFAULT_SEGMENT_IDENTIFIER) - const setSegmentIdentifier = useCallback((value: string, canEmpty?: boolean) => { - doSetSegmentIdentifier(value ? escape(value) : (canEmpty ? '' : DEFAULT_SEGMENT_IDENTIFIER)) - }, []) - const [maxChunkLength, setMaxChunkLength] = useState(DEFAULT_MAXIMUM_CHUNK_LENGTH) // default chunk length - const [limitMaxChunkLength, setLimitMaxChunkLength] = useState(MAXIMUM_CHUNK_TOKEN_LENGTH) - const [overlap, setOverlap] = useState(DEFAULT_OVERLAP) - const [rules, setRules] = useState([]) - const [defaultConfig, setDefaultConfig] = useState() - const hasSetIndexType = !!indexingType - const [indexType, setIndexType] = useState(() => { - if (hasSetIndexType) - return indexingType - return isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL - }) + const dataSourceType = isInCreatePage ? inCreatePageDataSourceType : (currentDataset?.data_source_type ?? inCreatePageDataSourceType) + const hasSetIndexType = !!propsIndexingType + const isModelAndRetrievalConfigDisabled = !!datasetId && !!currentDataset?.data_source_type - const [previewFile, setPreviewFile] = useState( - (datasetId && documentDetail) - ? documentDetail.file - : files[0], - ) - const [previewNotionPage, setPreviewNotionPage] = useState( - (datasetId && documentDetail) - ? documentDetail.notion_page - : notionPages[0], - ) - - const [previewWebsitePage, setPreviewWebsitePage] = useState( - (datasetId && documentDetail) - ? documentDetail.website_page - : websitePages[0], - ) - - // QA Related + // Document form state + const [docForm, setDocForm] = useState((datasetId && documentDetail) ? documentDetail.doc_form as ChunkingMode : ChunkingMode.text) + const [docLanguage, setDocLanguage] = useState(() => (datasetId && documentDetail) ? documentDetail.doc_language : (locale !== LanguagesSupported[1] ? 'English' : 'Chinese Simplified')) const [isQAConfirmDialogOpen, setIsQAConfirmDialogOpen] = useState(false) - const [docForm, setDocForm] = useState( - (datasetId && documentDetail) ? documentDetail.doc_form as ChunkingMode : ChunkingMode.text, - ) - const handleChangeDocform = (value: ChunkingMode) => { - if (value === ChunkingMode.qa && indexType === IndexingType.ECONOMICAL) { - setIsQAConfirmDialogOpen(true) - return - } - if (value === ChunkingMode.parentChild && indexType === IndexingType.ECONOMICAL) - setIndexType(IndexingType.QUALIFIED) - - setDocForm(value) - - if (value === ChunkingMode.parentChild) - setSegmentationType(ProcessMode.parentChild) - else - setSegmentationType(ProcessMode.general) - - // eslint-disable-next-line ts/no-use-before-define - currentEstimateMutation.reset() - } - - const [docLanguage, setDocLanguage] = useState( - (datasetId && documentDetail) ? documentDetail.doc_language : (locale !== LanguagesSupported[1] ? 'English' : 'Chinese Simplified'), - ) - - const [parentChildConfig, setParentChildConfig] = useState(defaultParentChildConfig) - - const getIndexing_technique = () => indexingType || indexType const currentDocForm = currentDataset?.doc_form || docForm - const getProcessRule = (): ProcessRule => { - if (currentDocForm === ChunkingMode.parentChild) { - return { - rules: { - pre_processing_rules: rules, - segmentation: { - separator: unescape( - parentChildConfig.parent.delimiter, - ), - max_tokens: parentChildConfig.parent.maxLength, - }, - parent_mode: parentChildConfig.chunkForContext, - subchunk_segmentation: { - separator: unescape(parentChildConfig.child.delimiter), - max_tokens: parentChildConfig.child.maxLength, - }, - }, - mode: 'hierarchical', - } as ProcessRule - } - return { - rules: { - pre_processing_rules: rules, - segmentation: { - separator: unescape(segmentIdentifier), - max_tokens: maxChunkLength, - chunk_overlap: overlap, - }, - }, // api will check this. It will be removed after api refactored. - mode: segmentationType, - } as ProcessRule - } - - const fileIndexingEstimateQuery = useFetchFileIndexingEstimateForFile({ - docForm: currentDocForm, - docLanguage, - dataSourceType: DataSourceType.FILE, - files: previewFile - ? [files.find(file => file.name === previewFile.name)!] - : files, - indexingTechnique: getIndexing_technique() as any, - processRule: getProcessRule(), - dataset_id: datasetId!, + // Custom hooks + const segmentation = useSegmentationState({ + initialSegmentationType: currentDataset?.doc_form === ChunkingMode.parentChild ? ProcessMode.parentChild : ProcessMode.general, }) - const notionIndexingEstimateQuery = useFetchFileIndexingEstimateForNotion({ - docForm: currentDocForm, - docLanguage, - dataSourceType: DataSourceType.NOTION, - notionPages: [previewNotionPage], - indexingTechnique: getIndexing_technique() as any, - processRule: getProcessRule(), - dataset_id: datasetId || '', - credential_id: notionCredentialId, + const indexing = useIndexingConfig({ + initialIndexType: propsIndexingType, + initialEmbeddingModel: currentDataset?.embedding_model ? { provider: currentDataset.embedding_model_provider, model: currentDataset.embedding_model } : undefined, + initialRetrievalConfig: currentDataset?.retrieval_model_dict, + isAPIKeySet, + hasSetIndexType, }) - - const websiteIndexingEstimateQuery = useFetchFileIndexingEstimateForWeb({ - docForm: currentDocForm, - docLanguage, - dataSourceType: DataSourceType.WEB, - websitePages: [previewWebsitePage], + const preview = usePreviewState({ dataSourceType, files, notionPages, websitePages, documentDetail, datasetId }) + const creation = useDocumentCreation({ + datasetId, + isSetting, + documentDetail, + dataSourceType, + files, + notionPages, + notionCredentialId, + websitePages, crawlOptions, websiteCrawlProvider, websiteCrawlJobId, - indexingTechnique: getIndexing_technique() as any, - processRule: getProcessRule(), - dataset_id: datasetId || '', + onStepChange, + updateIndexingTypeCache, + updateResultCache, + updateRetrievalMethodCache, + onSave, + mutateDatasetRes, + }) + const estimateHook = useIndexingEstimate({ + dataSourceType, + datasetId, + currentDocForm, + docLanguage, + files, + previewFileName: preview.previewFile?.name, + previewNotionPage: preview.previewNotionPage, + notionCredentialId, + previewWebsitePage: preview.previewWebsitePage, + crawlOptions, + websiteCrawlProvider, + websiteCrawlJobId, + indexingTechnique: indexing.getIndexingTechnique() as IndexingType, + processRule: segmentation.getProcessRule(currentDocForm), }) - const currentEstimateMutation = dataSourceType === DataSourceType.FILE - ? fileIndexingEstimateQuery - : dataSourceType === DataSourceType.NOTION - ? notionIndexingEstimateQuery - : websiteIndexingEstimateQuery + // Fetch default process rule + const fetchDefaultProcessRuleMutation = useFetchDefaultProcessRule({ + onSuccess(data) { + segmentation.setSegmentIdentifier(data.rules.segmentation.separator) + segmentation.setMaxChunkLength(data.rules.segmentation.max_tokens) + segmentation.setOverlap(data.rules.segmentation.chunk_overlap!) + segmentation.setRules(data.rules.pre_processing_rules) + segmentation.setDefaultConfig(data.rules) + segmentation.setLimitMaxChunkLength(data.limits.indexing_max_segmentation_tokens_length) + }, + }) - const fetchEstimate = useCallback(() => { - if (dataSourceType === DataSourceType.FILE) - fileIndexingEstimateQuery.mutate() - - if (dataSourceType === DataSourceType.NOTION) - notionIndexingEstimateQuery.mutate() - - if (dataSourceType === DataSourceType.WEB) - websiteIndexingEstimateQuery.mutate() - }, [dataSourceType, fileIndexingEstimateQuery, notionIndexingEstimateQuery, websiteIndexingEstimateQuery]) - - const estimate - = dataSourceType === DataSourceType.FILE - ? fileIndexingEstimateQuery.data - : dataSourceType === DataSourceType.NOTION - ? notionIndexingEstimateQuery.data - : websiteIndexingEstimateQuery.data - - const getRuleName = (key: string) => { - if (key === 'remove_extra_spaces') - return t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' }) - - if (key === 'remove_urls_emails') - return t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' }) - - if (key === 'remove_stopwords') - return t('stepTwo.removeStopwords', { ns: 'datasetCreation' }) - } - const ruleChangeHandle = (id: string) => { - const newRules = rules.map((rule) => { - if (rule.id === id) { - return { - id: rule.id, - enabled: !rule.enabled, - } - } - return rule - }) - setRules(newRules) - } - const resetRules = () => { - if (defaultConfig) { - setSegmentIdentifier(defaultConfig.segmentation.separator) - setMaxChunkLength(defaultConfig.segmentation.max_tokens) - setOverlap(defaultConfig.segmentation.chunk_overlap!) - setRules(defaultConfig.pre_processing_rules) + // Event handlers + const handleDocFormChange = useCallback((value: ChunkingMode) => { + if (value === ChunkingMode.qa && indexing.indexType === IndexingType.ECONOMICAL) { + setIsQAConfirmDialogOpen(true) + return } - setParentChildConfig(defaultParentChildConfig) - } + if (value === ChunkingMode.parentChild && indexing.indexType === IndexingType.ECONOMICAL) + indexing.setIndexType(IndexingType.QUALIFIED) + setDocForm(value) + segmentation.setSegmentationType(value === ChunkingMode.parentChild ? ProcessMode.parentChild : ProcessMode.general) + estimateHook.reset() + }, [indexing, segmentation, estimateHook]) - const updatePreview = () => { - if (segmentationType === ProcessMode.general && maxChunkLength > MAXIMUM_CHUNK_TOKEN_LENGTH) { + const updatePreview = useCallback(() => { + if (segmentation.segmentationType === ProcessMode.general && segmentation.maxChunkLength > MAXIMUM_CHUNK_TOKEN_LENGTH) { Toast.notify({ type: 'error', message: t('stepTwo.maxLengthCheck', { ns: 'datasetCreation', limit: MAXIMUM_CHUNK_TOKEN_LENGTH }) }) return } - fetchEstimate() - } + estimateHook.fetchEstimate() + }, [segmentation, t, estimateHook]) - const { - modelList: rerankModelList, - defaultModel: rerankDefaultModel, - currentModel: isRerankDefaultModelValid, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) - const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) - const { data: defaultEmbeddingModel } = useDefaultModel(ModelTypeEnum.textEmbedding) - const [embeddingModel, setEmbeddingModel] = useState( - currentDataset?.embedding_model - ? { - provider: currentDataset.embedding_model_provider, - model: currentDataset.embedding_model, - } - : { - provider: defaultEmbeddingModel?.provider.provider || '', - model: defaultEmbeddingModel?.model || '', - }, - ) - const [retrievalConfig, setRetrievalConfig] = useState(currentDataset?.retrieval_model_dict || { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - } as RetrievalConfig) - - useEffect(() => { - if (currentDataset?.retrieval_model_dict) - return - setRetrievalConfig({ - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: !!isRerankDefaultModelValid, - reranking_model: { - reranking_provider_name: isRerankDefaultModelValid ? rerankDefaultModel?.provider.provider ?? '' : '', - reranking_model_name: isRerankDefaultModelValid ? rerankDefaultModel?.model ?? '' : '', - }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, + const handleCreate = useCallback(async () => { + const isValid = creation.validateParams({ + segmentationType: segmentation.segmentationType, + maxChunkLength: segmentation.maxChunkLength, + limitMaxChunkLength: segmentation.limitMaxChunkLength, + overlap: segmentation.overlap, + indexType: indexing.indexType, + embeddingModel: indexing.embeddingModel, + rerankModelList: indexing.rerankModelList, + retrievalConfig: indexing.retrievalConfig, }) - }, [rerankDefaultModel, isRerankDefaultModelValid]) - - const getCreationParams = () => { - let params - if (segmentationType === ProcessMode.general && overlap > maxChunkLength) { - Toast.notify({ type: 'error', message: t('stepTwo.overlapCheck', { ns: 'datasetCreation' }) }) + if (!isValid) return - } - if (segmentationType === ProcessMode.general && maxChunkLength > limitMaxChunkLength) { - Toast.notify({ type: 'error', message: t('stepTwo.maxLengthCheck', { ns: 'datasetCreation', limit: limitMaxChunkLength }) }) - return - } - if (isSetting) { - params = { - original_document_id: documentDetail?.id, - doc_form: currentDocForm, - doc_language: docLanguage, - process_rule: getProcessRule(), - retrieval_model: retrievalConfig, // Readonly. If want to changed, just go to settings page. - embedding_model: embeddingModel.model, // Readonly - embedding_model_provider: embeddingModel.provider, // Readonly - indexing_technique: getIndexing_technique(), - } as CreateDocumentReq - } - else { // create - const indexMethod = getIndexing_technique() - if (indexMethod === IndexingType.QUALIFIED && (!embeddingModel.model || !embeddingModel.provider)) { - Toast.notify({ - type: 'error', - message: t('datasetConfig.embeddingModelRequired', { ns: 'appDebug' }), - }) - return - } - if ( - !isReRankModelSelected({ - rerankModelList, - retrievalConfig, - indexMethod: indexMethod as string, - }) - ) { - Toast.notify({ type: 'error', message: t('datasetConfig.rerankModelRequired', { ns: 'appDebug' }) }) - return - } - params = { - data_source: { - type: dataSourceType, - info_list: { - data_source_type: dataSourceType, - }, - }, - indexing_technique: getIndexing_technique(), - process_rule: getProcessRule(), - doc_form: currentDocForm, - doc_language: docLanguage, - retrieval_model: retrievalConfig, - embedding_model: embeddingModel.model, - embedding_model_provider: embeddingModel.provider, - } as CreateDocumentReq - if (dataSourceType === DataSourceType.FILE) { - params.data_source.info_list.file_info_list = { - file_ids: files.map(file => file.id || '').filter(Boolean), - } - } - if (dataSourceType === DataSourceType.NOTION) - params.data_source.info_list.notion_info_list = getNotionInfo(notionPages, notionCredentialId) - - if (dataSourceType === DataSourceType.WEB) { - params.data_source.info_list.website_info_list = getWebsiteInfo({ - websiteCrawlProvider, - websiteCrawlJobId, - websitePages, - }) - } - } - return params - } - - const fetchDefaultProcessRuleMutation = useFetchDefaultProcessRule({ - onSuccess(data) { - const separator = data.rules.segmentation.separator - setSegmentIdentifier(separator) - setMaxChunkLength(data.rules.segmentation.max_tokens) - setOverlap(data.rules.segmentation.chunk_overlap!) - setRules(data.rules.pre_processing_rules) - setDefaultConfig(data.rules) - setLimitMaxChunkLength(data.limits.indexing_max_segmentation_tokens_length) - }, - }) - - const getRulesFromDetail = () => { - if (documentDetail) { - const rules = documentDetail.dataset_process_rule.rules - const separator = rules.segmentation.separator - const max = rules.segmentation.max_tokens - const overlap = rules.segmentation.chunk_overlap - const isHierarchicalDocument = documentDetail.doc_form === ChunkingMode.parentChild - || (rules.parent_mode && rules.subchunk_segmentation) - setSegmentIdentifier(separator) - setMaxChunkLength(max) - setOverlap(overlap!) - setRules(rules.pre_processing_rules) - setDefaultConfig(rules) - - if (isHierarchicalDocument) { - setParentChildConfig({ - chunkForContext: rules.parent_mode || 'paragraph', - parent: { - delimiter: escape(rules.segmentation.separator), - maxLength: rules.segmentation.max_tokens, - }, - child: { - delimiter: escape(rules.subchunk_segmentation.separator), - maxLength: rules.subchunk_segmentation.max_tokens, - }, - }) - } - } - } - - const getDefaultMode = () => { - if (documentDetail) - setSegmentationType(documentDetail.dataset_process_rule.mode) - } - - const createFirstDocumentMutation = useCreateFirstDocument() - const createDocumentMutation = useCreateDocument(datasetId!) - - const isCreating = createFirstDocumentMutation.isPending || createDocumentMutation.isPending - const invalidDatasetList = useInvalidDatasetList() - - const createHandle = async () => { - const params = getCreationParams() + const params = creation.buildCreationParams(currentDocForm, docLanguage, segmentation.getProcessRule(currentDocForm), indexing.retrievalConfig, indexing.embeddingModel, indexing.getIndexingTechnique()) if (!params) - return false + return + await creation.executeCreation(params, indexing.indexType, indexing.retrievalConfig) + }, [creation, segmentation, indexing, currentDocForm, docLanguage]) - if (!datasetId) { - await createFirstDocumentMutation.mutateAsync( - params, - { - onSuccess(data) { - updateIndexingTypeCache?.(indexType as string) - updateResultCache?.(data) - updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) - }, - }, - ) - } - else { - await createDocumentMutation.mutateAsync(params, { - onSuccess(data) { - updateIndexingTypeCache?.(indexType as string) - updateResultCache?.(data) - updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) - }, - }) - } - if (mutateDatasetRes) - mutateDatasetRes() - invalidDatasetList() - trackEvent('create_datasets', { - data_source_type: dataSourceType, - indexing_technique: getIndexing_technique(), - }) - onStepChange?.(+1) - if (isSetting) - onSave?.() - } + const handlePickerChange = useCallback((selected: { id: string, name: string }) => { + estimateHook.reset() + preview.handlePreviewChange(selected) + estimateHook.fetchEstimate() + }, [estimateHook, preview]) + const handleQAConfirm = useCallback(() => { + setIsQAConfirmDialogOpen(false) + indexing.setIndexType(IndexingType.QUALIFIED) + setDocForm(ChunkingMode.qa) + }, [indexing]) + + // Initialize rules useEffect(() => { - // fetch rules if (!isSetting) { fetchDefaultProcessRuleMutation.mutate('/datasets/process-rule') } - else { - getRulesFromDetail() - getDefaultMode() + else if (documentDetail) { + const rules = documentDetail.dataset_process_rule.rules + const isHierarchical = documentDetail.doc_form === ChunkingMode.parentChild || Boolean(rules.parent_mode && rules.subchunk_segmentation) + segmentation.applyConfigFromRules(rules, isHierarchical) + segmentation.setSegmentationType(documentDetail.dataset_process_rule.mode) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - useEffect(() => { - // get indexing type by props - if (indexingType) - setIndexType(indexingType as IndexingType) - else - setIndexType(isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL) - }, [isAPIKeySet, indexingType, datasetId]) - - const isModelAndRetrievalConfigDisabled = !!datasetId && !!currentDataset?.data_source_type - - const showMultiModalTip = useMemo(() => { - return checkShowMultiModalTip({ - embeddingModel, - rerankingEnable: retrievalConfig.reranking_enable, - rerankModel: { - rerankingProviderName: retrievalConfig.reranking_model.reranking_provider_name, - rerankingModelName: retrievalConfig.reranking_model.reranking_model_name, - }, - indexMethod: indexType, - embeddingModelList, - rerankModelList, - }) - }, [embeddingModel, retrievalConfig.reranking_enable, retrievalConfig.reranking_model, indexType, embeddingModelList, rerankModelList]) + // Show options conditions + const showGeneralOption = (isInUpload && [ChunkingMode.text, ChunkingMode.qa].includes(currentDataset!.doc_form)) || isUploadInEmptyDataset || isInInit + const showParentChildOption = (isInUpload && currentDataset!.doc_form === ChunkingMode.parentChild) || isUploadInEmptyDataset || isInInit return (
{t('stepTwo.segmentation', { ns: 'datasetCreation' })}
- {((isInUpload && [ChunkingMode.text, ChunkingMode.qa].includes(currentDataset!.doc_form)) - || isUploadInEmptyDataset - || isInInit) - && ( - } - activeHeaderClassName="bg-dataset-option-card-blue-gradient" - description={t('stepTwo.generalTip', { ns: 'datasetCreation' })} - isActive={ - [ChunkingMode.text, ChunkingMode.qa].includes(currentDocForm) - } - onSwitched={() => - handleChangeDocform(ChunkingMode.text)} - actions={( - <> - - - - )} - noHighlight={isInUpload && isNotUploadInEmptyDataset} - > -
-
- setSegmentIdentifier(e.target.value, true)} - /> - - -
-
-
-
- {t('stepTwo.rules', { ns: 'datasetCreation' })} -
- -
-
- {rules.map(rule => ( -
{ - ruleChangeHandle(rule.id) - }} - > - - -
- ))} - {IS_CE_EDITION && ( - <> - -
-
{ - if (currentDataset?.doc_form) - return - if (docForm === ChunkingMode.qa) - handleChangeDocform(ChunkingMode.text) - else - handleChangeDocform(ChunkingMode.qa) - }} - > - - -
- - -
- {currentDocForm === ChunkingMode.qa && ( -
- - - {t('stepTwo.QATip', { ns: 'datasetCreation' })} - -
- )} - - )} -
-
-
-
+ {showGeneralOption && ( + segmentation.setSegmentIdentifier(value, true)} + onMaxChunkLengthChange={segmentation.setMaxChunkLength} + onOverlapChange={segmentation.setOverlap} + onRuleToggle={segmentation.toggleRule} + onDocFormChange={handleDocFormChange} + onDocLanguageChange={setDocLanguage} + onPreview={updatePreview} + onReset={segmentation.resetToDefaults} + locale={locale} + /> )} - { - ( - (isInUpload && currentDataset!.doc_form === ChunkingMode.parentChild) - || isUploadInEmptyDataset - || isInInit - ) - && ( - } - effectImg={BlueEffect.src} - className="text-util-colors-blue-light-blue-light-500" - activeHeaderClassName="bg-dataset-option-card-blue-gradient" - description={t('stepTwo.parentChildTip', { ns: 'datasetCreation' })} - isActive={currentDocForm === ChunkingMode.parentChild} - onSwitched={() => handleChangeDocform(ChunkingMode.parentChild)} - actions={( - <> - - - - )} - noHighlight={isInUpload && isNotUploadInEmptyDataset} - > -
-
-
-
- {t('stepTwo.parentChunkForContext', { ns: 'datasetCreation' })} -
- -
- } - title={t('stepTwo.paragraph', { ns: 'datasetCreation' })} - description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })} - isChosen={parentChildConfig.chunkForContext === 'paragraph'} - onChosen={() => setParentChildConfig( - { - ...parentChildConfig, - chunkForContext: 'paragraph', - }, - )} - chosenConfig={( -
- setParentChildConfig({ - ...parentChildConfig, - parent: { - ...parentChildConfig.parent, - delimiter: e.target.value ? escape(e.target.value) : '', - }, - })} - /> - setParentChildConfig({ - ...parentChildConfig, - parent: { - ...parentChildConfig.parent, - maxLength: value, - }, - })} - /> -
- )} - /> - } - title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })} - description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })} - onChosen={() => setParentChildConfig( - { - ...parentChildConfig, - chunkForContext: 'full-doc', - }, - )} - isChosen={parentChildConfig.chunkForContext === 'full-doc'} - /> -
- -
-
-
- {t('stepTwo.childChunkForRetrieval', { ns: 'datasetCreation' })} -
- -
-
- setParentChildConfig({ - ...parentChildConfig, - child: { - ...parentChildConfig.child, - delimiter: e.target.value ? escape(e.target.value) : '', - }, - })} - /> - setParentChildConfig({ - ...parentChildConfig, - child: { - ...parentChildConfig.child, - maxLength: value, - }, - })} - /> -
-
-
-
-
- {t('stepTwo.rules', { ns: 'datasetCreation' })} -
- -
-
- {rules.map(rule => ( -
{ - ruleChangeHandle(rule.id) - }} - > - - -
- ))} -
-
-
-
- ) - } - -
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
-
- {(!hasSetIndexType || (hasSetIndexType && indexingType === IndexingType.QUALIFIED)) && ( - - {t('stepTwo.qualified', { ns: 'datasetCreation' })} - - {t('stepTwo.recommend', { ns: 'datasetCreation' })} - - - {!hasSetIndexType && } - -
- )} - description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })} - icon={} - isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED} - disabled={hasSetIndexType} - onSwitched={() => { - setIndexType(IndexingType.QUALIFIED) - }} - /> - )} - - {(!hasSetIndexType || (hasSetIndexType && indexingType === IndexingType.ECONOMICAL)) && ( - <> - setIsQAConfirmDialogOpen(false)} className="w-[432px]"> -
-

- {t('stepTwo.qaSwitchHighQualityTipTitle', { ns: 'datasetCreation' })} -

-

- {t('stepTwo.qaSwitchHighQualityTipContent', { ns: 'datasetCreation' })} -

-
-
- - -
-
- - { - docForm === ChunkingMode.qa - ? t('stepTwo.notAvailableForQA', { ns: 'datasetCreation' }) - : t('stepTwo.notAvailableForParentChild', { ns: 'datasetCreation' }) - } -
- )} - noDecoration - position="top" - asChild={false} - triggerClassName="flex-1 self-stretch" - > - } - isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL} - disabled={hasSetIndexType || docForm !== ChunkingMode.text} - onSwitched={() => { - setIndexType(IndexingType.ECONOMICAL) - }} - /> - - - )} -
- {!hasSetIndexType && indexType === IndexingType.QUALIFIED && ( -
-
-
- -
- {t('stepTwo.highQualityTip', { ns: 'datasetCreation' })} -
- )} - {hasSetIndexType && indexType === IndexingType.ECONOMICAL && ( -
- {t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} - {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} -
- )} - {/* Embedding model */} - {indexType === IndexingType.QUALIFIED && ( -
-
{t('form.embeddingModel', { ns: 'datasetSettings' })}
- { - setEmbeddingModel(model) - }} - /> - {isModelAndRetrievalConfigDisabled && ( -
- {t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} - {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} -
- )} -
+ {showParentChildOption && ( + segmentation.updateParentConfig('delimiter', v)} + onParentMaxLengthChange={v => segmentation.updateParentConfig('maxLength', v)} + onChildDelimiterChange={v => segmentation.updateChildConfig('delimiter', v)} + onChildMaxLengthChange={v => segmentation.updateChildConfig('maxLength', v)} + onRuleToggle={segmentation.toggleRule} + onPreview={updatePreview} + onReset={segmentation.resetToDefaults} + /> )} - {/* Retrieval Method Config */} -
- {!isModelAndRetrievalConfigDisabled - ? ( -
-
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
-
- - {t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })} - - {t('form.retrievalSetting.longDescription', { ns: 'datasetSettings' })} -
-
- ) - : ( -
-
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
-
- )} - -
- { - getIndexing_technique() === IndexingType.QUALIFIED - ? ( - - ) - : ( - - ) - } -
-
- - {!isSetting - ? ( -
- - -
- ) - : ( -
- - -
- )} + setIsQAConfirmDialogOpen(false)} + onQAConfirmDialogConfirm={handleQAConfirm} + /> + onStepChange?.(-1)} onCreate={handleCreate} onCancel={onCancel} /> - - -
- {dataSourceType === DataSourceType.FILE - && ( - >} - onChange={(selected) => { - currentEstimateMutation.reset() - setPreviewFile(selected) - currentEstimateMutation.mutate() - }} - // when it is from setting, it just has one file - value={isSetting ? (files[0]! as Required) : previewFile} - /> - )} - {dataSourceType === DataSourceType.NOTION - && ( - ({ - id: page.page_id, - name: page.page_name, - extension: 'md', - })) - } - onChange={(selected) => { - currentEstimateMutation.reset() - const selectedPage = notionPages.find(page => page.page_id === selected.id) - setPreviewNotionPage(selectedPage!) - currentEstimateMutation.mutate() - }} - value={{ - id: previewNotionPage?.page_id || '', - name: previewNotionPage?.page_name || '', - extension: 'md', - }} - /> - )} - {dataSourceType === DataSourceType.WEB - && ( - ({ - id: page.source_url, - name: page.title, - extension: 'md', - })) - } - onChange={(selected) => { - currentEstimateMutation.reset() - const selectedPage = websitePages.find(page => page.source_url === selected.id) - setPreviewWebsitePage(selectedPage!) - currentEstimateMutation.mutate() - }} - value={ - { - id: previewWebsitePage?.source_url || '', - name: previewWebsitePage?.title || '', - extension: 'md', - } - } - /> - )} - { - currentDocForm !== ChunkingMode.qa - && ( - - ) - } -
- - )} - className={cn('relative flex h-full w-1/2 shrink-0 p-4 pr-0', isMobile && 'w-full max-w-[524px]')} - mainClassName="space-y-6" - > - {currentDocForm === ChunkingMode.qa && estimate?.qa_preview && ( - estimate?.qa_preview.map((item, index) => ( - - - - )) - )} - {currentDocForm === ChunkingMode.text && estimate?.preview && ( - estimate?.preview.map((item, index) => ( - - {item.content} - - )) - )} - {currentDocForm === ChunkingMode.parentChild && currentEstimateMutation.data?.preview && ( - estimate?.preview?.map((item, index) => { - const indexForLabel = index + 1 - const childChunks = parentChildConfig.chunkForContext === 'full-doc' - ? item.child_chunks.slice(0, FULL_DOC_PREVIEW_LENGTH) - : item.child_chunks - return ( - - - {childChunks.map((child, index) => { - const indexForLabel = index + 1 - return ( - - ) - })} - - - ) - }) - )} - {currentEstimateMutation.isIdle && ( -
-
- -

- {t('stepTwo.previewChunkTip', { ns: 'datasetCreation' })} -

-
-
- )} - {currentEstimateMutation.isPending && ( -
- {Array.from({ length: 10 }, (_, i) => ( - - - - - - - - - - - ))} -
- )} -
-
+ } + pickerValue={preview.getPreviewPickerValue()} + isIdle={estimateHook.isIdle} + isPending={estimateHook.isPending} + onPickerChange={handlePickerChange} + /> ) } diff --git a/web/app/components/datasets/create/step-two/types.ts b/web/app/components/datasets/create/step-two/types.ts new file mode 100644 index 0000000000..7f5291fb13 --- /dev/null +++ b/web/app/components/datasets/create/step-two/types.ts @@ -0,0 +1,28 @@ +import type { IndexingType } from './hooks' +import type { DataSourceProvider, NotionPage } from '@/models/common' +import type { CrawlOptions, CrawlResultItem, createDocumentResponse, CustomFile, DataSourceType, FullDocumentDetail } from '@/models/datasets' +import type { RETRIEVE_METHOD } from '@/types/app' + +export type StepTwoProps = { + isSetting?: boolean + documentDetail?: FullDocumentDetail + isAPIKeySet: boolean + onSetting: () => void + datasetId?: string + indexingType?: IndexingType + retrievalMethod?: string + dataSourceType: DataSourceType + files: CustomFile[] + notionPages?: NotionPage[] + notionCredentialId: string + websitePages?: CrawlResultItem[] + crawlOptions?: CrawlOptions + websiteCrawlProvider?: DataSourceProvider + websiteCrawlJobId?: string + onStepChange?: (delta: number) => void + updateIndexingTypeCache?: (type: string) => void + updateRetrievalMethodCache?: (method: RETRIEVE_METHOD | '') => void + updateResultCache?: (res: createDocumentResponse) => void + onSave?: () => void + onCancel?: () => void +} From 98df99b0ca2f13db64eba18e37489db5c534eadd Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Fri, 9 Jan 2026 10:21:27 +0800 Subject: [PATCH 06/22] feat(embedding-process): implement embedding process components and polling logic (#30622) Co-authored-by: CodingOnStar --- .../create/embedding-process/index.spec.tsx | 1562 +++++++++++++++++ .../create/embedding-process/index.tsx | 420 +---- .../indexing-progress-item.tsx | 120 ++ .../create/embedding-process/rule-detail.tsx | 133 ++ .../embedding-process/upgrade-banner.tsx | 22 + .../use-indexing-status-polling.ts | 90 + .../create/embedding-process/utils.ts | 64 + 7 files changed, 2086 insertions(+), 325 deletions(-) create mode 100644 web/app/components/datasets/create/embedding-process/index.spec.tsx create mode 100644 web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx create mode 100644 web/app/components/datasets/create/embedding-process/rule-detail.tsx create mode 100644 web/app/components/datasets/create/embedding-process/upgrade-banner.tsx create mode 100644 web/app/components/datasets/create/embedding-process/use-indexing-status-polling.ts create mode 100644 web/app/components/datasets/create/embedding-process/utils.ts diff --git a/web/app/components/datasets/create/embedding-process/index.spec.tsx b/web/app/components/datasets/create/embedding-process/index.spec.tsx new file mode 100644 index 0000000000..8d2bae03cd --- /dev/null +++ b/web/app/components/datasets/create/embedding-process/index.spec.tsx @@ -0,0 +1,1562 @@ +import type { FullDocumentDetail, IndexingStatusResponse, ProcessRuleResponse } from '@/models/datasets' +import { act, render, renderHook, screen } from '@testing-library/react' +import { DataSourceType, ProcessMode } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import IndexingProgressItem from './indexing-progress-item' +import RuleDetail from './rule-detail' +import UpgradeBanner from './upgrade-banner' +import { useIndexingStatusPolling } from './use-indexing-status-polling' +import { + createDocumentLookup, + getFileType, + getSourcePercent, + isLegacyDataSourceInfo, + isSourceEmbedding, +} from './utils' + +// ============================================================================= +// Mock External Dependencies +// ============================================================================= + +// Mock next/navigation +const mockPush = vi.fn() +const mockRouter = { push: mockPush } +vi.mock('next/navigation', () => ({ + useRouter: () => mockRouter, +})) + +// Mock next/image +vi.mock('next/image', () => ({ + default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => ( + // eslint-disable-next-line next/no-img-element + {alt} + ), +})) + +// Mock API service +const mockFetchIndexingStatusBatch = vi.fn() +vi.mock('@/service/datasets', () => ({ + fetchIndexingStatusBatch: (params: { datasetId: string, batchId: string }) => + mockFetchIndexingStatusBatch(params), +})) + +// Mock service hooks +const mockProcessRuleData: ProcessRuleResponse | undefined = undefined +vi.mock('@/service/knowledge/use-dataset', () => ({ + useProcessRule: vi.fn(() => ({ data: mockProcessRuleData })), +})) + +const mockInvalidDocumentList = vi.fn() +vi.mock('@/service/knowledge/use-document', () => ({ + useInvalidDocumentList: () => mockInvalidDocumentList, +})) + +// Mock useDatasetApiAccessUrl hook +vi.mock('@/hooks/use-api-access-url', () => ({ + useDatasetApiAccessUrl: () => 'https://api.example.com/docs', +})) + +// Mock provider context +let mockEnableBilling = false +let mockPlanType = 'sandbox' +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + enableBilling: mockEnableBilling, + plan: { type: mockPlanType }, + }), +})) + +// Mock icons +vi.mock('../icons', () => ({ + indexMethodIcon: { + economical: '/icons/economical.svg', + high_quality: '/icons/high-quality.svg', + }, + retrievalIcon: { + fullText: '/icons/full-text.svg', + hybrid: '/icons/hybrid.svg', + vector: '/icons/vector.svg', + }, +})) + +// Mock IndexingType enum from step-two +vi.mock('../step-two', () => ({ + IndexingType: { + QUALIFIED: 'high_quality', + ECONOMICAL: 'economy', + }, +})) + +// ============================================================================= +// Factory Functions for Test Data +// ============================================================================= + +/** + * Create a mock IndexingStatusResponse + */ +const createMockIndexingStatus = ( + overrides: Partial = {}, +): IndexingStatusResponse => ({ + id: 'doc-1', + indexing_status: 'completed', + processing_started_at: Date.now(), + parsing_completed_at: Date.now(), + cleaning_completed_at: Date.now(), + splitting_completed_at: Date.now(), + completed_at: Date.now(), + paused_at: null, + error: null, + stopped_at: null, + completed_segments: 10, + total_segments: 10, + ...overrides, +}) + +/** + * Create a mock FullDocumentDetail + */ +const createMockDocument = ( + overrides: Partial = {}, +): FullDocumentDetail => ({ + id: 'doc-1', + name: 'test-document.txt', + data_source_type: DataSourceType.FILE, + data_source_info: { + upload_file: { + id: 'file-1', + name: 'test-document.txt', + extension: 'txt', + mime_type: 'text/plain', + size: 1024, + created_by: 'user-1', + created_at: Date.now(), + }, + }, + batch: 'batch-1', + created_api_request_id: 'req-1', + processing_started_at: Date.now(), + parsing_completed_at: Date.now(), + cleaning_completed_at: Date.now(), + splitting_completed_at: Date.now(), + tokens: 100, + indexing_latency: 5000, + completed_at: Date.now(), + paused_by: '', + paused_at: 0, + stopped_at: 0, + indexing_status: 'completed', + disabled_at: 0, + ...overrides, +} as FullDocumentDetail) + +/** + * Create a mock ProcessRuleResponse + */ +const createMockProcessRule = ( + overrides: Partial = {}, +): ProcessRuleResponse => ({ + mode: ProcessMode.general, + rules: { + segmentation: { + separator: '\n', + max_tokens: 500, + chunk_overlap: 50, + }, + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ], + }, + ...overrides, +} as ProcessRuleResponse) + +// ============================================================================= +// Utils Tests +// ============================================================================= + +describe('utils', () => { + // Test utility functions for document handling + + describe('isLegacyDataSourceInfo', () => { + it('should return true for legacy data source with upload_file object', () => { + // Arrange + const info = { + upload_file: { id: 'file-1', name: 'test.txt' }, + } + + // Act & Assert + expect(isLegacyDataSourceInfo(info as Parameters[0])).toBe(true) + }) + + it('should return false for null', () => { + expect(isLegacyDataSourceInfo(null as unknown as Parameters[0])).toBe(false) + }) + + it('should return false for undefined', () => { + expect(isLegacyDataSourceInfo(undefined as unknown as Parameters[0])).toBe(false) + }) + + it('should return false when upload_file is not an object', () => { + // Arrange + const info = { upload_file: 'string-value' } + + // Act & Assert + expect(isLegacyDataSourceInfo(info as unknown as Parameters[0])).toBe(false) + }) + }) + + describe('isSourceEmbedding', () => { + it.each([ + ['indexing', true], + ['splitting', true], + ['parsing', true], + ['cleaning', true], + ['waiting', true], + ['completed', false], + ['error', false], + ['paused', false], + ])('should return %s for status "%s"', (status, expected) => { + // Arrange + const detail = createMockIndexingStatus({ indexing_status: status as IndexingStatusResponse['indexing_status'] }) + + // Act & Assert + expect(isSourceEmbedding(detail)).toBe(expected) + }) + }) + + describe('getSourcePercent', () => { + it('should return 0 when total_segments is 0', () => { + // Arrange + const detail = createMockIndexingStatus({ + completed_segments: 0, + total_segments: 0, + }) + + // Act & Assert + expect(getSourcePercent(detail)).toBe(0) + }) + + it('should calculate correct percentage', () => { + // Arrange + const detail = createMockIndexingStatus({ + completed_segments: 5, + total_segments: 10, + }) + + // Act & Assert + expect(getSourcePercent(detail)).toBe(50) + }) + + it('should cap percentage at 100', () => { + // Arrange + const detail = createMockIndexingStatus({ + completed_segments: 15, + total_segments: 10, + }) + + // Act & Assert + expect(getSourcePercent(detail)).toBe(100) + }) + + it('should handle undefined values', () => { + // Arrange + const detail = { indexing_status: 'indexing' } as IndexingStatusResponse + + // Act & Assert + expect(getSourcePercent(detail)).toBe(0) + }) + + it('should round to nearest integer', () => { + // Arrange + const detail = createMockIndexingStatus({ + completed_segments: 1, + total_segments: 3, + }) + + // Act & Assert + expect(getSourcePercent(detail)).toBe(33) + }) + }) + + describe('getFileType', () => { + it('should extract extension from filename', () => { + expect(getFileType('document.pdf')).toBe('pdf') + expect(getFileType('file.name.txt')).toBe('txt') + expect(getFileType('archive.tar.gz')).toBe('gz') + }) + + it('should return "txt" for undefined', () => { + expect(getFileType(undefined)).toBe('txt') + }) + + it('should return filename without extension', () => { + expect(getFileType('filename')).toBe('filename') + }) + }) + + describe('createDocumentLookup', () => { + it('should create lookup functions for documents', () => { + // Arrange + const documents = [ + createMockDocument({ id: 'doc-1', name: 'file1.txt' }), + createMockDocument({ id: 'doc-2', name: 'file2.pdf', data_source_type: DataSourceType.NOTION }), + ] + + // Act + const lookup = createDocumentLookup(documents) + + // Assert + expect(lookup.getName('doc-1')).toBe('file1.txt') + expect(lookup.getName('doc-2')).toBe('file2.pdf') + expect(lookup.getName('non-existent')).toBeUndefined() + }) + + it('should return source type correctly', () => { + // Arrange + const documents = [ + createMockDocument({ id: 'doc-1', data_source_type: DataSourceType.FILE }), + createMockDocument({ id: 'doc-2', data_source_type: DataSourceType.NOTION }), + ] + const lookup = createDocumentLookup(documents) + + // Assert + expect(lookup.getSourceType('doc-1')).toBe(DataSourceType.FILE) + expect(lookup.getSourceType('doc-2')).toBe(DataSourceType.NOTION) + }) + + it('should return notion icon for legacy data source', () => { + // Arrange + const documents = [ + createMockDocument({ + id: 'doc-1', + data_source_info: { + upload_file: { id: 'f1' }, + notion_page_icon: '📄', + } as FullDocumentDetail['data_source_info'], + }), + ] + const lookup = createDocumentLookup(documents) + + // Assert + expect(lookup.getNotionIcon('doc-1')).toBe('📄') + }) + + it('should return undefined for non-legacy notion icon', () => { + // Arrange + const documents = [ + createMockDocument({ + id: 'doc-1', + data_source_info: { some_other_field: 'value' } as unknown as FullDocumentDetail['data_source_info'], + }), + ] + const lookup = createDocumentLookup(documents) + + // Assert + expect(lookup.getNotionIcon('doc-1')).toBeUndefined() + }) + + it('should memoize lookups with Map for performance', () => { + // Arrange + const documents = Array.from({ length: 1000 }, (_, i) => + createMockDocument({ id: `doc-${i}`, name: `file${i}.txt` })) + + // Act + const lookup = createDocumentLookup(documents) + const startTime = performance.now() + for (let i = 0; i < 1000; i++) + lookup.getName(`doc-${i}`) + + const duration = performance.now() - startTime + + // Assert - should be very fast due to Map lookup + expect(duration).toBeLessThan(50) + }) + }) +}) + +// ============================================================================= +// useIndexingStatusPolling Hook Tests +// ============================================================================= + +describe('useIndexingStatusPolling', () => { + // Test the polling hook for indexing status + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should fetch status on mount', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'completed' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + const { result } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(mockFetchIndexingStatusBatch).toHaveBeenCalledWith({ + datasetId: 'ds-1', + batchId: 'batch-1', + }) + expect(result.current.statusList).toEqual(mockStatus) + }) + + it('should stop polling when all statuses are completed', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'completed' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert - should only be called once since status is completed + expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(1) + }) + + it('should continue polling when status is indexing', async () => { + // Arrange + const indexingStatus = [createMockIndexingStatus({ indexing_status: 'indexing' })] + const completedStatus = [createMockIndexingStatus({ indexing_status: 'completed' })] + + mockFetchIndexingStatusBatch + .mockResolvedValueOnce({ data: indexingStatus }) + .mockResolvedValueOnce({ data: completedStatus }) + + // Act + renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + // First poll + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Advance timer for next poll (2500ms) + await act(async () => { + await vi.advanceTimersByTimeAsync(2500) + }) + + // Assert + expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(2) + }) + + it('should stop polling when status is error', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'error', error: 'Some error' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + const { result } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(result.current.isEmbeddingCompleted).toBe(true) + expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(1) + }) + + it('should stop polling when status is paused', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'paused' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + const { result } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(result.current.isEmbeddingCompleted).toBe(true) + }) + + it('should continue polling on API error', async () => { + // Arrange + mockFetchIndexingStatusBatch + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ data: [createMockIndexingStatus({ indexing_status: 'completed' })] }) + + // Act + renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(2500) + }) + + // Assert - should retry after error + expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(2) + }) + + it('should return correct isEmbedding state', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'indexing' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + const { result } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(result.current.isEmbedding).toBe(true) + expect(result.current.isEmbeddingCompleted).toBe(false) + }) + + it('should cleanup timeout on unmount', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'indexing' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + const { unmount } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + const callCountBeforeUnmount = mockFetchIndexingStatusBatch.mock.calls.length + + unmount() + + // Advance timers - should not trigger more calls after unmount + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + + // Assert - no additional calls after unmount + expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(callCountBeforeUnmount) + }) + + it('should handle multiple documents with mixed statuses', async () => { + // Arrange + const mockStatus = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'completed' }), + createMockIndexingStatus({ id: 'doc-2', indexing_status: 'indexing' }), + ] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + const { result } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(result.current.isEmbedding).toBe(true) + expect(result.current.isEmbeddingCompleted).toBe(false) + expect(result.current.statusList).toHaveLength(2) + }) + + it('should return empty statusList initially', () => { + // Arrange & Act + const { result } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + // Assert + expect(result.current.statusList).toEqual([]) + expect(result.current.isEmbedding).toBe(false) + expect(result.current.isEmbeddingCompleted).toBe(false) + }) +}) + +// ============================================================================= +// UpgradeBanner Component Tests +// ============================================================================= + +describe('UpgradeBanner', () => { + // Test the upgrade banner component + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render upgrade message', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i)).toBeInTheDocument() + }) + + it('should render ZapFast icon', () => { + // Arrange & Act + const { container } = render() + + // Assert + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should render UpgradeBtn component', () => { + // Arrange & Act + render() + + // Assert - UpgradeBtn should be rendered + const upgradeContainer = screen.getByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i).parentElement + expect(upgradeContainer).toBeInTheDocument() + }) +}) + +// ============================================================================= +// IndexingProgressItem Component Tests +// ============================================================================= + +describe('IndexingProgressItem', () => { + // Test the progress item component for individual documents + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render document name', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render() + + // Assert + expect(screen.getByText('test-document.txt')).toBeInTheDocument() + }) + + it('should render progress percentage when embedding', () => { + // Arrange + const detail = createMockIndexingStatus({ + indexing_status: 'indexing', + completed_segments: 5, + total_segments: 10, + }) + + // Act + render() + + // Assert + expect(screen.getByText('50%')).toBeInTheDocument() + }) + + it('should not render progress percentage when completed', () => { + // Arrange + const detail = createMockIndexingStatus({ indexing_status: 'completed' }) + + // Act + render() + + // Assert + expect(screen.queryByText('%')).not.toBeInTheDocument() + }) + }) + + describe('Status Icons', () => { + it('should render success icon for completed status', () => { + // Arrange + const detail = createMockIndexingStatus({ indexing_status: 'completed' }) + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('.text-text-success')).toBeInTheDocument() + }) + + it('should render error icon for error status', () => { + // Arrange + const detail = createMockIndexingStatus({ + indexing_status: 'error', + error: 'Processing failed', + }) + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('.text-text-destructive')).toBeInTheDocument() + }) + + it('should not render status icon for indexing status', () => { + // Arrange + const detail = createMockIndexingStatus({ indexing_status: 'indexing' }) + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('.text-text-success')).not.toBeInTheDocument() + expect(container.querySelector('.text-text-destructive')).not.toBeInTheDocument() + }) + }) + + describe('Source Type Icons', () => { + it('should render file icon for FILE source type', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert - DocumentFileIcon should be rendered + expect(screen.getByText('document.pdf')).toBeInTheDocument() + }) + + // DocumentFileIcon branch coverage: different file extensions + describe('DocumentFileIcon file extensions', () => { + it.each([ + ['document.pdf', 'pdf'], + ['data.json', 'json'], + ['page.html', 'html'], + ['readme.txt', 'txt'], + ['notes.markdown', 'markdown'], + ['readme.md', 'md'], + ['spreadsheet.xlsx', 'xlsx'], + ['legacy.xls', 'xls'], + ['data.csv', 'csv'], + ['letter.doc', 'doc'], + ['report.docx', 'docx'], + ])('should render file icon for %s (%s extension)', (filename) => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert + expect(screen.getByText(filename)).toBeInTheDocument() + }) + + it('should handle unknown file extension with default icon', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert - should still render with default document icon + expect(screen.getByText('archive.zip')).toBeInTheDocument() + }) + + it('should handle uppercase extension', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('REPORT.PDF')).toBeInTheDocument() + }) + + it('should handle mixed case extension', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Document.Docx')).toBeInTheDocument() + }) + + it('should handle filename with multiple dots', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert - should extract "pdf" as extension + expect(screen.getByText('my.file.name.pdf')).toBeInTheDocument() + }) + + it('should handle filename without extension', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert - should use filename itself as fallback + expect(screen.getByText('noextension')).toBeInTheDocument() + }) + }) + + it('should render notion icon for NOTION source type', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Notion Page')).toBeInTheDocument() + }) + }) + + describe('Progress Bar', () => { + it('should render progress bar when embedding', () => { + // Arrange + const detail = createMockIndexingStatus({ + indexing_status: 'indexing', + completed_segments: 30, + total_segments: 100, + }) + + // Act + const { container } = render() + + // Assert + const progressBar = container.querySelector('[style*="width: 30%"]') + expect(progressBar).toBeInTheDocument() + }) + + it('should not render progress bar when completed', () => { + // Arrange + const detail = createMockIndexingStatus({ indexing_status: 'completed' }) + + // Act + const { container } = render() + + // Assert + const progressBar = container.querySelector('.bg-components-progress-bar-progress') + expect(progressBar).not.toBeInTheDocument() + }) + + it('should apply error styling for error status', () => { + // Arrange + const detail = createMockIndexingStatus({ indexing_status: 'error' }) + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('.bg-state-destructive-hover-alt')).toBeInTheDocument() + }) + }) + + describe('Billing', () => { + it('should render PriorityLabel when enableBilling is true', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render() + + // Assert - PriorityLabel component should be in the DOM + const container = screen.getByText('test.txt').parentElement + expect(container).toBeInTheDocument() + }) + + it('should not render PriorityLabel when enableBilling is false', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render() + + // Assert + expect(screen.getByText('test.txt')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined name', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render() + + // Assert - should not crash + expect(document.body).toBeInTheDocument() + }) + + it('should handle undefined sourceType', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render() + + // Assert - should render without source icon + expect(screen.getByText('test.txt')).toBeInTheDocument() + }) + }) +}) + +// ============================================================================= +// RuleDetail Component Tests +// ============================================================================= + +describe('RuleDetail', () => { + // Test the rule detail component for process configuration display + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.mode/i)).toBeInTheDocument() + }) + + it('should render all field labels', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.mode/i)).toBeInTheDocument() + expect(screen.getByText(/datasetDocuments\.embedding\.segmentLength/i)).toBeInTheDocument() + expect(screen.getByText(/datasetDocuments\.embedding\.textCleaning/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepTwo\.indexMode/i)).toBeInTheDocument() + expect(screen.getByText(/datasetSettings\.form\.retrievalSetting\.title/i)).toBeInTheDocument() + }) + }) + + describe('Mode Display', () => { + it('should show "-" when sourceData is undefined', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getAllByText('-')).toHaveLength(3) // mode, segmentLength, textCleaning + }) + + it('should show "custom" for general process mode', () => { + // Arrange + const sourceData = createMockProcessRule({ mode: ProcessMode.general }) + + // Act + render() + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.custom/i)).toBeInTheDocument() + }) + + it('should show hierarchical mode with paragraph parent', () => { + // Arrange + const sourceData = createMockProcessRule({ + mode: ProcessMode.parentChild, + rules: { + parent_mode: 'paragraph', + segmentation: { max_tokens: 500 }, + }, + } as Partial) + + // Act + render() + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.hierarchical/i)).toBeInTheDocument() + }) + }) + + describe('Segment Length Display', () => { + it('should show max_tokens for general mode', () => { + // Arrange + const sourceData = createMockProcessRule({ + mode: ProcessMode.general, + rules: { + segmentation: { max_tokens: 500 }, + }, + } as Partial) + + // Act + render() + + // Assert + expect(screen.getByText('500')).toBeInTheDocument() + }) + + it('should show parent and child tokens for hierarchical mode', () => { + // Arrange + const sourceData = createMockProcessRule({ + mode: ProcessMode.parentChild, + rules: { + segmentation: { max_tokens: 1000 }, + subchunk_segmentation: { max_tokens: 200 }, + }, + } as Partial) + + // Act + render() + + // Assert + expect(screen.getByText(/1000/)).toBeInTheDocument() + expect(screen.getByText(/200/)).toBeInTheDocument() + }) + }) + + describe('Text Cleaning Rules', () => { + it('should show enabled rule names', () => { + // Arrange + const sourceData = createMockProcessRule({ + mode: ProcessMode.general, + rules: { + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: true }, + { id: 'remove_stopwords', enabled: false }, + ], + }, + } as Partial) + + // Act + render() + + // Assert + expect(screen.getByText(/removeExtraSpaces/i)).toBeInTheDocument() + expect(screen.getByText(/removeUrlEmails/i)).toBeInTheDocument() + }) + + it('should show "-" when no rules are enabled', () => { + // Arrange + const sourceData = createMockProcessRule({ + mode: ProcessMode.general, + rules: { + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: false }, + ], + }, + } as Partial) + + // Act + render() + + // Assert - textCleaning should show "-" + const dashElements = screen.getAllByText('-') + expect(dashElements.length).toBeGreaterThan(0) + }) + }) + + describe('Indexing Type', () => { + it('should show qualified for high_quality indexing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/datasetCreation\.stepTwo\.qualified/i)).toBeInTheDocument() + }) + + it('should show economical for economy indexing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/datasetCreation\.stepTwo\.economical/i)).toBeInTheDocument() + }) + + it('should render correct icon for indexing type', () => { + // Arrange & Act + render() + + // Assert + const images = screen.getAllByTestId('next-image') + expect(images.length).toBeGreaterThan(0) + }) + }) + + describe('Retrieval Method', () => { + it('should show semantic search by default', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/dataset\.retrieval\.semantic_search\.title/i)).toBeInTheDocument() + }) + + it('should show keyword search for economical indexing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/dataset\.retrieval\.keyword_search\.title/i)).toBeInTheDocument() + }) + + it.each([ + [RETRIEVE_METHOD.fullText, 'full_text_search'], + [RETRIEVE_METHOD.hybrid, 'hybrid_search'], + [RETRIEVE_METHOD.semantic, 'semantic_search'], + ])('should show correct label for %s retrieval method', (method, expectedKey) => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(new RegExp(`dataset\\.retrieval\\.${expectedKey}\\.title`, 'i'))).toBeInTheDocument() + }) + }) +}) + +// ============================================================================= +// EmbeddingProcess Integration Tests +// ============================================================================= + +describe('EmbeddingProcess', () => { + // Integration tests for the main EmbeddingProcess component + + // Import the main component after mocks are set up + let EmbeddingProcess: typeof import('./index').default + + beforeEach(async () => { + vi.clearAllMocks() + vi.useFakeTimers() + mockEnableBilling = false + mockPlanType = 'sandbox' + + // Dynamically import to get fresh component with mocks + const embeddingModule = await import('./index') + EmbeddingProcess = embeddingModule.default + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('Rendering', () => { + it('should render without crashing', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(document.body).toBeInTheDocument() + }) + + it('should render status header', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'indexing' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.processing/i)).toBeInTheDocument() + }) + + it('should show completed status when all documents are done', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'completed' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.completed/i)).toBeInTheDocument() + }) + }) + + describe('Progress Items', () => { + it('should render progress items for each document', async () => { + // Arrange + const documents = [ + createMockDocument({ id: 'doc-1', name: 'file1.txt' }), + createMockDocument({ id: 'doc-2', name: 'file2.pdf' }), + ] + const mockStatus = [ + createMockIndexingStatus({ id: 'doc-1' }), + createMockIndexingStatus({ id: 'doc-2' }), + ] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + render( + , + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText('file1.txt')).toBeInTheDocument() + expect(screen.getByText('file2.pdf')).toBeInTheDocument() + }) + }) + + describe('Upgrade Banner', () => { + it('should show upgrade banner when billing is enabled and not team plan', async () => { + // Arrange + mockEnableBilling = true + mockPlanType = 'sandbox' + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Re-import to get updated mock values + const embeddingModule = await import('./index') + EmbeddingProcess = embeddingModule.default + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i)).toBeInTheDocument() + }) + + it('should not show upgrade banner when billing is disabled', async () => { + // Arrange + mockEnableBilling = false + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.queryByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i)).not.toBeInTheDocument() + }) + + it('should not show upgrade banner for team plan', async () => { + // Arrange + mockEnableBilling = true + mockPlanType = 'team' + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Re-import to get updated mock values + const embeddingModule = await import('./index') + EmbeddingProcess = embeddingModule.default + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.queryByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i)).not.toBeInTheDocument() + }) + }) + + describe('Action Buttons', () => { + it('should render API access button with correct link', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + const apiButton = screen.getByText('Access the API') + expect(apiButton).toBeInTheDocument() + expect(apiButton.closest('a')).toHaveAttribute('href', 'https://api.example.com/docs') + }) + + it('should render navigation button', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText(/datasetCreation\.stepThree\.navTo/i)).toBeInTheDocument() + }) + + it('should navigate to documents list when nav button clicked', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + const navButton = screen.getByText(/datasetCreation\.stepThree\.navTo/i) + + await act(async () => { + navButton.click() + }) + + // Assert + expect(mockInvalidDocumentList).toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/datasets/ds-1/documents') + }) + }) + + describe('Rule Detail', () => { + it('should render RuleDetail component', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render( + , + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.mode/i)).toBeInTheDocument() + }) + + it('should pass indexingType to RuleDetail', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render( + , + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText(/datasetCreation\.stepTwo\.economical/i)).toBeInTheDocument() + }) + }) + + describe('Document Lookup Memoization', () => { + it('should memoize document lookup based on documents array', async () => { + // Arrange + const documents = [createMockDocument({ id: 'doc-1', name: 'test.txt' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ + data: [createMockIndexingStatus({ id: 'doc-1' })], + }) + + // Act + const { rerender } = render( + , + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Rerender with same documents reference + rerender( + , + ) + + // Assert - component should render without issues + expect(screen.getByText('test.txt')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty documents array', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert - should render without crashing + expect(document.body).toBeInTheDocument() + }) + + it('should handle undefined documents', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert - should render without crashing + expect(document.body).toBeInTheDocument() + }) + + it('should handle status with missing document', async () => { + // Arrange + const documents = [createMockDocument({ id: 'doc-1', name: 'test.txt' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ + data: [ + createMockIndexingStatus({ id: 'doc-1' }), + createMockIndexingStatus({ id: 'doc-unknown' }), // No matching document + ], + }) + + // Act + render( + , + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert - should render known document and handle unknown gracefully + expect(screen.getByText('test.txt')).toBeInTheDocument() + }) + + it('should handle undefined retrievalMethod', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render( + , + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert - should use default semantic search + expect(screen.getByText(/dataset\.retrieval\.semantic_search\.title/i)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/embedding-process/index.tsx b/web/app/components/datasets/create/embedding-process/index.tsx index aa1f6cee50..e9cea84f00 100644 --- a/web/app/components/datasets/create/embedding-process/index.tsx +++ b/web/app/components/datasets/create/embedding-process/index.tsx @@ -1,47 +1,29 @@ import type { FC } from 'react' -import type { - DataSourceInfo, - FullDocumentDetail, - IndexingStatusResponse, - LegacyDataSourceInfo, - ProcessRuleResponse, -} from '@/models/datasets' +import type { FullDocumentDetail } from '@/models/datasets' +import type { RETRIEVE_METHOD } from '@/types/app' import { RiArrowRightLine, - RiCheckboxCircleFill, - RiErrorWarningFill, RiLoader2Fill, RiTerminalBoxLine, } from '@remixicon/react' -import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/navigation' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' -import { ZapFast } from '@/app/components/base/icons/src/vender/solid/general' -import NotionIcon from '@/app/components/base/notion-icon' -import Tooltip from '@/app/components/base/tooltip' -import PriorityLabel from '@/app/components/billing/priority-label' import { Plan } from '@/app/components/billing/type' -import UpgradeBtn from '@/app/components/billing/upgrade-btn' -import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata' import { useProviderContext } from '@/context/provider-context' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' -import { DataSourceType, ProcessMode } from '@/models/datasets' -import { fetchIndexingStatusBatch as doFetchIndexingStatus } from '@/service/datasets' import { useProcessRule } from '@/service/knowledge/use-dataset' import { useInvalidDocumentList } from '@/service/knowledge/use-document' -import { RETRIEVE_METHOD } from '@/types/app' -import { sleep } from '@/utils' -import { cn } from '@/utils/classnames' -import DocumentFileIcon from '../../common/document-file-icon' -import { indexMethodIcon, retrievalIcon } from '../icons' -import { IndexingType } from '../step-two' +import IndexingProgressItem from './indexing-progress-item' +import RuleDetail from './rule-detail' +import UpgradeBanner from './upgrade-banner' +import { useIndexingStatusPolling } from './use-indexing-status-polling' +import { createDocumentLookup } from './utils' -type Props = { +type EmbeddingProcessProps = { datasetId: string batchId: string documents?: FullDocumentDetail[] @@ -49,333 +31,121 @@ type Props = { retrievalMethod?: RETRIEVE_METHOD } -const RuleDetail: FC<{ - sourceData?: ProcessRuleResponse - indexingType?: string - retrievalMethod?: RETRIEVE_METHOD -}> = ({ sourceData, indexingType, retrievalMethod }) => { +// Status header component +const StatusHeader: FC<{ isEmbedding: boolean, isCompleted: boolean }> = ({ + isEmbedding, + isCompleted, +}) => { const { t } = useTranslation() - const segmentationRuleMap = { - mode: t('embedding.mode', { ns: 'datasetDocuments' }), - segmentLength: t('embedding.segmentLength', { ns: 'datasetDocuments' }), - textCleaning: t('embedding.textCleaning', { ns: 'datasetDocuments' }), - } - - const getRuleName = (key: string) => { - if (key === 'remove_extra_spaces') - return t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' }) - - if (key === 'remove_urls_emails') - return t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' }) - - if (key === 'remove_stopwords') - return t('stepTwo.removeStopwords', { ns: 'datasetCreation' }) - } - - const isNumber = (value: unknown) => { - return typeof value === 'number' - } - - const getValue = useCallback((field: string) => { - let value: string | number | undefined = '-' - const maxTokens = isNumber(sourceData?.rules?.segmentation?.max_tokens) - ? sourceData.rules.segmentation.max_tokens - : value - const childMaxTokens = isNumber(sourceData?.rules?.subchunk_segmentation?.max_tokens) - ? sourceData.rules.subchunk_segmentation.max_tokens - : value - switch (field) { - case 'mode': - value = !sourceData?.mode - ? value - : sourceData.mode === ProcessMode.general - ? (t('embedding.custom', { ns: 'datasetDocuments' }) as string) - : `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${sourceData?.rules?.parent_mode === 'paragraph' - ? t('parentMode.paragraph', { ns: 'dataset' }) - : t('parentMode.fullDoc', { ns: 'dataset' })}` - break - case 'segmentLength': - value = !sourceData?.mode - ? value - : sourceData.mode === ProcessMode.general - ? maxTokens - : `${t('embedding.parentMaxTokens', { ns: 'datasetDocuments' })} ${maxTokens}; ${t('embedding.childMaxTokens', { ns: 'datasetDocuments' })} ${childMaxTokens}` - break - default: - value = !sourceData?.mode - ? value - : sourceData?.rules?.pre_processing_rules?.filter(rule => - rule.enabled).map(rule => getRuleName(rule.id)).join(',') - break - } - return value - }, [sourceData]) - return ( -
- {Object.keys(segmentationRuleMap).map((field) => { - return ( - - ) - })} - - )} - /> - - )} - /> +
+ {isEmbedding && ( + <> + + {t('embedding.processing', { ns: 'datasetDocuments' })} + + )} + {isCompleted && t('embedding.completed', { ns: 'datasetDocuments' })}
) } -const EmbeddingProcess: FC = ({ datasetId, batchId, documents = [], indexingType, retrievalMethod }) => { +// Action buttons component +const ActionButtons: FC<{ + apiReferenceUrl: string + onNavToDocuments: () => void +}> = ({ apiReferenceUrl, onNavToDocuments }) => { const { t } = useTranslation() + + return ( +
+ + + + +
+ ) +} + +const EmbeddingProcess: FC = ({ + datasetId, + batchId, + documents = [], + indexingType, + retrievalMethod, +}) => { const { enableBilling, plan } = useProviderContext() - - const getFirstDocument = documents[0] - - const [indexingStatusBatchDetail, setIndexingStatusDetail] = useState([]) - const fetchIndexingStatus = async () => { - const status = await doFetchIndexingStatus({ datasetId, batchId }) - setIndexingStatusDetail(status.data) - return status.data - } - - const [isStopQuery, setIsStopQuery] = useState(false) - const isStopQueryRef = useRef(isStopQuery) - useEffect(() => { - isStopQueryRef.current = isStopQuery - }, [isStopQuery]) - const stopQueryStatus = () => { - setIsStopQuery(true) - } - - const startQueryStatus = async () => { - if (isStopQueryRef.current) - return - - try { - const indexingStatusBatchDetail = await fetchIndexingStatus() - const isCompleted = indexingStatusBatchDetail.every(indexingStatusDetail => ['completed', 'error', 'paused'].includes(indexingStatusDetail.indexing_status)) - if (isCompleted) { - stopQueryStatus() - return - } - await sleep(2500) - await startQueryStatus() - } - catch { - await sleep(2500) - await startQueryStatus() - } - } - - useEffect(() => { - setIsStopQuery(false) - startQueryStatus() - return () => { - stopQueryStatus() - } - }, []) - - // get rule - const { data: ruleDetail } = useProcessRule(getFirstDocument?.id) - const router = useRouter() const invalidDocumentList = useInvalidDocumentList() - const navToDocumentList = () => { + const apiReferenceUrl = useDatasetApiAccessUrl() + + // Polling hook for indexing status + const { statusList, isEmbedding, isEmbeddingCompleted } = useIndexingStatusPolling({ + datasetId, + batchId, + }) + + // Get process rule for the first document + const firstDocumentId = documents[0]?.id + const { data: ruleDetail } = useProcessRule(firstDocumentId) + + // Document lookup utilities - memoized for performance + const documentLookup = useMemo( + () => createDocumentLookup(documents), + [documents], + ) + + const handleNavToDocuments = () => { invalidDocumentList() router.push(`/datasets/${datasetId}/documents`) } - const apiReferenceUrl = useDatasetApiAccessUrl() - const isEmbedding = useMemo(() => { - return indexingStatusBatchDetail.some(indexingStatusDetail => ['indexing', 'splitting', 'parsing', 'cleaning'].includes(indexingStatusDetail?.indexing_status || '')) - }, [indexingStatusBatchDetail]) - const isEmbeddingCompleted = useMemo(() => { - return indexingStatusBatchDetail.every(indexingStatusDetail => ['completed', 'error', 'paused'].includes(indexingStatusDetail?.indexing_status || '')) - }, [indexingStatusBatchDetail]) - - const getSourceName = (id: string) => { - const doc = documents.find(document => document.id === id) - return doc?.name - } - const getFileType = (name?: string) => name?.split('.').pop() || 'txt' - const getSourcePercent = (detail: IndexingStatusResponse) => { - const completedCount = detail.completed_segments || 0 - const totalCount = detail.total_segments || 0 - if (totalCount === 0) - return 0 - const percent = Math.round(completedCount * 100 / totalCount) - return percent > 100 ? 100 : percent - } - const getSourceType = (id: string) => { - const doc = documents.find(document => document.id === id) - return doc?.data_source_type as DataSourceType - } - - const isLegacyDataSourceInfo = (info: DataSourceInfo): info is LegacyDataSourceInfo => { - return info != null && typeof (info as LegacyDataSourceInfo).upload_file === 'object' - } - - const getIcon = (id: string) => { - const doc = documents.find(document => document.id === id) - const info = doc?.data_source_info - if (info && isLegacyDataSourceInfo(info)) - return info.notion_page_icon - return undefined - } - const isSourceEmbedding = (detail: IndexingStatusResponse) => - ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'].includes(detail.indexing_status || '') + const showUpgradeBanner = enableBilling && plan.type !== Plan.team return ( <>
-
- {isEmbedding && ( - <> - - {t('embedding.processing', { ns: 'datasetDocuments' })} - - )} - {isEmbeddingCompleted && t('embedding.completed', { ns: 'datasetDocuments' })} -
- { - enableBilling && plan.type !== Plan.team && ( -
-
- -
-
- {t('plansCommon.documentProcessingPriorityUpgrade', { ns: 'billing' })} -
- -
- ) - } + + + {showUpgradeBanner && } +
- {indexingStatusBatchDetail.map(indexingStatusDetail => ( -
- {isSourceEmbedding(indexingStatusDetail) && ( -
- )} -
- {getSourceType(indexingStatusDetail.id) === DataSourceType.FILE && ( - - )} - {getSourceType(indexingStatusDetail.id) === DataSourceType.NOTION && ( - - )} -
-
- {getSourceName(indexingStatusDetail.id)} -
- { - enableBilling && ( - - ) - } -
- {isSourceEmbedding(indexingStatusDetail) && ( -
{`${getSourcePercent(indexingStatusDetail)}%`}
- )} - {indexingStatusDetail.indexing_status === 'error' && ( - - - - - - )} - {indexingStatusDetail.indexing_status === 'completed' && ( - - )} -
-
+ {statusList.map(detail => ( + ))}
+ +
-
- - - - -
+ + ) } diff --git a/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx new file mode 100644 index 0000000000..b7c085cff9 --- /dev/null +++ b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx @@ -0,0 +1,120 @@ +import type { FC } from 'react' +import type { IndexingStatusResponse } from '@/models/datasets' +import { + RiCheckboxCircleFill, + RiErrorWarningFill, +} from '@remixicon/react' +import NotionIcon from '@/app/components/base/notion-icon' +import Tooltip from '@/app/components/base/tooltip' +import PriorityLabel from '@/app/components/billing/priority-label' +import { DataSourceType } from '@/models/datasets' +import { cn } from '@/utils/classnames' +import DocumentFileIcon from '../../common/document-file-icon' +import { getFileType, getSourcePercent, isSourceEmbedding } from './utils' + +type IndexingProgressItemProps = { + detail: IndexingStatusResponse + name?: string + sourceType?: DataSourceType + notionIcon?: string + enableBilling?: boolean +} + +// Status icon component for completed/error states +const StatusIcon: FC<{ status: string, error?: string }> = ({ status, error }) => { + if (status === 'completed') + return + + if (status === 'error') { + return ( + + + + + + ) + } + + return null +} + +// Source type icon component +const SourceTypeIcon: FC<{ + sourceType?: DataSourceType + name?: string + notionIcon?: string +}> = ({ sourceType, name, notionIcon }) => { + if (sourceType === DataSourceType.FILE) { + return ( + + ) + } + + if (sourceType === DataSourceType.NOTION) { + return ( + + ) + } + + return null +} + +const IndexingProgressItem: FC = ({ + detail, + name, + sourceType, + notionIcon, + enableBilling, +}) => { + const isEmbedding = isSourceEmbedding(detail) + const percent = getSourcePercent(detail) + const isError = detail.indexing_status === 'error' + + return ( +
+ {isEmbedding && ( +
+ )} +
+ +
+
+ {name} +
+ {enableBilling && } +
+ {isEmbedding && ( +
{`${percent}%`}
+ )} + +
+
+ ) +} + +export default IndexingProgressItem diff --git a/web/app/components/datasets/create/embedding-process/rule-detail.tsx b/web/app/components/datasets/create/embedding-process/rule-detail.tsx new file mode 100644 index 0000000000..dff35100cb --- /dev/null +++ b/web/app/components/datasets/create/embedding-process/rule-detail.tsx @@ -0,0 +1,133 @@ +import type { FC } from 'react' +import type { ProcessRuleResponse } from '@/models/datasets' +import Image from 'next/image' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata' +import { ProcessMode } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import { indexMethodIcon, retrievalIcon } from '../icons' +import { IndexingType } from '../step-two' + +type RuleDetailProps = { + sourceData?: ProcessRuleResponse + indexingType?: string + retrievalMethod?: RETRIEVE_METHOD +} + +// Lookup table for pre-processing rule names +const PRE_PROCESSING_RULE_KEYS = { + remove_extra_spaces: 'stepTwo.removeExtraSpaces', + remove_urls_emails: 'stepTwo.removeUrlEmails', + remove_stopwords: 'stepTwo.removeStopwords', +} as const + +// Lookup table for retrieval method icons +const RETRIEVAL_ICON_MAP: Partial> = { + [RETRIEVE_METHOD.fullText]: retrievalIcon.fullText, + [RETRIEVE_METHOD.hybrid]: retrievalIcon.hybrid, + [RETRIEVE_METHOD.semantic]: retrievalIcon.vector, + [RETRIEVE_METHOD.invertedIndex]: retrievalIcon.fullText, + [RETRIEVE_METHOD.keywordSearch]: retrievalIcon.fullText, +} + +const isNumber = (value: unknown): value is number => typeof value === 'number' + +const RuleDetail: FC = ({ sourceData, indexingType, retrievalMethod }) => { + const { t } = useTranslation() + + const segmentationRuleLabels = { + mode: t('embedding.mode', { ns: 'datasetDocuments' }), + segmentLength: t('embedding.segmentLength', { ns: 'datasetDocuments' }), + textCleaning: t('embedding.textCleaning', { ns: 'datasetDocuments' }), + } + + const getRuleName = useCallback((key: string): string | undefined => { + const translationKey = PRE_PROCESSING_RULE_KEYS[key as keyof typeof PRE_PROCESSING_RULE_KEYS] + return translationKey ? t(translationKey, { ns: 'datasetCreation' }) : undefined + }, [t]) + + const getModeValue = useCallback((): string => { + if (!sourceData?.mode) + return '-' + + if (sourceData.mode === ProcessMode.general) + return t('embedding.custom', { ns: 'datasetDocuments' }) + + const parentModeLabel = sourceData.rules?.parent_mode === 'paragraph' + ? t('parentMode.paragraph', { ns: 'dataset' }) + : t('parentMode.fullDoc', { ns: 'dataset' }) + + return `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${parentModeLabel}` + }, [sourceData, t]) + + const getSegmentLengthValue = useCallback((): string | number => { + if (!sourceData?.mode) + return '-' + + const maxTokens = isNumber(sourceData.rules?.segmentation?.max_tokens) + ? sourceData.rules.segmentation.max_tokens + : '-' + + if (sourceData.mode === ProcessMode.general) + return maxTokens + + const childMaxTokens = isNumber(sourceData.rules?.subchunk_segmentation?.max_tokens) + ? sourceData.rules.subchunk_segmentation.max_tokens + : '-' + + return `${t('embedding.parentMaxTokens', { ns: 'datasetDocuments' })} ${maxTokens}; ${t('embedding.childMaxTokens', { ns: 'datasetDocuments' })} ${childMaxTokens}` + }, [sourceData, t]) + + const getTextCleaningValue = useCallback((): string => { + if (!sourceData?.mode) + return '-' + + const enabledRules = sourceData.rules?.pre_processing_rules?.filter(rule => rule.enabled) || [] + const ruleNames = enabledRules + .map((rule) => { + const name = getRuleName(rule.id) + return typeof name === 'string' ? name : '' + }) + .filter(name => name) + return ruleNames.length > 0 ? ruleNames.join(',') : '-' + }, [sourceData, getRuleName]) + + const fieldValueGetters: Record string | number> = { + mode: getModeValue, + segmentLength: getSegmentLengthValue, + textCleaning: getTextCleaningValue, + } + + const isEconomical = indexingType === IndexingType.ECONOMICAL + const indexMethodIconSrc = isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality + const indexModeLabel = t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) + + const effectiveRetrievalMethod = isEconomical ? 'keyword_search' : (retrievalMethod ?? 'semantic_search') + const retrievalLabel = t(`retrieval.${effectiveRetrievalMethod}.title`, { ns: 'dataset' }) + const retrievalIconSrc = RETRIEVAL_ICON_MAP[retrievalMethod as keyof typeof RETRIEVAL_ICON_MAP] ?? retrievalIcon.vector + + return ( +
+ {Object.keys(segmentationRuleLabels).map(field => ( + + ))} + } + /> + } + /> +
+ ) +} + +export default RuleDetail diff --git a/web/app/components/datasets/create/embedding-process/upgrade-banner.tsx b/web/app/components/datasets/create/embedding-process/upgrade-banner.tsx new file mode 100644 index 0000000000..49e5fe99a1 --- /dev/null +++ b/web/app/components/datasets/create/embedding-process/upgrade-banner.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' +import { ZapFast } from '@/app/components/base/icons/src/vender/solid/general' +import UpgradeBtn from '@/app/components/billing/upgrade-btn' + +const UpgradeBanner: FC = () => { + const { t } = useTranslation() + + return ( +
+
+ +
+
+ {t('plansCommon.documentProcessingPriorityUpgrade', { ns: 'billing' })} +
+ +
+ ) +} + +export default UpgradeBanner diff --git a/web/app/components/datasets/create/embedding-process/use-indexing-status-polling.ts b/web/app/components/datasets/create/embedding-process/use-indexing-status-polling.ts new file mode 100644 index 0000000000..f8e69e47af --- /dev/null +++ b/web/app/components/datasets/create/embedding-process/use-indexing-status-polling.ts @@ -0,0 +1,90 @@ +import type { IndexingStatusResponse } from '@/models/datasets' +import { useEffect, useRef, useState } from 'react' +import { fetchIndexingStatusBatch } from '@/service/datasets' + +const POLLING_INTERVAL = 2500 +const COMPLETED_STATUSES = ['completed', 'error', 'paused'] as const +const EMBEDDING_STATUSES = ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'] as const + +type IndexingStatusPollingParams = { + datasetId: string + batchId: string +} + +type IndexingStatusPollingResult = { + statusList: IndexingStatusResponse[] + isEmbedding: boolean + isEmbeddingCompleted: boolean +} + +const isStatusCompleted = (status: string): boolean => + COMPLETED_STATUSES.includes(status as typeof COMPLETED_STATUSES[number]) + +const isAllCompleted = (statusList: IndexingStatusResponse[]): boolean => + statusList.every(item => isStatusCompleted(item.indexing_status)) + +/** + * Custom hook for polling indexing status with automatic stop on completion. + * Handles the polling lifecycle and provides derived states for UI rendering. + */ +export const useIndexingStatusPolling = ({ + datasetId, + batchId, +}: IndexingStatusPollingParams): IndexingStatusPollingResult => { + const [statusList, setStatusList] = useState([]) + const isStopPollingRef = useRef(false) + + useEffect(() => { + // Reset polling state on mount + isStopPollingRef.current = false + let timeoutId: ReturnType | null = null + + const fetchStatus = async (): Promise => { + const response = await fetchIndexingStatusBatch({ datasetId, batchId }) + setStatusList(response.data) + return response.data + } + + const poll = async (): Promise => { + if (isStopPollingRef.current) + return + + try { + const data = await fetchStatus() + if (isAllCompleted(data)) { + isStopPollingRef.current = true + return + } + } + catch { + // Continue polling on error + } + + if (!isStopPollingRef.current) { + timeoutId = setTimeout(() => { + poll() + }, POLLING_INTERVAL) + } + } + + poll() + + return () => { + isStopPollingRef.current = true + if (timeoutId) + clearTimeout(timeoutId) + } + }, [datasetId, batchId]) + + const isEmbedding = statusList.some(item => + EMBEDDING_STATUSES.includes(item?.indexing_status as typeof EMBEDDING_STATUSES[number]), + ) + + const isEmbeddingCompleted = statusList.length > 0 && isAllCompleted(statusList) + + return { + statusList, + isEmbedding, + isEmbeddingCompleted, + } +} diff --git a/web/app/components/datasets/create/embedding-process/utils.ts b/web/app/components/datasets/create/embedding-process/utils.ts new file mode 100644 index 0000000000..6fbefb0230 --- /dev/null +++ b/web/app/components/datasets/create/embedding-process/utils.ts @@ -0,0 +1,64 @@ +import type { + DataSourceInfo, + DataSourceType, + FullDocumentDetail, + IndexingStatusResponse, + LegacyDataSourceInfo, +} from '@/models/datasets' + +const EMBEDDING_STATUSES = ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'] as const + +/** + * Type guard for legacy data source info with upload_file property + */ +export const isLegacyDataSourceInfo = (info: DataSourceInfo): info is LegacyDataSourceInfo => { + return info != null && typeof (info as LegacyDataSourceInfo).upload_file === 'object' +} + +/** + * Check if a status indicates the source is being embedded + */ +export const isSourceEmbedding = (detail: IndexingStatusResponse): boolean => + EMBEDDING_STATUSES.includes(detail.indexing_status as typeof EMBEDDING_STATUSES[number]) + +/** + * Calculate the progress percentage for a document + */ +export const getSourcePercent = (detail: IndexingStatusResponse): number => { + const completedCount = detail.completed_segments || 0 + const totalCount = detail.total_segments || 0 + + if (totalCount === 0) + return 0 + + const percent = Math.round(completedCount * 100 / totalCount) + return Math.min(percent, 100) +} + +/** + * Get file extension from filename, defaults to 'txt' + */ +export const getFileType = (name?: string): string => + name?.split('.').pop() || 'txt' + +/** + * Document lookup utilities - provides document info by ID from a list + */ +export const createDocumentLookup = (documents: FullDocumentDetail[]) => { + const documentMap = new Map(documents.map(doc => [doc.id, doc])) + + return { + getDocument: (id: string) => documentMap.get(id), + + getName: (id: string) => documentMap.get(id)?.name, + + getSourceType: (id: string) => documentMap.get(id)?.data_source_type as DataSourceType | undefined, + + getNotionIcon: (id: string) => { + const info = documentMap.get(id)?.data_source_info + if (info && isLegacyDataSourceInfo(info)) + return info.notion_page_icon + return undefined + }, + } +} From 7843afc91cc133c06b0b1fc057733b2ba81b9270 Mon Sep 17 00:00:00 2001 From: Maries Date: Fri, 9 Jan 2026 13:55:49 +0800 Subject: [PATCH 07/22] feat(workflows): add agent-dev deploy workflow (#30774) --- .../{deploy-trigger-dev.yml => deploy-agent-dev.yml} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename .github/workflows/{deploy-trigger-dev.yml => deploy-agent-dev.yml} (75%) diff --git a/.github/workflows/deploy-trigger-dev.yml b/.github/workflows/deploy-agent-dev.yml similarity index 75% rename from .github/workflows/deploy-trigger-dev.yml rename to .github/workflows/deploy-agent-dev.yml index 2d9a904fc5..dff48b5510 100644 --- a/.github/workflows/deploy-trigger-dev.yml +++ b/.github/workflows/deploy-agent-dev.yml @@ -1,4 +1,4 @@ -name: Deploy Trigger Dev +name: Deploy Agent Dev permissions: contents: read @@ -7,7 +7,7 @@ on: workflow_run: workflows: ["Build and Push API & Web"] branches: - - "deploy/trigger-dev" + - "deploy/agent-dev" types: - completed @@ -16,12 +16,12 @@ jobs: runs-on: ubuntu-latest if: | github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'deploy/trigger-dev' + github.event.workflow_run.head_branch == 'deploy/agent-dev' steps: - name: Deploy to server uses: appleboy/ssh-action@v0.1.8 with: - host: ${{ secrets.TRIGGER_SSH_HOST }} + host: ${{ secrets.AGENT_DEV_SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | From 77f097ce762455b33fc430047446368e2c597f9a Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Fri, 9 Jan 2026 14:07:40 +0800 Subject: [PATCH 08/22] fix: fix enhance app mode check (#30758) --- web/app/components/apps/list.spec.tsx | 1 + web/app/components/apps/list.tsx | 40 +++++++++++++++++++++++++++ web/service/use-apps.ts | 17 ++++++++++-- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/web/app/components/apps/list.spec.tsx b/web/app/components/apps/list.spec.tsx index e5854f68b4..07c30cd588 100644 --- a/web/app/components/apps/list.spec.tsx +++ b/web/app/components/apps/list.spec.tsx @@ -10,6 +10,7 @@ const mockReplace = vi.fn() const mockRouter = { replace: mockReplace } vi.mock('next/navigation', () => ({ useRouter: () => mockRouter, + useSearchParams: () => new URLSearchParams(''), })) // Mock app context diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 290a73fc7c..e5c9954626 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -12,6 +12,7 @@ import { useDebounceFn } from 'ahooks' import dynamic from 'next/dynamic' import { useRouter, + useSearchParams, } from 'next/navigation' import { parseAsString, useQueryState } from 'nuqs' import { useCallback, useEffect, useRef, useState } from 'react' @@ -36,6 +37,16 @@ import useAppsQueryState from './hooks/use-apps-query-state' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' import NewAppCard from './new-app-card' +// Define valid tabs at module scope to avoid re-creation on each render and stale closures +const validTabs = new Set([ + 'all', + AppModeEnum.WORKFLOW, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.CHAT, + AppModeEnum.AGENT_CHAT, + AppModeEnum.COMPLETION, +]) + const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { ssr: false, }) @@ -47,12 +58,41 @@ const List = () => { const { t } = useTranslation() const { systemFeatures } = useGlobalPublicStore() const router = useRouter() + const searchParams = useSearchParams() const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const [activeTab, setActiveTab] = useQueryState( 'category', parseAsString.withDefault('all').withOptions({ history: 'push' }), ) + + // valid tabs for apps list; anything else should fallback to 'all' + + // 1) Normalize legacy/incorrect query params like ?mode=discover -> ?category=all + useEffect(() => { + // avoid running on server + if (typeof window === 'undefined') + return + const mode = searchParams.get('mode') + if (!mode) + return + const url = new URL(window.location.href) + url.searchParams.delete('mode') + if (validTabs.has(mode)) { + // migrate to category key + url.searchParams.set('category', mode) + } + else { + url.searchParams.set('category', 'all') + } + router.replace(url.pathname + url.search) + }, [router, searchParams]) + + // 2) If category has an invalid value (e.g., 'discover'), reset to 'all' + useEffect(() => { + if (!validTabs.has(activeTab)) + setActiveTab('all') + }, [activeTab, setActiveTab]) const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState() const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe) const [tagFilterValue, setTagFilterValue] = useState(tagIDs) diff --git a/web/service/use-apps.ts b/web/service/use-apps.ts index d16d44af20..74e7662492 100644 --- a/web/service/use-apps.ts +++ b/web/service/use-apps.ts @@ -10,13 +10,14 @@ import type { AppVoicesListResponse, WorkflowDailyConversationsResponse, } from '@/models/app' -import type { App, AppModeEnum } from '@/types/app' +import type { App } from '@/types/app' import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient, } from '@tanstack/react-query' +import { AppModeEnum } from '@/types/app' import { get, post } from './base' import { useInvalid } from './use-base' @@ -36,6 +37,16 @@ type DateRangeParams = { end?: string } +// Allowed app modes for filtering; defined at module scope to avoid re-creating on every call +const allowedModes = new Set([ + 'all', + AppModeEnum.WORKFLOW, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.CHAT, + AppModeEnum.AGENT_CHAT, + AppModeEnum.COMPLETION, +]) + const normalizeAppListParams = (params: AppListParams) => { const { page = 1, @@ -46,11 +57,13 @@ const normalizeAppListParams = (params: AppListParams) => { is_created_by_me, } = params + const safeMode = allowedModes.has((mode as any)) ? mode : undefined + return { page, limit, name, - ...(mode && mode !== 'all' ? { mode } : {}), + ...(safeMode && safeMode !== 'all' ? { mode: safeMode } : {}), ...(tag_ids?.length ? { tag_ids } : {}), ...(is_created_by_me ? { is_created_by_me } : {}), } From 9d9f027246ff41901d5c46fb2c50beee28ea338d Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Fri, 9 Jan 2026 14:08:37 +0800 Subject: [PATCH 09/22] fix(web): invalidate app list cache after deleting app from detail page (#30751) Signed-off-by: majiayu000 <1835304752@qq.com> --- web/app/components/app-sidebar/app-info.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index cbd1640295..255feaccdf 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -26,6 +26,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' +import { useInvalidateAppList } from '@/service/use-apps' import { fetchWorkflowDraft } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' @@ -66,6 +67,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx const { onPlanInfoChanged } = useProviderContext() const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) + const invalidateAppList = useInvalidateAppList() const [open, setOpen] = useState(openState) const [showEditModal, setShowEditModal] = useState(false) const [showDuplicateModal, setShowDuplicateModal] = useState(false) @@ -191,6 +193,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx try { await deleteApp(appDetail.id) notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) }) + invalidateAppList() onPlanInfoChanged() setAppDetail() replace('/apps') @@ -202,7 +205,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx }) } setShowConfirmDelete(false) - }, [appDetail, notify, onPlanInfoChanged, replace, setAppDetail, t]) + }, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t]) const { isCurrentWorkspaceEditor } = useAppContext() From d4432ed80fa2d8bdbda08087d8a31fd50fcc7166 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:31:24 +0800 Subject: [PATCH 10/22] refactor: marketplace state management (#30702) Co-authored-by: Claude Opus 4.5 --- web/app/(commonLayout)/plugins/page.tsx | 4 +- .../components/plugins/marketplace/atoms.ts | 81 + .../plugins/marketplace/constants.ts | 24 + .../plugins/marketplace/context.tsx | 332 ---- .../components/plugins/marketplace/hooks.ts | 7 +- .../plugins/marketplace/hydration-client.tsx | 15 + .../plugins/marketplace/hydration-server.tsx | 45 + .../plugins/marketplace/index.spec.tsx | 1339 +---------------- .../components/plugins/marketplace/index.tsx | 58 +- .../plugins/marketplace/list/index.spec.tsx | 423 ++---- .../plugins/marketplace/list/index.tsx | 3 - .../marketplace/list/list-with-collection.tsx | 9 +- .../plugins/marketplace/list/list-wrapper.tsx | 43 +- .../marketplace/plugin-type-switch.tsx | 49 +- .../components/plugins/marketplace/query.ts | 38 + .../marketplace/search-box/index.spec.tsx | 42 +- .../search-box/search-box-wrapper.tsx | 8 +- .../plugins/marketplace/search-params.ts | 9 + .../marketplace/sort-dropdown/index.spec.tsx | 15 +- .../marketplace/sort-dropdown/index.tsx | 5 +- .../components/plugins/marketplace/state.ts | 54 + .../sticky-search-and-switch-wrapper.tsx | 6 +- .../components/plugins/marketplace/utils.ts | 81 +- .../components/plugins/plugin-page/index.tsx | 2 +- .../auto-update-setting/index.spec.tsx | 2 +- .../auto-update-setting/tool-picker.tsx | 5 +- web/context/query-client-server.ts | 16 + web/context/query-client.tsx | 28 +- web/hooks/use-query-params.spec.tsx | 169 --- web/hooks/use-query-params.ts | 34 - 30 files changed, 578 insertions(+), 2368 deletions(-) create mode 100644 web/app/components/plugins/marketplace/atoms.ts delete mode 100644 web/app/components/plugins/marketplace/context.tsx create mode 100644 web/app/components/plugins/marketplace/hydration-client.tsx create mode 100644 web/app/components/plugins/marketplace/hydration-server.tsx create mode 100644 web/app/components/plugins/marketplace/query.ts create mode 100644 web/app/components/plugins/marketplace/search-params.ts create mode 100644 web/app/components/plugins/marketplace/state.ts create mode 100644 web/context/query-client-server.ts diff --git a/web/app/(commonLayout)/plugins/page.tsx b/web/app/(commonLayout)/plugins/page.tsx index 81bda3a8a3..f366200cf9 100644 --- a/web/app/(commonLayout)/plugins/page.tsx +++ b/web/app/(commonLayout)/plugins/page.tsx @@ -2,11 +2,11 @@ import Marketplace from '@/app/components/plugins/marketplace' import PluginPage from '@/app/components/plugins/plugin-page' import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel' -const PluginList = async () => { +const PluginList = () => { return ( } - marketplace={} + marketplace={} /> ) } diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts new file mode 100644 index 0000000000..6ca9bd1c05 --- /dev/null +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -0,0 +1,81 @@ +import type { ActivePluginType } from './constants' +import type { PluginsSort, SearchParamsFromCollection } from './types' +import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' +import { useQueryState } from 'nuqs' +import { useCallback } from 'react' +import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' +import { marketplaceSearchParamsParsers } from './search-params' + +const marketplaceSortAtom = atom(DEFAULT_SORT) +export function useMarketplaceSort() { + return useAtom(marketplaceSortAtom) +} +export function useMarketplaceSortValue() { + return useAtomValue(marketplaceSortAtom) +} +export function useSetMarketplaceSort() { + return useSetAtom(marketplaceSortAtom) +} + +/** + * Preserve the state for marketplace + */ +export const preserveSearchStateInQueryAtom = atom(false) + +const searchPluginTextAtom = atom('') +const activePluginTypeAtom = atom('all') +const filterPluginTagsAtom = atom([]) + +export function useSearchPluginText() { + const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom) + const queryState = useQueryState('q', marketplaceSearchParamsParsers.q) + const atomState = useAtom(searchPluginTextAtom) + return preserveSearchStateInQuery ? queryState : atomState +} +export function useActivePluginType() { + const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom) + const queryState = useQueryState('category', marketplaceSearchParamsParsers.category) + const atomState = useAtom(activePluginTypeAtom) + return preserveSearchStateInQuery ? queryState : atomState +} +export function useFilterPluginTags() { + const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom) + const queryState = useQueryState('tags', marketplaceSearchParamsParsers.tags) + const atomState = useAtom(filterPluginTagsAtom) + return preserveSearchStateInQuery ? queryState : atomState +} + +/** + * Not all categories have collections, so we need to + * force the search mode for those categories. + */ +export const searchModeAtom = atom(null) + +export function useMarketplaceSearchMode() { + const [searchPluginText] = useSearchPluginText() + const [filterPluginTags] = useFilterPluginTags() + const [activePluginType] = useActivePluginType() + + const searchMode = useAtomValue(searchModeAtom) + const isSearchMode = !!searchPluginText + || filterPluginTags.length > 0 + || (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType))) + return isSearchMode +} + +export function useMarketplaceMoreClick() { + const [,setQ] = useSearchPluginText() + const setSort = useSetAtom(marketplaceSortAtom) + const setSearchMode = useSetAtom(searchModeAtom) + + return useCallback((searchParams?: SearchParamsFromCollection) => { + if (!searchParams) + return + setQ(searchParams?.query || '') + setSort({ + sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, + sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, + }) + setSearchMode(true) + }, [setQ, setSort, setSearchMode]) +} diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts index 92c3e7278f..6613fbe3de 100644 --- a/web/app/components/plugins/marketplace/constants.ts +++ b/web/app/components/plugins/marketplace/constants.ts @@ -1,6 +1,30 @@ +import { PluginCategoryEnum } from '../types' + export const DEFAULT_SORT = { sortBy: 'install_count', sortOrder: 'DESC', } export const SCROLL_BOTTOM_THRESHOLD = 100 + +export const PLUGIN_TYPE_SEARCH_MAP = { + all: 'all', + model: PluginCategoryEnum.model, + tool: PluginCategoryEnum.tool, + agent: PluginCategoryEnum.agent, + extension: PluginCategoryEnum.extension, + datasource: PluginCategoryEnum.datasource, + trigger: PluginCategoryEnum.trigger, + bundle: 'bundle', +} as const + +type ValueOf = T[keyof T] + +export type ActivePluginType = ValueOf + +export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set( + [ + PLUGIN_TYPE_SEARCH_MAP.all, + PLUGIN_TYPE_SEARCH_MAP.tool, + ], +) diff --git a/web/app/components/plugins/marketplace/context.tsx b/web/app/components/plugins/marketplace/context.tsx deleted file mode 100644 index 31b6a7f592..0000000000 --- a/web/app/components/plugins/marketplace/context.tsx +++ /dev/null @@ -1,332 +0,0 @@ -'use client' - -import type { - ReactNode, -} from 'react' -import type { TagKey } from '../constants' -import type { Plugin } from '../types' -import type { - MarketplaceCollection, - PluginsSort, - SearchParams, - SearchParamsFromCollection, -} from './types' -import { debounce } from 'es-toolkit/compat' -import { noop } from 'es-toolkit/function' -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' -import { - createContext, - useContextSelector, -} from 'use-context-selector' -import { useMarketplaceFilters } from '@/hooks/use-query-params' -import { useInstalledPluginList } from '@/service/use-plugins' -import { - getValidCategoryKeys, - getValidTagKeys, -} from '../utils' -import { DEFAULT_SORT } from './constants' -import { - useMarketplaceCollectionsAndPlugins, - useMarketplaceContainerScroll, - useMarketplacePlugins, -} from './hooks' -import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' -import { - getMarketplaceListCondition, - getMarketplaceListFilterType, -} from './utils' - -export type MarketplaceContextValue = { - searchPluginText: string - handleSearchPluginTextChange: (text: string) => void - filterPluginTags: string[] - handleFilterPluginTagsChange: (tags: string[]) => void - activePluginType: string - handleActivePluginTypeChange: (type: string) => void - page: number - handlePageChange: () => void - plugins?: Plugin[] - pluginsTotal?: number - resetPlugins: () => void - sort: PluginsSort - handleSortChange: (sort: PluginsSort) => void - handleQueryPlugins: () => void - handleMoreClick: (searchParams: SearchParamsFromCollection) => void - marketplaceCollectionsFromClient?: MarketplaceCollection[] - setMarketplaceCollectionsFromClient: (collections: MarketplaceCollection[]) => void - marketplaceCollectionPluginsMapFromClient?: Record - setMarketplaceCollectionPluginsMapFromClient: (map: Record) => void - isLoading: boolean - isSuccessCollections: boolean -} - -export const MarketplaceContext = createContext({ - searchPluginText: '', - handleSearchPluginTextChange: noop, - filterPluginTags: [], - handleFilterPluginTagsChange: noop, - activePluginType: 'all', - handleActivePluginTypeChange: noop, - page: 1, - handlePageChange: noop, - plugins: undefined, - pluginsTotal: 0, - resetPlugins: noop, - sort: DEFAULT_SORT, - handleSortChange: noop, - handleQueryPlugins: noop, - handleMoreClick: noop, - marketplaceCollectionsFromClient: [], - setMarketplaceCollectionsFromClient: noop, - marketplaceCollectionPluginsMapFromClient: {}, - setMarketplaceCollectionPluginsMapFromClient: noop, - isLoading: false, - isSuccessCollections: false, -}) - -type MarketplaceContextProviderProps = { - children: ReactNode - searchParams?: SearchParams - shouldExclude?: boolean - scrollContainerId?: string - showSearchParams?: boolean -} - -export function useMarketplaceContext(selector: (value: MarketplaceContextValue) => any) { - return useContextSelector(MarketplaceContext, selector) -} - -export const MarketplaceContextProvider = ({ - children, - searchParams, - shouldExclude, - scrollContainerId, - showSearchParams, -}: MarketplaceContextProviderProps) => { - // Use nuqs hook for URL-based filter state - const [urlFilters, setUrlFilters] = useMarketplaceFilters() - - const { data, isSuccess } = useInstalledPluginList(!shouldExclude) - const exclude = useMemo(() => { - if (shouldExclude) - return data?.plugins.map(plugin => plugin.plugin_id) - }, [data?.plugins, shouldExclude]) - - // Initialize from URL params (legacy support) or use nuqs state - const queryFromSearchParams = searchParams?.q || urlFilters.q - const tagsFromSearchParams = getValidTagKeys(urlFilters.tags as TagKey[]) - const hasValidTags = !!tagsFromSearchParams.length - const hasValidCategory = getValidCategoryKeys(urlFilters.category) - const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all - - const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams) - const searchPluginTextRef = useRef(searchPluginText) - const [filterPluginTags, setFilterPluginTags] = useState(tagsFromSearchParams) - const filterPluginTagsRef = useRef(filterPluginTags) - const [activePluginType, setActivePluginType] = useState(categoryFromSearchParams) - const activePluginTypeRef = useRef(activePluginType) - const [sort, setSort] = useState(DEFAULT_SORT) - const sortRef = useRef(sort) - const { - marketplaceCollections: marketplaceCollectionsFromClient, - setMarketplaceCollections: setMarketplaceCollectionsFromClient, - marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapFromClient, - setMarketplaceCollectionPluginsMap: setMarketplaceCollectionPluginsMapFromClient, - queryMarketplaceCollectionsAndPlugins, - isLoading, - isSuccess: isSuccessCollections, - } = useMarketplaceCollectionsAndPlugins() - const { - plugins, - total: pluginsTotal, - resetPlugins, - queryPlugins, - queryPluginsWithDebounced, - cancelQueryPluginsWithDebounced, - isLoading: isPluginsLoading, - fetchNextPage: fetchNextPluginsPage, - hasNextPage: hasNextPluginsPage, - page: pluginsPage, - } = useMarketplacePlugins() - const page = Math.max(pluginsPage || 0, 1) - - useEffect(() => { - if (queryFromSearchParams || hasValidTags || hasValidCategory) { - queryPlugins({ - query: queryFromSearchParams, - category: hasValidCategory, - tags: hasValidTags ? tagsFromSearchParams : [], - sortBy: sortRef.current.sortBy, - sortOrder: sortRef.current.sortOrder, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - else { - if (shouldExclude && isSuccess) { - queryMarketplaceCollectionsAndPlugins({ - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - } - }, [queryPlugins, queryMarketplaceCollectionsAndPlugins, isSuccess, exclude]) - - const handleQueryMarketplaceCollectionsAndPlugins = useCallback(() => { - queryMarketplaceCollectionsAndPlugins({ - category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current, - condition: getMarketplaceListCondition(activePluginTypeRef.current), - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - resetPlugins() - }, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins]) - - const applyUrlFilters = useCallback(() => { - if (!showSearchParams) - return - const nextFilters = { - q: searchPluginTextRef.current, - category: activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - } - const categoryChanged = urlFilters.category !== nextFilters.category - setUrlFilters(nextFilters, { - history: categoryChanged ? 'push' : 'replace', - }) - }, [setUrlFilters, showSearchParams, urlFilters.category]) - - const debouncedUpdateSearchParams = useMemo(() => debounce(() => { - applyUrlFilters() - }, 500), [applyUrlFilters]) - - const handleUpdateSearchParams = useCallback((debounced?: boolean) => { - if (debounced) { - debouncedUpdateSearchParams() - } - else { - applyUrlFilters() - } - }, [applyUrlFilters, debouncedUpdateSearchParams]) - - const handleQueryPlugins = useCallback((debounced?: boolean) => { - handleUpdateSearchParams(debounced) - if (debounced) { - queryPluginsWithDebounced({ - query: searchPluginTextRef.current, - category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - sortBy: sortRef.current.sortBy, - sortOrder: sortRef.current.sortOrder, - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - else { - queryPlugins({ - query: searchPluginTextRef.current, - category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - sortBy: sortRef.current.sortBy, - sortOrder: sortRef.current.sortOrder, - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - }, [exclude, queryPluginsWithDebounced, queryPlugins, handleUpdateSearchParams]) - - const handleQuery = useCallback((debounced?: boolean) => { - if (!searchPluginTextRef.current && !filterPluginTagsRef.current.length) { - handleUpdateSearchParams(debounced) - cancelQueryPluginsWithDebounced() - handleQueryMarketplaceCollectionsAndPlugins() - return - } - - handleQueryPlugins(debounced) - }, [handleQueryMarketplaceCollectionsAndPlugins, handleQueryPlugins, cancelQueryPluginsWithDebounced, handleUpdateSearchParams]) - - const handleSearchPluginTextChange = useCallback((text: string) => { - setSearchPluginText(text) - searchPluginTextRef.current = text - - handleQuery(true) - }, [handleQuery]) - - const handleFilterPluginTagsChange = useCallback((tags: string[]) => { - setFilterPluginTags(tags) - filterPluginTagsRef.current = tags - - handleQuery() - }, [handleQuery]) - - const handleActivePluginTypeChange = useCallback((type: string) => { - setActivePluginType(type) - activePluginTypeRef.current = type - - handleQuery() - }, [handleQuery]) - - const handleSortChange = useCallback((sort: PluginsSort) => { - setSort(sort) - sortRef.current = sort - - handleQueryPlugins() - }, [handleQueryPlugins]) - - const handlePageChange = useCallback(() => { - if (hasNextPluginsPage) - fetchNextPluginsPage() - }, [fetchNextPluginsPage, hasNextPluginsPage]) - - const handleMoreClick = useCallback((searchParams: SearchParamsFromCollection) => { - setSearchPluginText(searchParams?.query || '') - searchPluginTextRef.current = searchParams?.query || '' - setSort({ - sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, - sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, - }) - sortRef.current = { - sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, - sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, - } - handleQueryPlugins() - }, [handleQueryPlugins]) - - useMarketplaceContainerScroll(handlePageChange, scrollContainerId) - - return ( - - {children} - - ) -} diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 11558e8c96..b1e4f50767 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -26,6 +26,9 @@ import { getMarketplacePluginsByCollectionId, } from './utils' +/** + * @deprecated Use useMarketplaceCollectionsAndPlugins from query.ts instead + */ export const useMarketplaceCollectionsAndPlugins = () => { const [queryParams, setQueryParams] = useState() const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState() @@ -89,7 +92,9 @@ export const useMarketplacePluginsByCollectionId = ( isSuccess, } } - +/** + * @deprecated Use useMarketplacePlugins from query.ts instead + */ export const useMarketplacePlugins = () => { const queryClient = useQueryClient() const [queryParams, setQueryParams] = useState() diff --git a/web/app/components/plugins/marketplace/hydration-client.tsx b/web/app/components/plugins/marketplace/hydration-client.tsx new file mode 100644 index 0000000000..5698db711f --- /dev/null +++ b/web/app/components/plugins/marketplace/hydration-client.tsx @@ -0,0 +1,15 @@ +'use client' + +import { useHydrateAtoms } from 'jotai/utils' +import { preserveSearchStateInQueryAtom } from './atoms' + +export function HydrateMarketplaceAtoms({ + preserveSearchStateInQuery, + children, +}: { + preserveSearchStateInQuery: boolean + children: React.ReactNode +}) { + useHydrateAtoms([[preserveSearchStateInQueryAtom, preserveSearchStateInQuery]]) + return <>{children} +} diff --git a/web/app/components/plugins/marketplace/hydration-server.tsx b/web/app/components/plugins/marketplace/hydration-server.tsx new file mode 100644 index 0000000000..0aa544cff1 --- /dev/null +++ b/web/app/components/plugins/marketplace/hydration-server.tsx @@ -0,0 +1,45 @@ +import type { SearchParams } from 'nuqs' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' +import { createLoader } from 'nuqs/server' +import { getQueryClientServer } from '@/context/query-client-server' +import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' +import { marketplaceKeys } from './query' +import { marketplaceSearchParamsParsers } from './search-params' +import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' + +// The server side logic should move to marketplace's codebase so that we can get rid of Next.js + +async function getDehydratedState(searchParams?: Promise) { + if (!searchParams) { + return + } + const loadSearchParams = createLoader(marketplaceSearchParamsParsers) + const params = await loadSearchParams(searchParams) + + if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) { + return + } + + const queryClient = getQueryClientServer() + + await queryClient.prefetchQuery({ + queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)), + queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), + }) + return dehydrate(queryClient) +} + +export async function HydrateQueryClient({ + searchParams, + children, +}: { + searchParams: Promise | undefined + children: React.ReactNode +}) { + const dehydratedState = await getDehydratedState(searchParams) + return ( + + {children} + + ) +} diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx index b3b1d58dd4..1a3cd15b6b 100644 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ b/web/app/components/plugins/marketplace/index.spec.tsx @@ -1,6 +1,6 @@ -import type { MarketplaceCollection, SearchParams, SearchParamsFromCollection } from './types' +import type { MarketplaceCollection } from './types' import type { Plugin } from '@/app/components/plugins/types' -import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { act, render, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '@/app/components/plugins/types' @@ -9,10 +9,7 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types' // ================================ // Note: Import after mocks are set up -import { DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD } from './constants' -import { MarketplaceContext, MarketplaceContextProvider, useMarketplaceContext } from './context' -import PluginTypeSwitch, { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' -import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' +import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' import { getFormattedPlugin, getMarketplaceListCondition, @@ -62,7 +59,7 @@ vi.mock('@/service/use-plugins', () => ({ // Mock tanstack query const mockFetchNextPage = vi.fn() -let mockHasNextPage = false +const mockHasNextPage = false let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, pageSize: number }> } | undefined let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise) | null = null let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise) | null = null @@ -176,7 +173,7 @@ vi.mock('@/i18n-config/server', () => ({ })) // Mock useTheme hook -let mockTheme = 'light' +const mockTheme = 'light' vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: mockTheme, @@ -367,47 +364,6 @@ const createMockCollection = (overrides?: Partial): Marke ...overrides, }) -// ================================ -// Shared Test Components -// ================================ - -// Search input test component - used in multiple tests -const SearchInputTestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - - return ( -
- handleChange(e.target.value)} - /> -
{searchText}
-
- ) -} - -// Plugin type change test component -const PluginTypeChangeTestComponent = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - return ( - - ) -} - -// Page change test component -const PageChangeTestComponent = () => { - const handlePageChange = useMarketplaceContext(v => v.handlePageChange) - return ( - - ) -} - // ================================ // Constants Tests // ================================ @@ -490,7 +446,7 @@ describe('utils', () => { org: 'test-org', name: 'test-plugin', tags: [{ name: 'search' }], - } + } as unknown as Plugin const formatted = getFormattedPlugin(rawPlugin) @@ -504,7 +460,7 @@ describe('utils', () => { name: 'test-bundle', description: 'Bundle description', labels: { 'en-US': 'Test Bundle' }, - } + } as unknown as Plugin const formatted = getFormattedPlugin(rawBundle) @@ -1514,955 +1470,6 @@ describe('flatMap Coverage', () => { }) }) -// ================================ -// Context Tests -// ================================ -describe('MarketplaceContext', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('MarketplaceContext default values', () => { - it('should have correct default context values', () => { - expect(MarketplaceContext).toBeDefined() - }) - }) - - describe('useMarketplaceContext', () => { - it('should return selected value from context', () => { - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText}
- } - - render( - - - , - ) - - expect(screen.getByTestId('search-text')).toHaveTextContent('') - }) - }) - - describe('MarketplaceContextProvider', () => { - it('should render children', () => { - render( - -
Test Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - }) - - it('should initialize with default values', () => { - // Reset mock data before this test - mockInfiniteQueryData = undefined - - const TestComponent = () => { - const activePluginType = useMarketplaceContext(v => v.activePluginType) - const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) - const sort = useMarketplaceContext(v => v.sort) - const page = useMarketplaceContext(v => v.page) - - return ( -
-
{activePluginType}
-
{filterPluginTags.join(',')}
-
{sort.sortBy}
-
{page}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - expect(screen.getByTestId('tags')).toHaveTextContent('') - expect(screen.getByTestId('sort')).toHaveTextContent('install_count') - // Page depends on mock data, could be 0 or 1 depending on query state - expect(screen.getByTestId('page')).toBeInTheDocument() - }) - - it('should initialize with searchParams from props', () => { - const searchParams: SearchParams = { - q: 'test query', - category: 'tool', - } - - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText}
- } - - render( - - - , - ) - - expect(screen.getByTestId('search')).toHaveTextContent('test query') - }) - - it('should provide handleSearchPluginTextChange function', () => { - render( - - - , - ) - - const input = screen.getByTestId('search-input') - fireEvent.change(input, { target: { value: 'new search' } }) - - expect(screen.getByTestId('search-display')).toHaveTextContent('new search') - }) - - it('should provide handleFilterPluginTagsChange function', () => { - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) - - return ( -
- -
{tags.join(',')}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('add-tag')) - - expect(screen.getByTestId('tags-display')).toHaveTextContent('search,image') - }) - - it('should provide handleActivePluginTypeChange function', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-type')) - - expect(screen.getByTestId('type-display')).toHaveTextContent('tool') - }) - - it('should provide handleSortChange function', () => { - const TestComponent = () => { - const sort = useMarketplaceContext(v => v.sort) - const handleChange = useMarketplaceContext(v => v.handleSortChange) - - return ( -
- -
{`${sort.sortBy}-${sort.sortOrder}`}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-sort')) - - expect(screen.getByTestId('sort-display')).toHaveTextContent('created_at-ASC') - }) - - it('should provide handleMoreClick function', () => { - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const sort = useMarketplaceContext(v => v.sort) - const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick) - - const searchParams: SearchParamsFromCollection = { - query: 'more query', - sort_by: 'version_updated_at', - sort_order: 'DESC', - } - - return ( -
- -
{searchText}
-
{`${sort.sortBy}-${sort.sortOrder}`}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('more-click')) - - expect(screen.getByTestId('search-display')).toHaveTextContent('more query') - expect(screen.getByTestId('sort-display')).toHaveTextContent('version_updated_at-DESC') - }) - - it('should provide resetPlugins function', () => { - const TestComponent = () => { - const resetPlugins = useMarketplaceContext(v => v.resetPlugins) - const plugins = useMarketplaceContext(v => v.plugins) - - return ( -
- -
{plugins ? 'has plugins' : 'no plugins'}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('reset-plugins')) - - // Plugins should remain undefined after reset - expect(screen.getByTestId('plugins-display')).toHaveTextContent('no plugins') - }) - - it('should accept shouldExclude prop', () => { - const TestComponent = () => { - const isLoading = useMarketplaceContext(v => v.isLoading) - return
{isLoading.toString()}
- } - - render( - - - , - ) - - expect(screen.getByTestId('loading')).toBeInTheDocument() - }) - - it('should accept scrollContainerId prop', () => { - render( - -
Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - }) - - it('should accept showSearchParams prop', () => { - render( - -
Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - }) - }) -}) - -// ================================ -// PluginTypeSwitch Tests -// ================================ -describe('PluginTypeSwitch', () => { - // Mock context values for PluginTypeSwitch - const mockContextValues = { - activePluginType: 'all', - handleActivePluginTypeChange: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - mockContextValues.activePluginType = 'all' - mockContextValues.handleActivePluginTypeChange = vi.fn() - - vi.doMock('./context', () => ({ - useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), - })) - }) - - // Note: PluginTypeSwitch uses internal context, so we test within the provider - describe('Rendering', () => { - it('should render without crashing', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
-
handleChange('all')} - data-testid="all-option" - > - All -
-
handleChange('tool')} - data-testid="tool-option" - > - Tools -
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('all-option')).toBeInTheDocument() - expect(screen.getByTestId('tool-option')).toBeInTheDocument() - }) - - it('should highlight active plugin type', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
-
handleChange('all')} - data-testid="all-option" - > - All -
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('all-option')).toHaveClass('active') - }) - }) - - describe('User Interactions', () => { - it('should call handleActivePluginTypeChange when option is clicked', () => { - const TestComponent = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - const activeType = useMarketplaceContext(v => v.activePluginType) - - return ( -
-
handleChange('tool')} - data-testid="tool-option" - > - Tools -
-
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('tool-option')) - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') - }) - - it('should update active type when different option is selected', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
-
handleChange('model')} - data-testid="model-option" - > - Models -
-
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('model-option')) - - expect(screen.getByTestId('active-display')).toHaveTextContent('model') - }) - }) - - describe('Props', () => { - it('should accept locale prop', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return
{activeType}
- } - - render( - - - , - ) - - expect(screen.getByTestId('type')).toBeInTheDocument() - }) - - it('should accept className prop', () => { - const { container } = render( - -
- Content -
-
, - ) - - expect(container.querySelector('.custom-class')).toBeInTheDocument() - }) - }) -}) - -// ================================ -// StickySearchAndSwitchWrapper Tests -// ================================ -describe('StickySearchAndSwitchWrapper', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - const { container } = render( - - - , - ) - - expect(container.firstChild).toBeInTheDocument() - }) - - it('should apply default styling', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.mt-4.bg-background-body') - expect(wrapper).toBeInTheDocument() - }) - - it('should apply sticky positioning when pluginTypeSwitchClassName contains top-', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.sticky.z-10') - expect(wrapper).toBeInTheDocument() - }) - - it('should not apply sticky positioning without top- class', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.sticky') - expect(wrapper).toBeNull() - }) - }) - - describe('Props', () => { - it('should accept showSearchParams prop', () => { - render( - - - , - ) - - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() - }) - - it('should pass pluginTypeSwitchClassName to wrapper', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.top-16.custom-style') - expect(wrapper).toBeInTheDocument() - }) - }) -}) - -// ================================ -// Integration Tests -// ================================ -describe('Marketplace Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - mockTheme = 'light' - }) - - describe('Context with child components', () => { - it('should share state between multiple consumers', () => { - const SearchDisplay = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText || 'empty'}
- } - - const SearchInput = () => { - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - return ( - handleChange(e.target.value)} - /> - ) - } - - render( - - - - , - ) - - expect(screen.getByTestId('search-display')).toHaveTextContent('empty') - - fireEvent.change(screen.getByTestId('search-input'), { target: { value: 'test' } }) - - expect(screen.getByTestId('search-display')).toHaveTextContent('test') - }) - - it('should update tags and reset plugins when search criteria changes', () => { - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) - const resetPlugins = useMarketplaceContext(v => v.resetPlugins) - - const handleAddTag = () => { - handleTagsChange(['search']) - } - - const handleReset = () => { - handleTagsChange([]) - resetPlugins() - } - - return ( -
- - -
{tags.join(',') || 'none'}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('tags')).toHaveTextContent('none') - - fireEvent.click(screen.getByTestId('add-tag')) - expect(screen.getByTestId('tags')).toHaveTextContent('search') - - fireEvent.click(screen.getByTestId('reset')) - expect(screen.getByTestId('tags')).toHaveTextContent('none') - }) - }) - - describe('Sort functionality', () => { - it('should update sort and trigger query', () => { - const TestComponent = () => { - const sort = useMarketplaceContext(v => v.sort) - const handleSortChange = useMarketplaceContext(v => v.handleSortChange) - - return ( -
- - -
{sort.sortBy}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('current-sort')).toHaveTextContent('install_count') - - fireEvent.click(screen.getByTestId('sort-recent')) - expect(screen.getByTestId('current-sort')).toHaveTextContent('version_updated_at') - - fireEvent.click(screen.getByTestId('sort-popular')) - expect(screen.getByTestId('current-sort')).toHaveTextContent('install_count') - }) - }) - - describe('Plugin type switching', () => { - it('should filter by plugin type', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleTypeChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
- {Object.entries(PLUGIN_TYPE_SEARCH_MAP).map(([key, value]) => ( - - ))} -
{activeType}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - - fireEvent.click(screen.getByTestId('type-tool')) - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') - - fireEvent.click(screen.getByTestId('type-model')) - expect(screen.getByTestId('active-type')).toHaveTextContent('model') - - fireEvent.click(screen.getByTestId('type-bundle')) - expect(screen.getByTestId('active-type')).toHaveTextContent('bundle') - }) - }) -}) - -// ================================ -// Edge Cases Tests -// ================================ -describe('Edge Cases', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('Empty states', () => { - it('should handle empty search text', () => { - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText || 'empty'}
- } - - render( - - - , - ) - - expect(screen.getByTestId('search')).toHaveTextContent('empty') - }) - - it('should handle empty tags array', () => { - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - return
{tags.length === 0 ? 'no tags' : tags.join(',')}
- } - - render( - - - , - ) - - expect(screen.getByTestId('tags')).toHaveTextContent('no tags') - }) - - it('should handle undefined plugins', () => { - const TestComponent = () => { - const plugins = useMarketplaceContext(v => v.plugins) - return
{plugins === undefined ? 'undefined' : 'defined'}
- } - - render( - - - , - ) - - expect(screen.getByTestId('plugins')).toHaveTextContent('undefined') - }) - }) - - describe('Special characters in search', () => { - it('should handle special characters in search text', () => { - render( - - - , - ) - - const input = screen.getByTestId('search-input') - - // Test with special characters - fireEvent.change(input, { target: { value: 'test@#$%^&*()' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('test@#$%^&*()') - - // Test with unicode characters - fireEvent.change(input, { target: { value: '测试中文' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('测试中文') - - // Test with emojis - fireEvent.change(input, { target: { value: '🔍 search' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('🔍 search') - }) - }) - - describe('Rapid state changes', () => { - it('should handle rapid search text changes', async () => { - render( - - - , - ) - - const input = screen.getByTestId('search-input') - - // Rapidly change values - fireEvent.change(input, { target: { value: 'a' } }) - fireEvent.change(input, { target: { value: 'ab' } }) - fireEvent.change(input, { target: { value: 'abc' } }) - fireEvent.change(input, { target: { value: 'abcd' } }) - fireEvent.change(input, { target: { value: 'abcde' } }) - - // Final value should be the last one - expect(screen.getByTestId('search-display')).toHaveTextContent('abcde') - }) - - it('should handle rapid type changes', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
- - - -
{activeType}
-
- ) - } - - render( - - - , - ) - - // Rapidly click different types - fireEvent.click(screen.getByTestId('type-tool')) - fireEvent.click(screen.getByTestId('type-model')) - fireEvent.click(screen.getByTestId('type-all')) - fireEvent.click(screen.getByTestId('type-tool')) - - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') - }) - }) - - describe('Boundary conditions', () => { - it('should handle very long search text', () => { - const longText = 'a'.repeat(1000) - - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - - return ( -
- handleChange(e.target.value)} - /> -
{searchText.length}
-
- ) - } - - render( - - - , - ) - - fireEvent.change(screen.getByTestId('search-input'), { target: { value: longText } }) - - expect(screen.getByTestId('search-length')).toHaveTextContent('1000') - }) - - it('should handle large number of tags', () => { - const manyTags = Array.from({ length: 100 }, (_, i) => `tag-${i}`) - - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) - - return ( -
- -
{tags.length}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('add-many-tags')) - - expect(screen.getByTestId('tags-count')).toHaveTextContent('100') - }) - }) - - describe('Sort edge cases', () => { - it('should handle same sort selection', () => { - const TestComponent = () => { - const sort = useMarketplaceContext(v => v.sort) - const handleSortChange = useMarketplaceContext(v => v.handleSortChange) - - return ( -
- -
{`${sort.sortBy}-${sort.sortOrder}`}
-
- ) - } - - render( - - - , - ) - - // Initial sort should be install_count-DESC - expect(screen.getByTestId('sort-display')).toHaveTextContent('install_count-DESC') - - // Click same sort - should not cause issues - fireEvent.click(screen.getByTestId('select-same-sort')) - - expect(screen.getByTestId('sort-display')).toHaveTextContent('install_count-DESC') - }) - }) -}) - // ================================ // Async Utils Tests // ================================ @@ -2685,338 +1692,6 @@ describe('useMarketplaceContainerScroll', () => { }) }) -// ================================ -// Plugin Type Switch Component Tests -// ================================ -describe('PluginTypeSwitch Component', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('Rendering actual component', () => { - it('should render all plugin type options', () => { - render( - - - , - ) - - // Note: The global mock returns the key with namespace prefix (plugin.) - expect(screen.getByText('plugin.category.all')).toBeInTheDocument() - expect(screen.getByText('plugin.category.models')).toBeInTheDocument() - expect(screen.getByText('plugin.category.tools')).toBeInTheDocument() - expect(screen.getByText('plugin.category.datasources')).toBeInTheDocument() - expect(screen.getByText('plugin.category.triggers')).toBeInTheDocument() - expect(screen.getByText('plugin.category.agents')).toBeInTheDocument() - expect(screen.getByText('plugin.category.extensions')).toBeInTheDocument() - expect(screen.getByText('plugin.category.bundles')).toBeInTheDocument() - }) - - it('should apply className prop', () => { - const { container } = render( - - - , - ) - - expect(container.querySelector('.custom-class')).toBeInTheDocument() - }) - - it('should call handleActivePluginTypeChange on option click', () => { - const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByText('plugin.category.tools')) - expect(screen.getByTestId('active-type-display')).toHaveTextContent('tool') - }) - - it('should highlight active option with correct classes', () => { - const TestWrapper = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - return ( -
- - -
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('set-model')) - const modelOption = screen.getByText('plugin.category.models').closest('div') - expect(modelOption).toHaveClass('shadow-xs') - }) - }) - - describe('Popstate handling', () => { - it('should handle popstate event when showSearchParams is true', () => { - const originalHref = window.location.href - - const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - const popstateEvent = new PopStateEvent('popstate') - window.dispatchEvent(popstateEvent) - - expect(screen.getByTestId('active-type')).toBeInTheDocument() - expect(window.location.href).toBe(originalHref) - }) - - it('should not handle popstate when showSearchParams is false', () => { - const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - - const popstateEvent = new PopStateEvent('popstate') - window.dispatchEvent(popstateEvent) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - }) - }) -}) - -// ================================ -// Context Advanced Tests -// ================================ -describe('Context Advanced', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - mockSetUrlFilters.mockClear() - mockHasNextPage = false - }) - - describe('URL filter synchronization', () => { - it('should update URL filters when showSearchParams is true and type changes', () => { - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-type')) - expect(mockSetUrlFilters).toHaveBeenCalled() - }) - - it('should not update URL filters when showSearchParams is false', () => { - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-type')) - expect(mockSetUrlFilters).not.toHaveBeenCalled() - }) - }) - - describe('handlePageChange', () => { - it('should invoke fetchNextPage when hasNextPage is true', () => { - mockHasNextPage = true - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('next-page')) - expect(mockFetchNextPage).toHaveBeenCalled() - }) - - it('should not invoke fetchNextPage when hasNextPage is false', () => { - mockHasNextPage = false - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('next-page')) - expect(mockFetchNextPage).not.toHaveBeenCalled() - }) - }) - - describe('setMarketplaceCollectionsFromClient', () => { - it('should provide setMarketplaceCollectionsFromClient function', () => { - const TestComponent = () => { - const setCollections = useMarketplaceContext(v => v.setMarketplaceCollectionsFromClient) - - return ( -
- -
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('set-collections')).toBeInTheDocument() - // The function should be callable without throwing - expect(() => fireEvent.click(screen.getByTestId('set-collections'))).not.toThrow() - }) - }) - - describe('setMarketplaceCollectionPluginsMapFromClient', () => { - it('should provide setMarketplaceCollectionPluginsMapFromClient function', () => { - const TestComponent = () => { - const setPluginsMap = useMarketplaceContext(v => v.setMarketplaceCollectionPluginsMapFromClient) - - return ( -
- -
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('set-plugins-map')).toBeInTheDocument() - // The function should be callable without throwing - expect(() => fireEvent.click(screen.getByTestId('set-plugins-map'))).not.toThrow() - }) - }) - - describe('handleQueryPlugins', () => { - it('should provide handleQueryPlugins function that can be called', () => { - const TestComponent = () => { - const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins) - return ( - - ) - } - - render( - - - , - ) - - expect(screen.getByTestId('query-plugins')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('query-plugins')) - expect(screen.getByTestId('query-plugins')).toBeInTheDocument() - }) - }) - - describe('isLoading state', () => { - it('should expose isLoading state', () => { - const TestComponent = () => { - const isLoading = useMarketplaceContext(v => v.isLoading) - return
{isLoading.toString()}
- } - - render( - - - , - ) - - expect(screen.getByTestId('loading')).toHaveTextContent('false') - }) - }) - - describe('isSuccessCollections state', () => { - it('should expose isSuccessCollections state', () => { - const TestComponent = () => { - const isSuccess = useMarketplaceContext(v => v.isSuccessCollections) - return
{isSuccess.toString()}
- } - - render( - - - , - ) - - expect(screen.getByTestId('success')).toHaveTextContent('false') - }) - }) - - describe('pluginsTotal', () => { - it('should expose plugins total count', () => { - const TestComponent = () => { - const total = useMarketplaceContext(v => v.pluginsTotal) - return
{total || 0}
- } - - render( - - - , - ) - - expect(screen.getByTestId('total')).toHaveTextContent('0') - }) - }) -}) - // ================================ // Test Data Factory Tests // ================================ diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 08d1bc833f..1f32ee4d29 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,55 +1,39 @@ -import type { MarketplaceCollection, SearchParams } from './types' -import type { Plugin } from '@/app/components/plugins/types' +import type { SearchParams } from 'nuqs' import { TanstackQueryInitializer } from '@/context/query-client' -import { MarketplaceContextProvider } from './context' import Description from './description' +import { HydrateMarketplaceAtoms } from './hydration-client' +import { HydrateQueryClient } from './hydration-server' import ListWrapper from './list/list-wrapper' import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' -import { getMarketplaceCollectionsAndPlugins } from './utils' type MarketplaceProps = { showInstallButton?: boolean - shouldExclude?: boolean - searchParams?: SearchParams pluginTypeSwitchClassName?: string - scrollContainerId?: string - showSearchParams?: boolean + /** + * Pass the search params from the request to prefetch data on the server + * and preserve the search params in the URL. + */ + searchParams?: Promise } + const Marketplace = async ({ showInstallButton = true, - shouldExclude, - searchParams, pluginTypeSwitchClassName, - scrollContainerId, - showSearchParams = true, + searchParams, }: MarketplaceProps) => { - let marketplaceCollections: MarketplaceCollection[] = [] - let marketplaceCollectionPluginsMap: Record = {} - if (!shouldExclude) { - const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins() - marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections - marketplaceCollectionPluginsMap = marketplaceCollectionsAndPluginsData.marketplaceCollectionPluginsMap - } - return ( - - - - - + + + + + + + ) } diff --git a/web/app/components/plugins/marketplace/list/index.spec.tsx b/web/app/components/plugins/marketplace/list/index.spec.tsx index c8fc6309a4..81616f5958 100644 --- a/web/app/components/plugins/marketplace/list/index.spec.tsx +++ b/web/app/components/plugins/marketplace/list/index.spec.tsx @@ -1,6 +1,6 @@ import type { MarketplaceCollection, SearchParamsFromCollection } from '../types' import type { Plugin } from '@/app/components/plugins/types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '@/app/components/plugins/types' import List from './index' @@ -30,23 +30,27 @@ vi.mock('#i18n', () => ({ useLocale: () => 'en-US', })) -// Mock useMarketplaceContext with controllable values -const mockContextValues = { - plugins: undefined as Plugin[] | undefined, - pluginsTotal: 0, - marketplaceCollectionsFromClient: undefined as MarketplaceCollection[] | undefined, - marketplaceCollectionPluginsMapFromClient: undefined as Record | undefined, - isLoading: false, - isSuccessCollections: false, - handleQueryPlugins: vi.fn(), - searchPluginText: '', - filterPluginTags: [] as string[], - page: 1, - handleMoreClick: vi.fn(), -} +// Mock marketplace state hooks with controllable values +const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => { + return { + mockMarketplaceData: { + plugins: undefined as Plugin[] | undefined, + pluginsTotal: 0, + marketplaceCollections: undefined as MarketplaceCollection[] | undefined, + marketplaceCollectionPluginsMap: undefined as Record | undefined, + isLoading: false, + page: 1, + }, + mockMoreClick: vi.fn(), + } +}) -vi.mock('../context', () => ({ - useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), +vi.mock('../state', () => ({ + useMarketplaceData: () => mockMarketplaceData, +})) + +vi.mock('../atoms', () => ({ + useMarketplaceMoreClick: () => mockMoreClick, })) // Mock useLocale context @@ -578,7 +582,7 @@ describe('ListWithCollection', () => { // View More Button Tests // ================================ describe('View More Button', () => { - it('should render View More button when collection is searchable and onMoreClick is provided', () => { + it('should render View More button when collection is searchable', () => { const collections = [createMockCollection({ name: 'collection-0', searchable: true, @@ -587,14 +591,12 @@ describe('ListWithCollection', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) @@ -609,42 +611,19 @@ describe('ListWithCollection', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) expect(screen.queryByText('View More')).not.toBeInTheDocument() }) - it('should not render View More button when onMoreClick is not provided', () => { - const collections = [createMockCollection({ - name: 'collection-0', - searchable: true, - })] - const pluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - - render( - , - ) - - expect(screen.queryByText('View More')).not.toBeInTheDocument() - }) - - it('should call onMoreClick with search_params when View More is clicked', () => { + it('should call moreClick hook with search_params when View More is clicked', () => { const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' } const collections = [createMockCollection({ name: 'collection-0', @@ -654,21 +633,19 @@ describe('ListWithCollection', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) fireEvent.click(screen.getByText('View More')) - expect(onMoreClick).toHaveBeenCalledTimes(1) - expect(onMoreClick).toHaveBeenCalledWith(searchParams) + expect(mockMoreClick).toHaveBeenCalledTimes(1) + expect(mockMoreClick).toHaveBeenCalledWith(searchParams) }) }) @@ -802,24 +779,15 @@ describe('ListWithCollection', () => { // ListWrapper Component Tests // ================================ describe('ListWrapper', () => { - const defaultProps = { - marketplaceCollections: [] as MarketplaceCollection[], - marketplaceCollectionPluginsMap: {} as Record, - showInstallButton: false, - } - beforeEach(() => { vi.clearAllMocks() - // Reset context values - mockContextValues.plugins = undefined - mockContextValues.pluginsTotal = 0 - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined - mockContextValues.isLoading = false - mockContextValues.isSuccessCollections = false - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - mockContextValues.page = 1 + // Reset mock data + mockMarketplaceData.plugins = undefined + mockMarketplaceData.pluginsTotal = 0 + mockMarketplaceData.marketplaceCollections = undefined + mockMarketplaceData.marketplaceCollectionPluginsMap = undefined + mockMarketplaceData.isLoading = false + mockMarketplaceData.page = 1 }) // ================================ @@ -827,32 +795,32 @@ describe('ListWrapper', () => { // ================================ describe('Rendering', () => { it('should render without crashing', () => { - render() + render() expect(document.body).toBeInTheDocument() }) it('should render with scrollbarGutter style', () => { - const { container } = render() + const { container } = render() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveStyle({ scrollbarGutter: 'stable' }) }) it('should render Loading component when isLoading is true and page is 1', () => { - mockContextValues.isLoading = true - mockContextValues.page = 1 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 1 - render() + render() expect(screen.getByTestId('loading-component')).toBeInTheDocument() }) it('should not render Loading component when page > 1', () => { - mockContextValues.isLoading = true - mockContextValues.page = 2 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 - render() + render() expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() }) @@ -863,26 +831,26 @@ describe('ListWrapper', () => { // ================================ describe('Plugins Header', () => { it('should render plugins result count when plugins are present', () => { - mockContextValues.plugins = createMockPluginList(5) - mockContextValues.pluginsTotal = 5 + mockMarketplaceData.plugins = createMockPluginList(5) + mockMarketplaceData.pluginsTotal = 5 - render() + render() expect(screen.getByText('5 plugins found')).toBeInTheDocument() }) it('should render SortDropdown when plugins are present', () => { - mockContextValues.plugins = createMockPluginList(1) + mockMarketplaceData.plugins = createMockPluginList(1) - render() + render() expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument() }) it('should not render plugins header when plugins is undefined', () => { - mockContextValues.plugins = undefined + mockMarketplaceData.plugins = undefined - render() + render() expect(screen.queryByTestId('sort-dropdown')).not.toBeInTheDocument() }) @@ -892,197 +860,60 @@ describe('ListWrapper', () => { // List Rendering Logic Tests // ================================ describe('List Rendering Logic', () => { - it('should render List when not loading', () => { - mockContextValues.isLoading = false - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + it('should render collections when not loading', () => { + mockMarketplaceData.isLoading = false + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) it('should render List when loading but page > 1', () => { - mockContextValues.isLoading = true - mockContextValues.page = 2 - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) - - it('should use client collections when available', () => { - const serverCollections = createMockCollectionList(1) - serverCollections[0].label = { 'en-US': 'Server Collection' } - const clientCollections = createMockCollectionList(1) - clientCollections[0].label = { 'en-US': 'Client Collection' } - - const serverPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - const clientPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - - mockContextValues.marketplaceCollectionsFromClient = clientCollections - mockContextValues.marketplaceCollectionPluginsMapFromClient = clientPluginsMap - - render( - , - ) - - expect(screen.getByText('Client Collection')).toBeInTheDocument() - expect(screen.queryByText('Server Collection')).not.toBeInTheDocument() - }) - - it('should use server collections when client collections are not available', () => { - const serverCollections = createMockCollectionList(1) - serverCollections[0].label = { 'en-US': 'Server Collection' } - const serverPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined - - render( - , - ) - - expect(screen.getByText('Server Collection')).toBeInTheDocument() - }) }) // ================================ - // Context Integration Tests + // Data Integration Tests // ================================ - describe('Context Integration', () => { - it('should pass plugins from context to List', () => { - const plugins = createMockPluginList(2) - mockContextValues.plugins = plugins + describe('Data Integration', () => { + it('should pass plugins from state to List', () => { + mockMarketplaceData.plugins = createMockPluginList(2) - render() + render() expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument() expect(screen.getByTestId('card-plugin-1')).toBeInTheDocument() }) - it('should pass handleMoreClick from context to List', () => { - const mockHandleMoreClick = vi.fn() - mockContextValues.handleMoreClick = mockHandleMoreClick - - const collections = [createMockCollection({ + it('should show View More button and call moreClick hook', () => { + mockMarketplaceData.marketplaceCollections = [createMockCollection({ name: 'collection-0', searchable: true, search_params: { query: 'test' }, })] - const pluginsMap: Record = { + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() fireEvent.click(screen.getByText('View More')) - expect(mockHandleMoreClick).toHaveBeenCalled() - }) - }) - - // ================================ - // Effect Tests (handleQueryPlugins) - // ================================ - describe('handleQueryPlugins Effect', () => { - it('should call handleQueryPlugins when conditions are met', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when client collections exist', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = createMockCollectionList(1) - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - - render() - - // Give time for effect to run - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when search text exists', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = 'search text' - mockContextValues.filterPluginTags = [] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when filter tags exist', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = ['tag1'] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) + expect(mockMoreClick).toHaveBeenCalled() }) }) @@ -1090,32 +921,32 @@ describe('ListWrapper', () => { // Edge Cases Tests // ================================ describe('Edge Cases', () => { - it('should handle empty plugins array from context', () => { - mockContextValues.plugins = [] - mockContextValues.pluginsTotal = 0 + it('should handle empty plugins array', () => { + mockMarketplaceData.plugins = [] + mockMarketplaceData.pluginsTotal = 0 - render() + render() expect(screen.getByText('0 plugins found')).toBeInTheDocument() expect(screen.getByTestId('empty-component')).toBeInTheDocument() }) it('should handle large pluginsTotal', () => { - mockContextValues.plugins = createMockPluginList(10) - mockContextValues.pluginsTotal = 10000 + mockMarketplaceData.plugins = createMockPluginList(10) + mockMarketplaceData.pluginsTotal = 10000 - render() + render() expect(screen.getByText('10000 plugins found')).toBeInTheDocument() }) it('should handle both loading and has plugins', () => { - mockContextValues.isLoading = true - mockContextValues.page = 2 - mockContextValues.plugins = createMockPluginList(5) - mockContextValues.pluginsTotal = 50 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 + mockMarketplaceData.plugins = createMockPluginList(5) + mockMarketplaceData.pluginsTotal = 50 - render() + render() // Should show plugins header and list expect(screen.getByText('50 plugins found')).toBeInTheDocument() @@ -1428,106 +1259,72 @@ describe('CardWrapper (via List integration)', () => { describe('Combined Workflows', () => { beforeEach(() => { vi.clearAllMocks() - mockContextValues.plugins = undefined - mockContextValues.pluginsTotal = 0 - mockContextValues.isLoading = false - mockContextValues.page = 1 - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined + mockMarketplaceData.plugins = undefined + mockMarketplaceData.pluginsTotal = 0 + mockMarketplaceData.isLoading = false + mockMarketplaceData.page = 1 + mockMarketplaceData.marketplaceCollections = undefined + mockMarketplaceData.marketplaceCollectionPluginsMap = undefined }) it('should transition from loading to showing collections', async () => { - mockContextValues.isLoading = true - mockContextValues.page = 1 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 1 - const { rerender } = render( - , - ) + const { rerender } = render() expect(screen.getByTestId('loading-component')).toBeInTheDocument() // Simulate loading complete - mockContextValues.isLoading = false - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockMarketplaceData.isLoading = false + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - mockContextValues.marketplaceCollectionsFromClient = collections - mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap - rerender( - , - ) + rerender() expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) it('should transition from collections to search results', async () => { - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - mockContextValues.marketplaceCollectionsFromClient = collections - mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap - const { rerender } = render( - , - ) + const { rerender } = render() expect(screen.getByText('Collection 0')).toBeInTheDocument() // Simulate search results - mockContextValues.plugins = createMockPluginList(5) - mockContextValues.pluginsTotal = 5 + mockMarketplaceData.plugins = createMockPluginList(5) + mockMarketplaceData.pluginsTotal = 5 - rerender( - , - ) + rerender() expect(screen.queryByText('Collection 0')).not.toBeInTheDocument() expect(screen.getByText('5 plugins found')).toBeInTheDocument() }) it('should handle empty search results', () => { - mockContextValues.plugins = [] - mockContextValues.pluginsTotal = 0 + mockMarketplaceData.plugins = [] + mockMarketplaceData.pluginsTotal = 0 - render( - , - ) + render() expect(screen.getByTestId('empty-component')).toBeInTheDocument() expect(screen.getByText('0 plugins found')).toBeInTheDocument() }) it('should support pagination (page > 1)', () => { - mockContextValues.plugins = createMockPluginList(40) - mockContextValues.pluginsTotal = 80 - mockContextValues.isLoading = true - mockContextValues.page = 2 + mockMarketplaceData.plugins = createMockPluginList(40) + mockMarketplaceData.pluginsTotal = 80 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 - render( - , - ) + render() // Should show existing results while loading more expect(screen.getByText('80 plugins found')).toBeInTheDocument() @@ -1542,9 +1339,9 @@ describe('Combined Workflows', () => { describe('Accessibility', () => { beforeEach(() => { vi.clearAllMocks() - mockContextValues.plugins = undefined - mockContextValues.isLoading = false - mockContextValues.page = 1 + mockMarketplaceData.plugins = undefined + mockMarketplaceData.isLoading = false + mockMarketplaceData.page = 1 }) it('should have semantic structure with collections', () => { @@ -1573,13 +1370,11 @@ describe('Accessibility', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) diff --git a/web/app/components/plugins/marketplace/list/index.tsx b/web/app/components/plugins/marketplace/list/index.tsx index 80b33d0ffd..4ce7272e80 100644 --- a/web/app/components/plugins/marketplace/list/index.tsx +++ b/web/app/components/plugins/marketplace/list/index.tsx @@ -13,7 +13,6 @@ type ListProps = { showInstallButton?: boolean cardContainerClassName?: string cardRender?: (plugin: Plugin) => React.JSX.Element | null - onMoreClick?: () => void emptyClassName?: string } const List = ({ @@ -23,7 +22,6 @@ const List = ({ showInstallButton, cardContainerClassName, cardRender, - onMoreClick, emptyClassName, }: ListProps) => { return ( @@ -36,7 +34,6 @@ const List = ({ showInstallButton={showInstallButton} cardContainerClassName={cardContainerClassName} cardRender={cardRender} - onMoreClick={onMoreClick} /> ) } diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index c17715e71e..264227b666 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -1,12 +1,12 @@ 'use client' import type { MarketplaceCollection } from '../types' -import type { SearchParamsFromCollection } from '@/app/components/plugins/marketplace/types' import type { Plugin } from '@/app/components/plugins/types' import { useLocale, useTranslation } from '#i18n' import { RiArrowRightSLine } from '@remixicon/react' import { getLanguage } from '@/i18n-config/language' import { cn } from '@/utils/classnames' +import { useMarketplaceMoreClick } from '../atoms' import CardWrapper from './card-wrapper' type ListWithCollectionProps = { @@ -15,7 +15,6 @@ type ListWithCollectionProps = { showInstallButton?: boolean cardContainerClassName?: string cardRender?: (plugin: Plugin) => React.JSX.Element | null - onMoreClick?: (searchParams?: SearchParamsFromCollection) => void } const ListWithCollection = ({ marketplaceCollections, @@ -23,10 +22,10 @@ const ListWithCollection = ({ showInstallButton, cardContainerClassName, cardRender, - onMoreClick, }: ListWithCollectionProps) => { const { t } = useTranslation() const locale = useLocale() + const onMoreClick = useMarketplaceMoreClick() return ( <> @@ -44,10 +43,10 @@ const ListWithCollection = ({
{collection.description[getLanguage(locale)]}
{ - collection.searchable && onMoreClick && ( + collection.searchable && (
onMoreClick?.(collection.search_params)} + onClick={() => onMoreClick(collection.search_params)} > {t('marketplace.viewMore', { ns: 'plugin' })} diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index 84fcf92daf..a1b0c2529a 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -1,46 +1,26 @@ 'use client' -import type { Plugin } from '../../types' -import type { MarketplaceCollection } from '../types' import { useTranslation } from '#i18n' -import { useEffect } from 'react' import Loading from '@/app/components/base/loading' -import { useMarketplaceContext } from '../context' import SortDropdown from '../sort-dropdown' +import { useMarketplaceData } from '../state' import List from './index' type ListWrapperProps = { - marketplaceCollections: MarketplaceCollection[] - marketplaceCollectionPluginsMap: Record showInstallButton?: boolean } const ListWrapper = ({ - marketplaceCollections, - marketplaceCollectionPluginsMap, showInstallButton, }: ListWrapperProps) => { const { t } = useTranslation() - const plugins = useMarketplaceContext(v => v.plugins) - const pluginsTotal = useMarketplaceContext(v => v.pluginsTotal) - const marketplaceCollectionsFromClient = useMarketplaceContext(v => v.marketplaceCollectionsFromClient) - const marketplaceCollectionPluginsMapFromClient = useMarketplaceContext(v => v.marketplaceCollectionPluginsMapFromClient) - const isLoading = useMarketplaceContext(v => v.isLoading) - const isSuccessCollections = useMarketplaceContext(v => v.isSuccessCollections) - const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins) - const searchPluginText = useMarketplaceContext(v => v.searchPluginText) - const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) - const page = useMarketplaceContext(v => v.page) - const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick) - useEffect(() => { - if ( - !marketplaceCollectionsFromClient?.length - && isSuccessCollections - && !searchPluginText - && !filterPluginTags.length - ) { - handleQueryPlugins() - } - }, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections, searchPluginText, filterPluginTags]) + const { + plugins, + pluginsTotal, + marketplaceCollections, + marketplaceCollectionPluginsMap, + isLoading, + page, + } = useMarketplaceData() return (
1) && ( ) } diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index b9572413ed..6e56a288d8 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -1,4 +1,5 @@ 'use client' +import type { ActivePluginType } from './constants' import { useTranslation } from '#i18n' import { RiArchive2Line, @@ -8,35 +9,27 @@ import { RiPuzzle2Line, RiSpeakAiLine, } from '@remixicon/react' -import { useCallback, useEffect } from 'react' +import { useSetAtom } from 'jotai' import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' import { cn } from '@/utils/classnames' -import { PluginCategoryEnum } from '../types' -import { useMarketplaceContext } from './context' +import { searchModeAtom, useActivePluginType } from './atoms' +import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' -export const PLUGIN_TYPE_SEARCH_MAP = { - all: 'all', - model: PluginCategoryEnum.model, - tool: PluginCategoryEnum.tool, - agent: PluginCategoryEnum.agent, - extension: PluginCategoryEnum.extension, - datasource: PluginCategoryEnum.datasource, - trigger: PluginCategoryEnum.trigger, - bundle: 'bundle', -} type PluginTypeSwitchProps = { className?: string - showSearchParams?: boolean } const PluginTypeSwitch = ({ className, - showSearchParams, }: PluginTypeSwitchProps) => { const { t } = useTranslation() - const activePluginType = useMarketplaceContext(s => s.activePluginType) - const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange) + const [activePluginType, handleActivePluginTypeChange] = useActivePluginType() + const setSearchMode = useSetAtom(searchModeAtom) - const options = [ + const options: Array<{ + value: ActivePluginType + text: string + icon: React.ReactNode | null + }> = [ { value: PLUGIN_TYPE_SEARCH_MAP.all, text: t('category.all', { ns: 'plugin' }), @@ -79,23 +72,6 @@ const PluginTypeSwitch = ({ }, ] - const handlePopState = useCallback(() => { - if (!showSearchParams) - return - // nuqs handles popstate automatically - const url = new URL(window.location.href) - const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all - handleActivePluginTypeChange(category) - }, [showSearchParams, handleActivePluginTypeChange]) - - useEffect(() => { - // nuqs manages popstate internally, but we keep this for URL sync - window.addEventListener('popstate', handlePopState) - return () => { - window.removeEventListener('popstate', handlePopState) - } - }, [handlePopState]) - return (
{ handleActivePluginTypeChange(option.value) + if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) { + setSearchMode(null) + } }} > {option.icon} diff --git a/web/app/components/plugins/marketplace/query.ts b/web/app/components/plugins/marketplace/query.ts new file mode 100644 index 0000000000..c5a1421146 --- /dev/null +++ b/web/app/components/plugins/marketplace/query.ts @@ -0,0 +1,38 @@ +import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './types' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils' + +// TODO: Avoid manual maintenance of query keys and better service management, +// https://github.com/langgenius/dify/issues/30342 + +export const marketplaceKeys = { + all: ['marketplace'] as const, + collections: (params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collections', params] as const, + collectionPlugins: (collectionId: string, params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collectionPlugins', collectionId, params] as const, + plugins: (params?: PluginsSearchParams) => [...marketplaceKeys.all, 'plugins', params] as const, +} + +export function useMarketplaceCollectionsAndPlugins( + collectionsParams: CollectionsAndPluginsSearchParams, +) { + return useQuery({ + queryKey: marketplaceKeys.collections(collectionsParams), + queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }), + }) +} + +export function useMarketplacePlugins( + queryParams: PluginsSearchParams | undefined, +) { + return useInfiniteQuery({ + queryKey: marketplaceKeys.plugins(queryParams), + queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal), + getNextPageParam: (lastPage) => { + const nextPage = lastPage.page + 1 + const loaded = lastPage.page * lastPage.pageSize + return loaded < (lastPage.total || 0) ? nextPage : undefined + }, + initialPageParam: 1, + enabled: queryParams !== undefined, + }) +} diff --git a/web/app/components/plugins/marketplace/search-box/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/index.spec.tsx index 3e9cc40be0..85be82cb33 100644 --- a/web/app/components/plugins/marketplace/search-box/index.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.spec.tsx @@ -26,16 +26,19 @@ vi.mock('#i18n', () => ({ }), })) -// Mock useMarketplaceContext -const mockContextValues = { - searchPluginText: '', - handleSearchPluginTextChange: vi.fn(), - filterPluginTags: [] as string[], - handleFilterPluginTagsChange: vi.fn(), -} +// Mock marketplace state hooks +const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => { + return { + mockSearchPluginText: '', + mockHandleSearchPluginTextChange: vi.fn(), + mockFilterPluginTags: [] as string[], + mockHandleFilterPluginTagsChange: vi.fn(), + } +}) -vi.mock('../context', () => ({ - useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), +vi.mock('../atoms', () => ({ + useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange], + useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange], })) // Mock useTags hook @@ -430,9 +433,6 @@ describe('SearchBoxWrapper', () => { beforeEach(() => { vi.clearAllMocks() mockPortalOpenState = false - // Reset context values - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] }) describe('Rendering', () => { @@ -456,28 +456,14 @@ describe('SearchBoxWrapper', () => { }) }) - describe('Context Integration', () => { - it('should use searchPluginText from context', () => { - mockContextValues.searchPluginText = 'context search' - render() - - expect(screen.getByDisplayValue('context search')).toBeInTheDocument() - }) - + describe('Hook Integration', () => { it('should call handleSearchPluginTextChange when search changes', () => { render() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'new search' } }) - expect(mockContextValues.handleSearchPluginTextChange).toHaveBeenCalledWith('new search') - }) - - it('should use filterPluginTags from context', () => { - mockContextValues.filterPluginTags = ['agent', 'rag'] - render() - - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search') }) }) diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index d7fc004236..9957e9bc42 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -1,15 +1,13 @@ 'use client' import { useTranslation } from '#i18n' -import { useMarketplaceContext } from '../context' +import { useFilterPluginTags, useSearchPluginText } from '../atoms' import SearchBox from './index' const SearchBoxWrapper = () => { const { t } = useTranslation() - const searchPluginText = useMarketplaceContext(v => v.searchPluginText) - const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) - const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) + const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText() + const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags() return ( (Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }), + q: parseAsString.withDefault('').withOptions({ history: 'replace' }), + tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), +} diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx index 3ed7d78b07..f91c7ba4d3 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx @@ -1,4 +1,3 @@ -import type { MarketplaceContextValue } from '../context' import { fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -28,18 +27,12 @@ vi.mock('#i18n', () => ({ }), })) -// Mock marketplace context with controllable values -let mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } +// Mock marketplace atoms with controllable values +let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' } const mockHandleSortChange = vi.fn() -vi.mock('../context', () => ({ - useMarketplaceContext: (selector: (value: MarketplaceContextValue) => unknown) => { - const contextValue = { - sort: mockSort, - handleSortChange: mockHandleSortChange, - } as unknown as MarketplaceContextValue - return selector(contextValue) - }, +vi.mock('../atoms', () => ({ + useMarketplaceSort: () => [mockSort, mockHandleSortChange], })) // Mock portal component with controllable open state diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx index 984b114d03..1f7bab1005 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx @@ -10,7 +10,7 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { useMarketplaceContext } from '../context' +import { useMarketplaceSort } from '../atoms' const SortDropdown = () => { const { t } = useTranslation() @@ -36,8 +36,7 @@ const SortDropdown = () => { text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }), }, ] - const sort = useMarketplaceContext(v => v.sort) - const handleSortChange = useMarketplaceContext(v => v.handleSortChange) + const [sort, handleSortChange] = useMarketplaceSort() const [open, setOpen] = useState(false) const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0] diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts new file mode 100644 index 0000000000..1c1abfc0a1 --- /dev/null +++ b/web/app/components/plugins/marketplace/state.ts @@ -0,0 +1,54 @@ +import type { PluginsSearchParams } from './types' +import { useDebounce } from 'ahooks' +import { useCallback, useMemo } from 'react' +import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms' +import { PLUGIN_TYPE_SEARCH_MAP } from './constants' +import { useMarketplaceContainerScroll } from './hooks' +import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query' +import { getCollectionsParams, getMarketplaceListFilterType } from './utils' + +export function useMarketplaceData() { + const [searchPluginTextOriginal] = useSearchPluginText() + const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 }) + const [filterPluginTags] = useFilterPluginTags() + const [activePluginType] = useActivePluginType() + + const collectionsQuery = useMarketplaceCollectionsAndPlugins( + getCollectionsParams(activePluginType), + ) + + const sort = useMarketplaceSortValue() + const isSearchMode = useMarketplaceSearchMode() + const queryParams = useMemo((): PluginsSearchParams | undefined => { + if (!isSearchMode) + return undefined + return { + query: searchPluginText, + category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, + tags: filterPluginTags, + sortBy: sort.sortBy, + sortOrder: sort.sortOrder, + type: getMarketplaceListFilterType(activePluginType), + } + }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) + + const pluginsQuery = useMarketplacePlugins(queryParams) + const { hasNextPage, fetchNextPage, isFetching } = pluginsQuery + + const handlePageChange = useCallback(() => { + if (hasNextPage && !isFetching) + fetchNextPage() + }, [fetchNextPage, hasNextPage, isFetching]) + + // Scroll pagination + useMarketplaceContainerScroll(handlePageChange) + + return { + marketplaceCollections: collectionsQuery.data?.marketplaceCollections, + marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, + plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins), + pluginsTotal: pluginsQuery.data?.pages[0]?.total, + page: pluginsQuery.data?.pages.length || 1, + isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading, + } +} diff --git a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx index 3d3530c83e..4da3844c0a 100644 --- a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx +++ b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx @@ -6,12 +6,10 @@ import SearchBoxWrapper from './search-box/search-box-wrapper' type StickySearchAndSwitchWrapperProps = { pluginTypeSwitchClassName?: string - showSearchParams?: boolean } const StickySearchAndSwitchWrapper = ({ pluginTypeSwitchClassName, - showSearchParams, }: StickySearchAndSwitchWrapperProps) => { const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-') @@ -24,9 +22,7 @@ const StickySearchAndSwitchWrapper = ({ )} > - +
) } diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index e51c9b76a6..eaf299314c 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -1,16 +1,19 @@ +import type { ActivePluginType } from './constants' import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, + PluginsSearchParams, } from '@/app/components/plugins/marketplace/types' -import type { Plugin } from '@/app/components/plugins/types' +import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { APP_VERSION, IS_MARKETPLACE, MARKETPLACE_API_PREFIX, } from '@/config' +import { postMarketplace } from '@/service/base' import { getMarketplaceUrl } from '@/utils/var' -import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' +import { PLUGIN_TYPE_SEARCH_MAP } from './constants' type MarketplaceFetchOptions = { signal?: AbortSignal @@ -26,12 +29,13 @@ export const getPluginIconInMarketplace = (plugin: Plugin) => { return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon` } -export const getFormattedPlugin = (bundle: any) => { +export const getFormattedPlugin = (bundle: Plugin): Plugin => { if (bundle.type === 'bundle') { return { ...bundle, icon: getPluginIconInMarketplace(bundle), brief: bundle.description, + // @ts-expect-error I do not have enough information label: bundle.labels, } } @@ -129,6 +133,64 @@ export const getMarketplaceCollectionsAndPlugins = async ( } } +export const getMarketplacePlugins = async ( + queryParams: PluginsSearchParams | undefined, + pageParam: number, + signal?: AbortSignal, +) => { + if (!queryParams) { + return { + plugins: [] as Plugin[], + total: 0, + page: 1, + pageSize: 40, + } + } + + const { + query, + sortBy, + sortOrder, + category, + tags, + type, + pageSize = 40, + } = queryParams + const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' + + try { + const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, { + body: { + page: pageParam, + page_size: pageSize, + query, + sort_by: sortBy, + sort_order: sortOrder, + category: category !== 'all' ? category : '', + tags, + type, + }, + signal, + }) + const resPlugins = res.data.bundles || res.data.plugins || [] + + return { + plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)), + total: res.data.total, + page: pageParam, + pageSize, + } + } + catch { + return { + plugins: [], + total: 0, + page: pageParam, + pageSize, + } + } +} + export const getMarketplaceListCondition = (pluginType: string) => { if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum)) return `category=${pluginType}` @@ -142,7 +204,7 @@ export const getMarketplaceListCondition = (pluginType: string) => { return '' } -export const getMarketplaceListFilterType = (category: string) => { +export const getMarketplaceListFilterType = (category: ActivePluginType) => { if (category === PLUGIN_TYPE_SEARCH_MAP.all) return undefined @@ -151,3 +213,14 @@ export const getMarketplaceListFilterType = (category: string) => { return 'plugin' } + +export function getCollectionsParams(category: ActivePluginType): CollectionsAndPluginsSearchParams { + if (category === PLUGIN_TYPE_SEARCH_MAP.all) { + return {} + } + return { + category, + condition: getMarketplaceListCondition(category), + type: getMarketplaceListFilterType(category), + } +} diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index b8fc891254..1f88f691ef 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -27,7 +27,7 @@ import { cn } from '@/utils/classnames' import { PLUGIN_PAGE_TABS_MAP } from '../hooks' import InstallFromLocalPackage from '../install-plugin/install-from-local-package' import InstallFromMarketplace from '../install-plugin/install-from-marketplace' -import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/plugin-type-switch' +import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants' import { PluginPageContextProvider, usePluginPageContext, diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx index d65b0b7957..1008ef461d 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx @@ -262,7 +262,7 @@ vi.mock('@/app/components/base/icons/src/vender/other', () => ({ })) // Mock PLUGIN_TYPE_SEARCH_MAP -vi.mock('../../marketplace/plugin-type-switch', () => ({ +vi.mock('../../marketplace/constants', () => ({ PLUGIN_TYPE_SEARCH_MAP: { all: 'all', model: 'model', diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx index a91df6c793..4e681a6b67 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { ActivePluginType } from '../../marketplace/constants' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -12,7 +13,7 @@ import { import SearchBox from '@/app/components/plugins/marketplace/search-box' import { useInstalledPluginList } from '@/service/use-plugins' import { cn } from '@/utils/classnames' -import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/plugin-type-switch' +import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/constants' import { PluginSource } from '../../types' import NoDataPlaceholder from './no-data-placeholder' import ToolItem from './tool-item' @@ -73,7 +74,7 @@ const ToolPicker: FC = ({ }, ] - const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all) + const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all) const [query, setQuery] = useState('') const [tags, setTags] = useState([]) const { data, isLoading } = useInstalledPluginList() diff --git a/web/context/query-client-server.ts b/web/context/query-client-server.ts new file mode 100644 index 0000000000..3650e30f52 --- /dev/null +++ b/web/context/query-client-server.ts @@ -0,0 +1,16 @@ +import { QueryClient } from '@tanstack/react-query' +import { cache } from 'react' + +const STALE_TIME = 1000 * 60 * 30 // 30 minutes + +export function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: STALE_TIME, + }, + }, + }) +} + +export const getQueryClientServer = cache(makeQueryClient) diff --git a/web/context/query-client.tsx b/web/context/query-client.tsx index 9562686f6f..a72393490c 100644 --- a/web/context/query-client.tsx +++ b/web/context/query-client.tsx @@ -1,23 +1,27 @@ 'use client' +import type { QueryClient } from '@tanstack/react-query' import type { FC, PropsWithChildren } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' +import { useState } from 'react' import { TanStackDevtoolsLoader } from '@/app/components/devtools/tanstack/loader' +import { makeQueryClient } from './query-client-server' -const STALE_TIME = 1000 * 60 * 30 // 30 minutes +let browserQueryClient: QueryClient | undefined -const client = new QueryClient({ - defaultOptions: { - queries: { - staleTime: STALE_TIME, - }, - }, -}) +function getQueryClient() { + if (typeof window === 'undefined') { + return makeQueryClient() + } + if (!browserQueryClient) + browserQueryClient = makeQueryClient() + return browserQueryClient +} -export const TanstackQueryInitializer: FC = (props) => { - const { children } = props +export const TanstackQueryInitializer: FC = ({ children }) => { + const [queryClient] = useState(getQueryClient) return ( - + {children} diff --git a/web/hooks/use-query-params.spec.tsx b/web/hooks/use-query-params.spec.tsx index 2aa6b7998f..b187471809 100644 --- a/web/hooks/use-query-params.spec.tsx +++ b/web/hooks/use-query-params.spec.tsx @@ -8,7 +8,6 @@ import { PRICING_MODAL_QUERY_PARAM, PRICING_MODAL_QUERY_VALUE, useAccountSettingModal, - useMarketplaceFilters, usePluginInstallation, usePricingModal, } from './use-query-params' @@ -302,174 +301,6 @@ describe('useQueryParams hooks', () => { }) }) - // Marketplace filters query behavior. - describe('useMarketplaceFilters', () => { - it('should return default filters when query params are missing', () => { - // Arrange - const { result } = renderWithAdapter(() => useMarketplaceFilters()) - - // Act - const [filters] = result.current - - // Assert - expect(filters.q).toBe('') - expect(filters.category).toBe('all') - expect(filters.tags).toEqual([]) - }) - - it('should parse filters when query params are present', () => { - // Arrange - const { result } = renderWithAdapter( - () => useMarketplaceFilters(), - '?q=prompt&category=tool&tags=ai,ml', - ) - - // Act - const [filters] = result.current - - // Assert - expect(filters.q).toBe('prompt') - expect(filters.category).toBe('tool') - expect(filters.tags).toEqual(['ai', 'ml']) - }) - - it('should treat empty tags param as empty array', () => { - // Arrange - const { result } = renderWithAdapter( - () => useMarketplaceFilters(), - '?tags=', - ) - - // Act - const [filters] = result.current - - // Assert - expect(filters.tags).toEqual([]) - }) - - it('should preserve other filters when updating a single field', async () => { - // Arrange - const { result } = renderWithAdapter( - () => useMarketplaceFilters(), - '?category=tool&tags=ai,ml', - ) - - // Act - act(() => { - result.current[1]({ q: 'search' }) - }) - - // Assert - await waitFor(() => expect(result.current[0].q).toBe('search')) - expect(result.current[0].category).toBe('tool') - expect(result.current[0].tags).toEqual(['ai', 'ml']) - }) - - it('should clear q param when q is empty', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?q=search', - ) - - // Act - act(() => { - result.current[1]({ q: '' }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.has('q')).toBe(false) - }) - - it('should serialize tags as comma-separated values', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters()) - - // Act - act(() => { - result.current[1]({ tags: ['ai', 'ml'] }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.get('tags')).toBe('ai,ml') - }) - - it('should remove tags param when list is empty', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?tags=ai,ml', - ) - - // Act - act(() => { - result.current[1]({ tags: [] }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.has('tags')).toBe(false) - }) - - it('should keep category in the URL when set to default', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?category=tool', - ) - - // Act - act(() => { - result.current[1]({ category: 'all' }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.get('category')).toBe('all') - }) - - it('should clear all marketplace filters when set to null', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?q=search&category=tool&tags=ai,ml', - ) - - // Act - act(() => { - result.current[1](null) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.has('q')).toBe(false) - expect(update.searchParams.has('category')).toBe(false) - expect(update.searchParams.has('tags')).toBe(false) - }) - - it('should use replace history when updating filters', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters()) - - // Act - act(() => { - result.current[1]({ q: 'search' }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.options.history).toBe('replace') - }) - }) - // Plugin installation query behavior. describe('usePluginInstallation', () => { it('should parse package ids from JSON arrays', () => { diff --git a/web/hooks/use-query-params.ts b/web/hooks/use-query-params.ts index e0d7cc3c02..73798a4a4f 100644 --- a/web/hooks/use-query-params.ts +++ b/web/hooks/use-query-params.ts @@ -15,7 +15,6 @@ import { createParser, - parseAsArrayOf, parseAsString, useQueryState, useQueryStates, @@ -93,39 +92,6 @@ export function useAccountSettingModal() { return [{ isOpen, payload: currentTab }, setState] as const } -/** - * Marketplace Search Query Parameters - */ -export type MarketplaceFilters = { - q: string // search query - category: string // plugin category - tags: string[] // array of tags -} - -/** - * Hook to manage marketplace search/filter state via URL - * Provides atomic updates - all params update together - * - * @example - * const [filters, setFilters] = useMarketplaceFilters() - * setFilters({ q: 'search', category: 'tool', tags: ['ai'] }) // Updates all at once - * setFilters({ q: '' }) // Only updates q, keeps others - * setFilters(null) // Clears all marketplace params - */ -export function useMarketplaceFilters() { - return useQueryStates( - { - q: parseAsString.withDefault(''), - category: parseAsString.withDefault('all').withOptions({ clearOnDefault: false }), - tags: parseAsArrayOf(parseAsString).withDefault([]), - }, - { - // Update URL without pushing to history (replaceState behavior) - history: 'replace', - }, - ) -} - /** * Plugin Installation Query Parameters */ From ae0a26f5b6a8a77263fecd63fbea5a129522e9e7 Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Fri, 9 Jan 2026 16:08:24 +0800 Subject: [PATCH 11/22] revert: "fix: fix assign value stand as default (#30651)" (#30717) The original fix seems correct on its own. However, for chatflows with multiple answer nodes, the `message_replace` command only preserves the output of the last executed answer node. --- .../advanced_chat/generate_task_pipeline.py | 19 - ...test_generate_task_pipeline_answer_node.py | 390 ------------------ 2 files changed, 409 deletions(-) delete mode 100644 api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_answer_node.py diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index 4dd95be52d..da1e9f19b6 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -358,25 +358,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): if node_finish_resp: yield node_finish_resp - # For ANSWER nodes, check if we need to send a message_replace event - # Only send if the final output differs from the accumulated task_state.answer - # This happens when variables were updated by variable_assigner during workflow execution - if event.node_type == NodeType.ANSWER and event.outputs: - final_answer = event.outputs.get("answer") - if final_answer is not None and final_answer != self._task_state.answer: - logger.info( - "ANSWER node final output '%s' differs from accumulated answer '%s', sending message_replace event", - final_answer, - self._task_state.answer, - ) - # Update the task state answer - self._task_state.answer = str(final_answer) - # Send message_replace event to update the UI - yield self._message_cycle_manager.message_replace_to_stream_response( - answer=str(final_answer), - reason="variable_update", - ) - def _handle_node_failed_events( self, event: Union[QueueNodeFailedEvent, QueueNodeExceptionEvent], diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_answer_node.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_answer_node.py deleted file mode 100644 index 205b157542..0000000000 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_answer_node.py +++ /dev/null @@ -1,390 +0,0 @@ -""" -Tests for AdvancedChatAppGenerateTaskPipeline._handle_node_succeeded_event method, -specifically testing the ANSWER node message_replace logic. -""" - -from datetime import datetime -from types import SimpleNamespace -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity -from core.app.entities.queue_entities import QueueNodeSucceededEvent -from core.workflow.enums import NodeType -from models import EndUser -from models.model import AppMode - - -class TestAnswerNodeMessageReplace: - """Test cases for ANSWER node message_replace event logic.""" - - @pytest.fixture - def mock_application_generate_entity(self): - """Create a mock application generate entity.""" - entity = Mock(spec=AdvancedChatAppGenerateEntity) - entity.task_id = "test-task-id" - entity.app_id = "test-app-id" - entity.workflow_run_id = "test-workflow-run-id" - # minimal app_config used by pipeline internals - entity.app_config = SimpleNamespace( - tenant_id="test-tenant-id", - app_id="test-app-id", - app_mode=AppMode.ADVANCED_CHAT, - app_model_config_dict={}, - additional_features=None, - sensitive_word_avoidance=None, - ) - entity.query = "test query" - entity.files = [] - entity.extras = {} - entity.trace_manager = None - entity.inputs = {} - entity.invoke_from = "debugger" - return entity - - @pytest.fixture - def mock_workflow(self): - """Create a mock workflow.""" - workflow = Mock() - workflow.id = "test-workflow-id" - workflow.features_dict = {} - return workflow - - @pytest.fixture - def mock_queue_manager(self): - """Create a mock queue manager.""" - manager = Mock() - manager.listen.return_value = [] - manager.graph_runtime_state = None - return manager - - @pytest.fixture - def mock_conversation(self): - """Create a mock conversation.""" - conversation = Mock() - conversation.id = "test-conversation-id" - conversation.mode = "advanced_chat" - return conversation - - @pytest.fixture - def mock_message(self): - """Create a mock message.""" - message = Mock() - message.id = "test-message-id" - message.query = "test query" - message.created_at = Mock() - message.created_at.timestamp.return_value = 1234567890 - return message - - @pytest.fixture - def mock_user(self): - """Create a mock end user.""" - user = MagicMock(spec=EndUser) - user.id = "test-user-id" - user.session_id = "test-session-id" - return user - - @pytest.fixture - def mock_draft_var_saver_factory(self): - """Create a mock draft variable saver factory.""" - return Mock() - - @pytest.fixture - def pipeline( - self, - mock_application_generate_entity, - mock_workflow, - mock_queue_manager, - mock_conversation, - mock_message, - mock_user, - mock_draft_var_saver_factory, - ): - """Create an AdvancedChatAppGenerateTaskPipeline instance with mocked dependencies.""" - from core.app.apps.advanced_chat.generate_task_pipeline import AdvancedChatAppGenerateTaskPipeline - - with patch("core.app.apps.advanced_chat.generate_task_pipeline.db"): - pipeline = AdvancedChatAppGenerateTaskPipeline( - application_generate_entity=mock_application_generate_entity, - workflow=mock_workflow, - queue_manager=mock_queue_manager, - conversation=mock_conversation, - message=mock_message, - user=mock_user, - stream=True, - dialogue_count=1, - draft_var_saver_factory=mock_draft_var_saver_factory, - ) - # Initialize workflow run id to avoid validation errors - pipeline._workflow_run_id = "test-workflow-run-id" - # Mock the message cycle manager methods we need to track - pipeline._message_cycle_manager.message_replace_to_stream_response = Mock() - return pipeline - - def test_answer_node_with_different_output_sends_message_replace(self, pipeline, mock_application_generate_entity): - """ - Test that when an ANSWER node's final output differs from accumulated answer, - a message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "initial answer" - - # Create ANSWER node succeeded event with different final output - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"answer": "updated final answer"}, - ) - - # Mock the workflow response converter to avoid extra processing - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - responses = list(pipeline._handle_node_succeeded_event(event)) - - # Assert - assert pipeline._task_state.answer == "updated final answer" - # Verify message_replace was called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_called_once_with( - answer="updated final answer", reason="variable_update" - ) - - def test_answer_node_with_same_output_does_not_send_message_replace(self, pipeline): - """ - Test that when an ANSWER node's final output is the same as accumulated answer, - no message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "same answer" - - # Create ANSWER node succeeded event with same output - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"answer": "same answer"}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "same answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_answer_node_with_none_output_does_not_send_message_replace(self, pipeline): - """ - Test that when an ANSWER node's output is None or missing 'answer' key, - no message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Create ANSWER node succeeded event with None output - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"answer": None}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_answer_node_with_empty_outputs_does_not_send_message_replace(self, pipeline): - """ - Test that when an ANSWER node has empty outputs dict, - no message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Create ANSWER node succeeded event with empty outputs - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_answer_node_with_no_answer_key_in_outputs(self, pipeline): - """ - Test that when an ANSWER node's outputs don't contain 'answer' key, - no message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Create ANSWER node succeeded event without 'answer' key in outputs - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"other_key": "some value"}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_non_answer_node_does_not_send_message_replace(self, pipeline): - """ - Test that non-ANSWER nodes (e.g., LLM, END) don't trigger message_replace events. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Test with LLM node - llm_event = QueueNodeSucceededEvent( - node_execution_id="test-llm-execution-id", - node_id="test-llm-node", - node_type=NodeType.LLM, - start_at=datetime.now(), - outputs={"answer": "different answer"}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(llm_event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_end_node_does_not_send_message_replace(self, pipeline): - """ - Test that END nodes don't trigger message_replace events even with 'answer' output. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Create END node succeeded event with answer output - event = QueueNodeSucceededEvent( - node_execution_id="test-end-execution-id", - node_id="test-end-node", - node_type=NodeType.END, - start_at=datetime.now(), - outputs={"answer": "different answer"}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_answer_node_with_numeric_output_converts_to_string(self, pipeline): - """ - Test that when an ANSWER node's final output is numeric, - it gets converted to string properly. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "text answer" - - # Create ANSWER node succeeded event with numeric output - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"answer": 12345}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should be converted to string - assert pipeline._task_state.answer == "12345" - # Verify message_replace was called with string - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_called_once_with( - answer="12345", reason="variable_update" - ) - - def test_answer_node_files_are_recorded(self, pipeline): - """ - Test that ANSWER nodes properly record files from outputs. - """ - # Arrange - pipeline._task_state.answer = "existing answer" - - # Create ANSWER node succeeded event with files - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={ - "answer": "same answer", - "files": [ - {"type": "image", "transfer_method": "remote_url", "remote_url": "http://example.com/img.png"} - ], - }, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.fetch_files_from_node_outputs = Mock(return_value=event.outputs["files"]) - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: files should be recorded - assert len(pipeline._recorded_files) == 1 - assert pipeline._recorded_files[0] == event.outputs["files"][0] From 0711dd4159457d62a937273dd2d8162d92b4e5a1 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Fri, 9 Jan 2026 16:13:17 +0800 Subject: [PATCH 12/22] feat: enhance start node object value check (#30732) --- api/core/app/app_config/entities.py | 13 ++---- .../apps/advanced_chat/app_config_manager.py | 1 - api/core/app/apps/base_app_generator.py | 33 ++++++++------ api/core/workflow/nodes/start/start_node.py | 28 +++++------- .../nodes/test_start_node_json_object.py | 18 +++++--- .../config-var/config-modal/index.tsx | 2 +- web/app/components/base/chat/chat/utils.ts | 16 ++++++- .../share/text-generation/run-once/index.tsx | 2 +- .../components/before-run-form/form-item.tsx | 8 +++- .../nodes/_base/components/variable/utils.ts | 2 +- web/app/components/workflow/types.ts | 2 +- web/models/debug.ts | 2 +- web/service/workflow-payload.ts | 45 ++++++++++++++++++- web/service/workflow.ts | 4 +- 14 files changed, 121 insertions(+), 55 deletions(-) diff --git a/api/core/app/app_config/entities.py b/api/core/app/app_config/entities.py index 307af3747c..13c51529cc 100644 --- a/api/core/app/app_config/entities.py +++ b/api/core/app/app_config/entities.py @@ -1,4 +1,3 @@ -import json from collections.abc import Sequence from enum import StrEnum, auto from typing import Any, Literal @@ -121,7 +120,7 @@ class VariableEntity(BaseModel): allowed_file_types: Sequence[FileType] | None = Field(default_factory=list) allowed_file_extensions: Sequence[str] | None = Field(default_factory=list) allowed_file_upload_methods: Sequence[FileTransferMethod] | None = Field(default_factory=list) - json_schema: str | None = Field(default=None) + json_schema: dict | None = Field(default=None) @field_validator("description", mode="before") @classmethod @@ -135,17 +134,11 @@ class VariableEntity(BaseModel): @field_validator("json_schema") @classmethod - def validate_json_schema(cls, schema: str | None) -> str | None: + def validate_json_schema(cls, schema: dict | None) -> dict | None: if schema is None: return None - try: - json_schema = json.loads(schema) - except json.JSONDecodeError: - raise ValueError(f"invalid json_schema value {schema}") - - try: - Draft7Validator.check_schema(json_schema) + Draft7Validator.check_schema(schema) except SchemaError as e: raise ValueError(f"Invalid JSON schema: {e.message}") return schema diff --git a/api/core/app/apps/advanced_chat/app_config_manager.py b/api/core/app/apps/advanced_chat/app_config_manager.py index e4b308a6f6..c21c494efe 100644 --- a/api/core/app/apps/advanced_chat/app_config_manager.py +++ b/api/core/app/apps/advanced_chat/app_config_manager.py @@ -26,7 +26,6 @@ class AdvancedChatAppConfigManager(BaseAppConfigManager): @classmethod def get_app_config(cls, app_model: App, workflow: Workflow) -> AdvancedChatAppConfig: features_dict = workflow.features_dict - app_mode = AppMode.value_of(app_model.mode) app_config = AdvancedChatAppConfig( tenant_id=app_model.tenant_id, diff --git a/api/core/app/apps/base_app_generator.py b/api/core/app/apps/base_app_generator.py index a6aace168e..e4486e892c 100644 --- a/api/core/app/apps/base_app_generator.py +++ b/api/core/app/apps/base_app_generator.py @@ -1,4 +1,3 @@ -import json from collections.abc import Generator, Mapping, Sequence from typing import TYPE_CHECKING, Any, Union, final @@ -76,12 +75,24 @@ class BaseAppGenerator: user_inputs = {**user_inputs, **files_inputs, **file_list_inputs} # Check if all files are converted to File - if any(filter(lambda v: isinstance(v, dict), user_inputs.values())): - raise ValueError("Invalid input type") - if any( - filter(lambda v: isinstance(v, dict), filter(lambda item: isinstance(item, list), user_inputs.values())) - ): - raise ValueError("Invalid input type") + invalid_dict_keys = [ + k + for k, v in user_inputs.items() + if isinstance(v, dict) + and entity_dictionary[k].type not in {VariableEntityType.FILE, VariableEntityType.JSON_OBJECT} + ] + if invalid_dict_keys: + raise ValueError(f"Invalid input type for {invalid_dict_keys}") + + invalid_list_dict_keys = [ + k + for k, v in user_inputs.items() + if isinstance(v, list) + and any(isinstance(item, dict) for item in v) + and entity_dictionary[k].type != VariableEntityType.FILE_LIST + ] + if invalid_list_dict_keys: + raise ValueError(f"Invalid input type for {invalid_list_dict_keys}") return user_inputs @@ -178,12 +189,8 @@ class BaseAppGenerator: elif value == 0: value = False case VariableEntityType.JSON_OBJECT: - if not isinstance(value, str): - raise ValueError(f"{variable_entity.variable} in input form must be a string") - try: - json.loads(value) - except json.JSONDecodeError: - raise ValueError(f"{variable_entity.variable} in input form must be a valid JSON object") + if not isinstance(value, dict): + raise ValueError(f"{variable_entity.variable} in input form must be a dict") case _: raise AssertionError("this statement should be unreachable.") diff --git a/api/core/workflow/nodes/start/start_node.py b/api/core/workflow/nodes/start/start_node.py index 36fc5078c5..53c1b4ee6b 100644 --- a/api/core/workflow/nodes/start/start_node.py +++ b/api/core/workflow/nodes/start/start_node.py @@ -1,4 +1,3 @@ -import json from typing import Any from jsonschema import Draft7Validator, ValidationError @@ -43,25 +42,22 @@ class StartNode(Node[StartNodeData]): if value is None and variable.required: raise ValueError(f"{key} is required in input form") + # If no value provided, skip further processing for this key + if not value: + continue + + if not isinstance(value, dict): + raise ValueError(f"JSON object for '{key}' must be an object") + + # Overwrite with normalized dict to ensure downstream consistency + node_inputs[key] = value + + # If schema exists, then validate against it schema = variable.json_schema if not schema: continue - if not value: - continue - try: - json_schema = json.loads(schema) - except json.JSONDecodeError as e: - raise ValueError(f"{schema} must be a valid JSON object") - - try: - json_value = json.loads(value) - except json.JSONDecodeError as e: - raise ValueError(f"{value} must be a valid JSON object") - - try: - Draft7Validator(json_schema).validate(json_value) + Draft7Validator(schema).validate(value) except ValidationError as e: raise ValueError(f"JSON object for '{key}' does not match schema: {e.message}") - node_inputs[key] = json_value diff --git a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py index 539e72edb5..16b432bae6 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py @@ -58,6 +58,8 @@ def test_json_object_valid_schema(): } ) + schema = json.loads(schema) + variables = [ VariableEntity( variable="profile", @@ -68,7 +70,7 @@ def test_json_object_valid_schema(): ) ] - user_inputs = {"profile": json.dumps({"age": 20, "name": "Tom"})} + user_inputs = {"profile": {"age": 20, "name": "Tom"}} node = make_start_node(user_inputs, variables) result = node._run() @@ -87,6 +89,8 @@ def test_json_object_invalid_json_string(): "required": ["age", "name"], } ) + + schema = json.loads(schema) variables = [ VariableEntity( variable="profile", @@ -97,12 +101,12 @@ def test_json_object_invalid_json_string(): ) ] - # Missing closing brace makes this invalid JSON + # Providing a string instead of an object should raise a type error user_inputs = {"profile": '{"age": 20, "name": "Tom"'} node = make_start_node(user_inputs, variables) - with pytest.raises(ValueError, match='{"age": 20, "name": "Tom" must be a valid JSON object'): + with pytest.raises(ValueError, match="JSON object for 'profile' must be an object"): node._run() @@ -118,6 +122,8 @@ def test_json_object_does_not_match_schema(): } ) + schema = json.loads(schema) + variables = [ VariableEntity( variable="profile", @@ -129,7 +135,7 @@ def test_json_object_does_not_match_schema(): ] # age is a string, which violates the schema (expects number) - user_inputs = {"profile": json.dumps({"age": "twenty", "name": "Tom"})} + user_inputs = {"profile": {"age": "twenty", "name": "Tom"}} node = make_start_node(user_inputs, variables) @@ -149,6 +155,8 @@ def test_json_object_missing_required_schema_field(): } ) + schema = json.loads(schema) + variables = [ VariableEntity( variable="profile", @@ -160,7 +168,7 @@ def test_json_object_missing_required_schema_field(): ] # Missing required field "name" - user_inputs = {"profile": json.dumps({"age": 20})} + user_inputs = {"profile": {"age": 20}} node = make_start_node(user_inputs, variables) diff --git a/web/app/components/app/configuration/config-var/config-modal/index.tsx b/web/app/components/app/configuration/config-var/config-modal/index.tsx index 782744882e..5ffa87375c 100644 --- a/web/app/components/app/configuration/config-var/config-modal/index.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/index.tsx @@ -83,7 +83,7 @@ const ConfigModal: FC = ({ if (!isJsonObject || !tempPayload.json_schema) return '' try { - return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2) + return tempPayload.json_schema } catch { return '' diff --git a/web/app/components/base/chat/chat/utils.ts b/web/app/components/base/chat/chat/utils.ts index ab150f3e61..a64c8162dc 100644 --- a/web/app/components/base/chat/chat/utils.ts +++ b/web/app/components/base/chat/chat/utils.ts @@ -37,7 +37,7 @@ export const getProcessedInputs = (inputs: Record, inputsForm: Inpu return } - if (!inputValue) + if (inputValue == null) return if (item.type === InputVarType.singleFile) { @@ -52,6 +52,20 @@ export const getProcessedInputs = (inputs: Record, inputsForm: Inpu else processedInputs[item.variable] = getProcessedFiles(inputValue) } + else if (item.type === InputVarType.jsonObject) { + // Prefer sending an object if the user entered valid JSON; otherwise keep the raw string. + try { + const v = typeof inputValue === 'string' ? JSON.parse(inputValue) : inputValue + if (v && typeof v === 'object' && !Array.isArray(v)) + processedInputs[item.variable] = v + else + processedInputs[item.variable] = inputValue + } + catch { + // keep original string; backend will parse/validate + processedInputs[item.variable] = inputValue + } + } }) return processedInputs diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx index 6094147bbd..b8193fd944 100644 --- a/web/app/components/share/text-generation/run-once/index.tsx +++ b/web/app/components/share/text-generation/run-once/index.tsx @@ -195,7 +195,7 @@ const RunOnce: FC = ({ noWrapper className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" placeholder={ -
{item.json_schema}
+
{typeof item.json_schema === 'string' ? item.json_schema : JSON.stringify(item.json_schema || '', null, 2)}
} /> )} diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx index 81a8453582..3eef34bd7b 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx @@ -48,6 +48,12 @@ const FormItem: FC = ({ const { t } = useTranslation() const { type } = payload const fileSettings = useHooksStore(s => s.configsMap?.fileSettings) + const jsonSchemaPlaceholder = React.useMemo(() => { + const schema = (payload as any)?.json_schema + if (!schema) + return '' + return typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2) + }, [payload]) const handleArrayItemChange = useCallback((index: number) => { return (newValue: any) => { @@ -211,7 +217,7 @@ const FormItem: FC = ({ noWrapper className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" placeholder={ -
{payload.json_schema}
+
{jsonSchemaPlaceholder}
} /> )} diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 9f77be0ce2..e5e8174456 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -353,7 +353,7 @@ const formatItem = ( try { if (type === VarType.object && v.json_schema) { varRes.children = { - schema: JSON.parse(v.json_schema), + schema: typeof v.json_schema === 'string' ? JSON.parse(v.json_schema) : v.json_schema, } } } diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 740f1c1113..63a66eb5cd 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -223,7 +223,7 @@ export type InputVar = { getVarValueFromDependent?: boolean hide?: boolean isFileItem?: boolean - json_schema?: string // for jsonObject type + json_schema?: string | Record // for jsonObject type } & Partial export type ModelConfig = { diff --git a/web/models/debug.ts b/web/models/debug.ts index 5290268fe9..73d0910e82 100644 --- a/web/models/debug.ts +++ b/web/models/debug.ts @@ -62,7 +62,7 @@ export type PromptVariable = { icon?: string icon_background?: string hide?: boolean // used in frontend to hide variable - json_schema?: string + json_schema?: string | Record } export type CompletionParams = { diff --git a/web/service/workflow-payload.ts b/web/service/workflow-payload.ts index b294141cb7..5e2cdebdb3 100644 --- a/web/service/workflow-payload.ts +++ b/web/service/workflow-payload.ts @@ -66,7 +66,30 @@ export const sanitizeWorkflowDraftPayload = (params: WorkflowDraftSyncParams): W if (!graph?.nodes?.length) return params - const sanitizedNodes = graph.nodes.map(node => sanitizeTriggerPluginNode(node as Node)) + const sanitizedNodes = graph.nodes.map((node) => { + // First sanitize known node types (TriggerPlugin) + const n = sanitizeTriggerPluginNode(node as Node) as Node + + // Normalize Start node variable json_schema: ensure dict, not string + if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) { + const next = { ...n, data: { ...n.data } } + next.data.variables = (n.data as any).variables.map((v: any) => { + if (v && v.type === 'json_object' && typeof v.json_schema === 'string') { + try { + const obj = JSON.parse(v.json_schema) + return { ...v, json_schema: obj } + } + catch { + return v + } + } + return v + }) + return next + } + + return n + }) return { ...params, @@ -126,7 +149,25 @@ export const hydrateWorkflowDraftResponse = (draft: FetchWorkflowDraftResponse): if (node.data) removeTempProperties(node.data as Record) - return hydrateTriggerPluginNode(node) + let n = hydrateTriggerPluginNode(node) + // Normalize Start node variable json_schema to object when loading + if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) { + const next = { ...n, data: { ...n.data } } as Node + next.data.variables = (n.data as any).variables.map((v: any) => { + if (v && v.type === 'json_object' && typeof v.json_schema === 'string') { + try { + const obj = JSON.parse(v.json_schema) + return { ...v, json_schema: obj } + } + catch { + return v + } + } + return v + }) + n = next + } + return n }) } diff --git a/web/service/workflow.ts b/web/service/workflow.ts index 7571e804a9..3a37db791b 100644 --- a/web/service/workflow.ts +++ b/web/service/workflow.ts @@ -9,6 +9,7 @@ import type { } from '@/types/workflow' import { get, post } from './base' import { getFlowPrefix } from './utils' +import { sanitizeWorkflowDraftPayload } from './workflow-payload' export const fetchWorkflowDraft = (url: string) => { return get(url, {}, { silent: true }) as Promise @@ -18,7 +19,8 @@ export const syncWorkflowDraft = ({ url, params }: { url: string params: Pick }) => { - return post(url, { body: params }, { silent: true }) + const sanitized = sanitizeWorkflowDraftPayload(params) + return post(url, { body: sanitized }, { silent: true }) } export const fetchNodesDefaultConfigs = (url: string) => { From 8b1af36d94fa8491fc8d140b1954bcf5142bd703 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Sat, 10 Jan 2026 16:16:18 +0800 Subject: [PATCH 13/22] feat(web): migrate PWA to Serwist (#30808) --- web/app/components/provider/serwist.tsx | 3 + web/app/layout.tsx | 60 +- web/app/serwist/[path]/route.ts | 14 + web/app/sw.ts | 104 +++ web/knip.config.ts | 5 +- web/next.config.js | 72 +- web/package.json | 6 +- web/pnpm-lock.yaml | 1124 +++++++---------------- web/public/workbox-c05e7c83.js | 1 - 9 files changed, 505 insertions(+), 884 deletions(-) create mode 100644 web/app/components/provider/serwist.tsx create mode 100644 web/app/serwist/[path]/route.ts create mode 100644 web/app/sw.ts delete mode 100644 web/public/workbox-c05e7c83.js diff --git a/web/app/components/provider/serwist.tsx b/web/app/components/provider/serwist.tsx new file mode 100644 index 0000000000..39a80f5ac2 --- /dev/null +++ b/web/app/components/provider/serwist.tsx @@ -0,0 +1,3 @@ +'use client' + +export { SerwistProvider } from '@serwist/turbopack/react' diff --git a/web/app/layout.tsx b/web/app/layout.tsx index acd56e1da6..b970fddc7a 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -12,6 +12,7 @@ import { ToastProvider } from './components/base/toast' import BrowserInitializer from './components/browser-initializer' import { ReactScanLoader } from './components/devtools/react-scan/loader' import { I18nServerProvider } from './components/provider/i18n-server' +import { SerwistProvider } from './components/provider/serwist' import SentryInitializer from './components/sentry-initializer' import RoutePrefixHandle from './routePrefixHandle' import './styles/globals.css' @@ -39,6 +40,9 @@ const LocaleLayout = async ({ }) => { const locale = await getLocaleOnServer() + const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '' + const swUrl = `${basePath}/serwist/sw.js` + const datasetMap: Record = { [DatasetAttr.DATA_API_PREFIX]: process.env.NEXT_PUBLIC_API_PREFIX, [DatasetAttr.DATA_PUBLIC_API_PREFIX]: process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX, @@ -92,33 +96,35 @@ const LocaleLayout = async ({ className="color-scheme h-full select-auto" {...datasetMap} > - - - - - - - - - - - {children} - - - - - - - - - - + + + + + + + + + + + + {children} + + + + + + + + + + + ) diff --git a/web/app/serwist/[path]/route.ts b/web/app/serwist/[path]/route.ts new file mode 100644 index 0000000000..ad371756b5 --- /dev/null +++ b/web/app/serwist/[path]/route.ts @@ -0,0 +1,14 @@ +import { spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { createSerwistRoute } from '@serwist/turbopack' + +const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '' +const revision = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf-8' }).stdout?.trim() || randomUUID() + +export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } = createSerwistRoute({ + additionalPrecacheEntries: [{ url: `${basePath}/_offline.html`, revision }], + swSrc: 'app/sw.ts', + nextConfig: { + basePath, + }, +}) diff --git a/web/app/sw.ts b/web/app/sw.ts new file mode 100644 index 0000000000..4094a51e57 --- /dev/null +++ b/web/app/sw.ts @@ -0,0 +1,104 @@ +/// +/// +/// + +import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist' +import { CacheableResponsePlugin, CacheFirst, ExpirationPlugin, NetworkFirst, Serwist, StaleWhileRevalidate } from 'serwist' + +declare global { + // eslint-disable-next-line ts/consistent-type-definitions + interface WorkerGlobalScope extends SerwistGlobalConfig { + __SW_MANIFEST: (PrecacheEntry | string)[] | undefined + } +} + +declare const self: ServiceWorkerGlobalScope + +const scopePathname = new URL(self.registration.scope).pathname +const basePath = scopePathname.replace(/\/serwist\/$/, '').replace(/\/$/, '') +const offlineUrl = `${basePath}/_offline.html` + +const serwist = new Serwist({ + precacheEntries: self.__SW_MANIFEST, + skipWaiting: true, + clientsClaim: true, + navigationPreload: true, + runtimeCaching: [ + { + matcher: ({ url }) => url.origin === 'https://fonts.googleapis.com', + handler: new CacheFirst({ + cacheName: 'google-fonts', + plugins: [ + new CacheableResponsePlugin({ statuses: [0, 200] }), + new ExpirationPlugin({ + maxEntries: 4, + maxAgeSeconds: 365 * 24 * 60 * 60, + }), + ], + }), + }, + { + matcher: ({ url }) => url.origin === 'https://fonts.gstatic.com', + handler: new CacheFirst({ + cacheName: 'google-fonts-webfonts', + plugins: [ + new CacheableResponsePlugin({ statuses: [0, 200] }), + new ExpirationPlugin({ + maxEntries: 4, + maxAgeSeconds: 365 * 24 * 60 * 60, + }), + ], + }), + }, + { + matcher: ({ request }) => request.destination === 'image', + handler: new CacheFirst({ + cacheName: 'images', + plugins: [ + new CacheableResponsePlugin({ statuses: [0, 200] }), + new ExpirationPlugin({ + maxEntries: 64, + maxAgeSeconds: 30 * 24 * 60 * 60, + }), + ], + }), + }, + { + matcher: ({ request }) => request.destination === 'script' || request.destination === 'style', + handler: new StaleWhileRevalidate({ + cacheName: 'static-resources', + plugins: [ + new ExpirationPlugin({ + maxEntries: 32, + maxAgeSeconds: 24 * 60 * 60, + }), + ], + }), + }, + { + matcher: ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith('/api/'), + handler: new NetworkFirst({ + cacheName: 'api-cache', + networkTimeoutSeconds: 10, + plugins: [ + new ExpirationPlugin({ + maxEntries: 16, + maxAgeSeconds: 60 * 60, + }), + ], + }), + }, + ], + fallbacks: { + entries: [ + { + url: offlineUrl, + matcher({ request }) { + return request.destination === 'document' + }, + }, + ], + }, +}) + +serwist.addEventListeners() diff --git a/web/knip.config.ts b/web/knip.config.ts index 6ffda0316a..c597adb358 100644 --- a/web/knip.config.ts +++ b/web/knip.config.ts @@ -15,10 +15,7 @@ const config: KnipConfig = { ignoreBinaries: [ 'only-allow', ], - ignoreDependencies: [ - // required by next-pwa - 'babel-loader', - ], + ignoreDependencies: [], rules: { files: 'warn', dependencies: 'warn', diff --git a/web/next.config.js b/web/next.config.js index 3414d09021..7b1f57adec 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -1,77 +1,8 @@ import withBundleAnalyzerInit from '@next/bundle-analyzer' import createMDX from '@next/mdx' import { codeInspectorPlugin } from 'code-inspector-plugin' -import withPWAInit from 'next-pwa' const isDev = process.env.NODE_ENV === 'development' - -const withPWA = withPWAInit({ - dest: 'public', - register: true, - skipWaiting: true, - disable: process.env.NODE_ENV === 'development', - fallbacks: { - document: '/_offline.html', - }, - runtimeCaching: [ - { - urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, - handler: 'CacheFirst', - options: { - cacheName: 'google-fonts', - expiration: { - maxEntries: 4, - maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year - }, - }, - }, - { - urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, - handler: 'CacheFirst', - options: { - cacheName: 'google-fonts-webfonts', - expiration: { - maxEntries: 4, - maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year - }, - }, - }, - { - urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|avif)$/i, - handler: 'CacheFirst', - options: { - cacheName: 'images', - expiration: { - maxEntries: 64, - maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days - }, - }, - }, - { - urlPattern: /\.(?:js|css)$/i, - handler: 'StaleWhileRevalidate', - options: { - cacheName: 'static-resources', - expiration: { - maxEntries: 32, - maxAgeSeconds: 24 * 60 * 60, // 1 day - }, - }, - }, - { - urlPattern: /^\/api\/.*/i, - handler: 'NetworkFirst', - options: { - cacheName: 'api-cache', - networkTimeoutSeconds: 10, - expiration: { - maxEntries: 16, - maxAgeSeconds: 60 * 60, // 1 hour - }, - }, - }, - ], -}) const withMDX = createMDX({ extension: /\.mdx?$/, options: { @@ -97,6 +28,7 @@ const remoteImageURLs = [hasSetWebPrefix ? new URL(`${process.env.NEXT_PUBLIC_WE /** @type {import('next').NextConfig} */ const nextConfig = { basePath: process.env.NEXT_PUBLIC_BASE_PATH || '', + serverExternalPackages: ['esbuild-wasm'], transpilePackages: ['echarts', 'zrender'], turbopack: { rules: codeInspectorPlugin({ @@ -148,4 +80,4 @@ const nextConfig = { }, } -export default withPWA(withBundleAnalyzer(withMDX(nextConfig))) +export default withBundleAnalyzer(withMDX(nextConfig)) diff --git a/web/package.json b/web/package.json index dcebe742a7..4a14ab8601 100644 --- a/web/package.json +++ b/web/package.json @@ -111,7 +111,6 @@ "mitt": "^3.0.1", "negotiator": "^1.0.0", "next": "~15.5.9", - "next-pwa": "^5.6.0", "next-themes": "^0.4.6", "nuqs": "^2.8.6", "pinyin-pro": "^3.27.0", @@ -153,7 +152,6 @@ }, "devDependencies": { "@antfu/eslint-config": "^6.7.3", - "@babel/core": "^7.28.4", "@chromatic-com/storybook": "^4.1.1", "@eslint-react/eslint-plugin": "^2.3.13", "@mdx-js/loader": "^3.1.1", @@ -162,6 +160,7 @@ "@next/eslint-plugin-next": "15.5.9", "@next/mdx": "15.5.9", "@rgrove/parse-xml": "^4.2.0", + "@serwist/turbopack": "^9.5.0", "@storybook/addon-docs": "9.1.13", "@storybook/addon-links": "9.1.13", "@storybook/addon-onboarding": "9.1.13", @@ -194,9 +193,9 @@ "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "4.0.16", "autoprefixer": "^10.4.21", - "babel-loader": "^10.0.0", "code-inspector-plugin": "1.2.9", "cross-env": "^10.1.0", + "esbuild-wasm": "^0.27.2", "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.26", @@ -212,6 +211,7 @@ "postcss": "^8.5.6", "react-scan": "^0.4.3", "sass": "^1.93.2", + "serwist": "^9.5.0", "storybook": "9.1.17", "tailwindcss": "^3.4.18", "tsx": "^4.21.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 5ad6d0481b..2ce94ae8eb 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -62,7 +62,7 @@ importers: version: 2.33.1 '@amplitude/plugin-session-replay-browser': specifier: ^1.23.6 - version: 1.24.1(@amplitude/rrweb@2.0.0-alpha.33)(rollup@2.79.2) + version: 1.24.1(@amplitude/rrweb@2.0.0-alpha.33)(rollup@4.53.5) '@emoji-mart/data': specifier: ^1.2.1 version: 1.2.1 @@ -234,9 +234,6 @@ importers: next: specifier: ~15.5.9 version: 15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0) - next-pwa: - specifier: ^5.6.0 - version: 5.6.0(@babel/core@7.28.5)(@types/babel__core@7.20.5)(esbuild@0.25.0)(next@15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0))(uglify-js@3.19.3)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -355,9 +352,6 @@ importers: '@antfu/eslint-config': specifier: ^6.7.3 version: 6.7.3(@eslint-react/eslint-plugin@2.3.13(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@15.5.9)(@vue/compiler-sfc@3.5.25)(eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.16(@types/node@18.15.0)(happy-dom@20.0.11)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.0))(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@babel/core': - specifier: ^7.28.4 - version: 7.28.5 '@chromatic-com/storybook': specifier: ^4.1.1 version: 4.1.3(storybook@9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) @@ -382,6 +376,9 @@ importers: '@rgrove/parse-xml': specifier: ^4.2.0 version: 4.2.0 + '@serwist/turbopack': + specifier: ^9.5.0 + version: 9.5.0(@swc/helpers@0.5.17)(esbuild-wasm@0.27.2)(next@15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0))(react@19.2.3)(typescript@5.9.3) '@storybook/addon-docs': specifier: 9.1.13 version: 9.1.13(@types/react@19.2.7)(storybook@9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) @@ -478,15 +475,15 @@ importers: autoprefixer: specifier: ^10.4.21 version: 10.4.22(postcss@8.5.6) - babel-loader: - specifier: ^10.0.0 - version: 10.0.0(@babel/core@7.28.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)) code-inspector-plugin: specifier: 1.2.9 version: 1.2.9 cross-env: specifier: ^10.1.0 version: 10.1.0 + esbuild-wasm: + specifier: ^0.27.2 + version: 0.27.2 eslint: specifier: ^9.39.2 version: 9.39.2(jiti@1.21.7) @@ -528,10 +525,13 @@ importers: version: 8.5.6 react-scan: specifier: ^0.4.3 - version: 0.4.3(@types/react@19.2.7)(next@15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@2.79.2) + version: 0.4.3(@types/react@19.2.7)(next@15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.53.5) sass: specifier: ^1.93.2 version: 1.95.0 + serwist: + specifier: ^9.5.0 + version: 9.5.0(typescript@5.9.3) storybook: specifier: 9.1.17 version: 9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -704,12 +704,6 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@apideck/better-ajv-errors@0.3.6': - resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} - engines: {node: '>=10'} - peerDependencies: - ajv: '>=8' - '@asamuzakjp/css-color@4.1.1': resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} @@ -2133,13 +2127,9 @@ packages: cpu: [x64] os: [win32] - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2404,10 +2394,6 @@ packages: resolution: {integrity: sha512-y3SvzjuY1ygnzWA4Krwx/WaJAsTMP11DN+e21A8Fa8PW1oDtVB5NSRW7LWurAiS2oKRkuCgcjTYMkBuBkcPCRg==} engines: {node: '>=12.4.0'} - '@nolyfill/string.prototype.matchall@1.0.44': - resolution: {integrity: sha512-/lwVUaDPCeopUL6XPz2B2ZwaQeIbctP8YxNIyCxunxVKWhCAhii+w0ourNK7JedyGIcM+DaXZTeRlcbgEWaZig==} - engines: {node: '>=12.4.0'} - '@nolyfill/typed-array-buffer@1.0.44': resolution: {integrity: sha512-QDtsud32BpViorcc6KOgFaRYUI2hyQewOaRD9NF1fs7g+cv6d3MbIJCYWpkOwAXATKlCeELtSbuTYDXAaw7S+Q==} engines: {node: '>=12.4.0'} @@ -2643,6 +2629,10 @@ packages: react: '>=18' react-dom: '>=18' + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2967,28 +2957,6 @@ packages: '@rolldown/pluginutils@1.0.0-beta.53': resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} - '@rollup/plugin-babel@5.3.1': - resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} - engines: {node: '>= 10.0.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@types/babel__core': ^7.1.9 - rollup: ^1.20.0||^2.0.0 - peerDependenciesMeta: - '@types/babel__core': - optional: true - - '@rollup/plugin-node-resolve@11.2.1': - resolution: {integrity: sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==} - engines: {node: '>= 10.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0 - - '@rollup/plugin-replace@2.4.2': - resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} - peerDependencies: - rollup: ^1.20.0 || ^2.0.0 - '@rollup/plugin-replace@6.0.3': resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} engines: {node: '>=14.0.0'} @@ -2998,12 +2966,6 @@ packages: rollup: optional: true - '@rollup/pluginutils@3.1.0': - resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} - engines: {node: '>= 8.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0 - '@rollup/pluginutils@5.3.0': resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} engines: {node: '>=14.0.0'} @@ -3153,6 +3115,38 @@ packages: peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x + '@serwist/build@9.5.0': + resolution: {integrity: sha512-8D330WwYjBI5MadyVOphwUqJLMNQK76KWBoDykIPrbtt0C3uGFPxG4XNZCFXBkRG3O1QLedv9BWqH27SeqhcLg==} + engines: {node: '>=18.0.0'} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + '@serwist/turbopack@9.5.0': + resolution: {integrity: sha512-MPxDapkN6LPG25I8LgOxQHc2ifIWK8WY+4pOdAbAFmN4FvsgLwYJ/W4531lsRWu08sq5IZ9JlRtt6KLNm5DHSQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + esbuild-wasm: '>=0.25.0 <1.0.0' + next: '>=14.0.0' + react: '>=18.0.0' + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + '@serwist/utils@9.5.0': + resolution: {integrity: sha512-DBzmJgL63/VYcQ/TpSYXW1FJKRILesOxytu+1MHY0vW2WFhW7pYkLgVuHqksP5K+3ypt54M3+2QNDDMixrnutQ==} + + '@serwist/window@9.5.0': + resolution: {integrity: sha512-WqmEZjJ+u841sbUJh2LAbtPNrz8mU/wCTo9sEVqsMOk+EM5oBz5FRpF3kDzx1cF5rTIfXer1df0D354lIdFw1Q==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + '@sindresorhus/base62@1.0.0': resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} engines: {node: '>=18'} @@ -3305,18 +3299,90 @@ packages: peerDependencies: eslint: '>=9.0.0' - '@surma/rollup-plugin-off-main-thread@2.2.3': - resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} - '@svgdotjs/svg.js@3.2.5': resolution: {integrity: sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==} + '@swc/core-darwin-arm64@1.15.8': + resolution: {integrity: sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.8': + resolution: {integrity: sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.8': + resolution: {integrity: sha512-siAzDENu2rUbwr9+fayWa26r5A9fol1iORG53HWxQL1J8ym4k7xt9eME0dMPXlYZDytK5r9sW8zEA10F2U3Xwg==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.8': + resolution: {integrity: sha512-o+1y5u6k2FfPYbTRUPvurwzNt5qd0NTumCTFscCNuBksycloXY16J8L+SMW5QRX59n4Hp9EmFa3vpvNHRVv1+Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.15.8': + resolution: {integrity: sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-x64-gnu@1.15.8': + resolution: {integrity: sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.15.8': + resolution: {integrity: sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.15.8': + resolution: {integrity: sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.8': + resolution: {integrity: sha512-/wfAgxORg2VBaUoFdytcVBVCgf1isWZIEXB9MZEUty4wwK93M/PxAkjifOho9RN3WrM3inPLabICRCEgdHpKKQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.8': + resolution: {integrity: sha512-GpMePrh9Sl4d61o4KAHOOv5is5+zt6BEXCOCgs/H0FLGeii7j9bWDE8ExvKFy2GRRZVNR1ugsnzaGWHKM6kuzA==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.8': + resolution: {integrity: sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + '@tailwindcss/typography@0.5.19': resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} peerDependencies: @@ -3617,18 +3683,12 @@ packages: '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - '@types/estree@0.0.39': - resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/glob@7.2.0': - resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} - '@types/hast@2.3.10': resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} @@ -3656,10 +3716,6 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} - '@types/minimatch@6.0.0': - resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} - deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -3706,9 +3762,6 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} - '@types/resolve@1.17.1': - resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==} - '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} @@ -4198,18 +4251,6 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - array-union@1.0.2: - resolution: {integrity: sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==} - engines: {node: '>=0.10.0'} - - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - - array-uniq@1.0.3: - resolution: {integrity: sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==} - engines: {node: '>=0.10.0'} - asn1.js@4.10.1: resolution: {integrity: sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==} @@ -4231,10 +4272,6 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - at-least-node@1.0.0: - resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} - engines: {node: '>= 4.0.0'} - autoprefixer@10.4.22: resolution: {integrity: sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==} engines: {node: ^10 || ^12 || >=14} @@ -4242,20 +4279,6 @@ packages: peerDependencies: postcss: ^8.1.0 - babel-loader@10.0.0: - resolution: {integrity: sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA==} - engines: {node: ^18.20.0 || ^20.10.0 || >=22.0.0} - peerDependencies: - '@babel/core': ^7.12.0 - webpack: '>=5.61.0' - - babel-loader@8.4.1: - resolution: {integrity: sha512-nXzRChX+Z1GoE6yWavBQg6jDslyFF3SDjl2paADuoQtQW10JqShJt62R6eJQ5m/pjJFDT8xgKIWSP85OY8eXeA==} - engines: {node: '>= 8.9'} - peerDependencies: - '@babel/core': ^7.0.0 - webpack: '>=2' - babel-loader@9.2.1: resolution: {integrity: sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==} engines: {node: '>= 14.15.0'} @@ -4537,12 +4560,6 @@ packages: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} - clean-webpack-plugin@4.0.0: - resolution: {integrity: sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w==} - engines: {node: '>=10.0.0'} - peerDependencies: - webpack: '>=4.0.0 <6.0.0' - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -4711,10 +4728,6 @@ packages: resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==} engines: {node: '>= 0.10'} - crypto-random-string@2.0.0: - resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} - engines: {node: '>=8'} - css-loader@6.11.0: resolution: {integrity: sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==} engines: {node: '>= 12.13.0'} @@ -4963,10 +4976,6 @@ packages: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} - del@4.1.1: - resolution: {integrity: sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==} - engines: {node: '>=6'} - delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} @@ -5002,10 +5011,6 @@ packages: diffie-hellman@5.0.3: resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==} - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -5064,11 +5069,6 @@ packages: echarts@5.6.0: resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} - ejs@3.1.10: - resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} - engines: {node: '>=0.10.0'} - hasBin: true - electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -5144,6 +5144,11 @@ packages: peerDependencies: esbuild: 0.25.0 + esbuild-wasm@0.27.2: + resolution: {integrity: sha512-eUTnl8eh+v8UZIZh4MrMOKDAc8Lm7+NqP3pyuTORGFY1s/o9WoiJgKnwXy+te2J3hX7iRbFSHEyig7GsPeeJyw==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.25.0: resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} engines: {node: '>=18'} @@ -5454,9 +5459,6 @@ packages: estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} - estree-walker@1.0.1: - resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} - estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -5553,9 +5555,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - filelist@1.0.4: - resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} - filesize@10.1.6: resolution: {integrity: sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==} engines: {node: '>= 10.4.0'} @@ -5607,6 +5606,10 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fork-ts-checker-webpack-plugin@8.0.0: resolution: {integrity: sha512-mX3qW3idpueT2klaQXBzrIM/pHw+T0B/V9KHEvNrqijTq9NFnMZU6oreVxDYcf33P8a5cW+67PjodNHthGnNVg==} engines: {node: '>=12.13.0', yarn: '>=1.0.0'} @@ -5633,10 +5636,6 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} - fs-extra@9.1.0: - resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} - engines: {node: '>=10'} - fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} @@ -5668,9 +5667,6 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} - get-own-enumerable-property-symbols@3.0.2: - resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} - get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} @@ -5695,6 +5691,10 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -5711,14 +5711,6 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - - globby@6.1.0: - resolution: {integrity: sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==} - engines: {node: '>=0.10.0'} - globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} @@ -5915,12 +5907,12 @@ packages: idb-keyval@6.2.2: resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} - idb@7.1.1: - resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} - idb@8.0.0: resolution: {integrity: sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==} + idb@8.0.3: + resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -6052,9 +6044,6 @@ packages: eslint: '*' typescript: '>=4.7.4' - is-module@1.0.0: - resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} - is-node-process@1.2.0: resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} @@ -6062,22 +6051,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-obj@1.0.1: - resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} - engines: {node: '>=0.10.0'} - - is-path-cwd@2.2.0: - resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==} - engines: {node: '>=6'} - - is-path-in-cwd@2.1.0: - resolution: {integrity: sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==} - engines: {node: '>=6'} - - is-path-inside@2.1.0: - resolution: {integrity: sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==} - engines: {node: '>=6'} - is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -6089,14 +6062,6 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-regexp@1.0.0: - resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} - engines: {node: '>=0.10.0'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -6127,14 +6092,8 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - jake@10.9.4: - resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} - engines: {node: '>=10'} - hasBin: true - - jest-worker@26.6.2: - resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==} - engines: {node: '>= 10.13.0'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} @@ -6228,9 +6187,6 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json-schema@0.4.0: - resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} - json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -6249,10 +6205,6 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - jsonpointer@5.0.1: - resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} - engines: {node: '>=0.10.0'} - jsonschema@1.5.0: resolution: {integrity: sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==} @@ -6282,6 +6234,9 @@ packages: '@types/node': '>=18' typescript: '>=5.0.4 <7' + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + ky@1.14.1: resolution: {integrity: sha512-hYje4L9JCmpEQBtudo+v52X5X8tgWXUYyPcxKSuxQNboqufecl9VMWjGiucAFH060AwPXHZuH+WB2rrqfkmafw==} engines: {node: '>=18'} @@ -6302,10 +6257,6 @@ packages: layout-base@2.0.1: resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -6400,6 +6351,9 @@ packages: lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.4: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} @@ -6415,9 +6369,6 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true - magic-string@0.25.9: - resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -6683,17 +6634,9 @@ packages: minimalistic-crypto-utils@1.0.1: resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} - minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} - minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -6701,6 +6644,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} @@ -6749,11 +6696,6 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next-pwa@5.6.0: - resolution: {integrity: sha512-XV8g8C6B7UmViXU8askMEYhWwQ4qc/XqJGnexbLV68hzKaGHZDMtHsm2TNxFcbR7+ypVuth/wwpiIlMwpRJJ5A==} - peerDependencies: - next: '>=9.0.0' - next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -6920,14 +6862,13 @@ packages: resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - p-map@2.1.0: - resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} - engines: {node: '>=6'} - p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} @@ -6995,9 +6936,6 @@ packages: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} - path-is-inside@1.0.2: - resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -7009,6 +6947,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -7052,18 +6994,6 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} - pify@4.0.1: - resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} - engines: {node: '>=6'} - - pinkie-promise@2.0.1: - resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} - engines: {node: '>=0.10.0'} - - pinkie@2.0.4: - resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} - engines: {node: '>=0.10.0'} - pinyin-pro@3.27.0: resolution: {integrity: sha512-Osdgjwe7Rm17N2paDMM47yW+jUIUH3+0RGo8QP39ZTLpTaJVDK0T58hOLaMQJbcMmAebVuK2ePunTEVEx1clNQ==} @@ -7220,9 +7150,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - pretty-bytes@5.6.0: - resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} - engines: {node: '>=6'} + pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} pretty-error@4.0.0: resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==} @@ -7656,11 +7586,6 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rimraf@2.7.1: - resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -7676,17 +7601,6 @@ packages: robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} - rollup-plugin-terser@7.0.2: - resolution: {integrity: sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==} - deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser - peerDependencies: - rollup: ^2.0.0 - - rollup@2.79.2: - resolution: {integrity: sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==} - engines: {node: '>=10.0.0'} - hasBin: true - rollup@4.53.5: resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -7740,10 +7654,6 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - schema-utils@2.7.1: - resolution: {integrity: sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==} - engines: {node: '>= 8.9.0'} - schema-utils@3.3.0: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} @@ -7774,9 +7684,6 @@ packages: engines: {node: '>=10'} hasBin: true - serialize-javascript@4.0.0: - resolution: {integrity: sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==} - serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -7790,6 +7697,14 @@ packages: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} + serwist@9.5.0: + resolution: {integrity: sha512-wjrsPWHI5ZM20jIsVKZGN/uAdS2aKOgmIOE4dqUaFhK6SVIzgoJZjTnZ3v29T+NmneuD753jlhGui9eYypsj0A==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} @@ -7840,10 +7755,6 @@ packages: size-sensor@1.0.2: resolution: {integrity: sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw==} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - slice-ansi@5.0.0: resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} engines: {node: '>=12'} @@ -7862,9 +7773,6 @@ packages: sortablejs@1.15.6: resolution: {integrity: sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==} - source-list-map@2.0.1: - resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -7885,10 +7793,6 @@ packages: engines: {node: '>= 8'} deprecated: The work that was done in this beta branch won't be included in future versions - sourcemap-codec@1.4.8: - resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} - deprecated: Please use @jridgewell/sourcemap-codec instead - space-separated-tokens@1.1.5: resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} @@ -7954,10 +7858,6 @@ packages: stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} - stringify-object@3.3.0: - resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} - engines: {node: '>=4'} - strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -7970,10 +7870,6 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - strip-comments@2.0.1: - resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} - engines: {node: '>=10'} - strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} @@ -8085,14 +7981,6 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - temp-dir@2.0.0: - resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} - engines: {node: '>=8'} - - tempy@0.6.0: - resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} - engines: {node: '>=10'} - terser-webpack-plugin@5.3.15: resolution: {integrity: sha512-PGkOdpRFK+rb1TzVz+msVhw4YMRT9txLF4kRqvJhGhCM324xuR3REBSHALN+l+sAhKUmz0aotnjp5D+P83mLhQ==} engines: {node: '>= 10.13.0'} @@ -8267,10 +8155,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@0.16.0: - resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} - engines: {node: '>=10'} - type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} @@ -8318,10 +8202,6 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - unique-string@2.0.0: - resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} - engines: {node: '>=8'} - unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} @@ -8361,10 +8241,6 @@ packages: resolution: {integrity: sha512-us4j03/499KhbGP8BU7Hrzrgseo+KdfJYWcbcajCOqsAyb8Gk0Yn2kiUIcZISYCb1JFaZfIuG3b42HmguVOKCQ==} engines: {node: '>=18.12.0'} - upath@1.2.0: - resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} - engines: {node: '>=4'} - update-browserslist-db@1.2.2: resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} hasBin: true @@ -8662,9 +8538,6 @@ packages: webpack-hot-middleware@2.26.1: resolution: {integrity: sha512-khZGfAeJx6I8K9zKohEWWYN6KDlVw2DHownoe+6Vtwj1LP9WFgegXnVMSkZ/dBEBtXFwrkkydsaPFlB7f8wU2A==} - webpack-sources@1.4.3: - resolution: {integrity: sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==} - webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -8716,62 +8589,13 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - workbox-background-sync@6.6.0: - resolution: {integrity: sha512-jkf4ZdgOJxC9u2vztxLuPT/UjlH7m/nWRQ/MgGL0v8BJHoZdVGJd18Kck+a0e55wGXdqyHO+4IQTk0685g4MUw==} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} - workbox-broadcast-update@6.6.0: - resolution: {integrity: sha512-nm+v6QmrIFaB/yokJmQ/93qIJ7n72NICxIwQwe5xsZiV2aI93MGGyEyzOzDPVz5THEr5rC3FJSsO3346cId64Q==} - - workbox-build@6.6.0: - resolution: {integrity: sha512-Tjf+gBwOTuGyZwMz2Nk/B13Fuyeo0Q84W++bebbVsfr9iLkDSo6j6PST8tET9HYA58mlRXwlMGpyWO8ETJiXdQ==} - engines: {node: '>=10.0.0'} - - workbox-cacheable-response@6.6.0: - resolution: {integrity: sha512-JfhJUSQDwsF1Xv3EV1vWzSsCOZn4mQ38bWEBR3LdvOxSPgB65gAM6cS2CX8rkkKHRgiLrN7Wxoyu+TuH67kHrw==} - deprecated: workbox-background-sync@6.6.0 - - workbox-core@6.6.0: - resolution: {integrity: sha512-GDtFRF7Yg3DD859PMbPAYPeJyg5gJYXuBQAC+wyrWuuXgpfoOrIQIvFRZnQ7+czTIQjIr1DhLEGFzZanAT/3bQ==} - - workbox-expiration@6.6.0: - resolution: {integrity: sha512-baplYXcDHbe8vAo7GYvyAmlS4f6998Jff513L4XvlzAOxcl8F620O91guoJ5EOf5qeXG4cGdNZHkkVAPouFCpw==} - - workbox-google-analytics@6.6.0: - resolution: {integrity: sha512-p4DJa6OldXWd6M9zRl0H6vB9lkrmqYFkRQ2xEiNdBFp9U0LhsGO7hsBscVEyH9H2/3eZZt8c97NB2FD9U2NJ+Q==} - deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained - - workbox-navigation-preload@6.6.0: - resolution: {integrity: sha512-utNEWG+uOfXdaZmvhshrh7KzhDu/1iMHyQOV6Aqup8Mm78D286ugu5k9MFD9SzBT5TcwgwSORVvInaXWbvKz9Q==} - - workbox-precaching@6.6.0: - resolution: {integrity: sha512-eYu/7MqtRZN1IDttl/UQcSZFkHP7dnvr/X3Vn6Iw6OsPMruQHiVjjomDFCNtd8k2RdjLs0xiz9nq+t3YVBcWPw==} - - workbox-range-requests@6.6.0: - resolution: {integrity: sha512-V3aICz5fLGq5DpSYEU8LxeXvsT//mRWzKrfBOIxzIdQnV/Wj7R+LyJVTczi4CQ4NwKhAaBVaSujI1cEjXW+hTw==} - - workbox-recipes@6.6.0: - resolution: {integrity: sha512-TFi3kTgYw73t5tg73yPVqQC8QQjxJSeqjXRO4ouE/CeypmP2O/xqmB/ZFBBQazLTPxILUQ0b8aeh0IuxVn9a6A==} - - workbox-routing@6.6.0: - resolution: {integrity: sha512-x8gdN7VDBiLC03izAZRfU+WKUXJnbqt6PG9Uh0XuPRzJPpZGLKce/FkOX95dWHRpOHWLEq8RXzjW0O+POSkKvw==} - - workbox-strategies@6.6.0: - resolution: {integrity: sha512-eC07XGuINAKUWDnZeIPdRdVja4JQtTuc35TZ8SwMb1ztjp7Ddq2CJ4yqLvWzFWGlYI7CG/YGqaETntTxBGdKgQ==} - - workbox-streams@6.6.0: - resolution: {integrity: sha512-rfMJLVvwuED09CnH1RnIep7L9+mj4ufkTyDPVaXPKlhi9+0czCu+SJggWCIFbPpJaAZmp2iyVGLqS3RUmY3fxg==} - - workbox-sw@6.6.0: - resolution: {integrity: sha512-R2IkwDokbtHUE4Kus8pKO5+VkPHD2oqTgl+XJwh4zbF1HyjAbgNmK/FneZHVU7p03XUt9ICfuGDYISWG9qV/CQ==} - - workbox-webpack-plugin@6.6.0: - resolution: {integrity: sha512-xNZIZHalboZU66Wa7x1YkjIqEy1gTR+zPM+kjrYJzqN7iurYZBctBLISyScjhkJKYuRrZUP0iqViZTh8rS0+3A==} - engines: {node: '>=10.0.0'} - peerDependencies: - webpack: ^4.4.0 || ^5.9.0 - - workbox-window@6.6.0: - resolution: {integrity: sha512-L4N9+vka17d16geaJXXRjENLFldvkWy7JyGxElRD0JvBxvFEd8LOhr+uXCcar/NzAmIBRv9EZ+M+Qr4mOoBITw==} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} @@ -8865,6 +8689,9 @@ packages: zod@4.1.13: resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + zod@4.3.5: + resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + zrender@5.6.1: resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} @@ -8975,12 +8802,12 @@ snapshots: '@amplitude/analytics-core': 2.35.0 tslib: 2.8.1 - '@amplitude/plugin-session-replay-browser@1.24.1(@amplitude/rrweb@2.0.0-alpha.33)(rollup@2.79.2)': + '@amplitude/plugin-session-replay-browser@1.24.1(@amplitude/rrweb@2.0.0-alpha.33)(rollup@4.53.5)': dependencies: '@amplitude/analytics-client-common': 2.4.16 '@amplitude/analytics-core': 2.33.0 '@amplitude/analytics-types': 2.11.0 - '@amplitude/session-replay-browser': 1.30.0(@amplitude/rrweb@2.0.0-alpha.33)(rollup@2.79.2) + '@amplitude/session-replay-browser': 1.30.0(@amplitude/rrweb@2.0.0-alpha.33)(rollup@4.53.5) idb-keyval: 6.2.2 tslib: 2.8.1 transitivePeerDependencies: @@ -9034,7 +8861,7 @@ snapshots: base64-arraybuffer: 1.0.2 mitt: 3.0.1 - '@amplitude/session-replay-browser@1.30.0(@amplitude/rrweb@2.0.0-alpha.33)(rollup@2.79.2)': + '@amplitude/session-replay-browser@1.30.0(@amplitude/rrweb@2.0.0-alpha.33)(rollup@4.53.5)': dependencies: '@amplitude/analytics-client-common': 2.4.16 '@amplitude/analytics-core': 2.33.0 @@ -9045,7 +8872,7 @@ snapshots: '@amplitude/rrweb-types': 2.0.0-alpha.32 '@amplitude/rrweb-utils': 2.0.0-alpha.32 '@amplitude/targeting': 0.2.0 - '@rollup/plugin-replace': 6.0.3(rollup@2.79.2) + '@rollup/plugin-replace': 6.0.3(rollup@4.53.5) idb: 8.0.0 tslib: 2.8.1 transitivePeerDependencies: @@ -9117,13 +8944,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@apideck/better-ajv-errors@0.3.6(ajv@8.17.1)': - dependencies: - ajv: 8.17.1 - json-schema: 0.4.0 - jsonpointer: 5.0.1 - leven: 3.1.0 - '@asamuzakjp/css-color@4.1.1': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -10605,11 +10425,14 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.0': + '@isaacs/cliui@8.0.2': dependencies: - '@isaacs/balanced-match': 4.0.1 + string-width: 4.2.3 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -10968,10 +10791,6 @@ snapshots: '@nolyfill/side-channel@1.0.44': {} - '@nolyfill/string.prototype.matchall@1.0.44': - dependencies: - '@nolyfill/shared': 1.0.44 - '@nolyfill/typed-array-buffer@1.0.44': dependencies: '@nolyfill/shared': 1.0.44 @@ -11158,6 +10977,9 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + '@pkgjs/parseargs@0.11.0': + optional: true + '@pkgr/core@0.2.9': {} '@playwright/test@1.57.0': @@ -11483,54 +11305,20 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.53': {} - '@rollup/plugin-babel@5.3.1(@babel/core@7.28.5)(@types/babel__core@7.20.5)(rollup@2.79.2)': + '@rollup/plugin-replace@6.0.3(rollup@4.53.5)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-imports': 7.27.1 - '@rollup/pluginutils': 3.1.0(rollup@2.79.2) - rollup: 2.79.2 - optionalDependencies: - '@types/babel__core': 7.20.5 - transitivePeerDependencies: - - supports-color - - '@rollup/plugin-node-resolve@11.2.1(rollup@2.79.2)': - dependencies: - '@rollup/pluginutils': 3.1.0(rollup@2.79.2) - '@types/resolve': 1.17.1 - builtin-modules: 3.3.0 - deepmerge: 4.3.1 - is-module: 1.0.0 - resolve: 1.22.11 - rollup: 2.79.2 - - '@rollup/plugin-replace@2.4.2(rollup@2.79.2)': - dependencies: - '@rollup/pluginutils': 3.1.0(rollup@2.79.2) - magic-string: 0.25.9 - rollup: 2.79.2 - - '@rollup/plugin-replace@6.0.3(rollup@2.79.2)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@2.79.2) + '@rollup/pluginutils': 5.3.0(rollup@4.53.5) magic-string: 0.30.21 optionalDependencies: - rollup: 2.79.2 + rollup: 4.53.5 - '@rollup/pluginutils@3.1.0(rollup@2.79.2)': - dependencies: - '@types/estree': 0.0.39 - estree-walker: 1.0.1 - picomatch: 2.3.1 - rollup: 2.79.2 - - '@rollup/pluginutils@5.3.0(rollup@2.79.2)': + '@rollup/pluginutils@5.3.0(rollup@4.53.5)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 2.79.2 + rollup: 4.53.5 '@rollup/rollup-android-arm-eabi@4.53.5': optional: true @@ -11633,6 +11421,44 @@ snapshots: hoist-non-react-statics: 3.3.2 react: 19.2.3 + '@serwist/build@9.5.0(typescript@5.9.3)': + dependencies: + '@serwist/utils': 9.5.0 + common-tags: 1.8.2 + glob: 10.5.0 + pretty-bytes: 6.1.1 + source-map: 0.8.0-beta.0 + zod: 4.3.5 + optionalDependencies: + typescript: 5.9.3 + + '@serwist/turbopack@9.5.0(@swc/helpers@0.5.17)(esbuild-wasm@0.27.2)(next@15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0))(react@19.2.3)(typescript@5.9.3)': + dependencies: + '@serwist/build': 9.5.0(typescript@5.9.3) + '@serwist/utils': 9.5.0 + '@serwist/window': 9.5.0(typescript@5.9.3) + '@swc/core': 1.15.8(@swc/helpers@0.5.17) + esbuild-wasm: 0.27.2 + kolorist: 1.8.0 + next: 15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0) + react: 19.2.3 + semver: 7.7.3 + serwist: 9.5.0(typescript@5.9.3) + zod: 4.3.5 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@swc/helpers' + + '@serwist/utils@9.5.0': {} + + '@serwist/window@9.5.0(typescript@5.9.3)': + dependencies: + '@types/trusted-types': 2.0.7 + serwist: 9.5.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + '@sindresorhus/base62@1.0.0': {} '@solid-primitives/event-listener@2.4.3(solid-js@1.9.10)': @@ -11870,15 +11696,57 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.3 - '@surma/rollup-plugin-off-main-thread@2.2.3': - dependencies: - ejs: 3.1.10 - json5: 2.2.3 - magic-string: 0.25.9 - string.prototype.matchall: '@nolyfill/string.prototype.matchall@1.0.44' - '@svgdotjs/svg.js@3.2.5': {} + '@swc/core-darwin-arm64@1.15.8': + optional: true + + '@swc/core-darwin-x64@1.15.8': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.8': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.8': + optional: true + + '@swc/core-linux-arm64-musl@1.15.8': + optional: true + + '@swc/core-linux-x64-gnu@1.15.8': + optional: true + + '@swc/core-linux-x64-musl@1.15.8': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.8': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.8': + optional: true + + '@swc/core-win32-x64-msvc@1.15.8': + optional: true + + '@swc/core@1.15.8(@swc/helpers@0.5.17)': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.25 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.8 + '@swc/core-darwin-x64': 1.15.8 + '@swc/core-linux-arm-gnueabihf': 1.15.8 + '@swc/core-linux-arm64-gnu': 1.15.8 + '@swc/core-linux-arm64-musl': 1.15.8 + '@swc/core-linux-x64-gnu': 1.15.8 + '@swc/core-linux-x64-musl': 1.15.8 + '@swc/core-win32-arm64-msvc': 1.15.8 + '@swc/core-win32-ia32-msvc': 1.15.8 + '@swc/core-win32-x64-msvc': 1.15.8 + '@swc/helpers': 0.5.17 + + '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -11887,6 +11755,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@swc/types@0.1.25': + dependencies: + '@swc/counter': 0.1.3 + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2))': dependencies: postcss-selector-parser: 6.0.10 @@ -12257,17 +12129,10 @@ snapshots: dependencies: '@types/estree': 1.0.8 - '@types/estree@0.0.39': {} - '@types/estree@1.0.8': {} '@types/geojson@7946.0.16': {} - '@types/glob@7.2.0': - dependencies: - '@types/minimatch': 6.0.0 - '@types/node': 18.15.0 - '@types/hast@2.3.10': dependencies: '@types/unist': 2.0.11 @@ -12292,10 +12157,6 @@ snapshots: '@types/mdx@2.0.13': {} - '@types/minimatch@6.0.0': - dependencies: - minimatch: 10.1.1 - '@types/ms@2.1.0': {} '@types/negotiator@0.6.4': {} @@ -12343,10 +12204,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/resolve@1.17.1': - dependencies: - '@types/node': 18.15.0 - '@types/resolve@1.20.6': {} '@types/semver@7.7.1': {} @@ -12941,14 +12798,6 @@ snapshots: aria-query@5.3.2: {} - array-union@1.0.2: - dependencies: - array-uniq: 1.0.3 - - array-union@2.1.0: {} - - array-uniq@1.0.3: {} - asn1.js@4.10.1: dependencies: bn.js: 4.12.2 @@ -12971,8 +12820,6 @@ snapshots: async@3.2.6: {} - at-least-node@1.0.0: {} - autoprefixer@10.4.22(postcss@8.5.6): dependencies: browserslist: 4.28.1 @@ -12983,21 +12830,6 @@ snapshots: postcss: 8.5.6 postcss-value-parser: 4.2.0 - babel-loader@10.0.0(@babel/core@7.28.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)): - dependencies: - '@babel/core': 7.28.5 - find-up: 5.0.0 - webpack: 5.103.0(esbuild@0.25.0)(uglify-js@3.19.3) - - babel-loader@8.4.1(@babel/core@7.28.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)): - dependencies: - '@babel/core': 7.28.5 - find-cache-dir: 3.3.2 - loader-utils: 2.0.4 - make-dir: 3.1.0 - schema-utils: 2.7.1 - webpack: 5.103.0(esbuild@0.25.0)(uglify-js@3.19.3) - babel-loader@9.2.1(@babel/core@7.28.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)): dependencies: '@babel/core': 7.28.5 @@ -13288,11 +13120,6 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 - clean-webpack-plugin@4.0.0(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)): - dependencies: - del: 4.1.1 - webpack: 5.103.0(esbuild@0.25.0)(uglify-js@3.19.3) - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -13484,8 +13311,6 @@ snapshots: randombytes: 2.1.0 randomfill: 1.0.4 - crypto-random-string@2.0.0: {} - css-loader@6.11.0(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)): dependencies: icss-utils: 5.1.0(postcss@8.5.6) @@ -13749,16 +13574,6 @@ snapshots: define-lazy-prop@2.0.0: {} - del@4.1.1: - dependencies: - '@types/glob': 7.2.0 - globby: 6.1.0 - is-path-cwd: 2.2.0 - is-path-in-cwd: 2.1.0 - p-map: 2.1.0 - pify: 4.0.1 - rimraf: 2.7.1 - delaunator@5.0.1: dependencies: robust-predicates: 3.0.2 @@ -13791,10 +13606,6 @@ snapshots: miller-rabin: 4.0.1 randombytes: 2.1.0 - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - dlv@1.1.3: {} doctrine@3.0.0: @@ -13858,10 +13669,6 @@ snapshots: tslib: 2.3.0 zrender: 5.6.1 - ejs@3.1.10: - dependencies: - jake: 10.9.4 - electron-to-chromium@1.5.267: {} elkjs@0.9.3: {} @@ -13943,6 +13750,8 @@ snapshots: transitivePeerDependencies: - supports-color + esbuild-wasm@0.27.2: {} + esbuild@0.25.0: optionalDependencies: '@esbuild/aix-ppc64': 0.25.0 @@ -14458,8 +14267,6 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/unist': 3.0.3 - estree-walker@1.0.1: {} - estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -14554,10 +14361,6 @@ snapshots: dependencies: flat-cache: 4.0.1 - filelist@1.0.4: - dependencies: - minimatch: 5.1.6 - filesize@10.1.6: {} fill-range@7.1.1: @@ -14613,6 +14416,11 @@ snapshots: flatted@3.3.3: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fork-ts-checker-webpack-plugin@8.0.0(typescript@5.9.3)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)): dependencies: '@babel/code-frame': 7.27.1 @@ -14647,13 +14455,6 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 - fs-extra@9.1.0: - dependencies: - at-least-node: 1.0.0 - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - fs-monkey@1.1.0: {} fs.realpath@1.0.0: {} @@ -14672,8 +14473,6 @@ snapshots: get-nonce@1.0.1: {} - get-own-enumerable-property-symbols@3.0.2: {} - get-stream@8.0.1: {} get-tsconfig@4.13.0: @@ -14695,6 +14494,15 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -14710,23 +14518,6 @@ snapshots: globals@16.5.0: {} - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 - - globby@6.1.0: - dependencies: - array-union: 1.0.2 - glob: 7.2.3 - object-assign: 4.1.1 - pify: 2.3.0 - pinkie-promise: 2.0.1 - globrex@0.1.2: {} goober@2.1.18(csstype@3.2.3): @@ -15017,10 +14808,10 @@ snapshots: idb-keyval@6.2.2: {} - idb@7.1.1: {} - idb@8.0.0: {} + idb@8.0.3: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -15122,34 +14913,16 @@ snapshots: transitivePeerDependencies: - supports-color - is-module@1.0.0: {} - is-node-process@1.2.0: {} is-number@7.0.0: {} - is-obj@1.0.1: {} - - is-path-cwd@2.2.0: {} - - is-path-in-cwd@2.1.0: - dependencies: - is-path-inside: 2.1.0 - - is-path-inside@2.1.0: - dependencies: - path-is-inside: 1.0.2 - is-plain-obj@4.1.0: {} is-plain-object@5.0.0: {} is-potential-custom-element-name@1.0.1: {} - is-regexp@1.0.0: {} - - is-stream@2.0.1: {} - is-stream@3.0.0: {} is-wsl@2.2.0: @@ -15181,17 +14954,11 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jake@10.9.4: + jackspeak@3.4.3: dependencies: - async: 3.2.6 - filelist: 1.0.4 - picocolors: 1.1.1 - - jest-worker@26.6.2: - dependencies: - '@types/node': 18.15.0 - merge-stream: 2.0.0 - supports-color: 7.2.0 + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 jest-worker@27.5.1: dependencies: @@ -15274,8 +15041,6 @@ snapshots: json-schema-traverse@1.0.0: {} - json-schema@0.4.0: {} - json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: {} @@ -15295,8 +15060,6 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsonpointer@5.0.1: {} - jsonschema@1.5.0: {} jsx-ast-utils-x@0.1.0: {} @@ -15330,6 +15093,8 @@ snapshots: typescript: 5.9.3 zod: 4.1.13 + kolorist@1.8.0: {} + ky@1.14.1: {} lamejs@1.2.1: @@ -15353,8 +15118,6 @@ snapshots: layout-base@2.0.1: {} - leven@3.1.0: {} - levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -15459,6 +15222,8 @@ snapshots: fault: 1.0.4 highlight.js: 10.7.3 + lru-cache@10.4.3: {} + lru-cache@11.2.4: {} lru-cache@5.1.1: @@ -15469,10 +15234,6 @@ snapshots: lz-string@1.5.0: {} - magic-string@0.25.9: - dependencies: - sourcemap-codec: 1.4.8 - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -16043,24 +15804,18 @@ snapshots: minimalistic-crypto-utils@1.0.1: {} - minimatch@10.1.1: - dependencies: - '@isaacs/brace-expansion': 5.0.0 - minimatch@3.1.2: dependencies: brace-expansion: 2.0.2 - minimatch@5.1.6: - dependencies: - brace-expansion: 2.0.2 - minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 minimist@1.2.8: {} + minipass@7.1.2: {} + mitt@3.0.1: {} mkdirp-classic@0.5.3: @@ -16103,24 +15858,6 @@ snapshots: neo-async@2.6.2: {} - next-pwa@5.6.0(@babel/core@7.28.5)(@types/babel__core@7.20.5)(esbuild@0.25.0)(next@15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0))(uglify-js@3.19.3)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)): - dependencies: - babel-loader: 8.4.1(@babel/core@7.28.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)) - clean-webpack-plugin: 4.0.0(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)) - globby: 11.1.0 - next: 15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0) - terser-webpack-plugin: 5.3.15(esbuild@0.25.0)(uglify-js@3.19.3)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)) - workbox-webpack-plugin: 6.6.0(@types/babel__core@7.20.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)) - workbox-window: 6.6.0 - transitivePeerDependencies: - - '@babel/core' - - '@swc/core' - - '@types/babel__core' - - esbuild - - supports-color - - uglify-js - - webpack - next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 @@ -16314,10 +16051,10 @@ snapshots: dependencies: p-limit: 4.0.0 - p-map@2.1.0: {} - p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + package-manager-detector@1.6.0: {} pako@1.0.11: {} @@ -16398,14 +16135,17 @@ snapshots: path-is-absolute@1.0.1: {} - path-is-inside@1.0.2: {} - path-key@3.1.1: {} path-key@4.0.0: {} path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-type@4.0.0: {} path2d@0.2.2: @@ -16439,14 +16179,6 @@ snapshots: pify@2.3.0: {} - pify@4.0.1: {} - - pinkie-promise@2.0.1: - dependencies: - pinkie: 2.0.4 - - pinkie@2.0.4: {} - pinyin-pro@3.27.0: {} pirates@4.0.7: {} @@ -16606,7 +16338,7 @@ snapshots: prelude-ls@1.2.1: {} - pretty-bytes@5.6.0: {} + pretty-bytes@6.1.1: {} pretty-error@4.0.0: dependencies: @@ -16833,7 +16565,7 @@ snapshots: react-draggable: 4.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tslib: 2.6.2 - react-scan@0.4.3(@types/react@19.2.7)(next@15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@2.79.2): + react-scan@0.4.3(@types/react@19.2.7)(next@15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.53.5): dependencies: '@babel/core': 7.28.5 '@babel/generator': 7.28.5 @@ -16842,7 +16574,7 @@ snapshots: '@clack/prompts': 0.8.2 '@pivanov/utils': 0.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@preact/signals': 1.3.2(preact@10.28.0) - '@rollup/pluginutils': 5.3.0(rollup@2.79.2) + '@rollup/pluginutils': 5.3.0(rollup@4.53.5) '@types/node': 20.19.26 bippy: 0.3.34(@types/react@19.2.7)(react@19.2.3) esbuild: 0.25.12 @@ -17165,10 +16897,6 @@ snapshots: rfdc@1.4.1: {} - rimraf@2.7.1: - dependencies: - glob: 7.2.3 - rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -17185,18 +16913,6 @@ snapshots: robust-predicates@3.0.2: {} - rollup-plugin-terser@7.0.2(rollup@2.79.2): - dependencies: - '@babel/code-frame': 7.27.1 - jest-worker: 26.6.2 - rollup: 2.79.2 - serialize-javascript: 4.0.0 - terser: 5.44.1 - - rollup@2.79.2: - optionalDependencies: - fsevents: 2.3.3 - rollup@4.53.5: dependencies: '@types/estree': 1.0.8 @@ -17265,12 +16981,6 @@ snapshots: scheduler@0.27.0: {} - schema-utils@2.7.1: - dependencies: - '@types/json-schema': 7.0.15 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) - schema-utils@3.3.0: dependencies: '@types/json-schema': 7.0.15 @@ -17298,10 +17008,6 @@ snapshots: semver@7.7.3: {} - serialize-javascript@4.0.0: - dependencies: - randombytes: 2.1.0 - serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -17312,6 +17018,13 @@ snapshots: seroval@1.3.2: {} + serwist@9.5.0(typescript@5.9.3): + dependencies: + '@serwist/utils': 9.5.0 + idb: 8.0.3 + optionalDependencies: + typescript: 5.9.3 + setimmediate@1.0.5: {} sha.js@2.4.12: @@ -17412,8 +17125,6 @@ snapshots: size-sensor@1.0.2: {} - slash@3.0.0: {} - slice-ansi@5.0.0: dependencies: ansi-styles: 6.2.3 @@ -17434,8 +17145,6 @@ snapshots: sortablejs@1.15.6: {} - source-list-map@2.0.1: {} - source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -17451,8 +17160,6 @@ snapshots: dependencies: whatwg-url: 7.1.0 - sourcemap-codec@1.4.8: {} - space-separated-tokens@1.1.5: {} space-separated-tokens@2.0.2: {} @@ -17533,12 +17240,6 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 - stringify-object@3.3.0: - dependencies: - get-own-enumerable-property-symbols: 3.0.2 - is-obj: 1.0.1 - is-regexp: 1.0.0 - strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -17549,8 +17250,6 @@ snapshots: strip-bom@3.0.0: {} - strip-comments@2.0.1: {} - strip-final-newline@3.0.0: {} strip-indent@3.0.0: @@ -17671,15 +17370,6 @@ snapshots: readable-stream: 3.6.2 optional: true - temp-dir@2.0.0: {} - - tempy@0.6.0: - dependencies: - is-stream: 2.0.1 - temp-dir: 2.0.0 - type-fest: 0.16.0 - unique-string: 2.0.0 - terser-webpack-plugin@5.3.15(esbuild@0.25.0)(uglify-js@3.19.3)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -17833,8 +17523,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@0.16.0: {} - type-fest@2.19.0: {} type-fest@4.2.0: @@ -17871,10 +17559,6 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 - unique-string@2.0.0: - dependencies: - crypto-random-string: 2.0.0 - unist-util-find-after@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -17927,8 +17611,6 @@ snapshots: webpack-virtual-modules: 0.6.2 optional: true - upath@1.2.0: {} - update-browserslist-db@1.2.2(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -18191,11 +17873,6 @@ snapshots: html-entities: 2.6.0 strip-ansi: 6.0.1 - webpack-sources@1.4.3: - dependencies: - source-list-map: 2.0.1 - source-map: 0.6.1 - webpack-sources@3.3.3: {} webpack-virtual-modules@0.6.2: {} @@ -18263,130 +17940,17 @@ snapshots: word-wrap@1.2.5: {} - workbox-background-sync@6.6.0: + wrap-ansi@7.0.0: dependencies: - idb: 7.1.1 - workbox-core: 6.6.0 + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 - workbox-broadcast-update@6.6.0: + wrap-ansi@8.1.0: dependencies: - workbox-core: 6.6.0 - - workbox-build@6.6.0(@types/babel__core@7.20.5): - dependencies: - '@apideck/better-ajv-errors': 0.3.6(ajv@8.17.1) - '@babel/core': 7.28.5 - '@babel/preset-env': 7.28.5(@babel/core@7.28.5) - '@babel/runtime': 7.28.4 - '@rollup/plugin-babel': 5.3.1(@babel/core@7.28.5)(@types/babel__core@7.20.5)(rollup@2.79.2) - '@rollup/plugin-node-resolve': 11.2.1(rollup@2.79.2) - '@rollup/plugin-replace': 2.4.2(rollup@2.79.2) - '@surma/rollup-plugin-off-main-thread': 2.2.3 - ajv: 8.17.1 - common-tags: 1.8.2 - fast-json-stable-stringify: 2.1.0 - fs-extra: 9.1.0 - glob: 7.2.3 - lodash: 4.17.21 - pretty-bytes: 5.6.0 - rollup: 2.79.2 - rollup-plugin-terser: 7.0.2(rollup@2.79.2) - source-map: 0.8.0-beta.0 - stringify-object: 3.3.0 - strip-comments: 2.0.1 - tempy: 0.6.0 - upath: 1.2.0 - workbox-background-sync: 6.6.0 - workbox-broadcast-update: 6.6.0 - workbox-cacheable-response: 6.6.0 - workbox-core: 6.6.0 - workbox-expiration: 6.6.0 - workbox-google-analytics: 6.6.0 - workbox-navigation-preload: 6.6.0 - workbox-precaching: 6.6.0 - workbox-range-requests: 6.6.0 - workbox-recipes: 6.6.0 - workbox-routing: 6.6.0 - workbox-strategies: 6.6.0 - workbox-streams: 6.6.0 - workbox-sw: 6.6.0 - workbox-window: 6.6.0 - transitivePeerDependencies: - - '@types/babel__core' - - supports-color - - workbox-cacheable-response@6.6.0: - dependencies: - workbox-core: 6.6.0 - - workbox-core@6.6.0: {} - - workbox-expiration@6.6.0: - dependencies: - idb: 7.1.1 - workbox-core: 6.6.0 - - workbox-google-analytics@6.6.0: - dependencies: - workbox-background-sync: 6.6.0 - workbox-core: 6.6.0 - workbox-routing: 6.6.0 - workbox-strategies: 6.6.0 - - workbox-navigation-preload@6.6.0: - dependencies: - workbox-core: 6.6.0 - - workbox-precaching@6.6.0: - dependencies: - workbox-core: 6.6.0 - workbox-routing: 6.6.0 - workbox-strategies: 6.6.0 - - workbox-range-requests@6.6.0: - dependencies: - workbox-core: 6.6.0 - - workbox-recipes@6.6.0: - dependencies: - workbox-cacheable-response: 6.6.0 - workbox-core: 6.6.0 - workbox-expiration: 6.6.0 - workbox-precaching: 6.6.0 - workbox-routing: 6.6.0 - workbox-strategies: 6.6.0 - - workbox-routing@6.6.0: - dependencies: - workbox-core: 6.6.0 - - workbox-strategies@6.6.0: - dependencies: - workbox-core: 6.6.0 - - workbox-streams@6.6.0: - dependencies: - workbox-core: 6.6.0 - workbox-routing: 6.6.0 - - workbox-sw@6.6.0: {} - - workbox-webpack-plugin@6.6.0(@types/babel__core@7.20.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)): - dependencies: - fast-json-stable-stringify: 2.1.0 - pretty-bytes: 5.6.0 - upath: 1.2.0 - webpack: 5.103.0(esbuild@0.25.0)(uglify-js@3.19.3) - webpack-sources: 1.4.3 - workbox-build: 6.6.0(@types/babel__core@7.20.5) - transitivePeerDependencies: - - '@types/babel__core' - - supports-color - - workbox-window@6.6.0: - dependencies: - '@types/trusted-types': 2.0.7 - workbox-core: 6.6.0 + ansi-styles: 6.2.3 + string-width: 4.2.3 + strip-ansi: 7.1.2 wrap-ansi@9.0.2: dependencies: @@ -18442,6 +18006,8 @@ snapshots: zod@4.1.13: {} + zod@4.3.5: {} + zrender@5.6.1: dependencies: tslib: 2.3.0 diff --git a/web/public/workbox-c05e7c83.js b/web/public/workbox-c05e7c83.js deleted file mode 100644 index c2e0217441..0000000000 --- a/web/public/workbox-c05e7c83.js +++ /dev/null @@ -1 +0,0 @@ -define(["exports"],function(t){"use strict";try{self["workbox:core:6.5.4"]&&_()}catch(t){}const e=(t,...e)=>{let s=t;return e.length>0&&(s+=` :: ${JSON.stringify(e)}`),s};class s extends Error{constructor(t,s){super(e(t,s)),this.name=t,this.details=s}}try{self["workbox:routing:6.5.4"]&&_()}catch(t){}const n=t=>t&&"object"==typeof t?t:{handle:t};class i{constructor(t,e,s="GET"){this.handler=n(e),this.match=t,this.method=s}setCatchHandler(t){this.catchHandler=n(t)}}class r extends i{constructor(t,e,s){super(({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)},e,s)}}class a{constructor(){this.t=new Map,this.i=new Map}get routes(){return this.t}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)})}addCacheListener(){self.addEventListener("message",t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map(e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})}));t.waitUntil(s),t.ports&&t.ports[0]&&s.then(()=>t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:i,route:r}=this.findMatchingRoute({event:e,request:t,sameOrigin:n,url:s});let a=r&&r.handler;const o=t.method;if(!a&&this.i.has(o)&&(a=this.i.get(o)),!a)return;let c;try{c=a.handle({url:s,request:t,event:e,params:i})}catch(t){c=Promise.reject(t)}const h=r&&r.catchHandler;return c instanceof Promise&&(this.o||h)&&(c=c.catch(async n=>{if(h)try{return await h.handle({url:s,request:t,event:e,params:i})}catch(t){t instanceof Error&&(n=t)}if(this.o)return this.o.handle({url:s,request:t,event:e});throw n})),c}findMatchingRoute({url:t,sameOrigin:e,request:s,event:n}){const i=this.t.get(s.method)||[];for(const r of i){let i;const a=r.match({url:t,sameOrigin:e,request:s,event:n});if(a)return i=a,(Array.isArray(i)&&0===i.length||a.constructor===Object&&0===Object.keys(a).length||"boolean"==typeof a)&&(i=void 0),{route:r,params:i}}return{}}setDefaultHandler(t,e="GET"){this.i.set(e,n(t))}setCatchHandler(t){this.o=n(t)}registerRoute(t){this.t.has(t.method)||this.t.set(t.method,[]),this.t.get(t.method).push(t)}unregisterRoute(t){if(!this.t.has(t.method))throw new s("unregister-route-but-not-found-with-method",{method:t.method});const e=this.t.get(t.method).indexOf(t);if(!(e>-1))throw new s("unregister-route-route-not-registered");this.t.get(t.method).splice(e,1)}}let o;const c=()=>(o||(o=new a,o.addFetchListener(),o.addCacheListener()),o);function h(t,e,n){let a;if("string"==typeof t){const s=new URL(t,location.href);a=new i(({url:t})=>t.href===s.href,e,n)}else if(t instanceof RegExp)a=new r(t,e,n);else if("function"==typeof t)a=new i(t,e,n);else{if(!(t instanceof i))throw new s("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});a=t}return c().registerRoute(a),a}try{self["workbox:strategies:6.5.4"]&&_()}catch(t){}const u={cacheWillUpdate:async({response:t})=>200===t.status||0===t.status?t:null},l={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},f=t=>[l.prefix,t,l.suffix].filter(t=>t&&t.length>0).join("-"),w=t=>t||f(l.precache),d=t=>t||f(l.runtime);function p(t,e){const s=new URL(t);for(const t of e)s.searchParams.delete(t);return s.href}class y{constructor(){this.promise=new Promise((t,e)=>{this.resolve=t,this.reject=e})}}const m=new Set;function g(t){return"string"==typeof t?new Request(t):t}class R{constructor(t,e){this.h={},Object.assign(this,e),this.event=e.event,this.u=t,this.l=new y,this.p=[],this.m=[...t.plugins],this.R=new Map;for(const t of this.m)this.R.set(t,{});this.event.waitUntil(this.l.promise)}async fetch(t){const{event:e}=this;let n=g(t);if("navigate"===n.mode&&e instanceof FetchEvent&&e.preloadResponse){const t=await e.preloadResponse;if(t)return t}const i=this.hasCallback("fetchDidFail")?n.clone():null;try{for(const t of this.iterateCallbacks("requestWillFetch"))n=await t({request:n.clone(),event:e})}catch(t){if(t instanceof Error)throw new s("plugin-error-request-will-fetch",{thrownErrorMessage:t.message})}const r=n.clone();try{let t;t=await fetch(n,"navigate"===n.mode?void 0:this.u.fetchOptions);for(const s of this.iterateCallbacks("fetchDidSucceed"))t=await s({event:e,request:r,response:t});return t}catch(t){throw i&&await this.runCallbacks("fetchDidFail",{error:t,event:e,originalRequest:i.clone(),request:r.clone()}),t}}async fetchAndCachePut(t){const e=await this.fetch(t),s=e.clone();return this.waitUntil(this.cachePut(t,s)),e}async cacheMatch(t){const e=g(t);let s;const{cacheName:n,matchOptions:i}=this.u,r=await this.getCacheKey(e,"read"),a=Object.assign(Object.assign({},i),{cacheName:n});s=await caches.match(r,a);for(const t of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await t({cacheName:n,matchOptions:i,cachedResponse:s,request:r,event:this.event})||void 0;return s}async cachePut(t,e){const n=g(t);var i;await(i=0,new Promise(t=>setTimeout(t,i)));const r=await this.getCacheKey(n,"write");if(!e)throw new s("cache-put-with-no-response",{url:(a=r.url,new URL(String(a),location.href).href.replace(new RegExp(`^${location.origin}`),""))});var a;const o=await this.v(e);if(!o)return!1;const{cacheName:c,matchOptions:h}=this.u,u=await self.caches.open(c),l=this.hasCallback("cacheDidUpdate"),f=l?await async function(t,e,s,n){const i=p(e.url,s);if(e.url===i)return t.match(e,n);const r=Object.assign(Object.assign({},n),{ignoreSearch:!0}),a=await t.keys(e,r);for(const e of a)if(i===p(e.url,s))return t.match(e,n)}(u,r.clone(),["__WB_REVISION__"],h):null;try{await u.put(r,l?o.clone():o)}catch(t){if(t instanceof Error)throw"QuotaExceededError"===t.name&&await async function(){for(const t of m)await t()}(),t}for(const t of this.iterateCallbacks("cacheDidUpdate"))await t({cacheName:c,oldResponse:f,newResponse:o.clone(),request:r,event:this.event});return!0}async getCacheKey(t,e){const s=`${t.url} | ${e}`;if(!this.h[s]){let n=t;for(const t of this.iterateCallbacks("cacheKeyWillBeUsed"))n=g(await t({mode:e,request:n,event:this.event,params:this.params}));this.h[s]=n}return this.h[s]}hasCallback(t){for(const e of this.u.plugins)if(t in e)return!0;return!1}async runCallbacks(t,e){for(const s of this.iterateCallbacks(t))await s(e)}*iterateCallbacks(t){for(const e of this.u.plugins)if("function"==typeof e[t]){const s=this.R.get(e),n=n=>{const i=Object.assign(Object.assign({},n),{state:s});return e[t](i)};yield n}}waitUntil(t){return this.p.push(t),t}async doneWaiting(){let t;for(;t=this.p.shift();)await t}destroy(){this.l.resolve(null)}async v(t){let e=t,s=!1;for(const t of this.iterateCallbacks("cacheWillUpdate"))if(e=await t({request:this.request,response:e,event:this.event})||void 0,s=!0,!e)break;return s||e&&200!==e.status&&(e=void 0),e}}class v{constructor(t={}){this.cacheName=d(t.cacheName),this.plugins=t.plugins||[],this.fetchOptions=t.fetchOptions,this.matchOptions=t.matchOptions}handle(t){const[e]=this.handleAll(t);return e}handleAll(t){t instanceof FetchEvent&&(t={event:t,request:t.request});const e=t.event,s="string"==typeof t.request?new Request(t.request):t.request,n="params"in t?t.params:void 0,i=new R(this,{event:e,request:s,params:n}),r=this.q(i,s,e);return[r,this.D(r,i,s,e)]}async q(t,e,n){let i;await t.runCallbacks("handlerWillStart",{event:n,request:e});try{if(i=await this.U(e,t),!i||"error"===i.type)throw new s("no-response",{url:e.url})}catch(s){if(s instanceof Error)for(const r of t.iterateCallbacks("handlerDidError"))if(i=await r({error:s,event:n,request:e}),i)break;if(!i)throw s}for(const s of t.iterateCallbacks("handlerWillRespond"))i=await s({event:n,request:e,response:i});return i}async D(t,e,s,n){let i,r;try{i=await t}catch(r){}try{await e.runCallbacks("handlerDidRespond",{event:n,request:s,response:i}),await e.doneWaiting()}catch(t){t instanceof Error&&(r=t)}if(await e.runCallbacks("handlerDidComplete",{event:n,request:s,response:i,error:r}),e.destroy(),r)throw r}}function b(t){t.then(()=>{})}function q(){return q=Object.assign?Object.assign.bind():function(t){for(var e=1;e(t[e]=s,!0),has:(t,e)=>t instanceof IDBTransaction&&("done"===e||"store"===e)||e in t};function O(t){return t!==IDBDatabase.prototype.transaction||"objectStoreNames"in IDBTransaction.prototype?(U||(U=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(t)?function(...e){return t.apply(T(this),e),B(x.get(this))}:function(...e){return B(t.apply(T(this),e))}:function(e,...s){const n=t.call(T(this),e,...s);return L.set(n,e.sort?e.sort():[e]),B(n)}}function k(t){return"function"==typeof t?O(t):(t instanceof IDBTransaction&&function(t){if(I.has(t))return;const e=new Promise((e,s)=>{const n=()=>{t.removeEventListener("complete",i),t.removeEventListener("error",r),t.removeEventListener("abort",r)},i=()=>{e(),n()},r=()=>{s(t.error||new DOMException("AbortError","AbortError")),n()};t.addEventListener("complete",i),t.addEventListener("error",r),t.addEventListener("abort",r)});I.set(t,e)}(t),e=t,(D||(D=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])).some(t=>e instanceof t)?new Proxy(t,N):t);var e}function B(t){if(t instanceof IDBRequest)return function(t){const e=new Promise((e,s)=>{const n=()=>{t.removeEventListener("success",i),t.removeEventListener("error",r)},i=()=>{e(B(t.result)),n()},r=()=>{s(t.error),n()};t.addEventListener("success",i),t.addEventListener("error",r)});return e.then(e=>{e instanceof IDBCursor&&x.set(e,t)}).catch(()=>{}),C.set(e,t),e}(t);if(E.has(t))return E.get(t);const e=k(t);return e!==t&&(E.set(t,e),C.set(e,t)),e}const T=t=>C.get(t);const M=["get","getKey","getAll","getAllKeys","count"],P=["put","add","delete","clear"],W=new Map;function j(t,e){if(!(t instanceof IDBDatabase)||e in t||"string"!=typeof e)return;if(W.get(e))return W.get(e);const s=e.replace(/FromIndex$/,""),n=e!==s,i=P.includes(s);if(!(s in(n?IDBIndex:IDBObjectStore).prototype)||!i&&!M.includes(s))return;const r=async function(t,...e){const r=this.transaction(t,i?"readwrite":"readonly");let a=r.store;return n&&(a=a.index(e.shift())),(await Promise.all([a[s](...e),i&&r.done]))[0]};return W.set(e,r),r}N=(t=>q({},t,{get:(e,s,n)=>j(e,s)||t.get(e,s,n),has:(e,s)=>!!j(e,s)||t.has(e,s)}))(N);try{self["workbox:expiration:6.5.4"]&&_()}catch(t){}const S="cache-entries",K=t=>{const e=new URL(t,location.href);return e.hash="",e.href};class A{constructor(t){this._=null,this.I=t}L(t){const e=t.createObjectStore(S,{keyPath:"id"});e.createIndex("cacheName","cacheName",{unique:!1}),e.createIndex("timestamp","timestamp",{unique:!1})}C(t){this.L(t),this.I&&function(t,{blocked:e}={}){const s=indexedDB.deleteDatabase(t);e&&s.addEventListener("blocked",t=>e(t.oldVersion,t)),B(s).then(()=>{})}(this.I)}async setTimestamp(t,e){const s={url:t=K(t),timestamp:e,cacheName:this.I,id:this.N(t)},n=(await this.getDb()).transaction(S,"readwrite",{durability:"relaxed"});await n.store.put(s),await n.done}async getTimestamp(t){const e=await this.getDb(),s=await e.get(S,this.N(t));return null==s?void 0:s.timestamp}async expireEntries(t,e){const s=await this.getDb();let n=await s.transaction(S).store.index("timestamp").openCursor(null,"prev");const i=[];let r=0;for(;n;){const s=n.value;s.cacheName===this.I&&(t&&s.timestamp=e?i.push(n.value):r++),n=await n.continue()}const a=[];for(const t of i)await s.delete(S,t.id),a.push(t.url);return a}N(t){return this.I+"|"+K(t)}async getDb(){return this._||(this._=await function(t,e,{blocked:s,upgrade:n,blocking:i,terminated:r}={}){const a=indexedDB.open(t,e),o=B(a);return n&&a.addEventListener("upgradeneeded",t=>{n(B(a.result),t.oldVersion,t.newVersion,B(a.transaction),t)}),s&&a.addEventListener("blocked",t=>s(t.oldVersion,t.newVersion,t)),o.then(t=>{r&&t.addEventListener("close",()=>r()),i&&t.addEventListener("versionchange",t=>i(t.oldVersion,t.newVersion,t))}).catch(()=>{}),o}("workbox-expiration",1,{upgrade:this.C.bind(this)})),this._}}class F{constructor(t,e={}){this.O=!1,this.k=!1,this.B=e.maxEntries,this.T=e.maxAgeSeconds,this.M=e.matchOptions,this.I=t,this.P=new A(t)}async expireEntries(){if(this.O)return void(this.k=!0);this.O=!0;const t=this.T?Date.now()-1e3*this.T:0,e=await this.P.expireEntries(t,this.B),s=await self.caches.open(this.I);for(const t of e)await s.delete(t,this.M);this.O=!1,this.k&&(this.k=!1,b(this.expireEntries()))}async updateTimestamp(t){await this.P.setTimestamp(t,Date.now())}async isURLExpired(t){if(this.T){const e=await this.P.getTimestamp(t),s=Date.now()-1e3*this.T;return void 0===e||e{e&&(e.originalRequest=t)},this.cachedResponseWillBeUsed=async({event:t,state:e,cachedResponse:s})=>{if("install"===t.type&&e&&e.originalRequest&&e.originalRequest instanceof Request){const t=e.originalRequest.url;s?this.notUpdatedURLs.push(t):this.updatedURLs.push(t)}return s}}}class V{constructor({precacheController:t}){this.cacheKeyWillBeUsed=async({request:t,params:e})=>{const s=(null==e?void 0:e.cacheKey)||this.W.getCacheKeyForURL(t.url);return s?new Request(s,{headers:t.headers}):t},this.W=t}}let J,Q;async function z(t,e){let n=null;if(t.url){n=new URL(t.url).origin}if(n!==self.location.origin)throw new s("cross-origin-copy-response",{origin:n});const i=t.clone(),r={headers:new Headers(i.headers),status:i.status,statusText:i.statusText},a=e?e(r):r,o=function(){if(void 0===J){const t=new Response("");if("body"in t)try{new Response(t.body),J=!0}catch(t){J=!1}J=!1}return J}()?i.body:await i.blob();return new Response(o,a)}class X extends v{constructor(t={}){t.cacheName=w(t.cacheName),super(t),this.j=!1!==t.fallbackToNetwork,this.plugins.push(X.copyRedirectedCacheableResponsesPlugin)}async U(t,e){const s=await e.cacheMatch(t);return s||(e.event&&"install"===e.event.type?await this.S(t,e):await this.K(t,e))}async K(t,e){let n;const i=e.params||{};if(!this.j)throw new s("missing-precache-entry",{cacheName:this.cacheName,url:t.url});{const s=i.integrity,r=t.integrity,a=!r||r===s;n=await e.fetch(new Request(t,{integrity:"no-cors"!==t.mode?r||s:void 0})),s&&a&&"no-cors"!==t.mode&&(this.A(),await e.cachePut(t,n.clone()))}return n}async S(t,e){this.A();const n=await e.fetch(t);if(!await e.cachePut(t,n.clone()))throw new s("bad-precaching-response",{url:t.url,status:n.status});return n}A(){let t=null,e=0;for(const[s,n]of this.plugins.entries())n!==X.copyRedirectedCacheableResponsesPlugin&&(n===X.defaultPrecacheCacheabilityPlugin&&(t=s),n.cacheWillUpdate&&e++);0===e?this.plugins.push(X.defaultPrecacheCacheabilityPlugin):e>1&&null!==t&&this.plugins.splice(t,1)}}X.defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:t})=>!t||t.status>=400?null:t},X.copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:t})=>t.redirected?await z(t):t};class Y{constructor({cacheName:t,plugins:e=[],fallbackToNetwork:s=!0}={}){this.F=new Map,this.H=new Map,this.$=new Map,this.u=new X({cacheName:w(t),plugins:[...e,new V({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this.u}precache(t){this.addToCacheList(t),this.G||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this.G=!0)}addToCacheList(t){const e=[];for(const n of t){"string"==typeof n?e.push(n):n&&void 0===n.revision&&e.push(n.url);const{cacheKey:t,url:i}=$(n),r="string"!=typeof n&&n.revision?"reload":"default";if(this.F.has(i)&&this.F.get(i)!==t)throw new s("add-to-cache-list-conflicting-entries",{firstEntry:this.F.get(i),secondEntry:t});if("string"!=typeof n&&n.integrity){if(this.$.has(t)&&this.$.get(t)!==n.integrity)throw new s("add-to-cache-list-conflicting-integrities",{url:i});this.$.set(t,n.integrity)}if(this.F.set(i,t),this.H.set(i,r),e.length>0){const t=`Workbox is precaching URLs without revision info: ${e.join(", ")}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(t)}}}install(t){return H(t,async()=>{const e=new G;this.strategy.plugins.push(e);for(const[e,s]of this.F){const n=this.$.get(s),i=this.H.get(e),r=new Request(e,{integrity:n,cache:i,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:s},request:r,event:t}))}const{updatedURLs:s,notUpdatedURLs:n}=e;return{updatedURLs:s,notUpdatedURLs:n}})}activate(t){return H(t,async()=>{const t=await self.caches.open(this.strategy.cacheName),e=await t.keys(),s=new Set(this.F.values()),n=[];for(const i of e)s.has(i.url)||(await t.delete(i),n.push(i.url));return{deletedURLs:n}})}getURLsToCacheKeys(){return this.F}getCachedURLs(){return[...this.F.keys()]}getCacheKeyForURL(t){const e=new URL(t,location.href);return this.F.get(e.href)}getIntegrityForCacheKey(t){return this.$.get(t)}async matchPrecache(t){const e=t instanceof Request?t.url:t,s=this.getCacheKeyForURL(e);if(s){return(await self.caches.open(this.strategy.cacheName)).match(s)}}createHandlerBoundToURL(t){const e=this.getCacheKeyForURL(t);if(!e)throw new s("non-precached-url",{url:t});return s=>(s.request=new Request(t),s.params=Object.assign({cacheKey:e},s.params),this.strategy.handle(s))}}const Z=()=>(Q||(Q=new Y),Q);class tt extends i{constructor(t,e){super(({request:s})=>{const n=t.getURLsToCacheKeys();for(const i of function*(t,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:s="index.html",cleanURLs:n=!0,urlManipulation:i}={}){const r=new URL(t,location.href);r.hash="",yield r.href;const a=function(t,e=[]){for(const s of[...t.searchParams.keys()])e.some(t=>t.test(s))&&t.searchParams.delete(s);return t}(r,e);if(yield a.href,s&&a.pathname.endsWith("/")){const t=new URL(a.href);t.pathname+=s,yield t.href}if(n){const t=new URL(a.href);t.pathname+=".html",yield t.href}if(i){const t=i({url:r});for(const e of t)yield e.href}}(s.url,e)){const e=n.get(i);if(e){return{cacheKey:e,integrity:t.getIntegrityForCacheKey(e)}}}},t.strategy)}}t.CacheFirst=class extends v{async U(t,e){let n,i=await e.cacheMatch(t);if(!i)try{i=await e.fetchAndCachePut(t)}catch(t){t instanceof Error&&(n=t)}if(!i)throw new s("no-response",{url:t.url,error:n});return i}},t.ExpirationPlugin=class{constructor(t={}){this.cachedResponseWillBeUsed=async({event:t,request:e,cacheName:s,cachedResponse:n})=>{if(!n)return null;const i=this.V(n),r=this.J(s);b(r.expireEntries());const a=r.updateTimestamp(e.url);if(t)try{t.waitUntil(a)}catch(t){}return i?n:null},this.cacheDidUpdate=async({cacheName:t,request:e})=>{const s=this.J(t);await s.updateTimestamp(e.url),await s.expireEntries()},this.X=t,this.T=t.maxAgeSeconds,this.Y=new Map,t.purgeOnQuotaError&&function(t){m.add(t)}(()=>this.deleteCacheAndMetadata())}J(t){if(t===d())throw new s("expire-custom-caches-only");let e=this.Y.get(t);return e||(e=new F(t,this.X),this.Y.set(t,e)),e}V(t){if(!this.T)return!0;const e=this.Z(t);if(null===e)return!0;return e>=Date.now()-1e3*this.T}Z(t){if(!t.headers.has("date"))return null;const e=t.headers.get("date"),s=new Date(e).getTime();return isNaN(s)?null:s}async deleteCacheAndMetadata(){for(const[t,e]of this.Y)await self.caches.delete(t),await e.delete();this.Y=new Map}},t.NetworkFirst=class extends v{constructor(t={}){super(t),this.plugins.some(t=>"cacheWillUpdate"in t)||this.plugins.unshift(u),this.tt=t.networkTimeoutSeconds||0}async U(t,e){const n=[],i=[];let r;if(this.tt){const{id:s,promise:a}=this.et({request:t,logs:n,handler:e});r=s,i.push(a)}const a=this.st({timeoutId:r,request:t,logs:n,handler:e});i.push(a);const o=await e.waitUntil((async()=>await e.waitUntil(Promise.race(i))||await a)());if(!o)throw new s("no-response",{url:t.url});return o}et({request:t,logs:e,handler:s}){let n;return{promise:new Promise(e=>{n=setTimeout(async()=>{e(await s.cacheMatch(t))},1e3*this.tt)}),id:n}}async st({timeoutId:t,request:e,logs:s,handler:n}){let i,r;try{r=await n.fetchAndCachePut(e)}catch(t){t instanceof Error&&(i=t)}return t&&clearTimeout(t),!i&&r||(r=await n.cacheMatch(e)),r}},t.StaleWhileRevalidate=class extends v{constructor(t={}){super(t),this.plugins.some(t=>"cacheWillUpdate"in t)||this.plugins.unshift(u)}async U(t,e){const n=e.fetchAndCachePut(t).catch(()=>{});e.waitUntil(n);let i,r=await e.cacheMatch(t);if(r);else try{r=await n}catch(t){t instanceof Error&&(i=t)}if(!r)throw new s("no-response",{url:t.url,error:i});return r}},t.cleanupOutdatedCaches=function(){self.addEventListener("activate",t=>{const e=w();t.waitUntil((async(t,e="-precache-")=>{const s=(await self.caches.keys()).filter(s=>s.includes(e)&&s.includes(self.registration.scope)&&s!==t);return await Promise.all(s.map(t=>self.caches.delete(t))),s})(e).then(t=>{}))})},t.clientsClaim=function(){self.addEventListener("activate",()=>self.clients.claim())},t.precacheAndRoute=function(t,e){!function(t){Z().precache(t)}(t),function(t){const e=Z();h(new tt(e,t))}(e)},t.registerRoute=h}); From 1e10bf525c199422cb22b6cfda0fad46201e499c Mon Sep 17 00:00:00 2001 From: -LAN- Date: Sat, 10 Jan 2026 16:17:45 +0800 Subject: [PATCH 14/22] refactor(models): Refine MessageAgentThought SQLAlchemy typing (#27749) Co-authored-by: Asuka Minato Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/agent/base_agent_runner.py | 53 ++++++++++------ api/models/model.py | 62 +++++++++++-------- .../services/test_agent_service.py | 7 --- 3 files changed, 68 insertions(+), 54 deletions(-) diff --git a/api/core/agent/base_agent_runner.py b/api/core/agent/base_agent_runner.py index c196dbbdf1..3c6d36afe4 100644 --- a/api/core/agent/base_agent_runner.py +++ b/api/core/agent/base_agent_runner.py @@ -1,6 +1,7 @@ import json import logging import uuid +from decimal import Decimal from typing import Union, cast from sqlalchemy import select @@ -41,6 +42,7 @@ from core.tools.tool_manager import ToolManager from core.tools.utils.dataset_retriever_tool import DatasetRetrieverTool from extensions.ext_database import db from factories import file_factory +from models.enums import CreatorUserRole from models.model import Conversation, Message, MessageAgentThought, MessageFile logger = logging.getLogger(__name__) @@ -289,6 +291,7 @@ class BaseAgentRunner(AppRunner): thought = MessageAgentThought( message_id=message_id, message_chain_id=None, + tool_process_data=None, thought="", tool=tool_name, tool_labels_str="{}", @@ -296,20 +299,20 @@ class BaseAgentRunner(AppRunner): tool_input=tool_input, message=message, message_token=0, - message_unit_price=0, - message_price_unit=0, + message_unit_price=Decimal(0), + message_price_unit=Decimal("0.001"), message_files=json.dumps(messages_ids) if messages_ids else "", answer="", observation="", answer_token=0, - answer_unit_price=0, - answer_price_unit=0, + answer_unit_price=Decimal(0), + answer_price_unit=Decimal("0.001"), tokens=0, - total_price=0, + total_price=Decimal(0), position=self.agent_thought_count + 1, currency="USD", latency=0, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=self.user_id, ) @@ -342,7 +345,8 @@ class BaseAgentRunner(AppRunner): raise ValueError("agent thought not found") if thought: - agent_thought.thought += thought + existing_thought = agent_thought.thought or "" + agent_thought.thought = f"{existing_thought}{thought}" if tool_name: agent_thought.tool = tool_name @@ -440,21 +444,30 @@ class BaseAgentRunner(AppRunner): agent_thoughts: list[MessageAgentThought] = message.agent_thoughts if agent_thoughts: for agent_thought in agent_thoughts: - tools = agent_thought.tool - if tools: - tools = tools.split(";") + tool_names_raw = agent_thought.tool + if tool_names_raw: + tool_names = tool_names_raw.split(";") tool_calls: list[AssistantPromptMessage.ToolCall] = [] tool_call_response: list[ToolPromptMessage] = [] - try: - tool_inputs = json.loads(agent_thought.tool_input) - except Exception: - tool_inputs = {tool: {} for tool in tools} - try: - tool_responses = json.loads(agent_thought.observation) - except Exception: - tool_responses = dict.fromkeys(tools, agent_thought.observation) + tool_input_payload = agent_thought.tool_input + if tool_input_payload: + try: + tool_inputs = json.loads(tool_input_payload) + except Exception: + tool_inputs = {tool: {} for tool in tool_names} + else: + tool_inputs = {tool: {} for tool in tool_names} - for tool in tools: + observation_payload = agent_thought.observation + if observation_payload: + try: + tool_responses = json.loads(observation_payload) + except Exception: + tool_responses = dict.fromkeys(tool_names, observation_payload) + else: + tool_responses = dict.fromkeys(tool_names, observation_payload) + + for tool in tool_names: # generate a uuid for tool call tool_call_id = str(uuid.uuid4()) tool_calls.append( @@ -484,7 +497,7 @@ class BaseAgentRunner(AppRunner): *tool_call_response, ] ) - if not tools: + if not tool_names_raw: result.append(AssistantPromptMessage(content=agent_thought.thought)) else: if message.answer: diff --git a/api/models/model.py b/api/models/model.py index c791ae15b0..a48f4d34d4 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -1843,7 +1843,7 @@ class MessageChain(TypeBase): ) -class MessageAgentThought(Base): +class MessageAgentThought(TypeBase): __tablename__ = "message_agent_thoughts" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="message_agent_thought_pkey"), @@ -1851,34 +1851,42 @@ class MessageAgentThought(Base): sa.Index("message_agent_thought_message_chain_id_idx", "message_chain_id"), ) - id = mapped_column(StringUUID, default=lambda: str(uuid4())) - message_id = mapped_column(StringUUID, nullable=False) - message_chain_id = mapped_column(StringUUID, nullable=True) + id: Mapped[str] = mapped_column( + StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False + ) + message_id: Mapped[str] = mapped_column(StringUUID, nullable=False) position: Mapped[int] = mapped_column(sa.Integer, nullable=False) - thought = mapped_column(LongText, nullable=True) - tool = mapped_column(LongText, nullable=True) - tool_labels_str = mapped_column(LongText, nullable=False, default=sa.text("'{}'")) - tool_meta_str = mapped_column(LongText, nullable=False, default=sa.text("'{}'")) - tool_input = mapped_column(LongText, nullable=True) - observation = mapped_column(LongText, nullable=True) + created_by_role: Mapped[str] = mapped_column(String(255), nullable=False) + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) + message_chain_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + thought: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + tool: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + tool_labels_str: Mapped[str] = mapped_column(LongText, nullable=False, default=sa.text("'{}'")) + tool_meta_str: Mapped[str] = mapped_column(LongText, nullable=False, default=sa.text("'{}'")) + tool_input: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + observation: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) # plugin_id = mapped_column(StringUUID, nullable=True) ## for future design - tool_process_data = mapped_column(LongText, nullable=True) - message = mapped_column(LongText, nullable=True) - message_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True) - message_unit_price = mapped_column(sa.Numeric, nullable=True) - message_price_unit = mapped_column(sa.Numeric(10, 7), nullable=False, server_default=sa.text("0.001")) - message_files = mapped_column(LongText, nullable=True) - answer = mapped_column(LongText, nullable=True) - answer_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True) - answer_unit_price = mapped_column(sa.Numeric, nullable=True) - answer_price_unit = mapped_column(sa.Numeric(10, 7), nullable=False, server_default=sa.text("0.001")) - tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True) - total_price = mapped_column(sa.Numeric, nullable=True) - currency = mapped_column(String(255), nullable=True) - latency: Mapped[float | None] = mapped_column(sa.Float, nullable=True) - created_by_role = mapped_column(String(255), nullable=False) - created_by = mapped_column(StringUUID, nullable=False) - created_at = mapped_column(sa.DateTime, nullable=False, server_default=sa.func.current_timestamp()) + tool_process_data: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + message: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + message_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None) + message_unit_price: Mapped[Decimal | None] = mapped_column(sa.Numeric, nullable=True, default=None) + message_price_unit: Mapped[Decimal] = mapped_column( + sa.Numeric(10, 7), nullable=False, default=Decimal("0.001"), server_default=sa.text("0.001") + ) + message_files: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + answer: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + answer_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None) + answer_unit_price: Mapped[Decimal | None] = mapped_column(sa.Numeric, nullable=True, default=None) + answer_price_unit: Mapped[Decimal] = mapped_column( + sa.Numeric(10, 7), nullable=False, default=Decimal("0.001"), server_default=sa.text("0.001") + ) + tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None) + total_price: Mapped[Decimal | None] = mapped_column(sa.Numeric, nullable=True, default=None) + currency: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + latency: Mapped[float | None] = mapped_column(sa.Float, nullable=True, default=None) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, init=False, server_default=sa.func.current_timestamp() + ) @property def files(self) -> list[Any]: diff --git a/api/tests/test_containers_integration_tests/services/test_agent_service.py b/api/tests/test_containers_integration_tests/services/test_agent_service.py index 3be2798085..a22d6f8fbf 100644 --- a/api/tests/test_containers_integration_tests/services/test_agent_service.py +++ b/api/tests/test_containers_integration_tests/services/test_agent_service.py @@ -230,7 +230,6 @@ class TestAgentService: # Create first agent thought thought1 = MessageAgentThought( - id=fake.uuid4(), message_id=message.id, position=1, thought="I need to analyze the user's request", @@ -257,7 +256,6 @@ class TestAgentService: # Create second agent thought thought2 = MessageAgentThought( - id=fake.uuid4(), message_id=message.id, position=2, thought="Based on the analysis, I can provide a response", @@ -545,7 +543,6 @@ class TestAgentService: # Create agent thought with tool error thought_with_error = MessageAgentThought( - id=fake.uuid4(), message_id=message.id, position=1, thought="I need to analyze the user's request", @@ -759,7 +756,6 @@ class TestAgentService: # Create agent thought with multiple tools complex_thought = MessageAgentThought( - id=fake.uuid4(), message_id=message.id, position=1, thought="I need to use multiple tools to complete this task", @@ -877,7 +873,6 @@ class TestAgentService: # Create agent thought with files thought_with_files = MessageAgentThought( - id=fake.uuid4(), message_id=message.id, position=1, thought="I need to process some files", @@ -957,7 +952,6 @@ class TestAgentService: # Create agent thought with empty tool data empty_thought = MessageAgentThought( - id=fake.uuid4(), message_id=message.id, position=1, thought="I need to analyze the user's request", @@ -999,7 +993,6 @@ class TestAgentService: # Create agent thought with malformed JSON malformed_thought = MessageAgentThought( - id=fake.uuid4(), message_id=message.id, position=1, thought="I need to analyze the user's request", From a2e03b811e581674603617a4b13d9d25760c44be Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Sat, 10 Jan 2026 19:49:23 +0800 Subject: [PATCH 15/22] fix: Broken import in .storybook/preview.tsx (#30812) --- web/.storybook/preview.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/web/.storybook/preview.tsx b/web/.storybook/preview.tsx index 37c636cc75..5b38424776 100644 --- a/web/.storybook/preview.tsx +++ b/web/.storybook/preview.tsx @@ -1,8 +1,10 @@ import type { Preview } from '@storybook/react' +import type { Resource } from 'i18next' import { withThemeByDataAttribute } from '@storybook/addon-themes' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ToastProvider } from '../app/components/base/toast' -import I18N from '../app/components/i18n' +import { I18nClientProvider as I18N } from '../app/components/provider/i18n' +import commonEnUS from '../i18n/en-US/common.json' import '../app/styles/globals.css' import '../app/styles/markdown.scss' @@ -16,6 +18,14 @@ const queryClient = new QueryClient({ }, }) +const storyResources: Resource = { + 'en-US': { + // Preload the most common namespace to avoid missing keys during initial render; + // other namespaces will be loaded on demand via resourcesToBackend. + common: commonEnUS as unknown as Record, + }, +} + export const decorators = [ withThemeByDataAttribute({ themes: { @@ -28,7 +38,7 @@ export const decorators = [ (Story) => { return ( - + From 0c2729d9b30cda8ae596b0ece3783124fb4cd232 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Mon, 12 Jan 2026 09:35:31 +0800 Subject: [PATCH 16/22] fix: fix refresh token deadlock (#30828) --- web/service/refresh-token.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/service/refresh-token.ts b/web/service/refresh-token.ts index 3f63f628a1..998b7f2461 100644 --- a/web/service/refresh-token.ts +++ b/web/service/refresh-token.ts @@ -72,12 +72,12 @@ async function getNewAccessToken(timeout: number): Promise { } function releaseRefreshLock() { - if (isRefreshing) { - isRefreshing = false - globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY) - globalThis.localStorage.removeItem('last_refresh_time') - globalThis.removeEventListener('beforeunload', releaseRefreshLock) - } + // Always clear the refresh lock to avoid cross-tab deadlocks. + // This is safe to call multiple times and from tabs that were only waiting. + isRefreshing = false + globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY) + globalThis.localStorage.removeItem('last_refresh_time') + globalThis.removeEventListener('beforeunload', releaseRefreshLock) } export async function refreshAccessTokenOrRelogin(timeout: number) { From 9fad97ec9bc45daf2ac45a514470aa08a7362aaa Mon Sep 17 00:00:00 2001 From: yihong Date: Mon, 12 Jan 2026 09:45:49 +0800 Subject: [PATCH 17/22] fix: drop useless pyrefly in ci (#30826) Signed-off-by: yihong0618 --- .github/workflows/api-tests.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 152a9caee8..190e00d9fe 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -39,12 +39,6 @@ jobs: - name: Install dependencies run: uv sync --project api --dev - - name: Run pyrefly check - run: | - cd api - uv add --dev pyrefly - uv run pyrefly check || true - - name: Run dify config tests run: uv run --project api dev/pytest/pytest_config_tests.py From 31a8fd810c4b1fbd132a321a595bb4e31ae28f6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:44:11 +0800 Subject: [PATCH 18/22] chore(deps-dev): bump @storybook/react from 9.1.13 to 9.1.17 in /web (#30833) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/package.json | 2 +- web/pnpm-lock.yaml | 47 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/web/package.json b/web/package.json index 4a14ab8601..5b13f9c1f4 100644 --- a/web/package.json +++ b/web/package.json @@ -166,7 +166,7 @@ "@storybook/addon-onboarding": "9.1.13", "@storybook/addon-themes": "9.1.13", "@storybook/nextjs": "9.1.13", - "@storybook/react": "9.1.13", + "@storybook/react": "9.1.17", "@tanstack/eslint-plugin-query": "^5.91.2", "@tanstack/react-devtools": "^0.9.0", "@tanstack/react-form-devtools": "^0.2.9", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 2ce94ae8eb..f39f7503d9 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -395,8 +395,8 @@ importers: specifier: 9.1.13 version: 9.1.13(esbuild@0.25.0)(next@15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0)(storybook@9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(type-fest@4.2.0)(typescript@5.9.3)(uglify-js@3.19.3)(webpack-hot-middleware@2.26.1)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)) '@storybook/react': - specifier: 9.1.13 - version: 9.1.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3) + specifier: 9.1.17 + version: 9.1.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3) '@tanstack/eslint-plugin-query': specifier: ^5.91.2 version: 5.91.2(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) @@ -3281,6 +3281,13 @@ packages: react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta storybook: ^9.1.13 + '@storybook/react-dom-shim@9.1.17': + resolution: {integrity: sha512-Ss/lNvAy0Ziynu+KniQIByiNuyPz3dq7tD62hqSC/pHw190X+M7TKU3zcZvXhx2AQx1BYyxtdSHIZapb+P5mxQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^9.1.17 + '@storybook/react@9.1.13': resolution: {integrity: sha512-B0UpYikKf29t8QGcdmumWojSQQ0phSDy/Ne2HYdrpNIxnUvHHUVOlGpq4lFcIDt52Ip5YG5GuAwJg3+eR4LCRg==} engines: {node: '>=20.0.0'} @@ -3293,6 +3300,18 @@ packages: typescript: optional: true + '@storybook/react@9.1.17': + resolution: {integrity: sha512-TZCplpep5BwjHPIIcUOMHebc/2qKadJHYPisRn5Wppl014qgT3XkFLpYkFgY1BaRXtqw8Mn3gqq4M/49rQ7Iww==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^9.1.17 + typescript: '>= 4.9.x' + peerDependenciesMeta: + typescript: + optional: true + '@stylistic/eslint-plugin@5.6.1': resolution: {integrity: sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3728,8 +3747,8 @@ packages: '@types/node@20.19.26': resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==} - '@types/node@20.19.27': - resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} + '@types/node@20.19.28': + resolution: {integrity: sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==} '@types/papaparse@5.5.1': resolution: {integrity: sha512-esEO+VISsLIyE+JZBmb89NzsYYbpwV8lmv2rPo6oX5y9KhBaIP7hhHgjuTut54qjdKVMufTEcrh5fUl9+58huw==} @@ -11676,6 +11695,12 @@ snapshots: react-dom: 19.2.3(react@19.2.3) storybook: 9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/react-dom-shim@9.1.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + storybook: 9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/react@9.1.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 @@ -11686,6 +11711,16 @@ snapshots: optionalDependencies: typescript: 5.9.3 + '@storybook/react@9.1.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3)': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/react-dom-shim': 9.1.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + storybook: 9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + optionalDependencies: + typescript: 5.9.3 + '@stylistic/eslint-plugin@5.6.1(eslint@9.39.2(jiti@1.21.7))': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) @@ -12167,7 +12202,7 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@20.19.27': + '@types/node@20.19.28': dependencies: undici-types: 6.21.0 optional: true @@ -14536,7 +14571,7 @@ snapshots: happy-dom@20.0.11: dependencies: - '@types/node': 20.19.27 + '@types/node': 20.19.28 '@types/whatwg-mimetype': 3.0.2 whatwg-mimetype: 3.0.0 optional: true From 8cfdde594c722ca5a9b2e52dd604da549f0c0064 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:44:21 +0800 Subject: [PATCH 19/22] chore(deps-dev): bump tos from 2.7.2 to 2.9.0 in /api (#30834) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 2 +- api/uv.lock | 53 +++++++++++++++++++++++----------------------- 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index dbc6a2eb83..7d2d68bc8d 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -189,7 +189,7 @@ storage = [ "opendal~=0.46.0", "oss2==2.18.5", "supabase~=2.18.1", - "tos~=2.7.1", + "tos~=2.9.0", ] ############################################################ diff --git a/api/uv.lock b/api/uv.lock index 8e60fad3a7..a999c4ee18 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1731,7 +1731,7 @@ storage = [ { name = "opendal", specifier = "~=0.46.0" }, { name = "oss2", specifier = "==2.18.5" }, { name = "supabase", specifier = "~=2.18.1" }, - { name = "tos", specifier = "~=2.7.1" }, + { name = "tos", specifier = "~=2.9.0" }, ] tools = [ { name = "cloudscraper", specifier = "~=1.2.71" }, @@ -6148,7 +6148,7 @@ wheels = [ [[package]] name = "tos" -version = "2.7.2" +version = "2.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "crcmod" }, @@ -6156,8 +6156,9 @@ dependencies = [ { name = "pytz" }, { name = "requests" }, { name = "six" }, + { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/01/f811af86f1f80d5f289be075c3b281e74bf3fe081cfbe5cfce44954d2c3a/tos-2.7.2.tar.gz", hash = "sha256:3c31257716785bca7b2cac51474ff32543cda94075a7b7aff70d769c15c7b7ed", size = 123407, upload-time = "2024-10-16T15:59:08.634Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/b3/13451226f564f88d9db2323e9b7eabcced792a0ad5ee1e333751a7634257/tos-2.9.0.tar.gz", hash = "sha256:861cfc348e770f099f911cb96b2c41774ada6c9c51b7a89d97e0c426074dd99e", size = 157071, upload-time = "2026-01-06T04:13:08.921Z" } [[package]] name = "tqdm" @@ -7146,31 +7147,31 @@ wheels = [ [[package]] name = "wrapt" -version = "1.17.3" +version = "1.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", size = 53972, upload-time = "2023-11-09T06:33:30.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, - { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, - { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, - { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, - { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/fd/03/c188ac517f402775b90d6f312955a5e53b866c964b32119f2ed76315697e/wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09", size = 37313, upload-time = "2023-11-09T06:31:52.168Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/ea627d7817394db04518f62934a5de59874b587b792300991b3c347ff5e0/wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d", size = 38164, upload-time = "2023-11-09T06:31:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a7/f1212ba098f3de0fd244e2de0f8791ad2539c03bef6c05a9fcb03e45b089/wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389", size = 80890, upload-time = "2023-11-09T06:31:55.247Z" }, + { url = "https://files.pythonhosted.org/packages/b7/96/bb5e08b3d6db003c9ab219c487714c13a237ee7dcc572a555eaf1ce7dc82/wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060", size = 73118, upload-time = "2023-11-09T06:31:57.023Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/2da48b35193e39ac53cfb141467d9f259851522d0e8c87153f0ba4205fb1/wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1", size = 80746, upload-time = "2023-11-09T06:31:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/11/fb/18ec40265ab81c0e82a934de04596b6ce972c27ba2592c8b53d5585e6bcd/wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3", size = 85668, upload-time = "2023-11-09T06:31:59.992Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ef/0ecb1fa23145560431b970418dce575cfaec555ab08617d82eb92afc7ccf/wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956", size = 78556, upload-time = "2023-11-09T06:32:01.942Z" }, + { url = "https://files.pythonhosted.org/packages/25/62/cd284b2b747f175b5a96cbd8092b32e7369edab0644c45784871528eb852/wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d", size = 85712, upload-time = "2023-11-09T06:32:03.686Z" }, + { url = "https://files.pythonhosted.org/packages/e5/a7/47b7ff74fbadf81b696872d5ba504966591a3468f1bc86bca2f407baef68/wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362", size = 35327, upload-time = "2023-11-09T06:32:05.284Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c3/0084351951d9579ae83a3d9e38c140371e4c6b038136909235079f2e6e78/wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89", size = 37523, upload-time = "2023-11-09T06:32:07.17Z" }, + { url = "https://files.pythonhosted.org/packages/92/17/224132494c1e23521868cdd57cd1e903f3b6a7ba6996b7b8f077ff8ac7fe/wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b", size = 37614, upload-time = "2023-11-09T06:32:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d7/cfcd73e8f4858079ac59d9db1ec5a1349bc486ae8e9ba55698cc1f4a1dff/wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36", size = 38316, upload-time = "2023-11-09T06:32:10.719Z" }, + { url = "https://files.pythonhosted.org/packages/7e/79/5ff0a5c54bda5aec75b36453d06be4f83d5cd4932cc84b7cb2b52cee23e2/wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73", size = 86322, upload-time = "2023-11-09T06:32:12.592Z" }, + { url = "https://files.pythonhosted.org/packages/c4/81/e799bf5d419f422d8712108837c1d9bf6ebe3cb2a81ad94413449543a923/wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809", size = 79055, upload-time = "2023-11-09T06:32:14.394Z" }, + { url = "https://files.pythonhosted.org/packages/62/62/30ca2405de6a20448ee557ab2cd61ab9c5900be7cbd18a2639db595f0b98/wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b", size = 87291, upload-time = "2023-11-09T06:32:16.201Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/5d2f6d7b57fc9956bf06e944eb00463551f7d52fc73ca35cfc4c2cdb7aed/wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81", size = 90374, upload-time = "2023-11-09T06:32:18.052Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/c2c21b44ff5b9bf14a83252a8b973fb84923764ff63db3e6dfc3895cf2e0/wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9", size = 83896, upload-time = "2023-11-09T06:32:19.533Z" }, + { url = "https://files.pythonhosted.org/packages/14/26/93a9fa02c6f257df54d7570dfe8011995138118d11939a4ecd82cb849613/wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c", size = 91738, upload-time = "2023-11-09T06:32:20.989Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5b/4660897233eb2c8c4de3dc7cefed114c61bacb3c28327e64150dc44ee2f6/wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc", size = 35568, upload-time = "2023-11-09T06:32:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/8297f9658506b224aa4bd71906447dea6bb0ba629861a758c28f67428b91/wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8", size = 37653, upload-time = "2023-11-09T06:32:24.533Z" }, + { url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362, upload-time = "2023-11-09T06:33:28.271Z" }, ] [[package]] From 220e1df847326bfca0bb312a82b14f0908f2dabf Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:44:30 +0800 Subject: [PATCH 20/22] docs(web): add corepack recommendation (#30837) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- web/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/README.md b/web/README.md index 7f5740a471..ae4338d7be 100644 --- a/web/README.md +++ b/web/README.md @@ -11,6 +11,16 @@ Before starting the web frontend service, please make sure the following environ - [Node.js](https://nodejs.org) >= v22.11.x - [pnpm](https://pnpm.io) v10.x +> [!TIP] +> It is recommended to install and enable Corepack to manage package manager versions automatically: +> +> ```bash +> npm install -g corepack +> corepack enable +> ``` +> +> Learn more: [Corepack](https://github.com/nodejs/corepack#readme) + First, install the dependencies: ```bash From f9a21b56abda5ea77c6cdcd4006ff922fbee1b59 Mon Sep 17 00:00:00 2001 From: Lemonadeccc <82374719+Lemonadeccc@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:56:05 +0800 Subject: [PATCH 21/22] feat: add block-no-verify hook for Claude Code (#30839) --- .claude/settings.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.claude/settings.json b/.claude/settings.json index 509dbe8447..72dcb5ec73 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,5 +5,18 @@ "typescript-lsp@claude-plugins-official": true, "pyright-lsp@claude-plugins-official": true, "ralph-loop@claude-plugins-official": true + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "npx -y block-no-verify@1.1.1" + } + ] + } + ] } } From 9161936f4146e488e33c14b08d2f578eff4ce1b0 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:57:43 +0800 Subject: [PATCH 22/22] refactor(web): extract isServer/isClient utility & upgrade Node.js to 22.12.0 (#30803) Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> --- .nvmrc | 1 - web/.nvmrc | 1 + web/app/components/apps/list.tsx | 3 ++- .../base/chat/embedded-chatbot/header/index.tsx | 2 +- .../workflow/block-selector/featured-tools.tsx | 7 ++++--- .../workflow/block-selector/featured-triggers.tsx | 7 ++++--- .../rag-tool-recommendations/index.tsx | 7 ++++--- .../hooks/use-trigger-events-limit-modal.ts | 3 ++- web/context/query-client.tsx | 3 ++- web/hooks/use-query-params.spec.tsx | 14 +++++++++++--- web/hooks/use-query-params.ts | 3 ++- web/package.json | 2 +- web/utils/client.ts | 3 +++ web/utils/gtag.ts | 4 +++- 14 files changed, 40 insertions(+), 20 deletions(-) delete mode 100644 .nvmrc create mode 100644 web/.nvmrc create mode 100644 web/utils/client.ts diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 7af24b7ddb..0000000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -22.11.0 diff --git a/web/.nvmrc b/web/.nvmrc new file mode 100644 index 0000000000..5767036af0 --- /dev/null +++ b/web/.nvmrc @@ -0,0 +1 @@ +22.21.1 diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index e5c9954626..095ed3f696 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -29,6 +29,7 @@ import { CheckModal } from '@/hooks/use-pay' import { useInfiniteAppList } from '@/service/use-apps' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' +import { isServer } from '@/utils/client' import AppCard from './app-card' import { AppCardSkeleton } from './app-card-skeleton' import Empty from './empty' @@ -71,7 +72,7 @@ const List = () => { // 1) Normalize legacy/incorrect query params like ?mode=discover -> ?category=all useEffect(() => { // avoid running on server - if (typeof window === 'undefined') + if (isServer) return const mode = searchParams.get('mode') if (!mode) diff --git a/web/app/components/base/chat/embedded-chatbot/header/index.tsx b/web/app/components/base/chat/embedded-chatbot/header/index.tsx index 95ba6d212d..fe7afc9e22 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/index.tsx @@ -11,6 +11,7 @@ import DifyLogo from '@/app/components/base/logo/dify-logo' import Tooltip from '@/app/components/base/tooltip' import { useGlobalPublicStore } from '@/context/global-public-context' import { cn } from '@/utils/classnames' +import { isClient } from '@/utils/client' import { useEmbeddedChatbotContext, } from '../context' @@ -40,7 +41,6 @@ const Header: FC = ({ allInputsHidden, } = useEmbeddedChatbotContext() - const isClient = typeof window !== 'undefined' const isIframe = isClient ? window.self !== window.top : false const [parentOrigin, setParentOrigin] = useState('') const [showToggleExpandButton, setShowToggleExpandButton] = useState(false) diff --git a/web/app/components/workflow/block-selector/featured-tools.tsx b/web/app/components/workflow/block-selector/featured-tools.tsx index f2795e7ec0..12a1c59e0e 100644 --- a/web/app/components/workflow/block-selector/featured-tools.tsx +++ b/web/app/components/workflow/block-selector/featured-tools.tsx @@ -13,6 +13,7 @@ import Tooltip from '@/app/components/base/tooltip' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' import { useGetLanguage } from '@/context/i18n' +import { isServer } from '@/utils/client' import { formatNumber } from '@/utils/format' import { getMarketplaceUrl } from '@/utils/var' import BlockIcon from '../block-icon' @@ -49,14 +50,14 @@ const FeaturedTools = ({ const language = useGetLanguage() const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT) const [isCollapsed, setIsCollapsed] = useState(() => { - if (typeof window === 'undefined') + if (isServer) return false const stored = window.localStorage.getItem(STORAGE_KEY) return stored === 'true' }) useEffect(() => { - if (typeof window === 'undefined') + if (isServer) return const stored = window.localStorage.getItem(STORAGE_KEY) if (stored !== null) @@ -64,7 +65,7 @@ const FeaturedTools = ({ }, []) useEffect(() => { - if (typeof window === 'undefined') + if (isServer) return window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) }, [isCollapsed]) diff --git a/web/app/components/workflow/block-selector/featured-triggers.tsx b/web/app/components/workflow/block-selector/featured-triggers.tsx index 9ae6181a4f..01cb5d100f 100644 --- a/web/app/components/workflow/block-selector/featured-triggers.tsx +++ b/web/app/components/workflow/block-selector/featured-triggers.tsx @@ -12,6 +12,7 @@ import Tooltip from '@/app/components/base/tooltip' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' import { useGetLanguage } from '@/context/i18n' +import { isServer } from '@/utils/client' import { formatNumber } from '@/utils/format' import { getMarketplaceUrl } from '@/utils/var' import BlockIcon from '../block-icon' @@ -42,14 +43,14 @@ const FeaturedTriggers = ({ const language = useGetLanguage() const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT) const [isCollapsed, setIsCollapsed] = useState(() => { - if (typeof window === 'undefined') + if (isServer) return false const stored = window.localStorage.getItem(STORAGE_KEY) return stored === 'true' }) useEffect(() => { - if (typeof window === 'undefined') + if (isServer) return const stored = window.localStorage.getItem(STORAGE_KEY) if (stored !== null) @@ -57,7 +58,7 @@ const FeaturedTriggers = ({ }, []) useEffect(() => { - if (typeof window === 'undefined') + if (isServer) return window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) }, [isCollapsed]) diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx index 3c62f488dc..e934f27fd1 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx @@ -11,6 +11,7 @@ import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid import Loading from '@/app/components/base/loading' import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' import { useRAGRecommendedPlugins } from '@/service/use-tools' +import { isServer } from '@/utils/client' import { getMarketplaceUrl } from '@/utils/var' import List from './list' @@ -29,14 +30,14 @@ const RAGToolRecommendations = ({ }: RAGToolRecommendationsProps) => { const { t } = useTranslation() const [isCollapsed, setIsCollapsed] = useState(() => { - if (typeof window === 'undefined') + if (isServer) return false const stored = window.localStorage.getItem(STORAGE_KEY) return stored === 'true' }) useEffect(() => { - if (typeof window === 'undefined') + if (isServer) return const stored = window.localStorage.getItem(STORAGE_KEY) if (stored !== null) @@ -44,7 +45,7 @@ const RAGToolRecommendations = ({ }, []) useEffect(() => { - if (typeof window === 'undefined') + if (isServer) return window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) }, [isCollapsed]) diff --git a/web/context/hooks/use-trigger-events-limit-modal.ts b/web/context/hooks/use-trigger-events-limit-modal.ts index 403df58378..72342cd0d3 100644 --- a/web/context/hooks/use-trigger-events-limit-modal.ts +++ b/web/context/hooks/use-trigger-events-limit-modal.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { NUM_INFINITE } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' import { IS_CLOUD_EDITION } from '@/config' +import { isServer } from '@/utils/client' export type TriggerEventsLimitModalPayload = { usage: number @@ -46,7 +47,7 @@ export const useTriggerEventsLimitModal = ({ useEffect(() => { if (!IS_CLOUD_EDITION) return - if (typeof window === 'undefined') + if (isServer) return if (!currentWorkspaceId) return diff --git a/web/context/query-client.tsx b/web/context/query-client.tsx index a72393490c..1cd64b168b 100644 --- a/web/context/query-client.tsx +++ b/web/context/query-client.tsx @@ -5,12 +5,13 @@ import type { FC, PropsWithChildren } from 'react' import { QueryClientProvider } from '@tanstack/react-query' import { useState } from 'react' import { TanStackDevtoolsLoader } from '@/app/components/devtools/tanstack/loader' +import { isServer } from '@/utils/client' import { makeQueryClient } from './query-client-server' let browserQueryClient: QueryClient | undefined function getQueryClient() { - if (typeof window === 'undefined') { + if (isServer) { return makeQueryClient() } if (!browserQueryClient) diff --git a/web/hooks/use-query-params.spec.tsx b/web/hooks/use-query-params.spec.tsx index b187471809..35e234881d 100644 --- a/web/hooks/use-query-params.spec.tsx +++ b/web/hooks/use-query-params.spec.tsx @@ -12,6 +12,13 @@ import { usePricingModal, } from './use-query-params' +// Mock isServer to allow runtime control in tests +const mockIsServer = vi.hoisted(() => ({ value: false })) +vi.mock('@/utils/client', () => ({ + get isServer() { return mockIsServer.value }, + get isClient() { return !mockIsServer.value }, +})) + const renderWithAdapter = (hook: () => T, searchParams = '') => { const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() const wrapper = ({ children }: { children: ReactNode }) => ( @@ -428,6 +435,7 @@ describe('clearQueryParams', () => { afterEach(() => { vi.unstubAllGlobals() + mockIsServer.value = false }) it('should remove a single key when provided one key', () => { @@ -463,13 +471,13 @@ describe('clearQueryParams', () => { replaceSpy.mockRestore() }) - it('should no-op when window is undefined', () => { + it('should no-op when running on server', () => { // Arrange const replaceSpy = vi.spyOn(window.history, 'replaceState') - vi.stubGlobal('window', undefined) + mockIsServer.value = true // Act - expect(() => clearQueryParams('foo')).not.toThrow() + clearQueryParams('foo') // Assert expect(replaceSpy).not.toHaveBeenCalled() diff --git a/web/hooks/use-query-params.ts b/web/hooks/use-query-params.ts index 73798a4a4f..0749a1ffa5 100644 --- a/web/hooks/use-query-params.ts +++ b/web/hooks/use-query-params.ts @@ -21,6 +21,7 @@ import { } from 'nuqs' import { useCallback } from 'react' import { ACCOUNT_SETTING_MODAL_ACTION } from '@/app/components/header/account-setting/constants' +import { isServer } from '@/utils/client' /** * Modal State Query Parameters @@ -176,7 +177,7 @@ export function usePluginInstallation() { * clearQueryParams(['param1', 'param2']) */ export function clearQueryParams(keys: string | string[]) { - if (typeof window === 'undefined') + if (isServer) return const url = new URL(window.location.href) diff --git a/web/package.json b/web/package.json index 5b13f9c1f4..7537b942fb 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,7 @@ } }, "engines": { - "node": ">=v22.11.0" + "node": ">=22.12.0" }, "browserslist": [ "last 1 Chrome version", diff --git a/web/utils/client.ts b/web/utils/client.ts new file mode 100644 index 0000000000..5c4532997b --- /dev/null +++ b/web/utils/client.ts @@ -0,0 +1,3 @@ +export const isServer = typeof window === 'undefined' + +export const isClient = typeof window !== 'undefined' diff --git a/web/utils/gtag.ts b/web/utils/gtag.ts index 5af51a6564..5f199f1fbc 100644 --- a/web/utils/gtag.ts +++ b/web/utils/gtag.ts @@ -1,3 +1,5 @@ +import { isServer } from '@/utils/client' + /** * Send Google Analytics event * @param eventName - event name @@ -7,7 +9,7 @@ export const sendGAEvent = ( eventName: string, eventParams?: GtagEventParams, ): void => { - if (typeof window === 'undefined' || typeof (window as any).gtag !== 'function') { + if (isServer || typeof (window as any).gtag !== 'function') { return } (window as any).gtag('event', eventName, eventParams)