mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 17:08:03 +08:00
Merge branch 'main' into feat/llm-support-tools
This commit is contained in:
@ -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 (
|
||||
<PluginPage
|
||||
plugins={<PluginsPanel />}
|
||||
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" showSearchParams={false} />}
|
||||
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -83,7 +83,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
if (!isJsonObject || !tempPayload.json_schema)
|
||||
return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2)
|
||||
return tempPayload.json_schema
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
|
||||
228
web/app/components/app/log/list.spec.tsx
Normal file
228
web/app/components/app/log/list.spec.tsx
Normal file
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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<Record<string, string>>({})
|
||||
const isLoadingRef = useRef(false)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
const lastLoadTimeRef = useRef(0)
|
||||
const retryCountRef = useRef(0)
|
||||
const oldestAnswerIdRef = useRef<string | undefined>(undefined)
|
||||
const MAX_RETRY_COUNT = 3
|
||||
|
||||
const [allChatItems, setAllChatItems] = useState<IChatItem[]>([])
|
||||
const [chatItemTree, setChatItemTree] = useState<ChatItemInTree[]>([])
|
||||
const [threadChatItems, setThreadChatItems] = useState<IChatItem[]>([])
|
||||
|
||||
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 (
|
||||
<div ref={ref} className="flex h-full flex-col rounded-xl border-[0.5px] border-components-panel-border">
|
||||
{/* Panel Header */}
|
||||
|
||||
@ -10,6 +10,7 @@ const mockReplace = vi.fn()
|
||||
const mockRouter = { replace: mockReplace }
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => mockRouter,
|
||||
useSearchParams: () => new URLSearchParams(''),
|
||||
}))
|
||||
|
||||
// Mock app context
|
||||
|
||||
@ -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'
|
||||
@ -28,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'
|
||||
@ -36,6 +38,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<string | AppModeEnum>([
|
||||
'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 +59,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 (isServer)
|
||||
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<string[]>(tagIDs)
|
||||
|
||||
@ -37,7 +37,7 @@ export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: Inpu
|
||||
return
|
||||
}
|
||||
|
||||
if (!inputValue)
|
||||
if (inputValue == null)
|
||||
return
|
||||
|
||||
if (item.type === InputVarType.singleFile) {
|
||||
@ -52,6 +52,20 @@ export const getProcessedInputs = (inputs: Record<string, any>, 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
|
||||
|
||||
@ -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<IHeaderProps> = ({
|
||||
allInputsHidden,
|
||||
} = useEmbeddedChatbotContext()
|
||||
|
||||
const isClient = typeof window !== 'undefined'
|
||||
const isIframe = isClient ? window.self !== window.top : false
|
||||
const [parentOrigin, setParentOrigin] = useState('')
|
||||
const [showToggleExpandButton, setShowToggleExpandButton] = useState(false)
|
||||
@ -66,7 +66,9 @@ const Header: FC<IHeaderProps> = ({
|
||||
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])
|
||||
|
||||
1562
web/app/components/datasets/create/embedding-process/index.spec.tsx
Normal file
1562
web/app/components/datasets/create/embedding-process/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 (
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.keys(segmentationRuleMap).map((field) => {
|
||||
return (
|
||||
<FieldInfo
|
||||
key={field}
|
||||
label={segmentationRuleMap[field as keyof typeof segmentationRuleMap]}
|
||||
displayedValue={String(getValue(field))}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<FieldInfo
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
|
||||
valueIcon={(
|
||||
<Image
|
||||
className="size-4"
|
||||
src={
|
||||
indexingType === IndexingType.ECONOMICAL
|
||||
? indexMethodIcon.economical
|
||||
: indexMethodIcon.high_quality
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldInfo
|
||||
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
// displayedValue={t(`datasetSettings.form.retrievalSetting.${retrievalMethod}`) as string}
|
||||
displayedValue={t(`retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
|
||||
valueIcon={(
|
||||
<Image
|
||||
className="size-4"
|
||||
src={
|
||||
retrievalMethod === RETRIEVE_METHOD.fullText
|
||||
? retrievalIcon.fullText
|
||||
: retrievalMethod === RETRIEVE_METHOD.hybrid
|
||||
? retrievalIcon.hybrid
|
||||
: retrievalIcon.vector
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="system-md-semibold-uppercase flex items-center gap-x-1 text-text-secondary">
|
||||
{isEmbedding && (
|
||||
<>
|
||||
<RiLoader2Fill className="size-4 animate-spin" />
|
||||
<span>{t('embedding.processing', { ns: 'datasetDocuments' })}</span>
|
||||
</>
|
||||
)}
|
||||
{isCompleted && t('embedding.completed', { ns: 'datasetDocuments' })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EmbeddingProcess: FC<Props> = ({ datasetId, batchId, documents = [], indexingType, retrievalMethod }) => {
|
||||
// Action buttons component
|
||||
const ActionButtons: FC<{
|
||||
apiReferenceUrl: string
|
||||
onNavToDocuments: () => void
|
||||
}> = ({ apiReferenceUrl, onNavToDocuments }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex items-center gap-x-2 py-2">
|
||||
<Link href={apiReferenceUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button className="w-fit gap-x-0.5 px-3">
|
||||
<RiTerminalBoxLine className="size-4" />
|
||||
<span className="px-0.5">Access the API</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
className="w-fit gap-x-0.5 px-3"
|
||||
variant="primary"
|
||||
onClick={onNavToDocuments}
|
||||
>
|
||||
<span className="px-0.5">{t('stepThree.navTo', { ns: 'datasetCreation' })}</span>
|
||||
<RiArrowRightLine className="size-4 stroke-current stroke-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EmbeddingProcess: FC<EmbeddingProcessProps> = ({
|
||||
datasetId,
|
||||
batchId,
|
||||
documents = [],
|
||||
indexingType,
|
||||
retrievalMethod,
|
||||
}) => {
|
||||
const { enableBilling, plan } = useProviderContext()
|
||||
|
||||
const getFirstDocument = documents[0]
|
||||
|
||||
const [indexingStatusBatchDetail, setIndexingStatusDetail] = useState<IndexingStatusResponse[]>([])
|
||||
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 (
|
||||
<>
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="system-md-semibold-uppercase flex items-center gap-x-1 text-text-secondary">
|
||||
{isEmbedding && (
|
||||
<>
|
||||
<RiLoader2Fill className="size-4 animate-spin" />
|
||||
<span>{t('embedding.processing', { ns: 'datasetDocuments' })}</span>
|
||||
</>
|
||||
)}
|
||||
{isEmbeddingCompleted && t('embedding.completed', { ns: 'datasetDocuments' })}
|
||||
</div>
|
||||
{
|
||||
enableBilling && plan.type !== Plan.team && (
|
||||
<div className="flex h-14 items-center rounded-xl border-[0.5px] border-black/5 bg-white p-3 shadow-md">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[#FFF6ED]">
|
||||
<ZapFast className="h-4 w-4 text-[#FB6514]" />
|
||||
</div>
|
||||
<div className="mx-3 grow text-[13px] font-medium text-gray-700">
|
||||
{t('plansCommon.documentProcessingPriorityUpgrade', { ns: 'billing' })}
|
||||
</div>
|
||||
<UpgradeBtn loc="knowledge-speed-up" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<StatusHeader isEmbedding={isEmbedding} isCompleted={isEmbeddingCompleted} />
|
||||
|
||||
{showUpgradeBanner && <UpgradeBanner />}
|
||||
|
||||
<div className="flex flex-col gap-0.5 pb-2">
|
||||
{indexingStatusBatchDetail.map(indexingStatusDetail => (
|
||||
<div
|
||||
key={indexingStatusDetail.id}
|
||||
className={cn(
|
||||
'relative h-[26px] overflow-hidden rounded-md bg-components-progress-bar-bg',
|
||||
indexingStatusDetail.indexing_status === 'error' && 'bg-state-destructive-hover-alt',
|
||||
)}
|
||||
>
|
||||
{isSourceEmbedding(indexingStatusDetail) && (
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full min-w-0.5 border-r-[2px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress"
|
||||
style={{ width: `${getSourcePercent(indexingStatusDetail)}%` }}
|
||||
/>
|
||||
)}
|
||||
<div className="z-[1] flex h-full items-center gap-1 pl-[6px] pr-2">
|
||||
{getSourceType(indexingStatusDetail.id) === DataSourceType.FILE && (
|
||||
<DocumentFileIcon
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
name={getSourceName(indexingStatusDetail.id)}
|
||||
extension={getFileType(getSourceName(indexingStatusDetail.id))}
|
||||
/>
|
||||
)}
|
||||
{getSourceType(indexingStatusDetail.id) === DataSourceType.NOTION && (
|
||||
<NotionIcon
|
||||
className="shrink-0"
|
||||
type="page"
|
||||
src={getIcon(indexingStatusDetail.id)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-0 grow items-center gap-1" title={getSourceName(indexingStatusDetail.id)}>
|
||||
<div className="system-xs-medium truncate text-text-secondary">
|
||||
{getSourceName(indexingStatusDetail.id)}
|
||||
</div>
|
||||
{
|
||||
enableBilling && (
|
||||
<PriorityLabel className="ml-0" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{isSourceEmbedding(indexingStatusDetail) && (
|
||||
<div className="shrink-0 text-xs text-text-secondary">{`${getSourcePercent(indexingStatusDetail)}%`}</div>
|
||||
)}
|
||||
{indexingStatusDetail.indexing_status === 'error' && (
|
||||
<Tooltip
|
||||
popupClassName="px-4 py-[14px] max-w-60 body-xs-regular text-text-secondary border-[0.5px] border-components-panel-border rounded-xl"
|
||||
offset={4}
|
||||
popupContent={indexingStatusDetail.error}
|
||||
>
|
||||
<span>
|
||||
<RiErrorWarningFill className="size-4 shrink-0 text-text-destructive" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{indexingStatusDetail.indexing_status === 'completed' && (
|
||||
<RiCheckboxCircleFill className="size-4 shrink-0 text-text-success" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{statusList.map(detail => (
|
||||
<IndexingProgressItem
|
||||
key={detail.id}
|
||||
detail={detail}
|
||||
name={documentLookup.getName(detail.id)}
|
||||
sourceType={documentLookup.getSourceType(detail.id)}
|
||||
notionIcon={documentLookup.getNotionIcon(detail.id)}
|
||||
enableBilling={enableBilling}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider type="horizontal" className="my-0 bg-divider-subtle" />
|
||||
|
||||
<RuleDetail
|
||||
sourceData={ruleDetail}
|
||||
indexingType={indexingType}
|
||||
retrievalMethod={retrievalMethod}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center gap-x-2 py-2">
|
||||
<Link
|
||||
href={apiReferenceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
className="w-fit gap-x-0.5 px-3"
|
||||
>
|
||||
<RiTerminalBoxLine className="size-4" />
|
||||
<span className="px-0.5">Access the API</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
className="w-fit gap-x-0.5 px-3"
|
||||
variant="primary"
|
||||
onClick={navToDocumentList}
|
||||
>
|
||||
<span className="px-0.5">{t('stepThree.navTo', { ns: 'datasetCreation' })}</span>
|
||||
<RiArrowRightLine className="size-4 stroke-current stroke-1" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ActionButtons
|
||||
apiReferenceUrl={apiReferenceUrl}
|
||||
onNavToDocuments={handleNavToDocuments}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 <RiCheckboxCircleFill className="size-4 shrink-0 text-text-success" />
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<Tooltip
|
||||
popupClassName="px-4 py-[14px] max-w-60 body-xs-regular text-text-secondary border-[0.5px] border-components-panel-border rounded-xl"
|
||||
offset={4}
|
||||
popupContent={error}
|
||||
>
|
||||
<span>
|
||||
<RiErrorWarningFill className="size-4 shrink-0 text-text-destructive" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Source type icon component
|
||||
const SourceTypeIcon: FC<{
|
||||
sourceType?: DataSourceType
|
||||
name?: string
|
||||
notionIcon?: string
|
||||
}> = ({ sourceType, name, notionIcon }) => {
|
||||
if (sourceType === DataSourceType.FILE) {
|
||||
return (
|
||||
<DocumentFileIcon
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
name={name}
|
||||
extension={getFileType(name)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (sourceType === DataSourceType.NOTION) {
|
||||
return (
|
||||
<NotionIcon
|
||||
className="shrink-0"
|
||||
type="page"
|
||||
src={notionIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const IndexingProgressItem: FC<IndexingProgressItemProps> = ({
|
||||
detail,
|
||||
name,
|
||||
sourceType,
|
||||
notionIcon,
|
||||
enableBilling,
|
||||
}) => {
|
||||
const isEmbedding = isSourceEmbedding(detail)
|
||||
const percent = getSourcePercent(detail)
|
||||
const isError = detail.indexing_status === 'error'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-[26px] overflow-hidden rounded-md bg-components-progress-bar-bg',
|
||||
isError && 'bg-state-destructive-hover-alt',
|
||||
)}
|
||||
>
|
||||
{isEmbedding && (
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full min-w-0.5 border-r-[2px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
)}
|
||||
<div className="z-[1] flex h-full items-center gap-1 pl-[6px] pr-2">
|
||||
<SourceTypeIcon
|
||||
sourceType={sourceType}
|
||||
name={name}
|
||||
notionIcon={notionIcon}
|
||||
/>
|
||||
<div className="flex w-0 grow items-center gap-1" title={name}>
|
||||
<div className="system-xs-medium truncate text-text-secondary">
|
||||
{name}
|
||||
</div>
|
||||
{enableBilling && <PriorityLabel className="ml-0" />}
|
||||
</div>
|
||||
{isEmbedding && (
|
||||
<div className="shrink-0 text-xs text-text-secondary">{`${percent}%`}</div>
|
||||
)}
|
||||
<StatusIcon status={detail.indexing_status} error={detail.error} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IndexingProgressItem
|
||||
@ -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<Record<RETRIEVE_METHOD, string>> = {
|
||||
[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<RuleDetailProps> = ({ 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, () => 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 (
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.keys(segmentationRuleLabels).map(field => (
|
||||
<FieldInfo
|
||||
key={field}
|
||||
label={segmentationRuleLabels[field as keyof typeof segmentationRuleLabels]}
|
||||
displayedValue={String(fieldValueGetters[field]())}
|
||||
/>
|
||||
))}
|
||||
<FieldInfo
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={indexModeLabel}
|
||||
valueIcon={<Image className="size-4" src={indexMethodIconSrc} alt="" />}
|
||||
/>
|
||||
<FieldInfo
|
||||
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
displayedValue={retrievalLabel}
|
||||
valueIcon={<Image className="size-4" src={retrievalIconSrc} alt="" />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RuleDetail
|
||||
@ -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 (
|
||||
<div className="flex h-14 items-center rounded-xl border-[0.5px] border-black/5 bg-white p-3 shadow-md">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[#FFF6ED]">
|
||||
<ZapFast className="h-4 w-4 text-[#FB6514]" />
|
||||
</div>
|
||||
<div className="mx-3 grow text-[13px] font-medium text-gray-700">
|
||||
{t('plansCommon.documentProcessingPriorityUpgrade', { ns: 'billing' })}
|
||||
</div>
|
||||
<UpgradeBtn loc="knowledge-speed-up" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpgradeBanner
|
||||
@ -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<IndexingStatusResponse[]>([])
|
||||
const isStopPollingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Reset polling state on mount
|
||||
isStopPollingRef.current = false
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const fetchStatus = async (): Promise<IndexingStatusResponse[]> => {
|
||||
const response = await fetchIndexingStatusBatch({ datasetId, batchId })
|
||||
setStatusList(response.data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
const poll = async (): Promise<void> => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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<TextLabelProps> = ({ children }) => {
|
||||
return <label className="system-sm-semibold text-text-secondary">{children}</label>
|
||||
}
|
||||
|
||||
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<GeneralChunkingOptionsProps> = ({
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<OptionCard
|
||||
className="mb-2 bg-background-section"
|
||||
title={t('stepTwo.general', { ns: 'datasetCreation' })}
|
||||
icon={<Image width={20} height={20} src={SettingCog} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
|
||||
activeHeaderClassName="bg-dataset-option-card-blue-gradient"
|
||||
description={t('stepTwo.generalTip', { ns: 'datasetCreation' })}
|
||||
isActive={isActive}
|
||||
onSwitched={() => onDocFormChange(ChunkingMode.text)}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="secondary-accent" onClick={onPreview}>
|
||||
<RiSearchEyeLine className="mr-0.5 h-4 w-4" />
|
||||
{t('stepTwo.previewChunk', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onReset}>
|
||||
{t('stepTwo.reset', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
noHighlight={isInUpload && isNotUploadInEmptyDataset}
|
||||
>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex gap-3">
|
||||
<DelimiterInput
|
||||
value={segmentIdentifier}
|
||||
onChange={e => onSegmentIdentifierChange(e.target.value)}
|
||||
/>
|
||||
<MaxLengthInput
|
||||
unit="characters"
|
||||
value={maxChunkLength}
|
||||
onChange={onMaxChunkLengthChange}
|
||||
/>
|
||||
<OverlapInput
|
||||
unit="characters"
|
||||
value={overlap}
|
||||
min={1}
|
||||
onChange={onOverlapChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="inline-flex shrink-0">
|
||||
<TextLabel>{t('stepTwo.rules', { ns: 'datasetCreation' })}</TextLabel>
|
||||
</div>
|
||||
<Divider className="grow" bgStyle="gradient" />
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{rules.map(rule => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className={s.ruleItem}
|
||||
onClick={() => onRuleToggle(rule.id)}
|
||||
>
|
||||
<Checkbox checked={rule.enabled} />
|
||||
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
|
||||
{getRuleName(rule.id)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{IS_CE_EDITION && (
|
||||
<>
|
||||
<Divider type="horizontal" className="my-4 bg-divider-subtle" />
|
||||
<div className="flex items-center py-0.5">
|
||||
<div
|
||||
className="flex items-center"
|
||||
onClick={() => {
|
||||
if (hasCurrentDatasetDocForm)
|
||||
return
|
||||
if (currentDocForm === ChunkingMode.qa)
|
||||
onDocFormChange(ChunkingMode.text)
|
||||
else
|
||||
onDocFormChange(ChunkingMode.qa)
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={currentDocForm === ChunkingMode.qa}
|
||||
disabled={hasCurrentDatasetDocForm}
|
||||
/>
|
||||
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
|
||||
{t('stepTwo.useQALanguage', { ns: 'datasetCreation' })}
|
||||
</label>
|
||||
</div>
|
||||
<LanguageSelect
|
||||
currentLanguage={docLanguage || locale}
|
||||
onSelect={onDocLanguageChange}
|
||||
disabled={currentDocForm !== ChunkingMode.qa}
|
||||
/>
|
||||
<Tooltip popupContent={t('stepTwo.QATip', { ns: 'datasetCreation' })} />
|
||||
</div>
|
||||
{currentDocForm === ChunkingMode.qa && (
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.1) 0%, rgba(255, 255, 255, 0.00) 100%)',
|
||||
}}
|
||||
className="mt-2 flex h-10 items-center gap-2 rounded-xl border border-components-panel-border px-3 text-xs shadow-xs backdrop-blur-[5px]"
|
||||
>
|
||||
<RiAlertFill className="size-4 text-text-warning-secondary" />
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
{t('stepTwo.QATip', { ns: 'datasetCreation' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OptionCard>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
@ -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<IndexingModeSectionProps> = ({
|
||||
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 */}
|
||||
<div className="system-md-semibold mb-1 text-text-secondary">
|
||||
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Qualified option */}
|
||||
{(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.QUALIFIED)) && (
|
||||
<OptionCard
|
||||
className="flex-1 self-stretch"
|
||||
title={(
|
||||
<div className="flex items-center">
|
||||
{t('stepTwo.qualified', { ns: 'datasetCreation' })}
|
||||
<Badge
|
||||
className={cn(
|
||||
'ml-1 h-[18px]',
|
||||
(!hasSetIndexType && indexType === IndexingType.QUALIFIED)
|
||||
? 'border-text-accent-secondary text-text-accent-secondary'
|
||||
: '',
|
||||
)}
|
||||
uppercase
|
||||
>
|
||||
{t('stepTwo.recommend', { ns: 'datasetCreation' })}
|
||||
</Badge>
|
||||
<span className="ml-auto">
|
||||
{!hasSetIndexType && <span className={cn(s.radio)} />}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })}
|
||||
icon={<Image src={indexMethodIcon.high_quality} alt="" />}
|
||||
isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED}
|
||||
disabled={hasSetIndexType}
|
||||
onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Economical option */}
|
||||
{(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.ECONOMICAL)) && (
|
||||
<>
|
||||
<CustomDialog show={isQAConfirmDialogOpen} onClose={onQAConfirmDialogClose} className="w-[432px]">
|
||||
<header className="mb-4 pt-6">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{t('stepTwo.qaSwitchHighQualityTipTitle', { ns: 'datasetCreation' })}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm font-normal text-text-secondary">
|
||||
{t('stepTwo.qaSwitchHighQualityTipContent', { ns: 'datasetCreation' })}
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex gap-2 pb-6">
|
||||
<Button className="ml-auto" onClick={onQAConfirmDialogClose}>
|
||||
{t('stepTwo.cancel', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onQAConfirmDialogConfirm}>
|
||||
{t('stepTwo.switch', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
</div>
|
||||
</CustomDialog>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="rounded-lg border-components-panel-border bg-components-tooltip-bg p-3 text-xs font-medium text-text-secondary shadow-lg">
|
||||
{docForm === ChunkingMode.qa
|
||||
? t('stepTwo.notAvailableForQA', { ns: 'datasetCreation' })
|
||||
: t('stepTwo.notAvailableForParentChild', { ns: 'datasetCreation' })}
|
||||
</div>
|
||||
)}
|
||||
noDecoration
|
||||
position="top"
|
||||
asChild={false}
|
||||
triggerClassName="flex-1 self-stretch"
|
||||
>
|
||||
<OptionCard
|
||||
className="h-full"
|
||||
title={t('stepTwo.economical', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })}
|
||||
icon={<Image src={indexMethodIcon.economical} alt="" />}
|
||||
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
|
||||
disabled={hasSetIndexType || docForm !== ChunkingMode.text}
|
||||
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* High quality tip */}
|
||||
{!hasSetIndexType && indexType === IndexingType.QUALIFIED && (
|
||||
<div className="mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]">
|
||||
<div className="absolute bottom-0 left-0 right-0 top-0 bg-dataset-warning-message-bg opacity-40"></div>
|
||||
<div className="p-1">
|
||||
<AlertTriangle className="size-4 text-text-warning-secondary" />
|
||||
</div>
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
{t('stepTwo.highQualityTip', { ns: 'datasetCreation' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Economical index setting tip */}
|
||||
{hasSetIndexType && indexType === IndexingType.ECONOMICAL && (
|
||||
<div className="system-xs-medium mt-2 text-text-tertiary">
|
||||
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
|
||||
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
|
||||
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Embedding model */}
|
||||
{indexType === IndexingType.QUALIFIED && (
|
||||
<div className="mt-5">
|
||||
<div className={cn('system-md-semibold mb-1 text-text-secondary', datasetId && 'flex items-center justify-between')}>
|
||||
{t('form.embeddingModel', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<ModelSelector
|
||||
readonly={isModelAndRetrievalConfigDisabled}
|
||||
triggerClassName={isModelAndRetrievalConfigDisabled ? 'opacity-50' : ''}
|
||||
defaultModel={embeddingModel}
|
||||
modelList={embeddingModelList ?? []}
|
||||
onSelect={onEmbeddingModelChange}
|
||||
/>
|
||||
{isModelAndRetrievalConfigDisabled && (
|
||||
<div className="system-xs-medium mt-2 text-text-tertiary">
|
||||
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
|
||||
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
|
||||
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider className="my-5" />
|
||||
|
||||
{/* Retrieval Method Config */}
|
||||
<div>
|
||||
{!isModelAndRetrievalConfigDisabled
|
||||
? (
|
||||
<div className="mb-1">
|
||||
<div className="system-md-semibold mb-0.5 text-text-secondary">
|
||||
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<div className="body-xs-regular text-text-tertiary">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents')}
|
||||
className="text-text-accent"
|
||||
>
|
||||
{t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })}
|
||||
</a>
|
||||
{t('form.retrievalSetting.longDescription', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className={cn('system-md-semibold mb-0.5 text-text-secondary', 'flex items-center justify-between')}>
|
||||
<div>{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{getIndexingTechnique() === IndexingType.QUALIFIED
|
||||
? (
|
||||
<RetrievalMethodConfig
|
||||
disabled={isModelAndRetrievalConfigDisabled}
|
||||
value={retrievalConfig}
|
||||
onChange={onRetrievalConfigChange}
|
||||
showMultiModalTip={showMultiModalTip}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<EconomicalRetrievalMethodConfig
|
||||
disabled={isModelAndRetrievalConfigDisabled}
|
||||
value={retrievalConfig}
|
||||
onChange={onRetrievalConfigChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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<TextLabelProps> = ({ children }) => {
|
||||
return <label className="system-sm-semibold text-text-secondary">{children}</label>
|
||||
}
|
||||
|
||||
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<ParentChildOptionsProps> = ({
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<OptionCard
|
||||
title={t('stepTwo.parentChild', { ns: 'datasetCreation' })}
|
||||
icon={<ParentChildChunk className="h-[20px] w-[20px]" />}
|
||||
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={(
|
||||
<>
|
||||
<Button variant="secondary-accent" onClick={onPreview}>
|
||||
<RiSearchEyeLine className="mr-0.5 h-4 w-4" />
|
||||
{t('stepTwo.previewChunk', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onReset}>
|
||||
{t('stepTwo.reset', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
noHighlight={isInUpload && isNotUploadInEmptyDataset}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Parent chunk for context */}
|
||||
<div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="inline-flex shrink-0">
|
||||
<TextLabel>{t('stepTwo.parentChunkForContext', { ns: 'datasetCreation' })}</TextLabel>
|
||||
</div>
|
||||
<Divider className="grow" bgStyle="gradient" />
|
||||
</div>
|
||||
<RadioCard
|
||||
className="mt-1"
|
||||
icon={<Image src={Note} alt="" />}
|
||||
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
|
||||
isChosen={parentChildConfig.chunkForContext === 'paragraph'}
|
||||
onChosen={() => onChunkForContextChange('paragraph')}
|
||||
chosenConfig={(
|
||||
<div className="flex gap-3">
|
||||
<DelimiterInput
|
||||
value={parentChildConfig.parent.delimiter}
|
||||
tooltip={t('stepTwo.parentChildDelimiterTip', { ns: 'datasetCreation' })!}
|
||||
onChange={e => onParentDelimiterChange(e.target.value)}
|
||||
/>
|
||||
<MaxLengthInput
|
||||
unit="characters"
|
||||
value={parentChildConfig.parent.maxLength}
|
||||
onChange={onParentMaxLengthChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
className="mt-2"
|
||||
icon={<Image src={FileList} alt="" />}
|
||||
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
|
||||
onChosen={() => onChunkForContextChange('full-doc')}
|
||||
isChosen={parentChildConfig.chunkForContext === 'full-doc'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Child chunk for retrieval */}
|
||||
<div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="inline-flex shrink-0">
|
||||
<TextLabel>{t('stepTwo.childChunkForRetrieval', { ns: 'datasetCreation' })}</TextLabel>
|
||||
</div>
|
||||
<Divider className="grow" bgStyle="gradient" />
|
||||
</div>
|
||||
<div className="mt-1 flex gap-3">
|
||||
<DelimiterInput
|
||||
value={parentChildConfig.child.delimiter}
|
||||
tooltip={t('stepTwo.parentChildChunkDelimiterTip', { ns: 'datasetCreation' })!}
|
||||
onChange={e => onChildDelimiterChange(e.target.value)}
|
||||
/>
|
||||
<MaxLengthInput
|
||||
unit="characters"
|
||||
value={parentChildConfig.child.maxLength}
|
||||
onChange={onChildMaxLengthChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="inline-flex shrink-0">
|
||||
<TextLabel>{t('stepTwo.rules', { ns: 'datasetCreation' })}</TextLabel>
|
||||
</div>
|
||||
<Divider className="grow" bgStyle="gradient" />
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{rules.map(rule => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className={s.ruleItem}
|
||||
onClick={() => onRuleToggle(rule.id)}
|
||||
>
|
||||
<Checkbox checked={rule.enabled} />
|
||||
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
|
||||
{getRuleName(rule.id)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OptionCard>
|
||||
)
|
||||
}
|
||||
@ -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<PreviewPanelProps> = ({
|
||||
isMobile,
|
||||
dataSourceType: _dataSourceType,
|
||||
currentDocForm,
|
||||
estimate,
|
||||
parentChildConfig,
|
||||
isSetting,
|
||||
pickerFiles,
|
||||
pickerValue,
|
||||
isIdle,
|
||||
isPending,
|
||||
onPickerChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<FloatRightContainer isMobile={isMobile} isOpen={true} onClose={noop} footer={null}>
|
||||
<PreviewContainer
|
||||
header={(
|
||||
<PreviewHeader title={t('stepTwo.preview', { ns: 'datasetCreation' })}>
|
||||
<div className="flex items-center gap-1">
|
||||
<PreviewDocumentPicker
|
||||
files={pickerFiles as Array<Required<{ id: string, name: string, extension: string }>>}
|
||||
onChange={onPickerChange}
|
||||
value={isSetting ? pickerFiles[0] : pickerValue}
|
||||
/>
|
||||
{currentDocForm !== ChunkingMode.qa && (
|
||||
<Badge
|
||||
text={t('stepTwo.previewChunkCount', {
|
||||
ns: 'datasetCreation',
|
||||
count: estimate?.total_segments || 0,
|
||||
}) as string}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PreviewHeader>
|
||||
)}
|
||||
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) => (
|
||||
<ChunkContainer
|
||||
key={item.question}
|
||||
label={`Chunk-${index + 1}`}
|
||||
characterCount={item.question.length + item.answer.length}
|
||||
>
|
||||
<QAPreview qa={item} />
|
||||
</ChunkContainer>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Text Preview */}
|
||||
{currentDocForm === ChunkingMode.text && estimate?.preview && (
|
||||
estimate.preview.map((item, index) => (
|
||||
<ChunkContainer
|
||||
key={item.content}
|
||||
label={`Chunk-${index + 1}`}
|
||||
characterCount={item.content.length}
|
||||
>
|
||||
{item.content}
|
||||
</ChunkContainer>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<ChunkContainer
|
||||
key={item.content}
|
||||
label={`Chunk-${indexForLabel}`}
|
||||
characterCount={item.content.length}
|
||||
>
|
||||
<FormattedText>
|
||||
{childChunks.map((child, childIndex) => {
|
||||
const childIndexForLabel = childIndex + 1
|
||||
return (
|
||||
<PreviewSlice
|
||||
key={`C-${childIndexForLabel}-${child}`}
|
||||
label={`C-${childIndexForLabel}`}
|
||||
text={child}
|
||||
tooltip={`Child-chunk-${childIndexForLabel} · ${child.length} Characters`}
|
||||
labelInnerClassName="text-[10px] font-semibold align-bottom leading-7"
|
||||
dividerClassName="leading-7"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</FormattedText>
|
||||
</ChunkContainer>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Idle State */}
|
||||
{isIdle && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<RiSearchEyeLine className="size-10 text-text-empty-state-icon" />
|
||||
<p className="text-sm text-text-tertiary">
|
||||
{t('stepTwo.previewChunkTip', { ns: 'datasetCreation' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isPending && (
|
||||
<div className="space-y-6">
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<SkeletonContainer key={i}>
|
||||
<SkeletonRow>
|
||||
<SkeletonRectangle className="w-20" />
|
||||
<SkeletonPoint />
|
||||
<SkeletonRectangle className="w-24" />
|
||||
</SkeletonRow>
|
||||
<SkeletonRectangle className="w-full" />
|
||||
<SkeletonRectangle className="w-full" />
|
||||
<SkeletonRectangle className="w-[422px]" />
|
||||
</SkeletonContainer>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PreviewContainer>
|
||||
</FloatRightContainer>
|
||||
)
|
||||
}
|
||||
@ -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<StepTwoFooterProps> = ({
|
||||
isSetting,
|
||||
isCreating,
|
||||
onPrevious,
|
||||
onCreate,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!isSetting) {
|
||||
return (
|
||||
<div className="mt-8 flex items-center py-2">
|
||||
<Button onClick={onPrevious}>
|
||||
<RiArrowLeftLine className="mr-1 h-4 w-4" />
|
||||
{t('stepTwo.previousStep', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
loading={isCreating}
|
||||
variant="primary"
|
||||
onClick={onCreate}
|
||||
>
|
||||
{t('stepTwo.nextStep', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 flex items-center py-2">
|
||||
<Button
|
||||
loading={isCreating}
|
||||
variant="primary"
|
||||
onClick={onCreate}
|
||||
>
|
||||
{t('stepTwo.save', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
<Button className="ml-2" onClick={onCancel}>
|
||||
{t('stepTwo.cancel', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
web/app/components/datasets/create/step-two/hooks/index.ts
Normal file
14
web/app/components/datasets/create/step-two/hooks/index.ts
Normal file
@ -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'
|
||||
@ -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<typeof useDocumentCreation>
|
||||
@ -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<IndexingType>(() => {
|
||||
if (initialIndexType)
|
||||
return initialIndexType
|
||||
return isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL
|
||||
})
|
||||
|
||||
// Embedding model state
|
||||
const [embeddingModel, setEmbeddingModel] = useState<DefaultModel>(
|
||||
initialEmbeddingModel ?? {
|
||||
provider: defaultEmbeddingModel?.provider.provider || '',
|
||||
model: defaultEmbeddingModel?.model || '',
|
||||
},
|
||||
)
|
||||
|
||||
// Retrieval config state
|
||||
const [retrievalConfig, setRetrievalConfig] = useState<RetrievalConfig>(
|
||||
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<typeof useIndexingConfig>
|
||||
@ -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<typeof useIndexingEstimate>
|
||||
@ -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<DocumentItem>(
|
||||
(datasetId && documentDetail)
|
||||
? documentDetail.file
|
||||
: files[0],
|
||||
)
|
||||
|
||||
// Notion page preview state
|
||||
const [previewNotionPage, setPreviewNotionPage] = useState<NotionPage>(
|
||||
(datasetId && documentDetail)
|
||||
? documentDetail.notion_page
|
||||
: notionPages[0],
|
||||
)
|
||||
|
||||
// Website page preview state
|
||||
const [previewWebsitePage, setPreviewWebsitePage] = useState<CrawlResultItem>(
|
||||
(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<Required<CustomFile>>
|
||||
}
|
||||
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<CustomFile>
|
||||
}
|
||||
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<typeof usePreviewState>
|
||||
@ -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<ProcessMode>(
|
||||
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<PreProcessingRule[]>([])
|
||||
const [defaultConfig, setDefaultConfig] = useState<Rules>()
|
||||
|
||||
// Parent-child config
|
||||
const [parentChildConfig, setParentChildConfig] = useState<ParentChildConfig>(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<typeof useSegmentationState>
|
||||
2197
web/app/components/datasets/create/step-two/index.spec.tsx
Normal file
2197
web/app/components/datasets/create/step-two/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
28
web/app/components/datasets/create/step-two/types.ts
Normal file
28
web/app/components/datasets/create/step-two/types.ts
Normal file
@ -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
|
||||
}
|
||||
81
web/app/components/plugins/marketplace/atoms.ts
Normal file
81
web/app/components/plugins/marketplace/atoms.ts
Normal file
@ -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<PluginsSort>(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<boolean>(false)
|
||||
|
||||
const searchPluginTextAtom = atom<string>('')
|
||||
const activePluginTypeAtom = atom<ActivePluginType>('all')
|
||||
const filterPluginTagsAtom = atom<string[]>([])
|
||||
|
||||
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<true | null>(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])
|
||||
}
|
||||
@ -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> = T[keyof T]
|
||||
|
||||
export type ActivePluginType = ValueOf<typeof PLUGIN_TYPE_SEARCH_MAP>
|
||||
|
||||
export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
|
||||
[
|
||||
PLUGIN_TYPE_SEARCH_MAP.all,
|
||||
PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
],
|
||||
)
|
||||
|
||||
@ -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<string, Plugin[]>
|
||||
setMarketplaceCollectionPluginsMapFromClient: (map: Record<string, Plugin[]>) => void
|
||||
isLoading: boolean
|
||||
isSuccessCollections: boolean
|
||||
}
|
||||
|
||||
export const MarketplaceContext = createContext<MarketplaceContextValue>({
|
||||
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<string[]>(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 (
|
||||
<MarketplaceContext.Provider
|
||||
value={{
|
||||
searchPluginText,
|
||||
handleSearchPluginTextChange,
|
||||
filterPluginTags,
|
||||
handleFilterPluginTagsChange,
|
||||
activePluginType,
|
||||
handleActivePluginTypeChange,
|
||||
page,
|
||||
handlePageChange,
|
||||
plugins,
|
||||
pluginsTotal,
|
||||
resetPlugins,
|
||||
sort,
|
||||
handleSortChange,
|
||||
handleQueryPlugins,
|
||||
handleMoreClick,
|
||||
marketplaceCollectionsFromClient,
|
||||
setMarketplaceCollectionsFromClient,
|
||||
marketplaceCollectionPluginsMapFromClient,
|
||||
setMarketplaceCollectionPluginsMapFromClient,
|
||||
isLoading: isLoading || isPluginsLoading,
|
||||
isSuccessCollections,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MarketplaceContext.Provider>
|
||||
)
|
||||
}
|
||||
@ -26,6 +26,9 @@ import {
|
||||
getMarketplacePluginsByCollectionId,
|
||||
} from './utils'
|
||||
|
||||
/**
|
||||
* @deprecated Use useMarketplaceCollectionsAndPlugins from query.ts instead
|
||||
*/
|
||||
export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>()
|
||||
const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
|
||||
@ -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<PluginsSearchParams>()
|
||||
|
||||
15
web/app/components/plugins/marketplace/hydration-client.tsx
Normal file
15
web/app/components/plugins/marketplace/hydration-client.tsx
Normal file
@ -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}</>
|
||||
}
|
||||
45
web/app/components/plugins/marketplace/hydration-server.tsx
Normal file
45
web/app/components/plugins/marketplace/hydration-server.tsx
Normal file
@ -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<SearchParams>) {
|
||||
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<SearchParams> | undefined
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const dehydratedState = await getDehydratedState(searchParams)
|
||||
return (
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
{children}
|
||||
</HydrationBoundary>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<SearchParams>
|
||||
}
|
||||
|
||||
const Marketplace = async ({
|
||||
showInstallButton = true,
|
||||
shouldExclude,
|
||||
searchParams,
|
||||
pluginTypeSwitchClassName,
|
||||
scrollContainerId,
|
||||
showSearchParams = true,
|
||||
searchParams,
|
||||
}: MarketplaceProps) => {
|
||||
let marketplaceCollections: MarketplaceCollection[] = []
|
||||
let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {}
|
||||
if (!shouldExclude) {
|
||||
const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins()
|
||||
marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections
|
||||
marketplaceCollectionPluginsMap = marketplaceCollectionsAndPluginsData.marketplaceCollectionPluginsMap
|
||||
}
|
||||
|
||||
return (
|
||||
<TanstackQueryInitializer>
|
||||
<MarketplaceContextProvider
|
||||
searchParams={searchParams}
|
||||
shouldExclude={shouldExclude}
|
||||
scrollContainerId={scrollContainerId}
|
||||
showSearchParams={showSearchParams}
|
||||
>
|
||||
<Description />
|
||||
<StickySearchAndSwitchWrapper
|
||||
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
|
||||
showSearchParams={showSearchParams}
|
||||
/>
|
||||
<ListWrapper
|
||||
marketplaceCollections={marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
</MarketplaceContextProvider>
|
||||
<HydrateQueryClient searchParams={searchParams}>
|
||||
<HydrateMarketplaceAtoms preserveSearchStateInQuery={!!searchParams}>
|
||||
<Description />
|
||||
<StickySearchAndSwitchWrapper
|
||||
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
|
||||
/>
|
||||
<ListWrapper
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
</HydrateMarketplaceAtoms>
|
||||
</HydrateQueryClient>
|
||||
</TanstackQueryInitializer>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<string, Plugin[]> | 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<string, Plugin[]> | 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<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const onMoreClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={onMoreClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -609,42 +611,19 @@ describe('ListWithCollection', () => {
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const onMoreClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={onMoreClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const onMoreClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={onMoreClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
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<string, Plugin[]>,
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(document.body).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with scrollbarGutter style', () => {
|
||||
const { container } = render(<ListWrapper {...defaultProps} />)
|
||||
const { container } = render(<ListWrapper />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByText('5 plugins found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render SortDropdown when plugins are present', () => {
|
||||
mockContextValues.plugins = createMockPluginList(1)
|
||||
mockMarketplaceData.plugins = createMockPluginList(1)
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render plugins header when plugins is undefined', () => {
|
||||
mockContextValues.plugins = undefined
|
||||
mockMarketplaceData.plugins = undefined
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
it('should render collections when not loading', () => {
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
mockMarketplaceData.isLoading = true
|
||||
mockMarketplaceData.page = 2
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const clientPluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
mockContextValues.marketplaceCollectionsFromClient = clientCollections
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = clientPluginsMap
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={serverCollections}
|
||||
marketplaceCollectionPluginsMap={serverPluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
mockContextValues.marketplaceCollectionsFromClient = undefined
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={serverCollections}
|
||||
marketplaceCollectionPluginsMap={serverPluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
|
||||
// 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(<ListWrapper {...defaultProps} />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
// 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(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
const { rerender } = render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('loading-component')).toBeInTheDocument()
|
||||
|
||||
// Simulate loading complete
|
||||
mockContextValues.isLoading = false
|
||||
const collections = createMockCollectionList(1)
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
mockContextValues.marketplaceCollectionsFromClient = collections
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap
|
||||
|
||||
rerender(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
rerender(<ListWrapper />)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
mockContextValues.marketplaceCollectionsFromClient = collections
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap
|
||||
|
||||
const { rerender } = render(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
const { rerender } = render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByText('Collection 0')).toBeInTheDocument()
|
||||
|
||||
// Simulate search results
|
||||
mockContextValues.plugins = createMockPluginList(5)
|
||||
mockContextValues.pluginsTotal = 5
|
||||
mockMarketplaceData.plugins = createMockPluginList(5)
|
||||
mockMarketplaceData.pluginsTotal = 5
|
||||
|
||||
rerender(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
rerender(<ListWrapper />)
|
||||
|
||||
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(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
// 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<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const onMoreClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={onMoreClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 = ({
|
||||
<div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div>
|
||||
</div>
|
||||
{
|
||||
collection.searchable && onMoreClick && (
|
||||
collection.searchable && (
|
||||
<div
|
||||
className="system-xs-medium flex cursor-pointer items-center text-text-accent "
|
||||
onClick={() => onMoreClick?.(collection.search_params)}
|
||||
onClick={() => onMoreClick(collection.search_params)}
|
||||
>
|
||||
{t('marketplace.viewMore', { ns: 'plugin' })}
|
||||
<RiArrowRightSLine className="h-4 w-4" />
|
||||
|
||||
@ -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<string, Plugin[]>
|
||||
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 (
|
||||
<div
|
||||
@ -66,11 +46,10 @@ const ListWrapper = ({
|
||||
{
|
||||
(!isLoading || page > 1) && (
|
||||
<List
|
||||
marketplaceCollections={marketplaceCollectionsFromClient || marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMapFromClient || marketplaceCollectionPluginsMap}
|
||||
marketplaceCollections={marketplaceCollections || []}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
|
||||
plugins={plugins}
|
||||
showInstallButton={showInstallButton}
|
||||
onMoreClick={handleMoreClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
|
||||
@ -112,6 +88,9 @@ const PluginTypeSwitch = ({
|
||||
)}
|
||||
onClick={() => {
|
||||
handleActivePluginTypeChange(option.value)
|
||||
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) {
|
||||
setSearchMode(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.icon}
|
||||
|
||||
38
web/app/components/plugins/marketplace/query.ts
Normal file
38
web/app/components/plugins/marketplace/query.ts
Normal file
@ -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,
|
||||
})
|
||||
}
|
||||
@ -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(<SearchBoxWrapper />)
|
||||
|
||||
expect(screen.getByDisplayValue('context search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Hook Integration', () => {
|
||||
it('should call handleSearchPluginTextChange when search changes', () => {
|
||||
render(<SearchBoxWrapper />)
|
||||
|
||||
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(<SearchBoxWrapper />)
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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 (
|
||||
<SearchBox
|
||||
|
||||
9
web/app/components/plugins/marketplace/search-params.ts
Normal file
9
web/app/components/plugins/marketplace/search-params.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { ActivePluginType } from './constants'
|
||||
import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
|
||||
export const marketplaceSearchParamsParsers = {
|
||||
category: parseAsStringEnum<ActivePluginType>(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' }),
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
54
web/app/components/plugins/marketplace/state.ts
Normal file
54
web/app/components/plugins/marketplace/state.ts
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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 = ({
|
||||
)}
|
||||
>
|
||||
<SearchBoxWrapper />
|
||||
<PluginTypeSwitch
|
||||
showSearchParams={showSearchParams}
|
||||
/>
|
||||
<PluginTypeSwitch />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<Props> = ({
|
||||
},
|
||||
]
|
||||
|
||||
const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
const [pluginType, setPluginType] = useState<ActivePluginType>(PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
const [query, setQuery] = useState('')
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const { data, isLoading } = useInstalledPluginList()
|
||||
|
||||
3
web/app/components/provider/serwist.tsx
Normal file
3
web/app/components/provider/serwist.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
'use client'
|
||||
|
||||
export { SerwistProvider } from '@serwist/turbopack/react'
|
||||
@ -195,7 +195,7 @@ const RunOnce: FC<IRunOnceProps> = ({
|
||||
noWrapper
|
||||
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
placeholder={
|
||||
<div className="whitespace-pre">{item.json_schema}</div>
|
||||
<div className="whitespace-pre">{typeof item.json_schema === 'string' ? item.json_schema : JSON.stringify(item.json_schema || '', null, 2)}</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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<boolean>(() => {
|
||||
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])
|
||||
|
||||
@ -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<boolean>(() => {
|
||||
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])
|
||||
|
||||
@ -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<boolean>(() => {
|
||||
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])
|
||||
|
||||
@ -48,6 +48,12 @@ const FormItem: FC<Props> = ({
|
||||
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<Props> = ({
|
||||
noWrapper
|
||||
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
placeholder={
|
||||
<div className="whitespace-pre">{payload.json_schema}</div>
|
||||
<div className="whitespace-pre">{jsonSchemaPlaceholder}</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,7 +223,7 @@ export type InputVar = {
|
||||
getVarValueFromDependent?: boolean
|
||||
hide?: boolean
|
||||
isFileItem?: boolean
|
||||
json_schema?: string // for jsonObject type
|
||||
json_schema?: string | Record<string, any> // for jsonObject type
|
||||
} & Partial<UploadFileSetting>
|
||||
|
||||
export type ModelConfig = {
|
||||
|
||||
@ -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, string | undefined> = {
|
||||
[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}
|
||||
>
|
||||
<ReactScanLoader />
|
||||
<JotaiProvider>
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme={false}
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<BrowserInitializer>
|
||||
<SentryInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServerProvider>
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
{children}
|
||||
</GlobalPublicStoreProvider>
|
||||
</ToastProvider>
|
||||
</I18nServerProvider>
|
||||
</TanstackQueryInitializer>
|
||||
</SentryInitializer>
|
||||
</BrowserInitializer>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
</JotaiProvider>
|
||||
<RoutePrefixHandle />
|
||||
<SerwistProvider swUrl={swUrl}>
|
||||
<ReactScanLoader />
|
||||
<JotaiProvider>
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme={false}
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<BrowserInitializer>
|
||||
<SentryInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServerProvider>
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
{children}
|
||||
</GlobalPublicStoreProvider>
|
||||
</ToastProvider>
|
||||
</I18nServerProvider>
|
||||
</TanstackQueryInitializer>
|
||||
</SentryInitializer>
|
||||
</BrowserInitializer>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
</JotaiProvider>
|
||||
<RoutePrefixHandle />
|
||||
</SerwistProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
14
web/app/serwist/[path]/route.ts
Normal file
14
web/app/serwist/[path]/route.ts
Normal file
@ -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,
|
||||
},
|
||||
})
|
||||
104
web/app/sw.ts
Normal file
104
web/app/sw.ts
Normal file
@ -0,0 +1,104 @@
|
||||
/// <reference no-default-lib="true" />
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user