= ({ logs, appDetail, onRefresh })
)}
|
- {renderTdValue(rightValue === 0 ? 0 : (rightValue || t('table.empty.noOutput', { ns: 'appLog' })), !rightValue, !isChatMode && !!log.annotation?.content, log.annotation)}
+ {renderTdValue(rightValue, isRightEmpty, !isChatMode && !!log.annotation?.content, log.annotation)}
|
{(!log.user_feedback_stats.like && !log.user_feedback_stats.dislike)
diff --git a/web/app/components/app/overview/__tests__/app-card.spec.tsx b/web/app/components/app/overview/__tests__/app-card.spec.tsx
new file mode 100644
index 0000000000..0f67093e47
--- /dev/null
+++ b/web/app/components/app/overview/__tests__/app-card.spec.tsx
@@ -0,0 +1,367 @@
+import type { ReactNode } from 'react'
+import type { AppDetailResponse } from '@/models/app'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { AccessMode } from '@/models/access-control'
+import { AppModeEnum } from '@/types/app'
+import { basePath } from '@/utils/var'
+import AppCard from '../app-card'
+
+const mockFetchAppDetailDirect = vi.fn()
+const mockPush = vi.fn()
+const mockSetAppDetail = vi.fn()
+const mockOnChangeStatus = vi.fn()
+const mockOnGenerateCode = vi.fn()
+
+let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string } }> } } | null = null
+let mockAccessSubjects: { groups?: unknown[], members?: unknown[] } = { groups: [], members: [] }
+let mockAppDetail: AppDetailResponse | undefined
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ isCurrentWorkspaceManager: true,
+ isCurrentWorkspaceEditor: true,
+ langGeniusVersionInfo: {
+ current_env: 'TESTING',
+ },
+ }),
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => (path: string) => `https://docs.example.com${path}`,
+}))
+
+vi.mock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({
+ systemFeatures: {
+ webapp_auth: {
+ enabled: true,
+ },
+ },
+ }),
+}))
+
+vi.mock('@/app/components/app/store', () => ({
+ useStore: (selector: (state: { appDetail: AppDetailResponse, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({
+ appDetail: mockAppDetail as AppDetailResponse,
+ setAppDetail: mockSetAppDetail,
+ }),
+}))
+
+vi.mock('@/next/navigation', () => ({
+ usePathname: () => '/app/app-1/overview',
+ useRouter: () => ({
+ push: mockPush,
+ }),
+}))
+
+vi.mock('@/service/use-workflow', () => ({
+ useAppWorkflow: () => ({
+ data: mockWorkflow,
+ }),
+}))
+
+vi.mock('@/service/access-control', () => ({
+ useAppWhiteListSubjects: () => ({
+ data: mockAccessSubjects,
+ }),
+}))
+
+vi.mock('@/service/apps', () => ({
+ fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
+}))
+
+vi.mock('@/app/components/develop/secret-key/secret-key-button', () => ({
+ default: ({ appId }: { appId: string }) => {appId} ,
+}))
+
+vi.mock('../settings', () => ({
+ default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => isShow ? : null,
+}))
+
+vi.mock('../embedded', () => ({
+ default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => isShow ? : null,
+}))
+
+vi.mock('../customize', () => ({
+ default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => isShow ? : null,
+}))
+
+vi.mock('../../app-access-control', () => ({
+ default: ({ onConfirm, onClose }: { onConfirm: () => Promise, onClose: () => void }) => (
+
+
+
+
+ ),
+}))
+
+vi.mock('@/app/components/base/tooltip', () => ({
+ default: ({ children, popupContent }: { children: ReactNode, popupContent?: ReactNode }) => (
+
+ {children}
+ {popupContent}
+
+ ),
+}))
+
+const mockWindowOpen = vi.fn()
+Object.defineProperty(window, 'open', {
+ writable: true,
+ value: mockWindowOpen,
+})
+
+describe('AppCard', () => {
+ const appInfo = {
+ id: 'app-1',
+ mode: AppModeEnum.CHAT,
+ enable_site: true,
+ enable_api: true,
+ icon: 'app-icon',
+ icon_background: '#fff',
+ api_base_url: 'https://api.example.com',
+ site: {
+ app_base_url: 'https://example.com',
+ access_token: 'access-token',
+ },
+ } as AppDetailResponse
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockAppDetail = {
+ id: 'app-1',
+ access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
+ site: {
+ app_base_url: 'https://example.com',
+ access_token: 'access-token',
+ },
+ } as AppDetailResponse
+ mockWorkflow = {
+ graph: {
+ nodes: [{ data: { type: 'start' } }],
+ },
+ }
+ mockAccessSubjects = {
+ groups: [],
+ members: [],
+ }
+ mockFetchAppDetailDirect.mockResolvedValue({
+ id: 'app-1',
+ access_mode: AccessMode.PUBLIC,
+ })
+ })
+
+ it('should open the published webapp when launch is clicked', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('overview.appInfo.launch'))
+
+ expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com${basePath}/chat/access-token`, '_blank')
+ })
+
+ it('should show the access-control not-set badge when specific access has no subjects', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('publishApp.notSet')).toBeInTheDocument()
+ })
+
+ it('should hide the address and operation sections for unpublished workflows', () => {
+ mockWorkflow = null
+
+ render(
+ ,
+ )
+
+ expect(screen.queryByText('overview.appInfo.accessibleAddress')).not.toBeInTheDocument()
+ expect(screen.queryByText('overview.appInfo.launch')).not.toBeInTheDocument()
+ expect(screen.getByText('overview.status.disable')).toBeInTheDocument()
+ })
+
+ it('should render api operations and navigate to the develop page', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('secret-key-button')).toHaveTextContent('app-1')
+
+ fireEvent.click(screen.getByText('overview.apiInfo.doc'))
+
+ expect(mockPush).toHaveBeenCalledWith('/app/app-1/develop')
+ })
+
+ it('should open settings embedded and customize dialogs from webapp operations', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('overview.appInfo.embedded.entry'))
+ expect(screen.getByTestId('embedded-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('overview.appInfo.customize.entry'))
+ expect(screen.getByTestId('customize-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('overview.appInfo.settings.entry'))
+ expect(screen.getByTestId('settings-modal')).toBeInTheDocument()
+ })
+
+ it('should refresh app detail after confirming access control changes', async () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('publishApp.notSet'))
+ expect(screen.getByTestId('access-control-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('confirm-access-control'))
+
+ await waitFor(() => {
+ expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
+ expect(mockSetAppDetail).toHaveBeenCalledWith({
+ id: 'app-1',
+ access_mode: AccessMode.PUBLIC,
+ })
+ })
+ })
+
+ it('should surface the learn-more tooltip action for workflows without a start node', () => {
+ mockWorkflow = {
+ graph: {
+ nodes: [{ data: { type: 'llm' } }],
+ },
+ }
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('overview.appInfo.enableTooltip.learnMore'))
+
+ expect(mockWindowOpen).toHaveBeenCalledWith('https://docs.example.com/use-dify/nodes/user-input', '_blank')
+ })
+
+ it('should close the overview dialogs when their child callbacks are invoked', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('overview.appInfo.embedded.entry'))
+ fireEvent.click(screen.getByTestId('embedded-modal'))
+ expect(screen.queryByTestId('embedded-modal')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('overview.appInfo.customize.entry'))
+ fireEvent.click(screen.getByTestId('customize-modal'))
+ expect(screen.queryByTestId('customize-modal')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('overview.appInfo.settings.entry'))
+ fireEvent.click(screen.getByTestId('settings-modal'))
+ expect(screen.queryByTestId('settings-modal')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('publishApp.notSet'))
+ fireEvent.click(screen.getByText('close-access-control'))
+ expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument()
+ })
+
+ it('should report refresh failures from access control updates', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockFetchAppDetailDirect.mockRejectedValueOnce(new Error('refresh failed'))
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('publishApp.notSet'))
+ fireEvent.click(screen.getByText('confirm-access-control'))
+
+ await waitFor(() => {
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch app detail:', expect.any(Error))
+ })
+
+ consoleErrorSpy.mockRestore()
+ })
+
+ it('should close the regenerate confirmation even when no generator is configured', () => {
+ const { container } = render(
+ ,
+ )
+
+ const refreshButton = container.querySelector('[class*="refreshIcon"]')?.parentElement as HTMLElement
+ fireEvent.click(refreshButton)
+ expect(screen.getByText('overview.appInfo.regenerateNotice')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
+
+ expect(mockOnGenerateCode).not.toHaveBeenCalled()
+ return waitFor(() => {
+ expect(screen.queryByText('overview.appInfo.regenerateNotice')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should regenerate accessible urls when a generator is configured', async () => {
+ mockOnGenerateCode.mockResolvedValue(undefined)
+ const { container } = render(
+ ,
+ )
+
+ const refreshButton = container.querySelector('[class*="refreshIcon"]')?.parentElement as HTMLElement
+ fireEvent.click(refreshButton)
+ fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
+
+ await waitFor(() => {
+ expect(mockOnGenerateCode).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/web/app/components/app/overview/__tests__/app-chart-utils.spec.ts b/web/app/components/app/overview/__tests__/app-chart-utils.spec.ts
new file mode 100644
index 0000000000..a087df241a
--- /dev/null
+++ b/web/app/components/app/overview/__tests__/app-chart-utils.spec.ts
@@ -0,0 +1,132 @@
+/* eslint-disable ts/no-explicit-any */
+import { buildChartOptions, getChartValueField, getDefaultChartData, getSummaryValue, getTokenSummary } from '../app-chart-utils'
+
+describe('app-chart-utils', () => {
+ describe('getDefaultChartData', () => {
+ it('should build fallback rows for the requested date window', () => {
+ const rows = getDefaultChartData({
+ start: 'Jan 1, 2024',
+ end: 'Jan 4, 2024',
+ key: 'interactions',
+ })
+
+ expect(rows).toHaveLength(3)
+ expect(rows[0]).toEqual({ date: 'Jan 1, 2024', interactions: 0 })
+ expect(rows[2]).toEqual({ date: 'Jan 3, 2024', interactions: 0 })
+ })
+ })
+
+ describe('getSummaryValue', () => {
+ it('should average values when the chart is configured as an average', () => {
+ const summaryValue = getSummaryValue({
+ chartType: 'conversations',
+ statistics: [
+ { date: 'Jan 1, 2024', latency: 10 },
+ { date: 'Jan 2, 2024', latency: 20 },
+ ],
+ yField: 'latency',
+ isAvg: true,
+ unit: 'ms',
+ })
+
+ expect(summaryValue).toBe('15 ms')
+ })
+
+ it('should compress large cost totals with a k suffix', () => {
+ const summaryValue = getSummaryValue({
+ chartType: 'costs',
+ statistics: [
+ { date: 'Jan 1, 2024', count: 700 },
+ { date: 'Jan 2, 2024', count: 800 },
+ ],
+ yField: 'count',
+ })
+
+ expect(summaryValue).toBe('2k')
+ })
+
+ it('should keep small cost totals in their raw form', () => {
+ const summaryValue = getSummaryValue({
+ chartType: 'costs',
+ statistics: [
+ { date: 'Jan 1, 2024', count: 250 },
+ { date: 'Jan 2, 2024', count: 300 },
+ ],
+ yField: 'count',
+ })
+
+ expect(summaryValue).toBe('550')
+ })
+ })
+
+ describe('getChartValueField', () => {
+ it('should prefer the explicit value key and otherwise fall back to the count-like field', () => {
+ expect(getChartValueField([{ date: 'Jan 1, 2024', request_count: 2 }], 'total')).toBe('total')
+ expect(getChartValueField([{ date: 'Jan 1, 2024', request_count: 2 }])).toBe('request_count')
+ expect(getChartValueField([{ date: 'Jan 1, 2024', latency: 2 }])).toBe('count')
+ })
+ })
+
+ describe('getTokenSummary', () => {
+ it('should sum token costs using currency formatting', () => {
+ const tokenSummary = getTokenSummary([
+ { date: 'Jan 1, 2024', count: 1, total_price: '1.25' },
+ { date: 'Jan 2, 2024', count: 2, total_price: '2.5' },
+ ])
+
+ expect(tokenSummary).toBe('$3.7500')
+ })
+ })
+
+ describe('buildChartOptions', () => {
+ it('should build line chart options with dataset and y-axis max', () => {
+ const options = buildChartOptions({
+ statistics: [
+ { date: 'Jan 1, 2024', count: 5 },
+ { date: 'Jan 2, 2024', count: 10 },
+ ],
+ chartType: 'messages',
+ yField: 'count',
+ yMax: 100,
+ })
+
+ const dataset = options.dataset as { dimensions: string[], source: Array> }
+ const yAxis = options.yAxis as { max: number }
+ const series = options.series as Array<{ lineStyle: { color: string } }>
+
+ expect(dataset.dimensions).toEqual(['date', 'count'])
+ expect(dataset.source).toHaveLength(2)
+ expect(yAxis.max).toBe(100)
+ expect(series[0].lineStyle.color).toBe('rgba(6, 148, 162, 1)')
+ })
+
+ it('should build token-aware tooltip content and split-line intervals for cost charts', () => {
+ const options = buildChartOptions({
+ statistics: [
+ { date: 'Jan 1, 2024', total_cost: 5, total_price: '1.25' },
+ { date: 'Jan 2, 2024', total_cost: 10, total_price: '2.50' },
+ { date: 'Jan 3, 2024', total_cost: 15, total_price: '3.75' },
+ ],
+ chartType: 'costs',
+ yField: 'total_cost',
+ })
+
+ const xAxis = options.xAxis as Array>
+ const formatter = xAxis[0].axisLabel.formatter as (value: string) => string
+ const outerInterval = xAxis[0].splitLine.interval as (index: number) => boolean
+ const innerInterval = xAxis[1].splitLine.interval as (_index: number, value: string) => boolean
+ const series = options.series as Array>
+ const tooltipFormatter = series[0].tooltip.formatter as (params: { name: string, data: { total_cost: number, total_price: string } }) => string
+
+ expect(formatter('Jan 2, 2024')).toBe('Jan 2, 2024')
+ expect(outerInterval(0)).toBe(true)
+ expect(outerInterval(1)).toBe(false)
+ expect(innerInterval(0, '')).toBe(false)
+ expect(innerInterval(1, '1')).toBe(true)
+ expect(tooltipFormatter({
+ name: 'Jan 2, 2024',
+ data: { total_cost: 10, total_price: '2.50' },
+ })).toContain('~$2.50')
+ })
+ })
+})
diff --git a/web/app/components/app/overview/__tests__/app-chart.spec.tsx b/web/app/components/app/overview/__tests__/app-chart.spec.tsx
new file mode 100644
index 0000000000..ccd59a8f4a
--- /dev/null
+++ b/web/app/components/app/overview/__tests__/app-chart.spec.tsx
@@ -0,0 +1,101 @@
+import { render, screen } from '@testing-library/react'
+import Chart, { MessagesChart } from '../app-chart'
+
+const reactEChartsMock = vi.fn()
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('echarts-for-react', () => ({
+ default: ({ option }: { option: unknown }) => {
+ reactEChartsMock(option)
+ return
+ },
+}))
+
+const mockUseAppDailyMessages = vi.fn()
+
+vi.mock('@/service/use-apps', () => ({
+ useAppAverageResponseTime: vi.fn(),
+ useAppAverageSessionInteractions: vi.fn(),
+ useAppDailyConversations: vi.fn(),
+ useAppDailyEndUsers: vi.fn(),
+ useAppDailyMessages: (...args: unknown[]) => mockUseAppDailyMessages(...args),
+ useAppSatisfactionRate: vi.fn(),
+ useAppTokenCosts: vi.fn(),
+ useAppTokensPerSecond: vi.fn(),
+ useWorkflowAverageInteractions: vi.fn(),
+ useWorkflowDailyConversations: vi.fn(),
+ useWorkflowDailyTerminals: vi.fn(),
+ useWorkflowTokenCosts: vi.fn(),
+}))
+
+describe('app-chart', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ reactEChartsMock.mockClear()
+ })
+
+ describe('Chart', () => {
+ it('should render cost summaries with token pricing details', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Cost title')).toBeInTheDocument()
+ expect(screen.getByText('300')).toBeInTheDocument()
+ expect(screen.getByText(/\$3\.7500/)).toBeInTheDocument()
+ expect(screen.getByTestId('echarts')).toBeInTheDocument()
+ })
+ })
+
+ describe('MessagesChart', () => {
+ it('should render fallback chart data when the API returns no rows', () => {
+ mockUseAppDailyMessages.mockReturnValue({
+ data: { data: [] },
+ isLoading: false,
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('analysis.totalMessages.title')).toBeInTheDocument()
+ expect(screen.getByTestId('echarts')).toBeInTheDocument()
+
+ const options = reactEChartsMock.mock.calls[0][0] as {
+ dataset: { source: Array> }
+ yAxis: { max: number }
+ }
+
+ expect(options.yAxis.max).toBe(500)
+ expect(options.dataset.source).toHaveLength(3)
+ expect(options.dataset.source[0]).toEqual({ date: 'Jan 1, 2024', count: 0 })
+ })
+ })
+})
diff --git a/web/app/components/app/overview/__tests__/toggle-logic.test.ts b/web/app/components/app/overview/__tests__/toggle-logic.test.ts
index de4dee44a9..ee4c650d1f 100644
--- a/web/app/components/app/overview/__tests__/toggle-logic.test.ts
+++ b/web/app/components/app/overview/__tests__/toggle-logic.test.ts
@@ -1,233 +1,124 @@
-import type { MockedFunction } from 'vitest'
-import type { Node } from '@/app/components/workflow/types'
-import { getWorkflowEntryNode } from '@/app/components/workflow/utils/workflow-entry'
+import type { AppDetailResponse } from '@/models/app'
+import type { AppSSO } from '@/types/app'
+import { AccessMode } from '@/models/access-control'
+import { AppModeEnum } from '@/types/app'
+import { basePath } from '@/utils/var'
+import {
+ getAppCardDisplayState,
+ getAppCardOperationKeys,
+ hasWorkflowStartNode,
+ isAppAccessConfigured,
+} from '../app-card-utils'
-// Mock the getWorkflowEntryNode function
-vi.mock('@/app/components/workflow/utils/workflow-entry', () => ({
- getWorkflowEntryNode: vi.fn(),
-}))
+describe('app-card-utils', () => {
+ const baseAppInfo = {
+ id: 'app-1',
+ mode: AppModeEnum.WORKFLOW,
+ enable_site: true,
+ enable_api: true,
+ site: {
+ app_base_url: 'https://example.com',
+ access_token: 'token-1',
+ },
+ access_mode: AccessMode.PUBLIC,
+ } as AppDetailResponse & Partial
-const mockGetWorkflowEntryNode = getWorkflowEntryNode as MockedFunction
-
-// Mock entry node for testing (truthy value)
-const mockEntryNode = { id: 'start-node', data: { type: 'start' } } as Node
-
-describe('App Card Toggle Logic', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- // Helper function that mirrors the actual logic from app-card.tsx
- const calculateToggleState = (
- appMode: string,
- currentWorkflow: any,
- isCurrentWorkspaceEditor: boolean,
- isCurrentWorkspaceManager: boolean,
- cardType: 'webapp' | 'api',
- ) => {
- const isWorkflowApp = appMode === 'workflow'
- const appUnpublished = isWorkflowApp && !currentWorkflow?.graph
- const hasEntryNode = mockGetWorkflowEntryNode(currentWorkflow?.graph?.nodes || [])
- const missingEntryNode = isWorkflowApp && !hasEntryNode
- const hasInsufficientPermissions = cardType === 'webapp' ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager
- const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingEntryNode
- const isMinimalState = appUnpublished || missingEntryNode
-
- return {
- toggleDisabled,
- isMinimalState,
- appUnpublished,
- missingEntryNode,
- hasInsufficientPermissions,
- }
- }
-
- describe('Entry Node Detection Logic', () => {
- it('should disable toggle when workflow missing entry node', () => {
- mockGetWorkflowEntryNode.mockReturnValue(undefined)
-
- const result = calculateToggleState(
- 'workflow',
- { graph: { nodes: [] } },
- true,
- true,
- 'webapp',
- )
-
- expect(result.toggleDisabled).toBe(true)
- expect(result.missingEntryNode).toBe(true)
- expect(result.isMinimalState).toBe(true)
+ describe('hasWorkflowStartNode', () => {
+ it('should detect a workflow start node', () => {
+ expect(hasWorkflowStartNode({
+ graph: {
+ nodes: [
+ { data: { type: 'llm' } },
+ { data: { type: 'start' } },
+ ],
+ },
+ })).toBe(true)
})
- it('should enable toggle when workflow has entry node', () => {
- mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode)
-
- const result = calculateToggleState(
- 'workflow',
- { graph: { nodes: [{ data: { type: 'start' } }] } },
- true,
- true,
- 'webapp',
- )
-
- expect(result.toggleDisabled).toBe(false)
- expect(result.missingEntryNode).toBe(false)
- expect(result.isMinimalState).toBe(false)
+ it('should return false when the workflow has no start node', () => {
+ expect(hasWorkflowStartNode({
+ graph: {
+ nodes: [{ data: { type: 'llm' } }],
+ },
+ })).toBe(false)
})
})
- describe('Published State Logic', () => {
- it('should disable toggle when workflow unpublished (no graph)', () => {
- const result = calculateToggleState(
- 'workflow',
- null, // No workflow data = unpublished
- true,
- true,
- 'webapp',
- )
+ describe('getAppCardDisplayState', () => {
+ it('should disable unpublished workflow apps and mark them as minimal state', () => {
+ const state = getAppCardDisplayState({
+ appInfo: baseAppInfo,
+ cardType: 'webapp',
+ currentWorkflow: null,
+ isCurrentWorkspaceEditor: true,
+ isCurrentWorkspaceManager: true,
+ })
- expect(result.toggleDisabled).toBe(true)
- expect(result.appUnpublished).toBe(true)
- expect(result.isMinimalState).toBe(true)
+ expect(state.appUnpublished).toBe(true)
+ expect(state.missingStartNode).toBe(true)
+ expect(state.toggleDisabled).toBe(true)
+ expect(state.isMinimalState).toBe(true)
+ expect(state.runningStatus).toBe(false)
})
- it('should disable toggle when workflow unpublished (empty graph)', () => {
- const result = calculateToggleState(
- 'workflow',
- {}, // No graph property = unpublished
- true,
- true,
- 'webapp',
- )
+ it('should keep published workflow apps enabled when the user has permissions', () => {
+ const state = getAppCardDisplayState({
+ appInfo: baseAppInfo,
+ cardType: 'webapp',
+ currentWorkflow: {
+ graph: {
+ nodes: [{ data: { type: 'start' } }],
+ },
+ },
+ isCurrentWorkspaceEditor: true,
+ isCurrentWorkspaceManager: true,
+ })
- expect(result.toggleDisabled).toBe(true)
- expect(result.appUnpublished).toBe(true)
- expect(result.isMinimalState).toBe(true)
- })
-
- it('should consider published state when workflow has graph', () => {
- mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode)
-
- const result = calculateToggleState(
- 'workflow',
- { graph: { nodes: [] } },
- true,
- true,
- 'webapp',
- )
-
- expect(result.appUnpublished).toBe(false)
+ expect(state.appUnpublished).toBe(false)
+ expect(state.missingStartNode).toBe(false)
+ expect(state.toggleDisabled).toBe(false)
+ expect(state.isMinimalState).toBe(false)
+ expect(state.accessibleUrl).toBe(`https://example.com${basePath}/workflow/token-1`)
})
})
- describe('Permissions Logic', () => {
- it('should disable webapp toggle when user lacks editor permissions', () => {
- mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode)
-
- const result = calculateToggleState(
- 'workflow',
- { graph: { nodes: [] } },
- false, // No editor permission
- true,
- 'webapp',
- )
-
- expect(result.toggleDisabled).toBe(true)
- expect(result.hasInsufficientPermissions).toBe(true)
+ describe('getAppCardOperationKeys', () => {
+ it('should include embedded and settings actions for editable chat webapps', () => {
+ expect(getAppCardOperationKeys({
+ cardType: 'webapp',
+ appMode: AppModeEnum.CHAT,
+ isCurrentWorkspaceEditor: true,
+ })).toEqual(['launch', 'embedded', 'customize', 'settings'])
})
- it('should disable api toggle when user lacks manager permissions', () => {
- mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode)
-
- const result = calculateToggleState(
- 'workflow',
- { graph: { nodes: [] } },
- true,
- false, // No manager permission
- 'api',
- )
-
- expect(result.toggleDisabled).toBe(true)
- expect(result.hasInsufficientPermissions).toBe(true)
- })
-
- it('should enable toggle when user has proper permissions', () => {
- mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode)
-
- const webappResult = calculateToggleState(
- 'workflow',
- { graph: { nodes: [] } },
- true, // Has editor permission
- false,
- 'webapp',
- )
-
- const apiResult = calculateToggleState(
- 'workflow',
- { graph: { nodes: [] } },
- false,
- true, // Has manager permission
- 'api',
- )
-
- expect(webappResult.toggleDisabled).toBe(false)
- expect(apiResult.toggleDisabled).toBe(false)
+ it('should only expose the develop action for api cards', () => {
+ expect(getAppCardOperationKeys({
+ cardType: 'api',
+ appMode: AppModeEnum.COMPLETION,
+ isCurrentWorkspaceEditor: false,
+ })).toEqual(['develop'])
})
})
- describe('Combined Conditions Logic', () => {
- it('should handle multiple disable conditions correctly', () => {
- mockGetWorkflowEntryNode.mockReturnValue(undefined)
-
- const result = calculateToggleState(
- 'workflow',
- null, // Unpublished
- false, // No permissions
- false,
- 'webapp',
- )
-
- // All three conditions should be true
- expect(result.appUnpublished).toBe(true)
- expect(result.missingEntryNode).toBe(true)
- expect(result.hasInsufficientPermissions).toBe(true)
- expect(result.toggleDisabled).toBe(true)
- expect(result.isMinimalState).toBe(true)
+ describe('isAppAccessConfigured', () => {
+ it('should require members or groups for specific access mode', () => {
+ expect(isAppAccessConfigured(
+ {
+ id: 'app-1',
+ access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
+ } as unknown as AppDetailResponse,
+ { groups: [], members: [] },
+ )).toBe(false)
})
- it('should enable when all conditions are satisfied', () => {
- mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode)
-
- const result = calculateToggleState(
- 'workflow',
- { graph: { nodes: [{ data: { type: 'start' } }] } }, // Published
- true, // Has permissions
- true,
- 'webapp',
- )
-
- expect(result.appUnpublished).toBe(false)
- expect(result.missingEntryNode).toBe(false)
- expect(result.hasInsufficientPermissions).toBe(false)
- expect(result.toggleDisabled).toBe(false)
- expect(result.isMinimalState).toBe(false)
- })
- })
-
- describe('Non-Workflow Apps', () => {
- it('should not check workflow-specific conditions for non-workflow apps', () => {
- const result = calculateToggleState(
- 'chat', // Non-workflow mode
- null,
- true,
- true,
- 'webapp',
- )
-
- expect(result.appUnpublished).toBe(false) // isWorkflowApp is false
- expect(result.missingEntryNode).toBe(false) // isWorkflowApp is false
- expect(result.toggleDisabled).toBe(false)
- expect(result.isMinimalState).toBe(false)
+ it('should treat non-specific access modes as configured', () => {
+ expect(isAppAccessConfigured(
+ {
+ id: 'app-1',
+ access_mode: AccessMode.PUBLIC,
+ } as unknown as AppDetailResponse,
+ { groups: [], members: [] },
+ )).toBe(true)
})
})
})
diff --git a/web/app/components/app/overview/trigger-card.spec.tsx b/web/app/components/app/overview/__tests__/trigger-card.spec.tsx
similarity index 99%
rename from web/app/components/app/overview/trigger-card.spec.tsx
rename to web/app/components/app/overview/__tests__/trigger-card.spec.tsx
index 0ee9da582d..2a1cd6d43b 100644
--- a/web/app/components/app/overview/trigger-card.spec.tsx
+++ b/web/app/components/app/overview/__tests__/trigger-card.spec.tsx
@@ -1,7 +1,7 @@
import type { AppDetailResponse } from '@/models/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
-import TriggerCard from './trigger-card'
+import TriggerCard from '../trigger-card'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
diff --git a/web/app/components/app/overview/apikey-info-panel/cloud.spec.tsx b/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx
similarity index 98%
rename from web/app/components/app/overview/apikey-info-panel/cloud.spec.tsx
rename to web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx
index 06dc534cbb..037803d355 100644
--- a/web/app/components/app/overview/apikey-info-panel/cloud.spec.tsx
+++ b/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx
@@ -8,7 +8,7 @@ import {
mockUseModalContext,
scenarios,
textKeys,
-} from './apikey-info-panel.test-utils'
+} from '../apikey-info-panel.test-utils'
// Mock config for Cloud edition
vi.mock('@/config', () => ({
diff --git a/web/app/components/app/overview/apikey-info-panel/index.spec.tsx b/web/app/components/app/overview/apikey-info-panel/__tests__/index.spec.tsx
similarity index 99%
rename from web/app/components/app/overview/apikey-info-panel/index.spec.tsx
rename to web/app/components/app/overview/apikey-info-panel/__tests__/index.spec.tsx
index 3f50f7283d..d9c10b6ab9 100644
--- a/web/app/components/app/overview/apikey-info-panel/index.spec.tsx
+++ b/web/app/components/app/overview/apikey-info-panel/__tests__/index.spec.tsx
@@ -8,7 +8,7 @@ import {
mockUseModalContext,
scenarios,
textKeys,
-} from './apikey-info-panel.test-utils'
+} from '../apikey-info-panel.test-utils'
// Mock config for CE edition
vi.mock('@/config', () => ({
diff --git a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx
index 54763907df..4bab54b711 100644
--- a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx
+++ b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx
@@ -72,17 +72,17 @@ const defaultModalContext: ModalContextState = {
setShowTriggerEventsLimitModal: noop,
}
-export type MockOverrides = {
+type MockOverrides = {
providerContext?: Partial
modalContext?: Partial
}
-export type APIKeyInfoPanelRenderOptions = {
+type APIKeyInfoPanelRenderOptions = {
mockOverrides?: MockOverrides
} & Omit
// Setup function to configure mocks
-export function setupMocks(overrides: MockOverrides = {}) {
+function setupMocks(overrides: MockOverrides = {}) {
mockUseProviderContext.mockReturnValue({
...defaultProviderContext,
...overrides.providerContext,
@@ -95,7 +95,7 @@ export function setupMocks(overrides: MockOverrides = {}) {
}
// Custom render function
-export function renderAPIKeyInfoPanel(options: APIKeyInfoPanelRenderOptions = {}) {
+function renderAPIKeyInfoPanel(options: APIKeyInfoPanelRenderOptions = {}) {
const { mockOverrides, ...renderOptions } = options
setupMocks(mockOverrides)
@@ -210,4 +210,4 @@ export function clearAllMocks() {
}
// Export mock functions for external access
-export { defaultModalContext, mockUseModalContext, mockUseProviderContext }
+export { defaultModalContext, mockUseModalContext }
diff --git a/web/app/components/app/overview/app-card-sections.tsx b/web/app/components/app/overview/app-card-sections.tsx
new file mode 100644
index 0000000000..b66787a87d
--- /dev/null
+++ b/web/app/components/app/overview/app-card-sections.tsx
@@ -0,0 +1,351 @@
+/* eslint-disable react-refresh/only-export-components */
+import type { TFunction } from 'i18next'
+import type { ComponentType, ReactNode } from 'react'
+import type { OverviewOperationKey } from './app-card-utils'
+import type { ConfigParams } from './settings'
+import type { AppDetailResponse } from '@/models/app'
+import type { AppSSO } from '@/types/app'
+import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react'
+import Button from '@/app/components/base/button'
+import CopyFeedback from '@/app/components/base/copy-feedback'
+import Divider from '@/app/components/base/divider'
+import ShareQRCode from '@/app/components/base/qrcode'
+import {
+ AlertDialog,
+ AlertDialogActions,
+ AlertDialogCancelButton,
+ AlertDialogConfirmButton,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogTitle,
+} from '@/app/components/base/ui/alert-dialog'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/app/components/base/ui/tooltip'
+import { AccessMode } from '@/models/access-control'
+import { AppModeEnum } from '@/types/app'
+import AccessControl from '../app-access-control'
+import CustomizeModal from './customize'
+import EmbeddedModal from './embedded'
+import SettingsModal from './settings'
+import style from './style.module.css'
+
+type AppInfo = AppDetailResponse & Partial
+
+type OperationIcon = ComponentType<{ className?: string }>
+
+type AccessModeLabelKey
+ = | 'accessControlDialog.accessItems.organization'
+ | 'accessControlDialog.accessItems.specific'
+ | 'accessControlDialog.accessItems.anyone'
+ | 'accessControlDialog.accessItems.external'
+
+type AppCardOperation = {
+ key: OverviewOperationKey
+ label: string
+ Icon: OperationIcon
+ disabled: boolean
+ onClick: () => void
+}
+
+const OPERATION_ICON_MAP: Record = {
+ launch: RiExternalLinkLine,
+ embedded: RiWindowLine,
+ customize: RiPaintBrushLine,
+ settings: RiEqualizer2Line,
+ develop: RiBookOpenLine,
+}
+
+const ACCESS_MODE_ICON_MAP: Record = {
+ [AccessMode.ORGANIZATION]: RiBuildingLine,
+ [AccessMode.SPECIFIC_GROUPS_MEMBERS]: RiLockLine,
+ [AccessMode.PUBLIC]: RiGlobalLine,
+ [AccessMode.EXTERNAL_MEMBERS]: RiVerifiedBadgeLine,
+}
+
+const ACCESS_MODE_LABEL_MAP: Record = {
+ [AccessMode.ORGANIZATION]: 'accessControlDialog.accessItems.organization',
+ [AccessMode.SPECIFIC_GROUPS_MEMBERS]: 'accessControlDialog.accessItems.specific',
+ [AccessMode.PUBLIC]: 'accessControlDialog.accessItems.anyone',
+ [AccessMode.EXTERNAL_MEMBERS]: 'accessControlDialog.accessItems.external',
+}
+
+const MaybeTooltip = ({
+ children,
+ content,
+ popupClassName,
+ show = true,
+}: {
+ children: ReactNode
+ content?: ReactNode
+ popupClassName?: string
+ show?: boolean
+}) => {
+ if (!show || !content)
+ return <>{children}>
+
+ return (
+
+ {children}} />
+
+ {content}
+
+
+ )
+}
+
+export const createAppCardOperations = ({
+ operationKeys,
+ t,
+ runningStatus,
+ triggerModeDisabled,
+ onLaunch,
+ onEmbedded,
+ onCustomize,
+ onSettings,
+ onDevelop,
+}: {
+ operationKeys: OverviewOperationKey[]
+ t: TFunction
+ runningStatus: boolean
+ triggerModeDisabled: boolean
+ onLaunch: () => void
+ onEmbedded: () => void
+ onCustomize: () => void
+ onSettings: () => void
+ onDevelop: () => void
+}): AppCardOperation[] => {
+ const labelMap: Record = {
+ launch: t('overview.appInfo.launch', { ns: 'appOverview' }),
+ embedded: t('overview.appInfo.embedded.entry', { ns: 'appOverview' }),
+ customize: t('overview.appInfo.customize.entry', { ns: 'appOverview' }),
+ settings: t('overview.appInfo.settings.entry', { ns: 'appOverview' }),
+ develop: t('overview.apiInfo.doc', { ns: 'appOverview' }),
+ }
+ const onClickMap: Record void> = {
+ launch: onLaunch,
+ embedded: onEmbedded,
+ customize: onCustomize,
+ settings: onSettings,
+ develop: onDevelop,
+ }
+
+ return operationKeys.map((key) => {
+ const disabled = triggerModeDisabled ? true : (key === 'settings' ? false : !runningStatus)
+ return {
+ key,
+ label: labelMap[key],
+ Icon: OPERATION_ICON_MAP[key],
+ disabled,
+ onClick: onClickMap[key],
+ }
+ })
+}
+
+export const AppCardUrlSection = ({
+ t,
+ isApp,
+ accessibleUrl,
+ showConfirmDelete,
+ isCurrentWorkspaceManager,
+ genLoading,
+ onRegenerate,
+ onShowRegenerateConfirm,
+ onHideRegenerateConfirm,
+}: {
+ t: TFunction
+ isApp: boolean
+ accessibleUrl: string
+ showConfirmDelete: boolean
+ isCurrentWorkspaceManager: boolean
+ genLoading: boolean
+ onRegenerate: () => void
+ onShowRegenerateConfirm: () => void
+ onHideRegenerateConfirm: () => void
+}) => (
+
+
+ {isApp
+ ? t('overview.appInfo.accessibleAddress', { ns: 'appOverview' })
+ : t('overview.apiInfo.accessibleAddress', { ns: 'appOverview' })}
+
+
+
+
+ {isApp && }
+ {isApp && }
+ {showConfirmDelete && (
+ !open && onHideRegenerateConfirm()}>
+
+
+
+ {t('overview.appInfo.regenerate', { ns: 'appOverview' })}
+
+
+ {t('overview.appInfo.regenerateNotice', { ns: 'appOverview' })}
+
+
+
+
+ {t('operation.cancel', { ns: 'common' })}
+
+
+ {t('operation.confirm', { ns: 'common' })}
+
+
+
+
+ )}
+ {isApp && isCurrentWorkspaceManager && (
+
+
+
+ )}
+
+
+)
+
+export const AppCardAccessControlSection = ({
+ t,
+ appDetail,
+ isAppAccessSet,
+ onClick,
+}: {
+ t: TFunction
+ appDetail: AppDetailResponse
+ isAppAccessSet: boolean
+ onClick: () => void
+}) => {
+ const Icon = ACCESS_MODE_ICON_MAP[appDetail.access_mode]
+ const labelKey = ACCESS_MODE_LABEL_MAP[appDetail.access_mode]
+
+ return (
+
+ {t('publishApp.title', { ns: 'app' })}
+
+
+
+ {t(labelKey, { ns: 'app' })}
+
+ {!isAppAccessSet && {t('publishApp.notSet', { ns: 'app' })} }
+
+
+
+
+
+ )
+}
+
+export const AppCardOperations = ({
+ t,
+ operations,
+}: {
+ t: TFunction
+ operations: AppCardOperation[]
+}) => (
+ <>
+ {operations.map(({ key, label, Icon, disabled, onClick }) => (
+
+ ))}
+ >
+)
+
+export const AppCardDialogs = ({
+ isApp,
+ appInfo,
+ appMode,
+ showSettingsModal,
+ showEmbedded,
+ showCustomizeModal,
+ showAccessControl,
+ appDetail,
+ onCloseSettings,
+ onCloseEmbedded,
+ onCloseCustomize,
+ onCloseAccessControl,
+ onSaveSiteConfig,
+ onConfirmAccessControl,
+}: {
+ isApp: boolean
+ appInfo: AppInfo
+ appMode: AppModeEnum
+ showSettingsModal: boolean
+ showEmbedded: boolean
+ showCustomizeModal: boolean
+ showAccessControl: boolean
+ appDetail: AppDetailResponse | null | undefined
+ onCloseSettings: () => void
+ onCloseEmbedded: () => void
+ onCloseCustomize: () => void
+ onCloseAccessControl: () => void
+ onSaveSiteConfig?: (params: ConfigParams) => Promise
+ onConfirmAccessControl: () => Promise
+}) => {
+ if (!isApp)
+ return null
+
+ return (
+ <>
+
+
+
+ {showAccessControl && appDetail && (
+
+ )}
+ >
+ )
+}
diff --git a/web/app/components/app/overview/app-card-utils.ts b/web/app/components/app/overview/app-card-utils.ts
new file mode 100644
index 0000000000..345531091d
--- /dev/null
+++ b/web/app/components/app/overview/app-card-utils.ts
@@ -0,0 +1,119 @@
+import type { AppDetailResponse } from '@/models/app'
+import type { AppSSO } from '@/types/app'
+import { BlockEnum } from '@/app/components/workflow/types'
+import { AccessMode } from '@/models/access-control'
+import { AppModeEnum } from '@/types/app'
+import { basePath } from '@/utils/var'
+
+type OverviewCardType = 'api' | 'webapp'
+
+export type OverviewOperationKey = 'launch' | 'embedded' | 'customize' | 'settings' | 'develop'
+
+type AppInfo = AppDetailResponse & Partial
+
+type WorkflowLike = {
+ graph?: {
+ nodes?: Array<{
+ data?: {
+ type?: string
+ }
+ }>
+ }
+} | null | undefined
+
+type AccessSubjectsLike = {
+ groups?: unknown[]
+ members?: unknown[]
+} | null | undefined
+
+type AppCardDisplayState = {
+ isApp: boolean
+ appMode: AppModeEnum
+ appUnpublished: boolean
+ missingStartNode: boolean
+ hasInsufficientPermissions: boolean
+ toggleDisabled: boolean
+ runningStatus: boolean
+ isMinimalState: boolean
+ accessibleUrl: string
+}
+
+const getCardAppMode = (mode: AppModeEnum) => {
+ return (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : mode
+}
+
+export const hasWorkflowStartNode = (currentWorkflow: WorkflowLike) => {
+ return currentWorkflow?.graph?.nodes?.some(node => node.data?.type === BlockEnum.Start) ?? false
+}
+
+export const getAppCardDisplayState = ({
+ appInfo,
+ cardType,
+ currentWorkflow,
+ isCurrentWorkspaceEditor,
+ isCurrentWorkspaceManager,
+ triggerModeDisabled = false,
+}: {
+ appInfo: AppInfo
+ cardType: OverviewCardType
+ currentWorkflow: WorkflowLike
+ isCurrentWorkspaceEditor: boolean
+ isCurrentWorkspaceManager: boolean
+ triggerModeDisabled?: boolean
+}): AppCardDisplayState => {
+ const isApp = cardType === 'webapp'
+ const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
+ const appUnpublished = isWorkflowApp && !currentWorkflow?.graph
+ const missingStartNode = isWorkflowApp && !hasWorkflowStartNode(currentWorkflow)
+ const hasInsufficientPermissions = isApp ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager
+ const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
+ const runningStatus = (appUnpublished || missingStartNode) ? false : (isApp ? appInfo.enable_site : appInfo.enable_api)
+ const appMode = getCardAppMode(appInfo.mode)
+ const appBaseUrl = appInfo.site?.app_base_url ?? ''
+ const accessToken = appInfo.site?.access_token ?? ''
+
+ return {
+ isApp,
+ appMode,
+ appUnpublished,
+ missingStartNode,
+ hasInsufficientPermissions,
+ toggleDisabled,
+ runningStatus,
+ isMinimalState: appUnpublished || missingStartNode,
+ accessibleUrl: isApp ? `${appBaseUrl}${basePath}/${appMode}/${accessToken}` : (appInfo.api_base_url ?? ''),
+ }
+}
+
+export const isAppAccessConfigured = (appDetail: AppDetailResponse | null | undefined, appAccessSubjects: AccessSubjectsLike) => {
+ if (!appDetail || !appAccessSubjects)
+ return true
+
+ if (appDetail.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS)
+ return true
+
+ return Boolean(appAccessSubjects.groups?.length || appAccessSubjects.members?.length)
+}
+
+export const getAppCardOperationKeys = ({
+ cardType,
+ appMode,
+ isCurrentWorkspaceEditor,
+}: {
+ cardType: OverviewCardType
+ appMode: AppModeEnum
+ isCurrentWorkspaceEditor: boolean
+}): OverviewOperationKey[] => {
+ if (cardType === 'api')
+ return ['develop']
+
+ const operationKeys: OverviewOperationKey[] = ['launch']
+ if (appMode !== AppModeEnum.COMPLETION && appMode !== AppModeEnum.WORKFLOW)
+ operationKeys.push('embedded')
+
+ operationKeys.push('customize')
+ if (isCurrentWorkspaceEditor)
+ operationKeys.push('settings')
+
+ return operationKeys
+}
diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx
index 27b3dd9b6a..1b2fa2a6e8 100644
--- a/web/app/components/app/overview/app-card.tsx
+++ b/web/app/components/app/overview/app-card.tsx
@@ -2,33 +2,15 @@
import type { ConfigParams } from './settings'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
-import {
- RiArrowRightSLine,
- RiBookOpenLine,
- RiBuildingLine,
- RiEqualizer2Line,
- RiExternalLinkLine,
- RiGlobalLine,
- RiLockLine,
- RiPaintBrushLine,
- RiVerifiedBadgeLine,
- RiWindowLine,
-} from '@remixicon/react'
import * as React from 'react'
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppBasic from '@/app/components/app-sidebar/basic'
import { useStore as useAppStore } from '@/app/components/app/store'
-import Button from '@/app/components/base/button'
-import Confirm from '@/app/components/base/confirm'
-import CopyFeedback from '@/app/components/base/copy-feedback'
-import Divider from '@/app/components/base/divider'
-import ShareQRCode from '@/app/components/base/qrcode'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import SecretKeyButton from '@/app/components/develop/secret-key/secret-key-button'
import Indicator from '@/app/components/header/indicator'
-import { BlockEnum } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
@@ -39,12 +21,18 @@ import { fetchAppDetailDirect } from '@/service/apps'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
-import { basePath } from '@/utils/var'
-import AccessControl from '../app-access-control'
-import CustomizeModal from './customize'
-import EmbeddedModal from './embedded'
-import SettingsModal from './settings'
-import style from './style.module.css'
+import {
+ AppCardAccessControlSection,
+ AppCardDialogs,
+ AppCardOperations,
+ AppCardUrlSection,
+ createAppCardOperations,
+} from './app-card-sections'
+import {
+ getAppCardDisplayState,
+ getAppCardOperationKeys,
+ isAppAccessConfigured,
+} from './app-card-utils'
export type IAppCardProps = {
className?: string
@@ -52,8 +40,8 @@ export type IAppCardProps = {
isInPanel?: boolean
cardType?: 'api' | 'webapp'
customBgColor?: string
- triggerModeDisabled?: boolean // true when Trigger Node mode needs UI locked to avoid conflicting actions
- triggerModeMessage?: React.ReactNode // contextual copy explaining why the card is disabled in trigger mode
+ triggerModeDisabled?: boolean
+ triggerModeMessage?: React.ReactNode
onChangeStatus: (val: boolean) => Promise
onSaveSiteConfig?: (params: ConfigParams) => Promise
onGenerateCode?: () => Promise
@@ -83,104 +71,55 @@ function AppCard({
const [showCustomizeModal, setShowCustomizeModal] = useState(false)
const [genLoading, setGenLoading] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
- const [showAccessControl, setShowAccessControl] = useState(false)
+ const [showAccessControl, setShowAccessControl] = useState(false)
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
- const { data: appAccessSubjects } = useAppWhiteListSubjects(appDetail?.id, systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
+ const { data: appAccessSubjects } = useAppWhiteListSubjects(
+ appDetail?.id,
+ systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
+ )
- const OPERATIONS_MAP = useMemo(() => {
- const operationsMap = {
- webapp: [
- { opName: t('overview.appInfo.launch', { ns: 'appOverview' }), opIcon: RiExternalLinkLine },
- ] as { opName: string, opIcon: any }[],
- api: [{ opName: t('overview.apiInfo.doc', { ns: 'appOverview' }), opIcon: RiBookOpenLine }],
- app: [],
- }
- if (appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW)
- operationsMap.webapp.push({ opName: t('overview.appInfo.embedded.entry', { ns: 'appOverview' }), opIcon: RiWindowLine })
+ const cardState = getAppCardDisplayState({
+ appInfo,
+ cardType,
+ currentWorkflow,
+ isCurrentWorkspaceEditor,
+ isCurrentWorkspaceManager,
+ triggerModeDisabled,
+ })
- operationsMap.webapp.push({ opName: t('overview.appInfo.customize.entry', { ns: 'appOverview' }), opIcon: RiPaintBrushLine })
-
- if (isCurrentWorkspaceEditor)
- operationsMap.webapp.push({ opName: t('overview.appInfo.settings.entry', { ns: 'appOverview' }), opIcon: RiEqualizer2Line })
-
- return operationsMap
- }, [isCurrentWorkspaceEditor, appInfo, t])
-
- const isApp = cardType === 'webapp'
+ const isApp = cardState.isApp
const basicName = isApp
? t('overview.appInfo.title', { ns: 'appOverview' })
: t('overview.apiInfo.title', { ns: 'appOverview' })
- const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
- const appUnpublished = isWorkflowApp && !currentWorkflow?.graph
- const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
- const missingStartNode = isWorkflowApp && !hasStartNode
- const hasInsufficientPermissions = isApp ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager
- const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
- const runningStatus = (appUnpublished || missingStartNode) ? false : (isApp ? appInfo.enable_site : appInfo.enable_api)
- const isMinimalState = appUnpublished || missingStartNode
- const { app_base_url, access_token } = appInfo.site ?? {}
- const appMode = (appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appInfo.mode
- const appUrl = `${app_base_url}${basePath}/${appMode}/${access_token}`
- const apiUrl = appInfo?.api_base_url
- const genClickFuncByName = (opName: string) => {
- switch (opName) {
- case t('overview.appInfo.launch', { ns: 'appOverview' }):
- return () => {
- window.open(appUrl, '_blank')
- }
- case t('overview.appInfo.customize.entry', { ns: 'appOverview' }):
- return () => {
- setShowCustomizeModal(true)
- }
- case t('overview.appInfo.settings.entry', { ns: 'appOverview' }):
- return () => {
- setShowSettingsModal(true)
- }
- case t('overview.appInfo.embedded.entry', { ns: 'appOverview' }):
- return () => {
- setShowEmbedded(true)
- }
- default:
- // jump to page develop
- return () => {
- const pathSegments = pathname.split('/')
- pathSegments.pop()
- router.push(`${pathSegments.join('/')}/develop`)
- }
- }
- }
+ const isAppAccessSet = useMemo(
+ () => isAppAccessConfigured(appDetail, appAccessSubjects),
+ [appAccessSubjects, appDetail],
+ )
const onGenCode = async () => {
- if (onGenerateCode) {
- setGenLoading(true)
- await asyncRunSafe(onGenerateCode())
- setGenLoading(false)
- }
- }
+ if (!onGenerateCode)
+ return
- const [isAppAccessSet, setIsAppAccessSet] = useState(true)
- useEffect(() => {
- if (appDetail && appAccessSubjects) {
- if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
- setIsAppAccessSet(false)
- else
- setIsAppAccessSet(true)
- }
- else {
- setIsAppAccessSet(true)
- }
- }, [appAccessSubjects, appDetail])
+ setGenLoading(true)
+ await asyncRunSafe(onGenerateCode())
+ setGenLoading(false)
+ }
const handleClickAccessControl = useCallback(() => {
if (!appDetail)
return
+
setShowAccessControl(true)
}, [appDetail])
+
const handleAccessControlUpdate = useCallback(async () => {
+ if (!appDetail)
+ return
+
try {
- const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail!.id })
+ const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail.id })
setAppDetail(res)
setShowAccessControl(false)
}
@@ -189,11 +128,59 @@ function AppCard({
}
}, [appDetail, setAppDetail])
+ const operationKeys = useMemo(() => getAppCardOperationKeys({
+ cardType,
+ appMode: cardState.appMode,
+ isCurrentWorkspaceEditor,
+ }), [cardState.appMode, cardType, isCurrentWorkspaceEditor])
+
+ const handleLaunch = useCallback(() => {
+ window.open(cardState.accessibleUrl, '_blank')
+ }, [cardState.accessibleUrl])
+
+ const handleOpenCustomize = useCallback(() => {
+ setShowCustomizeModal(true)
+ }, [])
+
+ const handleOpenSettings = useCallback(() => {
+ setShowSettingsModal(true)
+ }, [])
+
+ const handleOpenEmbedded = useCallback(() => {
+ setShowEmbedded(true)
+ }, [])
+
+ const handleOpenDevelop = useCallback(() => {
+ const pathSegments = pathname.split('/')
+ pathSegments.pop()
+ router.push(`${pathSegments.join('/')}/develop`)
+ }, [pathname, router])
+
+ const operations = useMemo(() => createAppCardOperations({
+ operationKeys,
+ t,
+ runningStatus: cardState.runningStatus,
+ triggerModeDisabled,
+ onLaunch: handleLaunch,
+ onEmbedded: handleOpenEmbedded,
+ onCustomize: handleOpenCustomize,
+ onSettings: handleOpenSettings,
+ onDevelop: handleOpenDevelop,
+ }), [
+ cardState.runningStatus,
+ handleLaunch,
+ handleOpenCustomize,
+ handleOpenDevelop,
+ handleOpenEmbedded,
+ handleOpenSettings,
+ operationKeys,
+ t,
+ triggerModeDisabled,
+ ])
+
return (
{triggerModeDisabled && (
@@ -204,12 +191,12 @@ function AppCard({
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
-
+
)
- :
+ :
)}
-
+
-
-
- {runningStatus
+
+
+ {cardState.runningStatus
? t('overview.status.running', { ns: 'appOverview' })
: t('overview.status.disable', { ns: 'appOverview' })}
@@ -260,180 +247,58 @@ function AppCard({
offset={24}
>
-
+
- {!isMinimalState && (
-
-
- {isApp
- ? t('overview.appInfo.accessibleAddress', { ns: 'appOverview' })
- : t('overview.apiInfo.accessibleAddress', { ns: 'appOverview' })}
-
-
-
-
- {isApp ? appUrl : apiUrl}
-
-
-
- {isApp && }
- {isApp && }
- {/* button copy link/ button regenerate */}
- {showConfirmDelete && (
- {
- onGenCode()
- setShowConfirmDelete(false)
- }}
- onCancel={() => setShowConfirmDelete(false)}
- />
- )}
- {isApp && isCurrentWorkspaceManager && (
-
- setShowConfirmDelete(true)}
- >
-
-
-
-
- )}
-
-
+ {!cardState.isMinimalState && (
+ {
+ onGenCode()
+ setShowConfirmDelete(false)
+ }}
+ onShowRegenerateConfirm={() => setShowConfirmDelete(true)}
+ onHideRegenerateConfirm={() => setShowConfirmDelete(false)}
+ />
)}
- {!isMinimalState && isApp && systemFeatures.webapp_auth.enabled && appDetail && (
-
- {t('publishApp.title', { ns: 'app' })}
-
-
- {appDetail?.access_mode === AccessMode.ORGANIZATION
- && (
- <>
-
- {t('accessControlDialog.accessItems.organization', { ns: 'app' })}
- >
- )}
- {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS
- && (
- <>
-
- {t('accessControlDialog.accessItems.specific', { ns: 'app' })}
- >
- )}
- {appDetail?.access_mode === AccessMode.PUBLIC
- && (
- <>
-
- {t('accessControlDialog.accessItems.anyone', { ns: 'app' })}
- >
- )}
- {appDetail?.access_mode === AccessMode.EXTERNAL_MEMBERS
- && (
- <>
-
- {t('accessControlDialog.accessItems.external', { ns: 'app' })}
- >
- )}
-
- {!isAppAccessSet && {t('publishApp.notSet', { ns: 'app' })} }
-
-
-
-
-
+ {!cardState.isMinimalState && isApp && systemFeatures.webapp_auth.enabled && appDetail && (
+
)}
- {!isMinimalState && (
+ {!cardState.isMinimalState && (
{!isApp && }
- {OPERATIONS_MAP[cardType].map((op) => {
- const disabled
- = triggerModeDisabled
- ? true
- : op.opName === t('overview.appInfo.settings.entry', { ns: 'appOverview' })
- ? false
- : !runningStatus
- return (
-
- )
- })}
+
)}
- {isApp
- ? (
- <>
- setShowSettingsModal(false)}
- onSave={onSaveSiteConfig}
- />
- setShowEmbedded(false)}
- appBaseUrl={app_base_url}
- accessToken={access_token}
- />
- setShowCustomizeModal(false)}
- appId={appInfo.id}
- api_base_url={appInfo.api_base_url}
- mode={appInfo.mode}
- />
- {
- showAccessControl && (
- { setShowAccessControl(false) }}
- />
- )
- }
- >
- )
- : null}
+ setShowSettingsModal(false)}
+ onCloseEmbedded={() => setShowEmbedded(false)}
+ onCloseCustomize={() => setShowCustomizeModal(false)}
+ onCloseAccessControl={() => setShowAccessControl(false)}
+ onSaveSiteConfig={onSaveSiteConfig}
+ onConfirmAccessControl={handleAccessControlUpdate}
+ />
)
}
diff --git a/web/app/components/app/overview/app-chart-utils.ts b/web/app/components/app/overview/app-chart-utils.ts
new file mode 100644
index 0000000000..6777bacce7
--- /dev/null
+++ b/web/app/components/app/overview/app-chart-utils.ts
@@ -0,0 +1,269 @@
+import type { EChartsOption } from 'echarts'
+import dayjs from 'dayjs'
+import Decimal from 'decimal.js'
+import { get } from 'es-toolkit/compat'
+import { formatNumber } from '@/utils/format'
+
+type ColorType = 'green' | 'orange' | 'blue'
+
+type ChartType = 'messages' | 'conversations' | 'endUsers' | 'costs' | 'workflowCosts'
+
+export type ChartRow = {
+ date: string
+ total_price?: number | string
+} & Record
+
+type ChartConfig = {
+ colorType: ColorType
+ showTokens?: boolean
+}
+
+type TooltipParams = {
+ name: string
+ data?: ChartRow
+}
+
+const valueFormatter = (value: string | number) => value
+
+const COLOR_TYPE_MAP: Record = {
+ green: {
+ lineColor: 'rgba(6, 148, 162, 1)',
+ bgColor: ['rgba(6, 148, 162, 0.2)', 'rgba(67, 174, 185, 0.08)'],
+ },
+ orange: {
+ lineColor: 'rgba(255, 138, 76, 1)',
+ bgColor: ['rgba(254, 145, 87, 0.2)', 'rgba(255, 138, 76, 0.1)'],
+ },
+ blue: {
+ lineColor: 'rgba(28, 100, 242, 1)',
+ bgColor: ['rgba(28, 100, 242, 0.3)', 'rgba(28, 100, 242, 0.1)'],
+ },
+}
+
+const COMMON_COLOR_MAP = {
+ label: '#9CA3AF',
+ splitLineLight: '#F3F4F6',
+ splitLineDark: '#E5E7EB',
+}
+
+const commonDateFormat = 'MMM D, YYYY'
+
+export const defaultPeriod = {
+ start: dayjs().subtract(7, 'day').format(commonDateFormat),
+ end: dayjs().format(commonDateFormat),
+}
+
+export const CHART_TYPE_CONFIG: Record = {
+ messages: {
+ colorType: 'green',
+ },
+ conversations: {
+ colorType: 'green',
+ },
+ endUsers: {
+ colorType: 'orange',
+ },
+ costs: {
+ colorType: 'blue',
+ showTokens: true,
+ },
+ workflowCosts: {
+ colorType: 'blue',
+ },
+}
+
+const sumValues = (values: Decimal.Value[]): number => Decimal.sum(...values).toNumber()
+
+const getRowValue = (row: ChartRow, field: string): Decimal.Value => row[field] ?? 0
+
+const getChartColors = (chartType: ChartType) => COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType]
+
+const getMarkLineSeedData = (statisticsLength: number) => {
+ const markLineLength = statisticsLength >= 2 ? statisticsLength - 2 : statisticsLength
+ return ['', ...Array.from({ length: markLineLength }, () => '1'), '']
+}
+
+const getTooltipContent = (chartType: ChartType, yField: string, params: TooltipParams) => {
+ const row = params.data ?? { date: params.name }
+ const value = valueFormatter(row[yField] ?? 0)
+ if (!CHART_TYPE_CONFIG[chartType].showTokens)
+ return `${params.name} ${value} `
+
+ return `${params.name}
+ ${value}
+
+ (
+ ~$${get(row, 'total_price', 0)}
+ )
+
+ `
+}
+
+export const getChartValueField = (statistics: ChartRow[], valueKey?: string) => {
+ if (valueKey)
+ return valueKey
+
+ return Object.keys(statistics[0] ?? {}).find(name => name.includes('count')) ?? 'count'
+}
+
+export const getSummaryValue = ({
+ chartType,
+ statistics,
+ yField,
+ isAvg,
+ unit = '',
+}: {
+ chartType: ChartType
+ statistics: ChartRow[]
+ yField: string
+ isAvg?: boolean
+ unit?: string
+}) => {
+ const values = statistics.map(item => getRowValue(item, yField))
+ const divisor = values.length || 1
+ const sumData = isAvg ? (sumValues(values) / divisor) : sumValues(values)
+
+ if (chartType === 'costs') {
+ const formattedCost = sumData < 1000
+ ? sumData
+ : `${formatNumber(Math.round(sumData / 1000))}k`
+
+ return `${formattedCost}`
+ }
+
+ return `${sumData.toLocaleString()} ${unit}`.trim()
+}
+
+export const getTokenSummary = (statistics: ChartRow[]) => {
+ const totalPrice = sumValues(statistics.map(item => Number.parseFloat(String(get(item, 'total_price', '0')))))
+ return totalPrice.toLocaleString('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 4,
+ })
+}
+
+export const buildChartOptions = ({
+ statistics,
+ chartType,
+ yField,
+ yMax,
+}: {
+ statistics: ChartRow[]
+ chartType: ChartType
+ yField: string
+ yMax?: number
+}): EChartsOption => {
+ const xData = statistics.map(({ date }) => date)
+ const chartColors = getChartColors(chartType)
+ const markLineSeedData = getMarkLineSeedData(statistics.length)
+
+ return {
+ dataset: {
+ dimensions: ['date', yField],
+ source: statistics,
+ },
+ grid: { top: 8, right: 36, bottom: 10, left: 25, containLabel: true },
+ tooltip: {
+ trigger: 'item',
+ position: 'top',
+ borderWidth: 0,
+ },
+ xAxis: [{
+ type: 'category',
+ boundaryGap: false,
+ axisLabel: {
+ color: COMMON_COLOR_MAP.label,
+ hideOverlap: true,
+ overflow: 'break',
+ formatter(value) {
+ return dayjs(value).format(commonDateFormat)
+ },
+ },
+ axisLine: { show: false },
+ axisTick: { show: false },
+ splitLine: {
+ show: true,
+ lineStyle: {
+ color: COMMON_COLOR_MAP.splitLineLight,
+ width: 1,
+ type: [10, 10],
+ },
+ interval(index) {
+ return index === 0 || index === xData.length - 1
+ },
+ },
+ }, {
+ position: 'bottom',
+ boundaryGap: false,
+ data: markLineSeedData,
+ axisLabel: { show: false },
+ axisLine: { show: false },
+ axisTick: { show: false },
+ splitLine: {
+ show: true,
+ lineStyle: {
+ color: COMMON_COLOR_MAP.splitLineDark,
+ },
+ interval(_index, value) {
+ return !!value
+ },
+ },
+ }],
+ yAxis: {
+ max: yMax ?? 'dataMax',
+ type: 'value',
+ axisLabel: { color: COMMON_COLOR_MAP.label, hideOverlap: true },
+ splitLine: {
+ lineStyle: {
+ color: COMMON_COLOR_MAP.splitLineLight,
+ },
+ },
+ },
+ series: [
+ {
+ type: 'line',
+ showSymbol: true,
+ symbolSize: 4,
+ lineStyle: {
+ color: chartColors.lineColor,
+ width: 2,
+ },
+ itemStyle: {
+ color: chartColors.lineColor,
+ },
+ areaStyle: {
+ color: {
+ type: 'linear',
+ x: 0,
+ y: 0,
+ x2: 0,
+ y2: 1,
+ colorStops: [{
+ offset: 0,
+ color: chartColors.bgColor[0],
+ }, {
+ offset: 1,
+ color: chartColors.bgColor[1],
+ }],
+ global: false,
+ },
+ },
+ tooltip: {
+ padding: [8, 12, 8, 12],
+ formatter(params) {
+ return getTooltipContent(chartType, yField, params as unknown as TooltipParams)
+ },
+ },
+ },
+ ],
+ }
+}
+
+export const getDefaultChartData = ({ start, end, key = 'count' }: { start: string, end: string, key?: string }) => {
+ const diffDays = dayjs(end).diff(dayjs(start), 'day')
+ return Array.from({ length: diffDays || 1 }, () => ({ date: '', [key]: 0 })).map((item, index) => {
+ item.date = dayjs(start).add(index, 'day').format(commonDateFormat)
+ return item
+ })
+}
diff --git a/web/app/components/app/overview/app-chart.tsx b/web/app/components/app/overview/app-chart.tsx
index 028753d41c..10e022151f 100644
--- a/web/app/components/app/overview/app-chart.tsx
+++ b/web/app/components/app/overview/app-chart.tsx
@@ -1,12 +1,9 @@
+/* eslint-disable react-refresh/only-export-components, react/component-hook-factories */
'use client'
import type { Dayjs } from 'dayjs'
-import type { EChartsOption } from 'echarts'
import type { FC } from 'react'
-import type { AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppTokenCostsResponse } from '@/models/app'
-import dayjs from 'dayjs'
-import Decimal from 'decimal.js'
+import type { ChartRow } from './app-chart-utils'
import ReactECharts from 'echarts-for-react'
-import { get } from 'es-toolkit/compat'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Basic from '@/app/components/app-sidebar/basic'
@@ -25,64 +22,15 @@ import {
useWorkflowDailyTerminals,
useWorkflowTokenCosts,
} from '@/service/use-apps'
-import { formatNumber } from '@/utils/format'
-
-const valueFormatter = (v: string | number) => v
-
-const COLOR_TYPE_MAP = {
- green: {
- lineColor: 'rgba(6, 148, 162, 1)',
- bgColor: ['rgba(6, 148, 162, 0.2)', 'rgba(67, 174, 185, 0.08)'],
- },
- orange: {
- lineColor: 'rgba(255, 138, 76, 1)',
- bgColor: ['rgba(254, 145, 87, 0.2)', 'rgba(255, 138, 76, 0.1)'],
- },
- blue: {
- lineColor: 'rgba(28, 100, 242, 1)',
- bgColor: ['rgba(28, 100, 242, 0.3)', 'rgba(28, 100, 242, 0.1)'],
- },
-}
-
-const COMMON_COLOR_MAP = {
- label: '#9CA3AF',
- splitLineLight: '#F3F4F6',
- splitLineDark: '#E5E7EB',
-}
-
-type IColorType = 'green' | 'orange' | 'blue'
-type IChartType = 'messages' | 'conversations' | 'endUsers' | 'costs' | 'workflowCosts'
-type IChartConfigType = { colorType: IColorType, showTokens?: boolean }
-
-const commonDateFormat = 'MMM D, YYYY'
-
-const CHART_TYPE_CONFIG: Record = {
- messages: {
- colorType: 'green',
- },
- conversations: {
- colorType: 'green',
- },
- endUsers: {
- colorType: 'orange',
- },
- costs: {
- colorType: 'blue',
- showTokens: true,
- },
- workflowCosts: {
- colorType: 'blue',
- },
-}
-
-const sum = (arr: Decimal.Value[]): number => {
- return Decimal.sum(...arr).toNumber()
-}
-
-const defaultPeriod = {
- start: dayjs().subtract(7, 'day').format(commonDateFormat),
- end: dayjs().format(commonDateFormat),
-}
+import {
+ buildChartOptions,
+ CHART_TYPE_CONFIG,
+ defaultPeriod,
+ getChartValueField,
+ getDefaultChartData,
+ getSummaryValue,
+ getTokenSummary,
+} from './app-chart-utils'
export type PeriodParams = {
name: string
@@ -102,20 +50,20 @@ export type PeriodParamsWithTimeRange = {
query?: TimeRange
}
-export type IBizChartProps = {
+type IBizChartProps = {
period: PeriodParams
id: string
}
-export type IChartProps = {
+type IChartProps = {
className?: string
basicInfo: { title: string, explanation: string, timePeriod: string }
valueKey?: string
isAvg?: boolean
unit?: string
yMax?: number
- chartType: IChartType
- chartData: AppDailyMessagesResponse | AppDailyConversationsResponse | AppDailyEndUsersResponse | AppTokenCostsResponse | { data: Array<{ date: string, count: number }> }
+ chartType: keyof typeof CHART_TYPE_CONFIG
+ chartData: { data: ChartRow[] }
}
const Chart: React.FC = ({
@@ -130,131 +78,21 @@ const Chart: React.FC = ({
}) => {
const { t } = useTranslation()
const statistics = chartData.data
- const statisticsLen = statistics.length
- const markLineLength = statisticsLen >= 2 ? statisticsLen - 2 : statisticsLen
- const extraDataForMarkLine = Array.from({ length: markLineLength }, () => '1')
- extraDataForMarkLine.push('')
- extraDataForMarkLine.unshift('')
-
- const xData = statistics.map(({ date }) => date)
- const yField = valueKey || Object.keys(statistics[0]).find(name => name.includes('count')) || ''
- const yData = statistics.map((item) => {
- // @ts-expect-error field is valid
- return item[yField] || 0
+ const yField = getChartValueField(statistics, valueKey)
+ const options = buildChartOptions({
+ statistics,
+ chartType,
+ yField,
+ yMax,
})
-
- const options: EChartsOption = {
- dataset: {
- dimensions: ['date', yField],
- source: statistics,
- },
- grid: { top: 8, right: 36, bottom: 10, left: 25, containLabel: true },
- tooltip: {
- trigger: 'item',
- position: 'top',
- borderWidth: 0,
- },
- xAxis: [{
- type: 'category',
- boundaryGap: false,
- axisLabel: {
- color: COMMON_COLOR_MAP.label,
- hideOverlap: true,
- overflow: 'break',
- formatter(value) {
- return dayjs(value).format(commonDateFormat)
- },
- },
- axisLine: { show: false },
- axisTick: { show: false },
- splitLine: {
- show: true,
- lineStyle: {
- color: COMMON_COLOR_MAP.splitLineLight,
- width: 1,
- type: [10, 10],
- },
- interval(index) {
- return index === 0 || index === xData.length - 1
- },
- },
- }, {
- position: 'bottom',
- boundaryGap: false,
- data: extraDataForMarkLine,
- axisLabel: { show: false },
- axisLine: { show: false },
- axisTick: { show: false },
- splitLine: {
- show: true,
- lineStyle: {
- color: COMMON_COLOR_MAP.splitLineDark,
- },
- interval(_index, value) {
- return !!value
- },
- },
- }],
- yAxis: {
- max: yMax ?? 'dataMax',
- type: 'value',
- axisLabel: { color: COMMON_COLOR_MAP.label, hideOverlap: true },
- splitLine: {
- lineStyle: {
- color: COMMON_COLOR_MAP.splitLineLight,
- },
- },
- },
- series: [
- {
- type: 'line',
- showSymbol: true,
- // symbol: 'circle',
- // triggerLineEvent: true,
- symbolSize: 4,
- lineStyle: {
- color: COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType].lineColor,
- width: 2,
- },
- itemStyle: {
- color: COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType].lineColor,
- },
- areaStyle: {
- color: {
- type: 'linear',
- x: 0,
- y: 0,
- x2: 0,
- y2: 1,
- colorStops: [{
- offset: 0,
- color: COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType].bgColor[0],
- }, {
- offset: 1,
- color: COLOR_TYPE_MAP[CHART_TYPE_CONFIG[chartType].colorType].bgColor[1],
- }],
- global: false,
- },
- },
- tooltip: {
- padding: [8, 12, 8, 12],
- formatter(params) {
- return `${params.name}
- ${valueFormatter((params.data as any)[yField])}
- ${!CHART_TYPE_CONFIG[chartType].showTokens
- ? ''
- : `
- (
- ~$${get(params.data, 'total_price', 0)}
- )
- `}
- `
- },
- },
- },
- ],
- }
- const sumData = isAvg ? (sum(yData) / yData.length) : sum(yData)
+ const summaryValue = getSummaryValue({
+ chartType,
+ statistics,
+ yField,
+ isAvg,
+ unit,
+ })
+ const tokenSummary = getTokenSummary(statistics)
return (
@@ -264,7 +102,7 @@ const Chart: React.FC = ({
= ({
(
~
- {sum(statistics.map(item => Number.parseFloat(String(get(item, 'total_price', '0'))))).toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 4 })}
+ {tokenSummary}
)
)}
- textStyle={{ main: `text-3xl! font-normal! ${sumData === 0 ? 'text-text-quaternary!' : ''}` }}
+ textStyle={{ main: `text-3xl! font-normal! ${summaryValue === '0' || summaryValue === '0 ms' ? 'text-text-quaternary!' : ''}` }}
/>
@@ -290,223 +128,192 @@ const Chart: React.FC = ({
)
}
-const getDefaultChartData = ({ start, end, key = 'count' }: { start: string, end: string, key?: string }) => {
- const diffDays = dayjs(end).diff(dayjs(start), 'day')
- return Array.from({ length: diffDays || 1 }, () => ({ date: '', [key]: 0 })).map((item, index) => {
- item.date = dayjs(start).add(index, 'day').format(commonDateFormat)
- return item
- })
+type ChartResponse = {
+ data: ChartRow[]
}
-export const MessagesChart: FC = ({ id, period }) => {
- const { t } = useTranslation()
- const { data: response, isLoading } = useAppDailyMessages(id, period.query)
- if (isLoading || !response)
- return
- const noDataFlag = !response.data || response.data.length === 0
- return (
-
- )
+type UseChartData = (id: string, query?: PeriodParams['query']) => {
+ data?: ChartResponse
+ isLoading: boolean
}
-export const ConversationsChart: FC = ({ id, period }) => {
- const { t } = useTranslation()
- const { data: response, isLoading } = useAppDailyConversations(id, period.query)
- if (isLoading || !response)
- return
- const noDataFlag = !response.data || response.data.length === 0
- return (
-
- )
+type BizChartConfig = {
+ chartType: keyof typeof CHART_TYPE_CONFIG
+ titleKey: string
+ explanationKey: string
+ useChartData: UseChartData
+ valueKey?: string
+ emptyValueKey?: string
+ yMaxWhenEmpty: number
+ isAvg?: boolean
+ unitKey?: string
+ className?: string
}
-export const EndUsersChart: FC = ({ id, period }) => {
- const { t } = useTranslation()
+const createBizChartComponent = ({
+ chartType,
+ titleKey,
+ explanationKey,
+ useChartData,
+ valueKey,
+ emptyValueKey,
+ yMaxWhenEmpty,
+ isAvg,
+ unitKey,
+ className,
+}: BizChartConfig): FC => {
+ const BizChart: FC = ({ id, period }) => {
+ const { t } = useTranslation()
+ const { data: response, isLoading } = useChartData(id, period.query)
- const { data: response, isLoading } = useAppDailyEndUsers(id, period.query)
- if (isLoading || !response)
- return
- const noDataFlag = !response.data || response.data.length === 0
- return (
-
- )
+ if (isLoading || !response)
+ return
+
+ const noDataFlag = !response.data || response.data.length === 0
+ const fallbackKey = emptyValueKey ?? valueKey
+ const fallbackData = {
+ data: getDefaultChartData({
+ ...(period.query ?? defaultPeriod),
+ ...(fallbackKey ? { key: fallbackKey } : {}),
+ }),
+ }
+
+ return (
+
+ )
+ }
+
+ return BizChart
}
-export const AvgSessionInteractions: FC = ({ id, period }) => {
- const { t } = useTranslation()
- const { data: response, isLoading } = useAppAverageSessionInteractions(id, period.query)
- if (isLoading || !response)
- return
- const noDataFlag = !response.data || response.data.length === 0
- return (
-
- )
-}
+export const MessagesChart = createBizChartComponent({
+ chartType: 'messages',
+ titleKey: 'analysis.totalMessages.title',
+ explanationKey: 'analysis.totalMessages.explanation',
+ useChartData: useAppDailyMessages,
+ yMaxWhenEmpty: 500,
+})
-export const AvgResponseTime: FC = ({ id, period }) => {
- const { t } = useTranslation()
- const { data: response, isLoading } = useAppAverageResponseTime(id, period.query)
- if (isLoading || !response)
- return
- const noDataFlag = !response.data || response.data.length === 0
- return (
-
- )
-}
+export const ConversationsChart = createBizChartComponent({
+ chartType: 'conversations',
+ titleKey: 'analysis.totalConversations.title',
+ explanationKey: 'analysis.totalConversations.explanation',
+ useChartData: useAppDailyConversations,
+ yMaxWhenEmpty: 500,
+})
-export const TokenPerSecond: FC = ({ id, period }) => {
- const { t } = useTranslation()
- const { data: response, isLoading } = useAppTokensPerSecond(id, period.query)
- if (isLoading || !response)
- return
- const noDataFlag = !response.data || response.data.length === 0
- return (
-
- )
-}
+export const EndUsersChart = createBizChartComponent({
+ chartType: 'endUsers',
+ titleKey: 'analysis.activeUsers.title',
+ explanationKey: 'analysis.activeUsers.explanation',
+ useChartData: useAppDailyEndUsers,
+ yMaxWhenEmpty: 500,
+})
-export const UserSatisfactionRate: FC = ({ id, period }) => {
- const { t } = useTranslation()
- const { data: response, isLoading } = useAppSatisfactionRate(id, period.query)
- if (isLoading || !response)
- return
- const noDataFlag = !response.data || response.data.length === 0
- return (
-
- )
-}
+export const AvgSessionInteractions = createBizChartComponent({
+ chartType: 'conversations',
+ titleKey: 'analysis.avgSessionInteractions.title',
+ explanationKey: 'analysis.avgSessionInteractions.explanation',
+ useChartData: useAppAverageSessionInteractions,
+ valueKey: 'interactions',
+ emptyValueKey: 'interactions',
+ yMaxWhenEmpty: 500,
+ isAvg: true,
+})
-export const CostChart: FC = ({ id, period }) => {
- const { t } = useTranslation()
+export const AvgResponseTime = createBizChartComponent({
+ chartType: 'conversations',
+ titleKey: 'analysis.avgResponseTime.title',
+ explanationKey: 'analysis.avgResponseTime.explanation',
+ useChartData: useAppAverageResponseTime,
+ valueKey: 'latency',
+ emptyValueKey: 'latency',
+ yMaxWhenEmpty: 500,
+ isAvg: true,
+ unitKey: 'analysis.ms',
+})
- const { data: response, isLoading } = useAppTokenCosts(id, period.query)
- if (isLoading || !response)
- return
- const noDataFlag = !response.data || response.data.length === 0
- return (
-
- )
-}
+export const TokenPerSecond = createBizChartComponent({
+ chartType: 'conversations',
+ titleKey: 'analysis.tps.title',
+ explanationKey: 'analysis.tps.explanation',
+ useChartData: useAppTokensPerSecond,
+ valueKey: 'tps',
+ emptyValueKey: 'tps',
+ yMaxWhenEmpty: 100,
+ isAvg: true,
+ unitKey: 'analysis.tokenPS',
+ className: 'min-w-0',
+})
-export const WorkflowMessagesChart: FC = ({ id, period }) => {
- const { t } = useTranslation()
- const { data: response, isLoading } = useWorkflowDailyConversations(id, period.query)
- if (isLoading || !response)
- return
- const noDataFlag = !response.data || response.data.length === 0
- return (
-
- )
-}
+export const UserSatisfactionRate = createBizChartComponent({
+ chartType: 'endUsers',
+ titleKey: 'analysis.userSatisfactionRate.title',
+ explanationKey: 'analysis.userSatisfactionRate.explanation',
+ useChartData: useAppSatisfactionRate,
+ valueKey: 'rate',
+ emptyValueKey: 'rate',
+ yMaxWhenEmpty: 1000,
+ isAvg: true,
+ className: 'h-full',
+})
-export const WorkflowDailyTerminalsChart: FC = ({ id, period }) => {
- const { t } = useTranslation()
+export const CostChart = createBizChartComponent({
+ chartType: 'costs',
+ titleKey: 'analysis.tokenUsage.title',
+ explanationKey: 'analysis.tokenUsage.explanation',
+ useChartData: useAppTokenCosts,
+ yMaxWhenEmpty: 100,
+})
- const { data: response, isLoading } = useWorkflowDailyTerminals(id, period.query)
- if (isLoading || !response)
- return
- const noDataFlag = !response.data || response.data.length === 0
- return (
-
- )
-}
+export const WorkflowMessagesChart = createBizChartComponent({
+ chartType: 'conversations',
+ titleKey: 'analysis.totalMessages.title',
+ explanationKey: 'analysis.totalMessages.explanation',
+ useChartData: useWorkflowDailyConversations,
+ valueKey: 'runs',
+ emptyValueKey: 'runs',
+ yMaxWhenEmpty: 500,
+})
-export const WorkflowCostChart: FC = ({ id, period }) => {
- const { t } = useTranslation()
+export const WorkflowDailyTerminalsChart = createBizChartComponent({
+ chartType: 'endUsers',
+ titleKey: 'analysis.activeUsers.title',
+ explanationKey: 'analysis.activeUsers.explanation',
+ useChartData: useWorkflowDailyTerminals,
+ yMaxWhenEmpty: 500,
+})
- const { data: response, isLoading } = useWorkflowTokenCosts(id, period.query)
- if (isLoading || !response)
- return
- const noDataFlag = !response.data || response.data.length === 0
- return (
-
- )
-}
+export const WorkflowCostChart = createBizChartComponent({
+ chartType: 'workflowCosts',
+ titleKey: 'analysis.tokenUsage.title',
+ explanationKey: 'analysis.tokenUsage.explanation',
+ useChartData: useWorkflowTokenCosts,
+ yMaxWhenEmpty: 100,
+})
-export const AvgUserInteractions: FC = ({ id, period }) => {
- const { t } = useTranslation()
- const { data: response, isLoading } = useWorkflowAverageInteractions(id, period.query)
- if (isLoading || !response)
- return
- const noDataFlag = !response.data || response.data.length === 0
- return (
-
- )
-}
+export const AvgUserInteractions = createBizChartComponent({
+ chartType: 'conversations',
+ titleKey: 'analysis.avgUserInteractions.title',
+ explanationKey: 'analysis.avgUserInteractions.explanation',
+ useChartData: useWorkflowAverageInteractions,
+ valueKey: 'interactions',
+ emptyValueKey: 'interactions',
+ yMaxWhenEmpty: 500,
+ isAvg: true,
+})
export default Chart
diff --git a/web/app/components/app/overview/customize/index.spec.tsx b/web/app/components/app/overview/customize/__tests__/index.spec.tsx
similarity index 99%
rename from web/app/components/app/overview/customize/index.spec.tsx
rename to web/app/components/app/overview/customize/__tests__/index.spec.tsx
index fab78347d0..0e065fcdfe 100644
--- a/web/app/components/app/overview/customize/index.spec.tsx
+++ b/web/app/components/app/overview/customize/__tests__/index.spec.tsx
@@ -1,6 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
-import CustomizeModal from './index'
+import CustomizeModal from '../index'
// Mock useDocLink from context
const mockDocLink = vi.fn((path?: string) => `https://docs.dify.ai/en-US${path || ''}`)
diff --git a/web/app/components/app/overview/embedded/index.spec.tsx b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx
similarity index 97%
rename from web/app/components/app/overview/embedded/index.spec.tsx
rename to web/app/components/app/overview/embedded/__tests__/index.spec.tsx
index 9dca304bf4..9b8a2ad8c4 100644
--- a/web/app/components/app/overview/embedded/index.spec.tsx
+++ b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx
@@ -5,9 +5,9 @@ import * as React from 'react'
import { act } from 'react'
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
-import Embedded from './index'
+import Embedded from '../index'
-vi.mock('./style.module.css', () => ({
+vi.mock('../style.module.css', () => ({
default: {
option: 'option',
active: 'active',
diff --git a/web/app/components/app/overview/settings/index.spec.tsx b/web/app/components/app/overview/settings/__tests__/index.spec.tsx
similarity index 79%
rename from web/app/components/app/overview/settings/index.spec.tsx
rename to web/app/components/app/overview/settings/__tests__/index.spec.tsx
index d6f9612f75..7a723ce09d 100644
--- a/web/app/components/app/overview/settings/index.spec.tsx
+++ b/web/app/components/app/overview/settings/__tests__/index.spec.tsx
@@ -7,7 +7,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import { baseProviderContextValue } from '@/context/provider-context'
import { AppModeEnum } from '@/types/app'
-import SettingsModal from './index'
+import SettingsModal from '../index'
vi.mock('react-i18next', async () => {
const actual = await vi.importActual('react-i18next')
@@ -110,11 +110,11 @@ const mockAppInfo = {
enable_sso: false,
} as unknown as AppDetailResponse & Partial
-const renderSettingsModal = () => render(
+const renderSettingsModal = (appInfo = mockAppInfo) => render(
,
@@ -273,4 +273,79 @@ describe('SettingsModal', () => {
setTimeoutSpy.mockRestore()
clearTimeoutSpy.mockRestore()
})
+
+ it('should open the pricing modal from the copyright upgrade badge for sandbox plans', async () => {
+ renderSettingsModal()
+
+ fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry'))
+ fireEvent.click(await screen.findByText('billing.upgradeBtn.encourageShort'))
+
+ expect(mockSetShowPricingModal).toHaveBeenCalled()
+ expect(mockSetShowAccountSettingModal).not.toHaveBeenCalled()
+ })
+
+ it('should hide the upgrade badge for non-sandbox plans', async () => {
+ mockUseProviderContext.mockReturnValue({
+ ...baseProviderContextValue,
+ enableBilling: true,
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.professional,
+ },
+ webappCopyrightEnabled: true,
+ })
+
+ renderSettingsModal()
+
+ fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry'))
+ await waitFor(() => {
+ expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should preserve image icons and apply textarea or switch changes when saving image-based settings', async () => {
+ mockOnSave.mockResolvedValueOnce(undefined)
+ const imageAppInfo = {
+ ...mockAppInfo,
+ site: {
+ ...mockAppInfo.site,
+ icon_type: 'image',
+ icon: 'file-1',
+ icon_background: null,
+ icon_url: 'https://example.com/uploaded.png',
+ },
+ } as typeof mockAppInfo
+
+ renderSettingsModal(imageAppInfo)
+
+ fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry'))
+
+ fireEvent.change(screen.getByDisplayValue('A description'), {
+ target: { value: 'Updated description' },
+ })
+ fireEvent.change(screen.getByPlaceholderText('E.g #A020F0'), {
+ target: { value: '' },
+ })
+
+ const switches = screen.getAllByRole('switch')
+ switches.forEach((toggle) => {
+ fireEvent.click(toggle)
+ })
+
+ fireEvent.click(screen.getByText('common.operation.save'))
+
+ await waitFor(() => {
+ expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
+ description: 'Updated description',
+ chat_color_theme: '',
+ chat_color_theme_inverted: false,
+ copyright: '',
+ icon_type: 'image',
+ icon: 'file-1',
+ icon_background: undefined,
+ show_workflow_steps: false,
+ use_icon_as_answer_icon: false,
+ }))
+ })
+ })
})
diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx
index db880839c9..cd50e7f8ee 100644
--- a/web/app/components/app/overview/settings/index.tsx
+++ b/web/app/components/app/overview/settings/index.tsx
@@ -29,7 +29,7 @@ import Link from '@/next/link'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
-export type ISettingsModalProps = {
+type ISettingsModalProps = {
isChat: boolean
appInfo: AppDetailResponse & Partial
isShow: boolean
diff --git a/web/app/components/app/overview/trigger-card.tsx b/web/app/components/app/overview/trigger-card.tsx
index 11f08cf994..53301a353a 100644
--- a/web/app/components/app/overview/trigger-card.tsx
+++ b/web/app/components/app/overview/trigger-card.tsx
@@ -22,7 +22,7 @@ import {
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { canFindTool } from '@/utils'
-export type ITriggerCardProps = {
+type ITriggerCardProps = {
appInfo: AppDetailResponse & Partial
onToggleResult?: (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => void
}
diff --git a/web/app/components/app/switch-app-modal/index.spec.tsx b/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx
similarity index 77%
rename from web/app/components/app/switch-app-modal/index.spec.tsx
rename to web/app/components/app/switch-app-modal/__tests__/index.spec.tsx
index 147edeb5ed..ccc21686ae 100644
--- a/web/app/components/app/switch-app-modal/index.spec.tsx
+++ b/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx
@@ -6,7 +6,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import { Plan } from '@/app/components/billing/type'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { AppModeEnum } from '@/types/app'
-import SwitchAppModal from './index'
+import SwitchAppModal from '../index'
const mockPush = vi.fn()
const mockReplace = vi.fn()
@@ -15,6 +15,7 @@ vi.mock('@/next/navigation', () => ({
push: mockPush,
replace: mockReplace,
}),
+ useParams: () => ({}),
}))
// Use real store - global zustand mock will auto-reset between tests
@@ -77,6 +78,24 @@ vi.mock('@/app/components/billing/apps-full-in-dialog', () => ({
),
}))
+vi.mock('@/app/components/base/app-icon', () => ({
+ default: ({ onClick }: { onClick: () => void }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/base/app-icon-picker', () => ({
+ default: ({ onSelect, onClose }: {
+ onSelect: (payload: { type: 'image', url: string, fileId: string }) => void
+ onClose: () => void
+ }) => (
+
+
+
+
+ ),
+}))
+
const createMockApp = (overrides: Partial = {}): App => ({
id: 'app-123',
name: 'Demo App',
@@ -156,6 +175,8 @@ const setAppDetailSpy = vi.fn()
describe('SwitchAppModal', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockSwitchApp.mockReset()
+ mockDeleteApp.mockReset()
// Spy on setAppDetail
const originalSetAppDetail = useAppStore.getState().setAppDetail
setAppDetailSpy.mockImplementation((...args: Parameters) => {
@@ -279,6 +300,52 @@ describe('SwitchAppModal', () => {
})
})
+ it('should update the icon through the picker before switching apps', async () => {
+ const user = userEvent.setup()
+ const { appDetail } = renderComponent()
+ mockSwitchApp.mockResolvedValueOnce({ new_app_id: 'new-app-003' })
+
+ await user.click(screen.getByText('open-icon-picker'))
+ expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
+
+ await user.click(screen.getByText('select-app-icon'))
+ await user.click(screen.getByRole('button', { name: 'app.switchStart' }))
+
+ await waitFor(() => {
+ expect(mockSwitchApp).toHaveBeenCalledWith(expect.objectContaining({
+ appID: appDetail.id,
+ icon_type: 'image',
+ icon: 'file-id-1',
+ icon_background: undefined,
+ }))
+ })
+ })
+
+ it('should close the icon picker and reset remove-original confirmation when cancelled', async () => {
+ const user = userEvent.setup()
+ renderComponent()
+
+ await user.click(screen.getByText('open-icon-picker'))
+ expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
+ await user.click(screen.getByText('close-app-icon-picker'))
+ expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
+
+ await user.click(screen.getByText('app.removeOriginal'))
+ expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+
+ expect(screen.queryByRole('button', { name: 'common.operation.confirm' })).not.toBeInTheDocument()
+ })
+
+ it('should toggle remove-original from the checkbox control itself', async () => {
+ const user = userEvent.setup()
+ renderComponent()
+
+ await user.click(screen.getByRole('checkbox'))
+
+ expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeInTheDocument()
+ })
+
it('should delete the original app and use replace when remove original is confirmed', async () => {
const user = userEvent.setup()
// Arrange
diff --git a/web/app/components/app/text-generate/item/__tests__/action-groups.spec.tsx b/web/app/components/app/text-generate/item/__tests__/action-groups.spec.tsx
new file mode 100644
index 0000000000..d2f54bbfd0
--- /dev/null
+++ b/web/app/components/app/text-generate/item/__tests__/action-groups.spec.tsx
@@ -0,0 +1,214 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { AppSourceType } from '@/service/share'
+import GenerationActionGroups from '../action-groups'
+
+const mockCopy = vi.fn()
+const mockSuccess = vi.fn()
+const mockOnFeedback = vi.fn()
+const mockOnMoreLikeThis = vi.fn()
+const mockOnOpenLogModal = vi.fn()
+const mockOnRetry = vi.fn()
+const mockOnSave = vi.fn()
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('copy-to-clipboard', () => ({
+ default: (...args: unknown[]) => mockCopy(...args),
+}))
+
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: {
+ success: (...args: unknown[]) => mockSuccess(...args),
+ },
+}))
+
+describe('GenerationActionGroups', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should copy content and show a success toast', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.copy' }))
+
+ expect(mockCopy).toHaveBeenCalledWith('hello world')
+ expect(mockSuccess).toHaveBeenCalledWith('actionMsg.copySuccessfully')
+ })
+
+ it('should handle more-like-this and feedback actions', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'feature.moreLikeThis.title' }))
+ fireEvent.click(screen.getByRole('button', { name: 'operation.agree' }))
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockOnMoreLikeThis).toHaveBeenCalledTimes(1)
+ expect(mockOnFeedback).toHaveBeenCalledWith({ rating: 'like' })
+ expect(mockOnSave).toHaveBeenCalledWith('msg-1')
+ })
+
+ it('should disable more-like-this when recursion reaches the limit', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'feature.moreLikeThis.title' })).toBeDisabled()
+ })
+
+ it('should stringify non-string content before copying', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.copy' }))
+
+ expect(mockCopy).toHaveBeenCalledWith(JSON.stringify({ result: 'hello world' }))
+ })
+
+ it('should expose retry and disagree actions in the appropriate states', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'generation.batchFailed.retry' }))
+
+ expect(mockOnRetry).toHaveBeenCalledTimes(1)
+ })
+
+ it('should support disagree and cancel feedback actions', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.disagree' }))
+ expect(mockOnFeedback).toHaveBeenCalledWith({ rating: 'dislike' })
+
+ rerender(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.cancelAgree' }))
+ expect(mockOnFeedback).toHaveBeenCalledWith({ rating: null })
+
+ rerender(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.cancelDisagree' }))
+ expect(mockOnFeedback).toHaveBeenCalledWith({ rating: null })
+ })
+})
diff --git a/web/app/components/app/text-generate/item/__tests__/index.spec.tsx b/web/app/components/app/text-generate/item/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..8401585f94
--- /dev/null
+++ b/web/app/components/app/text-generate/item/__tests__/index.spec.tsx
@@ -0,0 +1,322 @@
+/* eslint-disable ts/no-explicit-any */
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { AppSourceType } from '@/service/share'
+import GenerationItem from '../index'
+
+const mockFetchMoreLikeThis = vi.fn()
+const mockFetchTextGenerationMessage = vi.fn()
+const mockUpdateFeedback = vi.fn()
+const mockSetCurrentLogItem = vi.fn()
+const mockSetShowPromptLogModal = vi.fn()
+const mockSubmitHumanInputForm = vi.fn()
+const mockSubmitHumanInputFormWorkflow = vi.fn()
+const mockToastWarning = vi.fn()
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/next/navigation', () => ({
+ useParams: () => ({
+ appId: 'app-1',
+ }),
+}))
+
+vi.mock('@/service/share', async () => {
+ const actual = await vi.importActual('@/service/share')
+ return {
+ ...actual,
+ fetchMoreLikeThis: (...args: unknown[]) => mockFetchMoreLikeThis(...args),
+ submitHumanInputForm: (...args: unknown[]) => mockSubmitHumanInputForm(...args),
+ updateFeedback: (...args: unknown[]) => mockUpdateFeedback(...args),
+ }
+})
+
+vi.mock('@/service/workflow', () => ({
+ submitHumanInputForm: (...args: unknown[]) => mockSubmitHumanInputFormWorkflow(...args),
+}))
+
+vi.mock('@/service/debug', () => ({
+ fetchTextGenerationMessage: (...args: unknown[]) => mockFetchTextGenerationMessage(...args),
+}))
+
+vi.mock('@/app/components/app/store', () => ({
+ useStore: (selector: (state: Record) => unknown) => selector({
+ setCurrentLogItem: mockSetCurrentLogItem,
+ setShowPromptLogModal: mockSetShowPromptLogModal,
+ }),
+}))
+
+vi.mock('@/app/components/base/chat/chat/context', () => ({
+ useChatContext: () => ({
+ config: {
+ text_to_speech: {
+ voice: 'alloy',
+ },
+ },
+ }),
+}))
+
+vi.mock('@/app/components/base/markdown', () => ({
+ Markdown: ({ content }: { content: string }) => {`markdown:${content}`} ,
+}))
+
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: {
+ warning: (...args: unknown[]) => mockToastWarning(...args),
+ success: vi.fn(),
+ },
+}))
+
+vi.mock('../workflow-body', () => ({
+ default: ({
+ currentTab,
+ onSubmitHumanInputForm,
+ onSwitchTab,
+ }: {
+ currentTab: string
+ onSubmitHumanInputForm: (token: string, data: { inputs: Record, action: string }) => Promise
+ onSwitchTab: (tab: string) => Promise
+ }) => (
+
+ {`workflow-body:${currentTab}`}
+
+
+
+ ),
+}))
+
+describe('GenerationItem', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render markdown content and allow more-like-this recursion', async () => {
+ mockFetchMoreLikeThis.mockResolvedValue({
+ answer: 'follow up answer',
+ id: 'msg-2',
+ })
+ mockUpdateFeedback.mockResolvedValue(undefined)
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('markdown:hello world')).toBeInTheDocument()
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'feature.moreLikeThis.title' }))
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('markdown:follow up answer')).toBeInTheDocument()
+ })
+ expect(mockFetchMoreLikeThis).toHaveBeenCalledWith('msg-1', AppSourceType.webApp, undefined)
+
+ await act(async () => {
+ fireEvent.click(screen.getAllByRole('button', { name: 'operation.agree' }).at(-1)!)
+ })
+
+ expect(mockUpdateFeedback).toHaveBeenCalledWith({
+ body: { rating: 'like' },
+ url: '/messages/msg-2/feedbacks',
+ }, AppSourceType.webApp, undefined)
+ })
+
+ it('should open the prompt log modal with normalized log data', async () => {
+ mockFetchTextGenerationMessage.mockResolvedValue({
+ answer: 'assistant answer',
+ message: [{ role: 'user', text: 'hello' }],
+ message_files: [{ belongs_to: 'assistant', id: 'file-1' }],
+ })
+
+ render(
+ ,
+ )
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'operation.log' }))
+ })
+
+ expect(mockFetchTextGenerationMessage).toHaveBeenCalledWith({
+ appId: 'app-1',
+ messageId: 'msg-1',
+ })
+ expect(mockSetCurrentLogItem).toHaveBeenCalledWith(expect.objectContaining({
+ log: [
+ { role: 'user', text: 'hello' },
+ {
+ role: 'assistant',
+ text: 'assistant answer',
+ files: [{ belongs_to: 'assistant', id: 'file-1' }],
+ },
+ ],
+ }))
+ expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true)
+ })
+
+ it('should route human input submissions to the workflow service for installed apps', async () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('workflow-body:RESULT')).toBeInTheDocument()
+
+ await act(async () => {
+ fireEvent.click(screen.getByText('submit-human-input'))
+ })
+
+ expect(mockSubmitHumanInputFormWorkflow).toHaveBeenCalledWith('token-1', {
+ action: 'submit',
+ inputs: { name: 'dify' },
+ })
+ })
+
+ it('should route human input submissions to the share service and allow workflow tab switching', async () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('workflow-body:RESULT')).toBeInTheDocument()
+
+ await act(async () => {
+ fireEvent.click(screen.getByText('submit-human-input'))
+ fireEvent.click(screen.getByText('switch-workflow-tab'))
+ })
+
+ expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('token-1', {
+ action: 'submit',
+ inputs: { name: 'dify' },
+ })
+ expect(screen.getByText('workflow-body:LOG')).toBeInTheDocument()
+ })
+
+ it('should clear recursive results when requested or when the parent reloads', async () => {
+ mockFetchMoreLikeThis.mockResolvedValue({
+ answer: 'follow up answer',
+ id: 'msg-2',
+ })
+
+ const { rerender } = render(
+ ,
+ )
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'feature.moreLikeThis.title' }))
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('markdown:follow up answer')).toBeInTheDocument()
+ })
+
+ rerender(
+ ,
+ )
+
+ expect(screen.queryByText('markdown:follow up answer')).not.toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ expect(screen.queryByText('markdown:follow up answer')).not.toBeInTheDocument()
+ })
+
+ it('should warn instead of requesting more-like-this without a message id', async () => {
+ render(
+ ,
+ )
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'feature.moreLikeThis.title' }))
+ })
+
+ expect(mockToastWarning).toHaveBeenCalledWith('errorMessage.waitForResponse')
+ expect(mockFetchMoreLikeThis).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx b/web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx
new file mode 100644
index 0000000000..26a64bf2e7
--- /dev/null
+++ b/web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx
@@ -0,0 +1,65 @@
+/* eslint-disable ts/no-explicit-any */
+import { render, screen } from '@testing-library/react'
+import ResultTab from '../result-tab'
+
+vi.mock('@/app/components/base/markdown', () => ({
+ Markdown: ({ content }: { content: string }) => (
+
+ markdown:
+ {content}
+
+ ),
+}))
+
+vi.mock('@/app/components/base/file-uploader', () => ({
+ FileList: ({ files }: { files: Array<{ id: string }> }) => (
+
+ files:
+ {files.length}
+
+ ),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
+ default: ({ value }: { value: string }) => (
+
+ code-editor:
+ {value}
+
+ ),
+}))
+
+describe('ResultTab', () => {
+ it('should render workflow result text and files on the result tab', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('markdown:Hello world')).toBeInTheDocument()
+ expect(screen.getByText('attachments')).toBeInTheDocument()
+ expect(screen.getByText('files:1')).toBeInTheDocument()
+ })
+
+ it('should render the raw detail view on the detail tab', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('code-editor:{"answer":"ok"}')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/app/text-generate/item/__tests__/utils.spec.ts b/web/app/components/app/text-generate/item/__tests__/utils.spec.ts
new file mode 100644
index 0000000000..5f5219351e
--- /dev/null
+++ b/web/app/components/app/text-generate/item/__tests__/utils.spec.ts
@@ -0,0 +1,71 @@
+/* eslint-disable ts/no-explicit-any */
+import {
+ buildPromptLogItem,
+ getCopyContent,
+ getDefaultGenerationTab,
+ getGenerationTaskLabel,
+ shouldShowWorkflowResultTabs,
+} from '../utils'
+
+describe('text generation utils', () => {
+ it('should show workflow result tabs when workflow output is present', () => {
+ expect(shouldShowWorkflowResultTabs({
+ resultText: 'done',
+ files: [],
+ } as any)).toBe(true)
+ expect(getDefaultGenerationTab({
+ humanInputFormDataList: [{ formToken: 'token-1' }],
+ } as any)).toBe('RESULT')
+ })
+
+ it('should keep the detail tab when workflow output is empty', () => {
+ expect(shouldShowWorkflowResultTabs({
+ files: [],
+ humanInputFilledFormDataList: [],
+ humanInputFormDataList: [],
+ resultText: '',
+ } as any)).toBe(false)
+ expect(getDefaultGenerationTab(undefined)).toBe('DETAIL')
+ })
+
+ it('should build a prompt log item for array messages without duplicating assistant entries', () => {
+ const logItem = buildPromptLogItem({
+ answer: 'final answer',
+ message: [
+ { role: 'user', text: 'hello' },
+ ],
+ message_files: [
+ { belongs_to: 'assistant', id: 'file-1' },
+ { belongs_to: 'user', id: 'file-2' },
+ ],
+ })
+
+ expect(logItem.log).toEqual([
+ { role: 'user', text: 'hello' },
+ {
+ role: 'assistant',
+ text: 'final answer',
+ files: [{ belongs_to: 'assistant', id: 'file-1' }],
+ },
+ ])
+ })
+
+ it('should normalize prompt log items with scalar messages', () => {
+ const logItem = buildPromptLogItem({
+ answer: 'final answer',
+ message: 'raw log',
+ })
+
+ expect(logItem.log).toEqual([{ text: 'raw log' }])
+ })
+
+ it('should derive task labels and copy content', () => {
+ expect(getGenerationTaskLabel('task-1', 1)).toBe('task-1')
+ expect(getGenerationTaskLabel('task-1', 3)).toBe('task-1-2')
+ expect(getCopyContent({
+ content: 'fallback',
+ isWorkflow: true,
+ workflowProcessData: { resultText: 'workflow-result' } as any,
+ })).toBe('workflow-result')
+ })
+})
diff --git a/web/app/components/app/text-generate/item/__tests__/workflow-body.spec.tsx b/web/app/components/app/text-generate/item/__tests__/workflow-body.spec.tsx
new file mode 100644
index 0000000000..8bdf010c7e
--- /dev/null
+++ b/web/app/components/app/text-generate/item/__tests__/workflow-body.spec.tsx
@@ -0,0 +1,93 @@
+/* eslint-disable ts/no-explicit-any */
+import { fireEvent, render, screen } from '@testing-library/react'
+import WorkflowBody from '../workflow-body'
+
+const mockSubmit = vi.fn()
+const mockSwitchTab = vi.fn()
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/app/components/base/chat/chat/answer/workflow-process', () => ({
+ default: () => workflow-process ,
+}))
+
+vi.mock('@/app/components/base/chat/chat/answer/human-input-form-list', () => ({
+ default: ({ onHumanInputFormSubmit }: { onHumanInputFormSubmit: typeof mockSubmit }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/base/chat/chat/answer/human-input-filled-form-list', () => ({
+ default: () => filled-human-input ,
+}))
+
+vi.mock('../result-tab', () => ({
+ default: ({ currentTab }: { currentTab: string }) => {`result-tab:${currentTab}`} ,
+}))
+
+describe('WorkflowBody', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render workflow information and allow tab switching', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('workflow-process')).toBeInTheDocument()
+ expect(screen.getByText('task-1-1')).toBeInTheDocument()
+ expect(screen.getByText('result-tab:RESULT')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('detail'))
+
+ expect(mockSwitchTab).toHaveBeenCalledWith('DETAIL')
+ })
+
+ it('should forward human input submissions', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('submit-human-input'))
+
+ expect(mockSubmit).toHaveBeenCalledWith('token-1', {
+ action: 'submit',
+ inputs: { name: 'dify' },
+ })
+ })
+})
diff --git a/web/app/components/app/text-generate/item/action-groups.tsx b/web/app/components/app/text-generate/item/action-groups.tsx
new file mode 100644
index 0000000000..783d488a78
--- /dev/null
+++ b/web/app/components/app/text-generate/item/action-groups.tsx
@@ -0,0 +1,187 @@
+'use client'
+import type { FC } from 'react'
+import type { FeedbackType } from '@/app/components/base/chat/chat/type'
+import type { WorkflowProcess } from '@/app/components/base/chat/types'
+import type { AppSourceType } from '@/service/share'
+import {
+ RiBookmark3Line,
+ RiClipboardLine,
+ RiFileList3Line,
+ RiResetLeftLine,
+ RiSparklingLine,
+ RiThumbDownLine,
+ RiThumbUpLine,
+} from '@remixicon/react'
+import copy from 'copy-to-clipboard'
+import { useTranslation } from 'react-i18next'
+import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
+import NewAudioButton from '@/app/components/base/new-audio-button'
+import { toast } from '@/app/components/base/ui/toast'
+import { AppSourceType as AppSourceTypeEnum } from '@/service/share'
+import { getCopyContent, MAX_GENERATION_DEPTH } from './utils'
+
+type GenerationActionGroupsProps = {
+ appSourceType: AppSourceType
+ content: unknown
+ currentTab: string
+ depth: number
+ feedback?: FeedbackType
+ isError: boolean
+ isInWebApp: boolean
+ isResponding?: boolean
+ isShowTextToSpeech?: boolean
+ isWorkflow?: boolean
+ messageId?: string | null
+ moreLikeThis?: boolean
+ onFeedback?: (feedback: FeedbackType) => void
+ onMoreLikeThis: () => void
+ onOpenLogModal: () => void
+ onRetry: () => void
+ onSave?: (messageId: string) => void
+ supportFeedback?: boolean
+ voice?: string
+ workflowProcessData?: WorkflowProcess
+}
+
+const GenerationActionGroups: FC = ({
+ appSourceType,
+ content,
+ currentTab,
+ depth,
+ feedback,
+ isError,
+ isInWebApp,
+ isResponding,
+ isShowTextToSpeech,
+ isWorkflow,
+ messageId,
+ moreLikeThis,
+ onFeedback,
+ onMoreLikeThis,
+ onOpenLogModal,
+ onRetry,
+ onSave,
+ supportFeedback,
+ voice,
+ workflowProcessData,
+}) => {
+ const { t } = useTranslation()
+ const isTryApp = appSourceType === AppSourceTypeEnum.tryApp
+ const showCopyAction = (currentTab === 'RESULT' && workflowProcessData?.resultText) || !isWorkflow
+
+ return (
+ <>
+ {!isInWebApp && (appSourceType !== AppSourceTypeEnum.installedApp) && !isResponding && (
+
+ )}
+
+ {moreLikeThis && !isTryApp && (
+
+
+
+ )}
+ {isShowTextToSpeech && !isTryApp && (
+
+ )}
+ {showCopyAction && (
+ {
+ const copyContent = getCopyContent({ content, isWorkflow, workflowProcessData })
+ if (typeof copyContent === 'string')
+ copy(copyContent)
+ else
+ copy(JSON.stringify(copyContent))
+ toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
+ }}
+ >
+
+
+ )}
+ {isInWebApp && isError && (
+
+
+
+ )}
+ {isInWebApp && !isWorkflow && !isTryApp && (
+ { onSave?.(messageId as string) }}
+ >
+
+
+ )}
+
+ {(supportFeedback || isInWebApp) && !isWorkflow && !isTryApp && !isError && messageId && (
+
+ {!feedback?.rating && (
+ <>
+ onFeedback?.({ rating: 'like' })}
+ >
+
+
+ onFeedback?.({ rating: 'dislike' })}
+ >
+
+
+ >
+ )}
+ {feedback?.rating === 'like' && (
+ onFeedback?.({ rating: null })}
+ >
+
+
+ )}
+ {feedback?.rating === 'dislike' && (
+ onFeedback?.({ rating: null })}
+ >
+
+
+ )}
+
+ )}
+ >
+ )
+}
+
+export default GenerationActionGroups
diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx
index 62f1e5752e..7eb7c9ad2b 100644
--- a/web/app/components/app/text-generate/item/index.tsx
+++ b/web/app/components/app/text-generate/item/index.tsx
@@ -4,41 +4,34 @@ import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { SiteInfo } from '@/models/share'
import {
- RiBookmark3Line,
- RiClipboardLine,
- RiFileList3Line,
RiPlayList2Line,
- RiResetLeftLine,
RiSparklingFill,
- RiSparklingLine,
- RiThumbDownLine,
- RiThumbUpLine,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
-import copy from 'copy-to-clipboard'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
-import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
-import HumanInputFilledFormList from '@/app/components/base/chat/chat/answer/human-input-filled-form-list'
-import HumanInputFormList from '@/app/components/base/chat/chat/answer/human-input-form-list'
-import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process'
import { useChatContext } from '@/app/components/base/chat/chat/context'
import Loading from '@/app/components/base/loading'
import { Markdown } from '@/app/components/base/markdown'
-import NewAudioButton from '@/app/components/base/new-audio-button'
import { toast } from '@/app/components/base/ui/toast'
import { useParams } from '@/next/navigation'
import { fetchTextGenerationMessage } from '@/service/debug'
import { AppSourceType, fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share'
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { cn } from '@/utils/classnames'
-import ResultTab from './result-tab'
+import GenerationActionGroups from './action-groups'
+import {
+ buildPromptLogItem,
+ getDefaultGenerationTab,
+ getGenerationTaskLabel,
+ MAX_GENERATION_DEPTH,
+ shouldShowWorkflowResultTabs,
+} from './utils'
+import WorkflowBody from './workflow-body'
-const MAX_DEPTH = 3
-
-export type IGenerationItemProps = {
+type IGenerationItemProps = {
isWorkflow?: boolean
workflowProcessData?: WorkflowProcess
className?: string
@@ -67,12 +60,6 @@ export type IGenerationItemProps = {
inSidePanel?: boolean
}
-export const copyIcon = (
-
-)
-
const GenerationItem: FC = ({
isWorkflow,
workflowProcessData,
@@ -103,7 +90,6 @@ const GenerationItem: FC = ({
const { t } = useTranslation()
const params = useParams()
const isTop = depth === 1
- const isTryApp = appSourceType === AppSourceType.tryApp
const [completionRes, setCompletionRes] = useState('')
const [childMessageId, setChildMessageId] = useState(null)
const [childFeedback, setChildFeedback] = useState({
@@ -160,7 +146,9 @@ const GenerationItem: FC = ({
useEffect(() => {
if (controlClearMoreLikeThis) {
+ // eslint-disable-next-line react/set-state-in-effect
setChildMessageId(null)
+ // eslint-disable-next-line react/set-state-in-effect
setCompletionRes('')
}
}, [controlClearMoreLikeThis])
@@ -168,6 +156,7 @@ const GenerationItem: FC = ({
// regeneration clear child
useEffect(() => {
if (isLoading)
+ // eslint-disable-next-line react/set-state-in-effect
setChildMessageId(null)
}, [isLoading])
@@ -176,45 +165,19 @@ const GenerationItem: FC = ({
appId: params.appId as string,
messageId: messageId!,
})
- const logItem = Array.isArray(data.message)
- ? {
- ...data,
- log: [
- ...data.message,
- ...(data.message[data.message.length - 1].role !== 'assistant'
- ? [
- {
- role: 'assistant',
- text: data.answer,
- files: data.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
- },
- ]
- : []),
- ],
- }
- : {
- ...data,
- log: [typeof data.message === 'string'
- ? {
- text: data.message,
- }
- : data.message],
- }
- setCurrentLogItem(logItem)
+ setCurrentLogItem(buildPromptLogItem(data))
setShowPromptLogModal(true)
}
const [currentTab, setCurrentTab] = useState('DETAIL')
- const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0)
+ const showResultTabs = shouldShowWorkflowResultTabs(workflowProcessData)
const switchTab = async (tab: string) => {
setCurrentTab(tab)
}
useEffect(() => {
- if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0))
- switchTab('RESULT')
- else
- switchTab('DETAIL')
- }, [workflowProcessData?.files?.length, workflowProcessData?.resultText, workflowProcessData?.humanInputFormDataList, workflowProcessData?.humanInputFilledFormDataList])
+ // eslint-disable-next-line react/set-state-in-effect
+ setCurrentTab(getDefaultGenerationTab(workflowProcessData))
+ }, [workflowProcessData])
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record, action: string }) => {
if (appSourceType === AppSourceType.installedApp)
await submitHumanInputFormService(formToken, formData)
@@ -236,81 +199,25 @@ const GenerationItem: FC = ({
!inSidePanel && 'rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg',
)}
>
- {workflowProcessData && (
- <>
-
- {taskId && (
-
-
- {t('generation.execution', { ns: 'share' })}
- ·
- {taskId}
-
- )}
- {siteInfo && workflowProcessData && (
-
- )}
- {showResultTabs && (
-
- switchTab('RESULT')}
- >
- {t('result', { ns: 'runLog' })}
-
- switchTab('DETAIL')}
- >
- {t('detail', { ns: 'runLog' })}
-
-
- )}
-
- {!isError && (
- <>
- {currentTab === 'RESULT' && workflowProcessData.humanInputFormDataList && workflowProcessData.humanInputFormDataList.length > 0 && (
-
-
-
- )}
- {currentTab === 'RESULT' && workflowProcessData.humanInputFilledFormDataList && workflowProcessData.humanInputFilledFormDataList.length > 0 && (
-
-
-
- )}
-
- >
- )}
- >
- )}
+
{!workflowProcessData && taskId && (
{t('generation.execution', { ns: 'share' })}
·
- {`${taskId}${depth > 1 ? `-${depth - 1}` : ''}`}
+ {getGenerationTaskLabel(taskId, depth)}
)}
{isError && (
@@ -325,7 +232,7 @@ const GenerationItem: FC = ({
{/* meta data */}
{!isWorkflow && (
@@ -337,76 +244,28 @@ const GenerationItem: FC = ({
)}
{/* action buttons */}
- {!isInWebApp && (appSourceType !== AppSourceType.installedApp) && !isResponding && (
-
-
-
- {/* {t('common.operation.log')} */}
-
-
- )}
-
- {moreLikeThis && !isTryApp && (
-
-
-
- )}
- {isShowTextToSpeech && !isTryApp && (
-
- )}
- {((currentTab === 'RESULT' && workflowProcessData?.resultText) || !isWorkflow) && (
- {
- const copyContent = isWorkflow ? workflowProcessData?.resultText : content
- if (typeof copyContent === 'string')
- copy(copyContent)
- else
- copy(JSON.stringify(copyContent))
- toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
- }}
- >
-
-
- )}
- {isInWebApp && isError && (
-
-
-
- )}
- {isInWebApp && !isWorkflow && !isTryApp && (
- { onSave?.(messageId as string) }}>
-
-
- )}
-
- {(supportFeedback || isInWebApp) && !isWorkflow && !isTryApp && !isError && messageId && (
-
- {!feedback?.rating && (
- <>
- onFeedback?.({ rating: 'like' })}>
-
-
- onFeedback?.({ rating: 'dislike' })}>
-
-
- >
- )}
- {feedback?.rating === 'like' && (
- onFeedback?.({ rating: null })}>
-
-
- )}
- {feedback?.rating === 'dislike' && (
- onFeedback?.({ rating: null })}>
-
-
- )}
-
- )}
+
{/* more like this elements */}
@@ -429,7 +288,7 @@ const GenerationItem: FC = ({
>
)}
- {((childMessageId || isQuerying) && depth < 3) && (
+ {((childMessageId || isQuerying) && depth < MAX_GENERATION_DEPTH) && (
)}
>
diff --git a/web/app/components/app/text-generate/item/utils.ts b/web/app/components/app/text-generate/item/utils.ts
new file mode 100644
index 0000000000..09f4a0be5f
--- /dev/null
+++ b/web/app/components/app/text-generate/item/utils.ts
@@ -0,0 +1,86 @@
+import type { WorkflowProcess } from '@/app/components/base/chat/types'
+
+type GenerationTab = 'DETAIL' | 'RESULT'
+
+type PromptLogSource = {
+ answer: string
+ message: unknown
+ message_files?: Array>
+}
+
+type PromptLogAssistantMessage = {
+ role: 'assistant'
+ text: string
+ files: Array>
+}
+
+type PromptLogMessage = {
+ role?: string
+ text?: string
+ files?: Array>
+}
+
+export const MAX_GENERATION_DEPTH = 3
+
+export const shouldShowWorkflowResultTabs = (workflowProcessData?: WorkflowProcess | null) => {
+ if (!workflowProcessData)
+ return false
+
+ return Boolean(
+ workflowProcessData.resultText
+ || workflowProcessData.files?.length
+ || workflowProcessData.humanInputFormDataList?.length
+ || workflowProcessData.humanInputFilledFormDataList?.length,
+ )
+}
+
+export const getDefaultGenerationTab = (workflowProcessData?: WorkflowProcess | null): GenerationTab => {
+ if (shouldShowWorkflowResultTabs(workflowProcessData))
+ return 'RESULT'
+
+ return 'DETAIL'
+}
+
+const getAssistantFiles = (messageFiles?: Array>) =>
+ messageFiles?.filter(file => file.belongs_to === 'assistant') || []
+
+export const buildPromptLogItem = (data: T): T & { log: PromptLogMessage[] } => {
+ if (Array.isArray(data.message)) {
+ const messages = data.message as PromptLogMessage[]
+ const lastMessage = messages[messages.length - 1]
+ const assistantMessage: PromptLogAssistantMessage[] = lastMessage?.role !== 'assistant'
+ ? [{
+ role: 'assistant',
+ text: data.answer,
+ files: getAssistantFiles(data.message_files),
+ }]
+ : []
+
+ return {
+ ...data,
+ log: [...messages, ...assistantMessage],
+ }
+ }
+
+ return {
+ ...data,
+ log: [typeof data.message === 'string'
+ ? {
+ text: data.message,
+ }
+ : data.message as PromptLogMessage],
+ }
+}
+
+export const getGenerationTaskLabel = (taskId: string, depth: number) =>
+ depth > 1 ? `${taskId}-${depth - 1}` : taskId
+
+export const getCopyContent = ({
+ content,
+ isWorkflow,
+ workflowProcessData,
+}: {
+ content: unknown
+ isWorkflow?: boolean
+ workflowProcessData?: WorkflowProcess
+}) => isWorkflow ? workflowProcessData?.resultText : content
diff --git a/web/app/components/app/text-generate/item/workflow-body.tsx b/web/app/components/app/text-generate/item/workflow-body.tsx
new file mode 100644
index 0000000000..96c27dd803
--- /dev/null
+++ b/web/app/components/app/text-generate/item/workflow-body.tsx
@@ -0,0 +1,118 @@
+'use client'
+import type { FC } from 'react'
+import type { WorkflowProcess } from '@/app/components/base/chat/types'
+import type { SiteInfo } from '@/models/share'
+import { RiPlayList2Line } from '@remixicon/react'
+import { useTranslation } from 'react-i18next'
+import HumanInputFilledFormList from '@/app/components/base/chat/chat/answer/human-input-filled-form-list'
+import HumanInputFormList from '@/app/components/base/chat/chat/answer/human-input-form-list'
+import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process'
+import { cn } from '@/utils/classnames'
+import ResultTab from './result-tab'
+import { getGenerationTaskLabel } from './utils'
+
+type WorkflowBodyProps = {
+ content: unknown
+ currentTab: string
+ depth: number
+ hideProcessDetail?: boolean
+ isError: boolean
+ onSubmitHumanInputForm: (formToken: string, formData: { inputs: Record, action: string }) => Promise
+ onSwitchTab: (tab: string) => void
+ showResultTabs: boolean
+ siteInfo: SiteInfo | null
+ taskId?: string
+ workflowProcessData?: WorkflowProcess
+}
+
+const WorkflowBody: FC = ({
+ content,
+ currentTab,
+ depth,
+ hideProcessDetail,
+ isError,
+ onSubmitHumanInputForm,
+ onSwitchTab,
+ showResultTabs,
+ siteInfo,
+ taskId,
+ workflowProcessData,
+}) => {
+ const { t } = useTranslation()
+
+ if (!workflowProcessData)
+ return null
+
+ return (
+ <>
+
+ {taskId && (
+
+
+ {t('generation.execution', { ns: 'share' })}
+ ·
+ {getGenerationTaskLabel(taskId, depth)}
+
+ )}
+ {siteInfo && (
+
+ )}
+ {showResultTabs && (
+
+ onSwitchTab('RESULT')}
+ >
+ {t('result', { ns: 'runLog' })}
+
+ onSwitchTab('DETAIL')}
+ >
+ {t('detail', { ns: 'runLog' })}
+
+
+ )}
+
+ {!isError && (
+ <>
+ {currentTab === 'RESULT' && workflowProcessData.humanInputFormDataList && workflowProcessData.humanInputFormDataList.length > 0 && (
+
+
+
+ )}
+ {currentTab === 'RESULT' && workflowProcessData.humanInputFilledFormDataList && workflowProcessData.humanInputFilledFormDataList.length > 0 && (
+
+
+
+ )}
+
+ >
+ )}
+ >
+ )
+}
+
+export default WorkflowBody
diff --git a/web/app/components/app/text-generate/saved-items/index.spec.tsx b/web/app/components/app/text-generate/saved-items/__tests__/index.spec.tsx
similarity index 96%
rename from web/app/components/app/text-generate/saved-items/index.spec.tsx
rename to web/app/components/app/text-generate/saved-items/__tests__/index.spec.tsx
index dff0950f89..ddfea01951 100644
--- a/web/app/components/app/text-generate/saved-items/index.spec.tsx
+++ b/web/app/components/app/text-generate/saved-items/__tests__/index.spec.tsx
@@ -1,11 +1,11 @@
-import type { ISavedItemsProps } from './index'
+import type { ISavedItemsProps } from '../index'
import { fireEvent, render, screen } from '@testing-library/react'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from '@/app/components/base/ui/toast'
-import SavedItems from './index'
+import SavedItems from '../index'
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
diff --git a/web/app/components/app/text-generate/saved-items/no-data/index.spec.tsx b/web/app/components/app/text-generate/saved-items/no-data/__tests__/index.spec.tsx
similarity index 96%
rename from web/app/components/app/text-generate/saved-items/no-data/index.spec.tsx
rename to web/app/components/app/text-generate/saved-items/no-data/__tests__/index.spec.tsx
index 59b950054c..7f6a38a442 100644
--- a/web/app/components/app/text-generate/saved-items/no-data/index.spec.tsx
+++ b/web/app/components/app/text-generate/saved-items/no-data/__tests__/index.spec.tsx
@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
-import NoData from './index'
+import NoData from '../index'
describe('NoData', () => {
it('renders title/description and calls callback when button clicked', () => {
diff --git a/web/app/components/app/text-generate/saved-items/no-data/index.tsx b/web/app/components/app/text-generate/saved-items/no-data/index.tsx
index e73a1db1df..c6596c8ec3 100644
--- a/web/app/components/app/text-generate/saved-items/no-data/index.tsx
+++ b/web/app/components/app/text-generate/saved-items/no-data/index.tsx
@@ -8,7 +8,7 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
-export type INoDataProps = {
+type INoDataProps = {
onStartCreateContent: () => void
}
diff --git a/web/app/components/app/type-selector/index.spec.tsx b/web/app/components/app/type-selector/__tests__/index.spec.tsx
similarity index 98%
rename from web/app/components/app/type-selector/index.spec.tsx
rename to web/app/components/app/type-selector/__tests__/index.spec.tsx
index 711678f0a8..7f2e0c3850 100644
--- a/web/app/components/app/type-selector/index.spec.tsx
+++ b/web/app/components/app/type-selector/__tests__/index.spec.tsx
@@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
-import AppTypeSelector, { AppTypeIcon, AppTypeLabel } from './index'
+import AppTypeSelector, { AppTypeIcon, AppTypeLabel } from '../index'
describe('AppTypeSelector', () => {
beforeEach(() => {
diff --git a/web/app/components/app/type-selector/index.tsx b/web/app/components/app/type-selector/index.tsx
index 2b0923a6a0..cb4db155d4 100644
--- a/web/app/components/app/type-selector/index.tsx
+++ b/web/app/components/app/type-selector/index.tsx
@@ -11,7 +11,7 @@ import {
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
-export type AppSelectorProps = {
+type AppSelectorProps = {
value: Array
onChange: (value: AppSelectorProps['value']) => void
}
diff --git a/web/app/components/app/workflow-log/detail.spec.tsx b/web/app/components/app/workflow-log/__tests__/detail.spec.tsx
similarity index 99%
rename from web/app/components/app/workflow-log/detail.spec.tsx
rename to web/app/components/app/workflow-log/__tests__/detail.spec.tsx
index 806c6e71b2..a58f55b031 100644
--- a/web/app/components/app/workflow-log/detail.spec.tsx
+++ b/web/app/components/app/workflow-log/__tests__/detail.spec.tsx
@@ -12,7 +12,7 @@ import type { App, AppIconType, AppModeEnum } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
-import DetailPanel from './detail'
+import DetailPanel from '../detail'
// ============================================================================
// Mocks
diff --git a/web/app/components/app/workflow-log/filter.spec.tsx b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx
similarity index 99%
rename from web/app/components/app/workflow-log/filter.spec.tsx
rename to web/app/components/app/workflow-log/__tests__/filter.spec.tsx
index 488b8856b7..d255537354 100644
--- a/web/app/components/app/workflow-log/filter.spec.tsx
+++ b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx
@@ -7,11 +7,11 @@
* - Keyword search
*/
-import type { QueryParam } from './index'
+import type { QueryParam } from '../index'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
-import Filter, { TIME_PERIOD_MAPPING } from './filter'
+import Filter, { TIME_PERIOD_MAPPING } from '../filter'
// ============================================================================
// Mocks
diff --git a/web/app/components/app/workflow-log/index.spec.tsx b/web/app/components/app/workflow-log/__tests__/index.spec.tsx
similarity index 99%
rename from web/app/components/app/workflow-log/index.spec.tsx
rename to web/app/components/app/workflow-log/__tests__/index.spec.tsx
index 92f8eddf83..1fe3db30db 100644
--- a/web/app/components/app/workflow-log/index.spec.tsx
+++ b/web/app/components/app/workflow-log/__tests__/index.spec.tsx
@@ -16,7 +16,7 @@ import type { UseQueryResult } from '@tanstack/react-query'
*/
import type { MockedFunction } from 'vitest'
-import type { ILogsProps } from './index'
+import type { ILogsProps } from '../index'
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
import type { App, AppIconType, AppModeEnum } from '@/types/app'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@@ -25,8 +25,8 @@ import userEvent from '@testing-library/user-event'
import { APP_PAGE_LIMIT } from '@/config'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import * as useLogModule from '@/service/use-log'
-import { TIME_PERIOD_MAPPING } from './filter'
-import Logs from './index'
+import { TIME_PERIOD_MAPPING } from '../filter'
+import Logs from '../index'
// ============================================================================
// Mocks
diff --git a/web/app/components/app/workflow-log/list.spec.tsx b/web/app/components/app/workflow-log/__tests__/list.spec.tsx
similarity index 99%
rename from web/app/components/app/workflow-log/list.spec.tsx
rename to web/app/components/app/workflow-log/__tests__/list.spec.tsx
index 36cc911248..356aaa5a48 100644
--- a/web/app/components/app/workflow-log/list.spec.tsx
+++ b/web/app/components/app/workflow-log/__tests__/list.spec.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable ts/no-explicit-any */
/**
* WorkflowAppLogList Component Tests
*
@@ -16,7 +17,7 @@ import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { APP_PAGE_LIMIT } from '@/config'
import { WorkflowRunTriggeredFrom } from '@/models/log'
-import WorkflowAppLogList from './list'
+import WorkflowAppLogList from '../list'
// ============================================================================
// Mocks
diff --git a/web/app/components/app/workflow-log/trigger-by-display.spec.tsx b/web/app/components/app/workflow-log/__tests__/trigger-by-display.spec.tsx
similarity index 99%
rename from web/app/components/app/workflow-log/trigger-by-display.spec.tsx
rename to web/app/components/app/workflow-log/__tests__/trigger-by-display.spec.tsx
index 69665064f5..a10c4aa5c5 100644
--- a/web/app/components/app/workflow-log/trigger-by-display.spec.tsx
+++ b/web/app/components/app/workflow-log/__tests__/trigger-by-display.spec.tsx
@@ -9,7 +9,7 @@ import type { TriggerMetadata } from '@/models/log'
import { render, screen } from '@testing-library/react'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import { Theme } from '@/types/app'
-import TriggerByDisplay from './trigger-by-display'
+import TriggerByDisplay from '../trigger-by-display'
// ============================================================================
// Mocks
diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx
index 079e8fa8bc..7227e412e7 100644
--- a/web/app/components/apps/app-card.tsx
+++ b/web/app/components/apps/app-card.tsx
@@ -62,7 +62,7 @@ const AccessControl = dynamic(() => import('@/app/components/app/app-access-cont
ssr: false,
})
-export type AppCardProps = {
+type AppCardProps = {
app: App
onRefresh?: () => void
}
diff --git a/web/app/components/apps/new-app-card.tsx b/web/app/components/apps/new-app-card.tsx
index 7741190b8c..31a374f6f7 100644
--- a/web/app/components/apps/new-app-card.tsx
+++ b/web/app/components/apps/new-app-card.tsx
@@ -25,7 +25,7 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false,
})
-export type CreateAppCardProps = {
+type CreateAppCardProps = {
className?: string
isLoading?: boolean
onSuccess?: () => void
diff --git a/web/app/components/base/agent-log-modal/detail.tsx b/web/app/components/base/agent-log-modal/detail.tsx
index 6550b305f8..45f34c9711 100644
--- a/web/app/components/base/agent-log-modal/detail.tsx
+++ b/web/app/components/base/agent-log-modal/detail.tsx
@@ -15,7 +15,7 @@ import { cn } from '@/utils/classnames'
import ResultPanel from './result'
import TracingPanel from './tracing'
-export type AgentLogDetailProps = {
+type AgentLogDetailProps = {
activeTab?: 'DETAIL' | 'TRACING'
conversationID: string
log: IChatItem
diff --git a/web/app/components/base/amplitude/index.ts b/web/app/components/base/amplitude/index.ts
index 44cbf728e2..21152d1220 100644
--- a/web/app/components/base/amplitude/index.ts
+++ b/web/app/components/base/amplitude/index.ts
@@ -1,2 +1,2 @@
export { default } from './lazy-amplitude-provider'
-export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'
+export { setUserId, setUserProperties, trackEvent } from './utils'
diff --git a/web/app/components/base/answer-icon/index.tsx b/web/app/components/base/answer-icon/index.tsx
index 56e932ad71..1ae1b4f076 100644
--- a/web/app/components/base/answer-icon/index.tsx
+++ b/web/app/components/base/answer-icon/index.tsx
@@ -8,7 +8,7 @@ import { cn } from '@/utils/classnames'
init({ data })
-export type AnswerIconProps = {
+type AnswerIconProps = {
iconType?: AppIconType | null
icon?: string | null
background?: string | null
diff --git a/web/app/components/base/app-icon-picker/style.module.css b/web/app/components/base/app-icon-picker/style.module.css
index c22ca04a66..5ec199a232 100644
--- a/web/app/components/base/app-icon-picker/style.module.css
+++ b/web/app/components/base/app-icon-picker/style.module.css
@@ -1,5 +1,3 @@
-@reference "../../../styles/globals.css";
-
.container {
display: flex;
flex-direction: column;
diff --git a/web/app/components/base/app-icon/index.tsx b/web/app/components/base/app-icon/index.tsx
index b3fe5f3c4f..d4eaeb69d9 100644
--- a/web/app/components/base/app-icon/index.tsx
+++ b/web/app/components/base/app-icon/index.tsx
@@ -12,7 +12,7 @@ import { cn } from '@/utils/classnames'
init({ data })
-export type AppIconProps = {
+type AppIconProps = {
size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large' | 'xl' | 'xxl'
rounded?: boolean
iconType?: AppIconType | null
diff --git a/web/app/components/base/audio-btn/style.module.css b/web/app/components/base/audio-btn/style.module.css
index 2a07ad0697..7e3175aa13 100644
--- a/web/app/components/base/audio-btn/style.module.css
+++ b/web/app/components/base/audio-btn/style.module.css
@@ -1,5 +1,3 @@
-@reference "../../../styles/globals.css";
-
.playIcon {
background-image: url(~@/app/components/develop/secret-key/assets/play.svg);
background-position: center;
diff --git a/web/app/components/base/avatar/index.tsx b/web/app/components/base/avatar/index.tsx
index f53e1f8985..885022dded 100644
--- a/web/app/components/base/avatar/index.tsx
+++ b/web/app/components/base/avatar/index.tsx
@@ -24,11 +24,11 @@ export type AvatarProps = {
onLoadingStatusChange?: (status: ImageLoadingStatus) => void
}
-export type AvatarRootProps = React.ComponentPropsWithRef & {
+type AvatarRootProps = React.ComponentPropsWithRef & {
size?: AvatarSize
}
-export function AvatarRoot({
+function AvatarRoot({
size = 'md',
className,
...props
@@ -45,9 +45,9 @@ export function AvatarRoot({
)
}
-export type AvatarImageProps = React.ComponentPropsWithRef
+type AvatarImageProps = React.ComponentPropsWithRef
-export function AvatarImage({
+function AvatarImage({
className,
...props
}: AvatarImageProps) {
@@ -59,11 +59,11 @@ export function AvatarImage({
)
}
-export type AvatarFallbackProps = React.ComponentPropsWithRef & {
+type AvatarFallbackProps = React.ComponentPropsWithRef & {
size?: AvatarSize
}
-export function AvatarFallback({
+function AvatarFallback({
size = 'md',
className,
...props
diff --git a/web/app/components/base/block-input/index.tsx b/web/app/components/base/block-input/index.tsx
index 2a917306cd..cf832d9f94 100644
--- a/web/app/components/base/block-input/index.tsx
+++ b/web/app/components/base/block-input/index.tsx
@@ -29,7 +29,7 @@ export const getInputKeys = (value: string) => {
return res
}
-export type IBlockInputProps = {
+type IBlockInputProps = {
value: string
className?: string // wrapper class
highLightClassName?: string // class for the highlighted text default is text-blue-500
diff --git a/web/app/components/base/chat/chat-with-history/index.tsx b/web/app/components/base/chat/chat-with-history/index.tsx
index a645775965..562502a573 100644
--- a/web/app/components/base/chat/chat-with-history/index.tsx
+++ b/web/app/components/base/chat/chat-with-history/index.tsx
@@ -97,7 +97,7 @@ const ChatWithHistory: FC = ({
)
}
-export type ChatWithHistoryWrapProps = {
+type ChatWithHistoryWrapProps = {
installedAppInfo?: InstalledApp
className?: string
}
diff --git a/web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx b/web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx
index 66a5ad6a36..e66ca351f2 100644
--- a/web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx
+++ b/web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx
@@ -7,7 +7,7 @@ import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
-export type IRenameModalProps = {
+type IRenameModalProps = {
isShow: boolean
saveLoading: boolean
name: string
diff --git a/web/app/components/base/chat/chat/context.ts b/web/app/components/base/chat/chat/context.ts
index ff0bd26336..c4fbf9dd3e 100644
--- a/web/app/components/base/chat/chat/context.ts
+++ b/web/app/components/base/chat/chat/context.ts
@@ -26,5 +26,3 @@ export const ChatContext = createContext({
})
export const useChatContext = () => useContext(ChatContext)
-
-export default ChatContext
diff --git a/web/app/components/base/chat/chat/loading-anim/index.tsx b/web/app/components/base/chat/chat/loading-anim/index.tsx
index 74cc3444de..6ba37288e7 100644
--- a/web/app/components/base/chat/chat/loading-anim/index.tsx
+++ b/web/app/components/base/chat/chat/loading-anim/index.tsx
@@ -4,7 +4,7 @@ import * as React from 'react'
import { cn } from '@/utils/classnames'
import s from './style.module.css'
-export type ILoadingAnimProps = {
+type ILoadingAnimProps = {
type: 'text' | 'avatar'
}
diff --git a/web/app/components/base/chat/chat/loading-anim/style.module.css b/web/app/components/base/chat/chat/loading-anim/style.module.css
index 1e1a87a312..d5a373df6f 100644
--- a/web/app/components/base/chat/chat/loading-anim/style.module.css
+++ b/web/app/components/base/chat/chat/loading-anim/style.module.css
@@ -1,5 +1,3 @@
-@reference "../../../../../styles/globals.css";
-
.dot-flashing {
position: relative;
animation: dot-flashing 1s infinite linear alternate;
diff --git a/web/app/components/base/chat/chat/type.ts b/web/app/components/base/chat/chat/type.ts
index 7bd4de5b05..6ddb4f958e 100644
--- a/web/app/components/base/chat/chat/type.ts
+++ b/web/app/components/base/chat/chat/type.ts
@@ -29,8 +29,6 @@ export type SubmitAnnotationFunc = (
content: string,
) => Promise
-export type DisplayScene = 'web' | 'console'
-
export type ToolInfoInThought = {
name: string
label: string
@@ -151,15 +149,6 @@ export type MessageReplace = {
conversation_id: string
}
-export type AnnotationReply = {
- id: string
- task_id: string
- answer: string
- conversation_id: string
- annotation_id: string
- annotation_author_name: string
-}
-
export type InputForm = {
type: InputVarType
label: string
diff --git a/web/app/components/base/chat/embedded-chatbot/header/index.tsx b/web/app/components/base/chat/embedded-chatbot/header/index.tsx
index 9cca48b42a..7b1fb46fa0 100644
--- a/web/app/components/base/chat/embedded-chatbot/header/index.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/header/index.tsx
@@ -16,7 +16,7 @@ import {
} from '../context'
import { CssTransform } from '../theme/utils'
-export type IHeaderProps = {
+type IHeaderProps = {
isMobile?: boolean
allowResetChat?: boolean
customerIcon?: React.ReactNode
diff --git a/web/app/components/base/chat/types.ts b/web/app/components/base/chat/types.ts
index 1502a32e92..341dd3c689 100644
--- a/web/app/components/base/chat/types.ts
+++ b/web/app/components/base/chat/types.ts
@@ -3,42 +3,16 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { WorkflowRunningStatus } from '@/app/components/workflow/types'
import type {
ModelConfig,
- VisionSettings,
} from '@/types/app'
import type { HumanInputFilledFormData, HumanInputFormData, NodeTracing } from '@/types/workflow'
export type {
Inputs,
- PromptVariable,
+
} from '@/models/debug'
-export type { VisionFile } from '@/types/app'
+
export { TransferMethod } from '@/types/app'
-export type UserInputForm = {
- default: string
- label: string
- required: boolean
- variable: string
-}
-
-export type UserInputFormTextInput = {
- 'text-input': UserInputForm & {
- max_length: number
- }
-}
-
-export type UserInputFormSelect = {
- select: UserInputForm & {
- options: string[]
- }
-}
-
-export type UserInputFormParagraph = {
- paragraph: UserInputForm
-}
-
-export type VisionConfig = VisionSettings
-
export type EnableType = {
enabled: boolean
}
diff --git a/web/app/components/base/checkbox-list/index.tsx b/web/app/components/base/checkbox-list/index.tsx
index 6eda2aebd0..5fb5ca6028 100644
--- a/web/app/components/base/checkbox-list/index.tsx
+++ b/web/app/components/base/checkbox-list/index.tsx
@@ -9,13 +9,13 @@ import SearchMenu from '@/assets/search-menu.svg'
import { cn } from '@/utils/classnames'
import Button from '../button'
-export type CheckboxListOption = {
+type CheckboxListOption = {
label: string
value: string
disabled?: boolean
}
-export type CheckboxListProps = {
+type CheckboxListProps = {
title?: string
label?: string
description?: string
diff --git a/web/app/components/base/copy-feedback/style.module.css b/web/app/components/base/copy-feedback/style.module.css
index 1335976835..83625d6189 100644
--- a/web/app/components/base/copy-feedback/style.module.css
+++ b/web/app/components/base/copy-feedback/style.module.css
@@ -1,5 +1,3 @@
-@reference "../../../styles/globals.css";
-
.copyIcon {
background-image: url(~@/app/components/develop/secret-key/assets/copy.svg);
background-position: center;
diff --git a/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx b/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx
index ac14d49ead..46670b27b3 100644
--- a/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx
+++ b/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx
@@ -17,5 +17,3 @@ export const DaysOfWeek = () => {
)
}
-
-export default React.memo(DaysOfWeek)
diff --git a/web/app/components/base/date-and-time-picker/utils/dayjs.ts b/web/app/components/base/date-and-time-picker/utils/dayjs.ts
index f1c77ecc57..3e20e51cf3 100644
--- a/web/app/components/base/date-and-time-picker/utils/dayjs.ts
+++ b/web/app/components/base/date-and-time-picker/utils/dayjs.ts
@@ -126,7 +126,7 @@ export const convertTimezoneToOffsetStr = (timezone?: string) => {
export const isDayjsObject = (value: unknown): value is Dayjs => dayjs.isDayjs(value)
-export type ToDayjsOptions = {
+type ToDayjsOptions = {
timezone?: string
format?: string
formats?: string[]
diff --git a/web/app/components/base/divider/index.tsx b/web/app/components/base/divider/index.tsx
index d3693e9ffd..b096844079 100644
--- a/web/app/components/base/divider/index.tsx
+++ b/web/app/components/base/divider/index.tsx
@@ -21,7 +21,7 @@ const dividerVariants = cva('', {
},
})
-export type DividerProps = {
+type DividerProps = {
className?: string
style?: CSSProperties
} & VariantProps
diff --git a/web/app/components/base/features/__tests__/context.spec.tsx b/web/app/components/base/features/__tests__/context.spec.tsx
index 64bfb256f2..4be4e00d26 100644
--- a/web/app/components/base/features/__tests__/context.spec.tsx
+++ b/web/app/components/base/features/__tests__/context.spec.tsx
@@ -1,10 +1,10 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
-import { useContext } from 'react'
+import { use } from 'react'
import { FeaturesContext, FeaturesProvider } from '../context'
const TestConsumer = () => {
- const store = useContext(FeaturesContext)
+ const store = use(FeaturesContext)
if (!store)
return no store
@@ -34,10 +34,10 @@ describe('FeaturesProvider', () => {
})
it('should maintain the same store reference across re-renders', () => {
- const storeRefs: Array> = []
+ const storeRefs: Array> = []
const StoreRefCollector = () => {
- const store = useContext(FeaturesContext)
+ const store = use(FeaturesContext)
storeRefs.push(store)
return null
}
diff --git a/web/app/components/base/file-uploader/index.ts b/web/app/components/base/file-uploader/index.ts
index 1ab4fdae3b..5f27f61d90 100644
--- a/web/app/components/base/file-uploader/index.ts
+++ b/web/app/components/base/file-uploader/index.ts
@@ -1,7 +1,7 @@
export { default as FileTypeIcon } from './file-type-icon'
export { default as FileUploaderInAttachmentWrapper } from './file-uploader-in-attachment'
-export { default as FileItemInAttachment } from './file-uploader-in-attachment/file-item'
+
export { default as FileUploaderInChatInput } from './file-uploader-in-chat-input'
-export { default as FileItem } from './file-uploader-in-chat-input/file-item'
+
export { FileListInChatInput } from './file-uploader-in-chat-input/file-list'
export { FileList } from './file-uploader-in-chat-input/file-list'
diff --git a/web/app/components/base/form/form-scenarios/demo/types.ts b/web/app/components/base/form/form-scenarios/demo/types.ts
index 91ab1c7747..a8aa18b27d 100644
--- a/web/app/components/base/form/form-scenarios/demo/types.ts
+++ b/web/app/components/base/form/form-scenarios/demo/types.ts
@@ -30,5 +30,3 @@ export const UserSchema = z.object({
preferredContactMethod: ContactMethod,
}),
})
-
-export type User = z.infer
diff --git a/web/app/components/base/form/index.tsx b/web/app/components/base/form/index.tsx
index 6c60826c32..663b7f1fe8 100644
--- a/web/app/components/base/form/index.tsx
+++ b/web/app/components/base/form/index.tsx
@@ -14,9 +14,11 @@ import UploadMethodField from './components/field/upload-method'
import VariableOrConstantInputField from './components/field/variable-selector'
import Actions from './components/form/actions'
-export const { fieldContext, useFieldContext, formContext, useFormContext }
+const { fieldContext, useFieldContext, formContext, useFormContext }
= createFormHookContexts()
+export { formContext, useFieldContext, useFormContext }
+
export const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
diff --git a/web/app/components/base/form/types.ts b/web/app/components/base/form/types.ts
index a2b434f3cf..4b83b9e4c9 100644
--- a/web/app/components/base/form/types.ts
+++ b/web/app/components/base/form/types.ts
@@ -82,8 +82,6 @@ export type FormSchema = {
}
}
-export type FormValues = Record
-
export type GetValuesOptions = {
needTransformWhenSecretFieldIsPristine?: boolean
needCheckValidatedValues?: boolean
diff --git a/web/app/components/base/ga/index.tsx b/web/app/components/base/ga/index.tsx
index 3e19afd974..79783c75cc 100644
--- a/web/app/components/base/ga/index.tsx
+++ b/web/app/components/base/ga/index.tsx
@@ -9,16 +9,16 @@ export enum GaType {
webapp = 'webapp',
}
-export const GA_MEASUREMENT_ID_ADMIN = 'G-DM9497FN4V'
-export const GA_MEASUREMENT_ID_WEBAPP = 'G-2MFWXK7WYT'
-export const COOKIEYES_SCRIPT_SRC = 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js'
+const GA_MEASUREMENT_ID_ADMIN = 'G-DM9497FN4V'
+const GA_MEASUREMENT_ID_WEBAPP = 'G-2MFWXK7WYT'
+const COOKIEYES_SCRIPT_SRC = 'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js'
const gaIdMaps = {
[GaType.admin]: GA_MEASUREMENT_ID_ADMIN,
[GaType.webapp]: GA_MEASUREMENT_ID_WEBAPP,
}
-export type IGAProps = {
+type IGAProps = {
gaType: GaType
}
diff --git a/web/app/components/base/grid-mask/style.module.css b/web/app/components/base/grid-mask/style.module.css
index 24d73a62af..e051271fab 100644
--- a/web/app/components/base/grid-mask/style.module.css
+++ b/web/app/components/base/grid-mask/style.module.css
@@ -1,5 +1,3 @@
-@reference "../../../styles/globals.css";
-
.gridBg{
background-image: url(./Grid.svg);
background-repeat: repeat;
diff --git a/web/app/components/base/icons/IconBase.tsx b/web/app/components/base/icons/IconBase.tsx
index 13ab7c816b..2de0f3239e 100644
--- a/web/app/components/base/icons/IconBase.tsx
+++ b/web/app/components/base/icons/IconBase.tsx
@@ -6,7 +6,7 @@ export type IconData = {
icon: AbstractNode
}
-export type IconBaseProps = {
+type IconBaseProps = {
data: IconData
className?: string
onClick?: React.MouseEventHandler
diff --git a/web/app/components/base/icons/src/image/llm/BaichuanTextCn.module.css b/web/app/components/base/icons/src/image/llm/BaichuanTextCn.module.css
index 1b6bd72501..97ab9b22f9 100644
--- a/web/app/components/base/icons/src/image/llm/BaichuanTextCn.module.css
+++ b/web/app/components/base/icons/src/image/llm/BaichuanTextCn.module.css
@@ -1,5 +1,3 @@
-@reference "../../../../../../styles/globals.css";
-
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/baichuan-text-cn.png) center center no-repeat;
diff --git a/web/app/components/base/icons/src/image/llm/Minimax.module.css b/web/app/components/base/icons/src/image/llm/Minimax.module.css
index c20144d754..551ecc3c62 100644
--- a/web/app/components/base/icons/src/image/llm/Minimax.module.css
+++ b/web/app/components/base/icons/src/image/llm/Minimax.module.css
@@ -1,5 +1,3 @@
-@reference "../../../../../../styles/globals.css";
-
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/minimax.png) center center no-repeat;
diff --git a/web/app/components/base/icons/src/image/llm/MinimaxText.module.css b/web/app/components/base/icons/src/image/llm/MinimaxText.module.css
index 459b6e9b3b..a63be49e8b 100644
--- a/web/app/components/base/icons/src/image/llm/MinimaxText.module.css
+++ b/web/app/components/base/icons/src/image/llm/MinimaxText.module.css
@@ -1,5 +1,3 @@
-@reference "../../../../../../styles/globals.css";
-
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/minimax-text.png) center center no-repeat;
diff --git a/web/app/components/base/icons/src/image/llm/Tongyi.module.css b/web/app/components/base/icons/src/image/llm/Tongyi.module.css
index d510c6bc28..3ca440768c 100644
--- a/web/app/components/base/icons/src/image/llm/Tongyi.module.css
+++ b/web/app/components/base/icons/src/image/llm/Tongyi.module.css
@@ -1,5 +1,3 @@
-@reference "../../../../../../styles/globals.css";
-
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/tongyi.png) center center no-repeat;
diff --git a/web/app/components/base/icons/src/image/llm/TongyiText.module.css b/web/app/components/base/icons/src/image/llm/TongyiText.module.css
index c76ea97e69..f713671808 100644
--- a/web/app/components/base/icons/src/image/llm/TongyiText.module.css
+++ b/web/app/components/base/icons/src/image/llm/TongyiText.module.css
@@ -1,5 +1,3 @@
-@reference "../../../../../../styles/globals.css";
-
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/tongyi-text.png) center center no-repeat;
diff --git a/web/app/components/base/icons/src/image/llm/TongyiTextCn.module.css b/web/app/components/base/icons/src/image/llm/TongyiTextCn.module.css
index c6404a0ed8..d07e6e8bc4 100644
--- a/web/app/components/base/icons/src/image/llm/TongyiTextCn.module.css
+++ b/web/app/components/base/icons/src/image/llm/TongyiTextCn.module.css
@@ -1,5 +1,3 @@
-@reference "../../../../../../styles/globals.css";
-
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/tongyi-text-cn.png) center center no-repeat;
diff --git a/web/app/components/base/icons/src/image/llm/Wxyy.module.css b/web/app/components/base/icons/src/image/llm/Wxyy.module.css
index 891fb1e80c..44344a495f 100644
--- a/web/app/components/base/icons/src/image/llm/Wxyy.module.css
+++ b/web/app/components/base/icons/src/image/llm/Wxyy.module.css
@@ -1,5 +1,3 @@
-@reference "../../../../../../styles/globals.css";
-
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/wxyy.png) center center no-repeat;
diff --git a/web/app/components/base/icons/src/image/llm/WxyyText.module.css b/web/app/components/base/icons/src/image/llm/WxyyText.module.css
index cfa5523fbc..58a0c62047 100644
--- a/web/app/components/base/icons/src/image/llm/WxyyText.module.css
+++ b/web/app/components/base/icons/src/image/llm/WxyyText.module.css
@@ -1,5 +1,3 @@
-@reference "../../../../../../styles/globals.css";
-
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/wxyy-text.png) center center no-repeat;
diff --git a/web/app/components/base/icons/src/image/llm/WxyyTextCn.module.css b/web/app/components/base/icons/src/image/llm/WxyyTextCn.module.css
index a3b3b9e03e..fb5839ab07 100644
--- a/web/app/components/base/icons/src/image/llm/WxyyTextCn.module.css
+++ b/web/app/components/base/icons/src/image/llm/WxyyTextCn.module.css
@@ -1,5 +1,3 @@
-@reference "../../../../../../styles/globals.css";
-
.wrapper {
display: inline-flex;
background: url(~@/app/components/base/icons/assets/image/llm/wxyy-text-cn.png) center center no-repeat;
diff --git a/web/app/components/base/icons/src/image/llm/index.ts b/web/app/components/base/icons/src/image/llm/index.ts
deleted file mode 100644
index 1b7f8c48eb..0000000000
--- a/web/app/components/base/icons/src/image/llm/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export { default as BaichuanTextCn } from './BaichuanTextCn'
-export { default as Minimax } from './Minimax'
-export { default as MinimaxText } from './MinimaxText'
-export { default as Tongyi } from './Tongyi'
-export { default as TongyiText } from './TongyiText'
-export { default as TongyiTextCn } from './TongyiTextCn'
-export { default as Wxyy } from './Wxyy'
-export { default as WxyyText } from './WxyyText'
-export { default as WxyyTextCn } from './WxyyTextCn'
diff --git a/web/app/components/base/icons/src/public/billing/index.ts b/web/app/components/base/icons/src/public/billing/index.ts
index bd8fdc10dd..c80933b0db 100644
--- a/web/app/components/base/icons/src/public/billing/index.ts
+++ b/web/app/components/base/icons/src/public/billing/index.ts
@@ -1,12 +1,5 @@
-export { default as ArCube1 } from './ArCube1'
-export { default as Asterisk } from './Asterisk'
export { default as AwsMarketplaceDark } from './AwsMarketplaceDark'
export { default as AwsMarketplaceLight } from './AwsMarketplaceLight'
export { default as Azure } from './Azure'
-export { default as Buildings } from './Buildings'
-export { default as Diamond } from './Diamond'
+
export { default as GoogleCloud } from './GoogleCloud'
-export { default as Group2 } from './Group2'
-export { default as Keyframe } from './Keyframe'
-export { default as Sparkles } from './Sparkles'
-export { default as SparklesSoft } from './SparklesSoft'
diff --git a/web/app/components/base/icons/src/public/common/index.ts b/web/app/components/base/icons/src/public/common/index.ts
index c19ab569fa..894570bc8f 100644
--- a/web/app/components/base/icons/src/public/common/index.ts
+++ b/web/app/components/base/icons/src/public/common/index.ts
@@ -1,16 +1,9 @@
-export { default as D } from './D'
-export { default as DiagonalDividingLine } from './DiagonalDividingLine'
-export { default as Dify } from './Dify'
-export { default as Gdpr } from './Gdpr'
export { default as Github } from './Github'
export { default as Highlight } from './Highlight'
-export { default as Iso } from './Iso'
+
export { default as Line3 } from './Line3'
-export { default as Lock } from './Lock'
-export { default as MessageChatSquare } from './MessageChatSquare'
-export { default as MultiPathRetrieval } from './MultiPathRetrieval'
+
export { default as Notion } from './Notion'
-export { default as NTo1Retrieval } from './NTo1Retrieval'
-export { default as Soc2 } from './Soc2'
+
export { default as SparklesSoft } from './SparklesSoft'
export { default as SparklesSoftAccent } from './SparklesSoftAccent'
diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/index.ts b/web/app/components/base/icons/src/public/knowledge/dataset-card/index.ts
index 9f45717e73..50d6bac1ed 100644
--- a/web/app/components/base/icons/src/public/knowledge/dataset-card/index.ts
+++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/index.ts
@@ -1,5 +1,5 @@
export { default as ExternalKnowledgeBase } from './ExternalKnowledgeBase'
export { default as General } from './General'
-export { default as Graph } from './Graph'
+
export { default as ParentChild } from './ParentChild'
export { default as Qa } from './Qa'
diff --git a/web/app/components/base/icons/src/public/knowledge/index.ts b/web/app/components/base/icons/src/public/knowledge/index.ts
index 4acde1663b..c0d35e9ef3 100644
--- a/web/app/components/base/icons/src/public/knowledge/index.ts
+++ b/web/app/components/base/icons/src/public/knowledge/index.ts
@@ -1,8 +1,6 @@
-export { default as File } from './File'
export { default as OptionCardEffectBlue } from './OptionCardEffectBlue'
export { default as OptionCardEffectBlueLight } from './OptionCardEffectBlueLight'
export { default as OptionCardEffectOrange } from './OptionCardEffectOrange'
export { default as OptionCardEffectPurple } from './OptionCardEffectPurple'
export { default as OptionCardEffectTeal } from './OptionCardEffectTeal'
export { default as SelectionMod } from './SelectionMod'
-export { default as Watercrawl } from './Watercrawl'
diff --git a/web/app/components/base/icons/src/public/llm/index.ts b/web/app/components/base/icons/src/public/llm/index.ts
index 0c5cef4a36..6b77aefe51 100644
--- a/web/app/components/base/icons/src/public/llm/index.ts
+++ b/web/app/components/base/icons/src/public/llm/index.ts
@@ -1,50 +1,14 @@
-export { default as Anthropic } from './Anthropic'
export { default as AnthropicDark } from './AnthropicDark'
export { default as AnthropicLight } from './AnthropicLight'
export { default as AnthropicShortLight } from './AnthropicShortLight'
-export { default as AnthropicText } from './AnthropicText'
-export { default as Azureai } from './Azureai'
-export { default as AzureaiText } from './AzureaiText'
-export { default as AzureOpenaiService } from './AzureOpenaiService'
-export { default as AzureOpenaiServiceText } from './AzureOpenaiServiceText'
-export { default as Baichuan } from './Baichuan'
-export { default as BaichuanText } from './BaichuanText'
-export { default as Chatglm } from './Chatglm'
-export { default as ChatglmText } from './ChatglmText'
-export { default as Cohere } from './Cohere'
-export { default as CohereText } from './CohereText'
+
export { default as Deepseek } from './Deepseek'
export { default as Gemini } from './Gemini'
-export { default as Gpt3 } from './Gpt3'
-export { default as Gpt4 } from './Gpt4'
+
export { default as Grok } from './Grok'
-export { default as Huggingface } from './Huggingface'
-export { default as HuggingfaceText } from './HuggingfaceText'
-export { default as HuggingfaceTextHub } from './HuggingfaceTextHub'
-export { default as IflytekSpark } from './IflytekSpark'
-export { default as IflytekSparkText } from './IflytekSparkText'
-export { default as IflytekSparkTextCn } from './IflytekSparkTextCn'
-export { default as Jina } from './Jina'
-export { default as JinaText } from './JinaText'
-export { default as Localai } from './Localai'
-export { default as LocalaiText } from './LocalaiText'
-export { default as Microsoft } from './Microsoft'
-export { default as OpenaiBlack } from './OpenaiBlack'
-export { default as OpenaiBlue } from './OpenaiBlue'
-export { default as OpenaiGreen } from './OpenaiGreen'
+
export { default as OpenaiSmall } from './OpenaiSmall'
-export { default as OpenaiTeal } from './OpenaiTeal'
-export { default as OpenaiText } from './OpenaiText'
-export { default as OpenaiTransparent } from './OpenaiTransparent'
-export { default as OpenaiViolet } from './OpenaiViolet'
+
export { default as OpenaiYellow } from './OpenaiYellow'
-export { default as Openllm } from './Openllm'
-export { default as OpenllmText } from './OpenllmText'
-export { default as Replicate } from './Replicate'
-export { default as ReplicateText } from './ReplicateText'
+
export { default as Tongyi } from './Tongyi'
-export { default as XorbitsInference } from './XorbitsInference'
-export { default as XorbitsInferenceText } from './XorbitsInferenceText'
-export { default as Zhipuai } from './Zhipuai'
-export { default as ZhipuaiText } from './ZhipuaiText'
-export { default as ZhipuaiTextCn } from './ZhipuaiTextCn'
diff --git a/web/app/components/base/icons/src/public/model/index.ts b/web/app/components/base/icons/src/public/model/index.ts
deleted file mode 100644
index 719a6f0309..0000000000
--- a/web/app/components/base/icons/src/public/model/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default as Checked } from './Checked'
diff --git a/web/app/components/base/icons/src/public/other/index.ts b/web/app/components/base/icons/src/public/other/index.ts
index 10987368fb..a8f91dd98b 100644
--- a/web/app/components/base/icons/src/public/other/index.ts
+++ b/web/app/components/base/icons/src/public/other/index.ts
@@ -1,5 +1,5 @@
export { default as DefaultToolIcon } from './DefaultToolIcon'
-export { default as Icon3Dots } from './Icon3Dots'
+
export { default as Message3Fill } from './Message3Fill'
export { default as RowStruct } from './RowStruct'
export { default as Slack } from './Slack'
diff --git a/web/app/components/base/icons/src/public/plugins/index.ts b/web/app/components/base/icons/src/public/plugins/index.ts
deleted file mode 100644
index 87dc37167c..0000000000
--- a/web/app/components/base/icons/src/public/plugins/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export { default as Google } from './Google'
-export { default as PartnerDark } from './PartnerDark'
-export { default as PartnerLight } from './PartnerLight'
-export { default as VerifiedDark } from './VerifiedDark'
-export { default as VerifiedLight } from './VerifiedLight'
-export { default as WebReader } from './WebReader'
-export { default as Wikipedia } from './Wikipedia'
diff --git a/web/app/components/base/icons/src/public/thought/index.ts b/web/app/components/base/icons/src/public/thought/index.ts
index 8a45489dbf..10f5e3f5c3 100644
--- a/web/app/components/base/icons/src/public/thought/index.ts
+++ b/web/app/components/base/icons/src/public/thought/index.ts
@@ -1,5 +1 @@
-export { default as DataSet } from './DataSet'
export { default as Loading } from './Loading'
-export { default as Search } from './Search'
-export { default as ThoughtList } from './ThoughtList'
-export { default as WebReader } from './WebReader'
diff --git a/web/app/components/base/icons/src/vender/knowledge/index.ts b/web/app/components/base/icons/src/vender/knowledge/index.ts
index 44055c4975..99a4f26ed5 100644
--- a/web/app/components/base/icons/src/vender/knowledge/index.ts
+++ b/web/app/components/base/icons/src/vender/knowledge/index.ts
@@ -3,7 +3,7 @@ export { default as ApiAggregate } from './ApiAggregate'
export { default as ArrowShape } from './ArrowShape'
export { default as Chunk } from './Chunk'
export { default as Collapse } from './Collapse'
-export { default as Divider } from './Divider'
+
export { default as Economic } from './Economic'
export { default as FullTextSearch } from './FullTextSearch'
export { default as GeneralChunk } from './GeneralChunk'
diff --git a/web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts
index 4e721d70eb..27f8709bed 100644
--- a/web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts
+++ b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts
@@ -1,4 +1,2 @@
-export { default as AlertTriangle } from './AlertTriangle'
-export { default as ThumbsDown } from './ThumbsDown'
export { default as ThumbsUp } from './ThumbsUp'
export { default as Warning } from './Warning'
diff --git a/web/app/components/base/icons/src/vender/line/arrows/index.ts b/web/app/components/base/icons/src/vender/line/arrows/index.ts
index 174c69bd95..b27efc2e9c 100644
--- a/web/app/components/base/icons/src/vender/line/arrows/index.ts
+++ b/web/app/components/base/icons/src/vender/line/arrows/index.ts
@@ -3,7 +3,5 @@ export { default as ArrowUpRight } from './ArrowUpRight'
export { default as ChevronDownDouble } from './ChevronDownDouble'
export { default as ChevronRight } from './ChevronRight'
export { default as ChevronSelectorVertical } from './ChevronSelectorVertical'
-export { default as IconR } from './IconR'
+
export { default as RefreshCcw01 } from './RefreshCcw01'
-export { default as RefreshCw05 } from './RefreshCw05'
-export { default as ReverseLeft } from './ReverseLeft'
diff --git a/web/app/components/base/icons/src/vender/line/communication/index.ts b/web/app/components/base/icons/src/vender/line/communication/index.ts
index a6844c2b69..45a762a1dd 100644
--- a/web/app/components/base/icons/src/vender/line/communication/index.ts
+++ b/web/app/components/base/icons/src/vender/line/communication/index.ts
@@ -1,6 +1,4 @@
-export { default as AiText } from './AiText'
-export { default as ChatBot } from './ChatBot'
export { default as ChatBotSlim } from './ChatBotSlim'
-export { default as CuteRobot } from './CuteRobot'
+
export { default as MessageCheckRemove } from './MessageCheckRemove'
export { default as MessageFastPlus } from './MessageFastPlus'
diff --git a/web/app/components/base/icons/src/vender/line/development/index.ts b/web/app/components/base/icons/src/vender/line/development/index.ts
index 93bb1956bb..7c3c48aa5e 100644
--- a/web/app/components/base/icons/src/vender/line/development/index.ts
+++ b/web/app/components/base/icons/src/vender/line/development/index.ts
@@ -1,14 +1,2 @@
-export { default as ArtificialBrain } from './ArtificialBrain'
-export { default as BarChartSquare02 } from './BarChartSquare02'
export { default as BracketsX } from './BracketsX'
export { default as CodeBrowser } from './CodeBrowser'
-export { default as Container } from './Container'
-export { default as Database01 } from './Database01'
-export { default as Database03 } from './Database03'
-export { default as FileHeart02 } from './FileHeart02'
-export { default as GitBranch01 } from './GitBranch01'
-export { default as PromptEngineering } from './PromptEngineering'
-export { default as PuzzlePiece01 } from './PuzzlePiece01'
-export { default as TerminalSquare } from './TerminalSquare'
-export { default as Variable } from './Variable'
-export { default as Webhooks } from './Webhooks'
diff --git a/web/app/components/base/icons/src/vender/line/editor/index.ts b/web/app/components/base/icons/src/vender/line/editor/index.ts
index b31c42e390..c968aa814c 100644
--- a/web/app/components/base/icons/src/vender/line/editor/index.ts
+++ b/web/app/components/base/icons/src/vender/line/editor/index.ts
@@ -1,8 +1 @@
-export { default as AlignLeft } from './AlignLeft'
-export { default as BezierCurve03 } from './BezierCurve03'
-export { default as Collapse } from './Collapse'
-export { default as Colors } from './Colors'
export { default as ImageIndentLeft } from './ImageIndentLeft'
-export { default as LeftIndent02 } from './LeftIndent02'
-export { default as LetterSpacing01 } from './LetterSpacing01'
-export { default as TypeSquare } from './TypeSquare'
diff --git a/web/app/components/base/icons/src/vender/line/files/index.ts b/web/app/components/base/icons/src/vender/line/files/index.ts
index 8455f7b56a..afdc65cb24 100644
--- a/web/app/components/base/icons/src/vender/line/files/index.ts
+++ b/web/app/components/base/icons/src/vender/line/files/index.ts
@@ -1,11 +1,10 @@
export { default as Copy } from './Copy'
export { default as CopyCheck } from './CopyCheck'
-export { default as File02 } from './File02'
+
export { default as FileArrow01 } from './FileArrow01'
-export { default as FileCheck02 } from './FileCheck02'
+
export { default as FileDownload02 } from './FileDownload02'
export { default as FilePlus01 } from './FilePlus01'
export { default as FilePlus02 } from './FilePlus02'
-export { default as FileText } from './FileText'
-export { default as FileUpload } from './FileUpload'
+
export { default as Folder } from './Folder'
diff --git a/web/app/components/base/icons/src/vender/line/financeAndECommerce/index.ts b/web/app/components/base/icons/src/vender/line/financeAndECommerce/index.ts
index 8a98a4612c..736eeca453 100644
--- a/web/app/components/base/icons/src/vender/line/financeAndECommerce/index.ts
+++ b/web/app/components/base/icons/src/vender/line/financeAndECommerce/index.ts
@@ -1,7 +1,6 @@
export { default as Balance } from './Balance'
-export { default as CoinsStacked01 } from './CoinsStacked01'
+
export { default as CreditsCoin } from './CreditsCoin'
-export { default as GoldCoin } from './GoldCoin'
-export { default as ReceiptList } from './ReceiptList'
+
export { default as Tag01 } from './Tag01'
export { default as Tag03 } from './Tag03'
diff --git a/web/app/components/base/icons/src/vender/line/general/index.ts b/web/app/components/base/icons/src/vender/line/general/index.ts
index 2409367264..33f67f01a5 100644
--- a/web/app/components/base/icons/src/vender/line/general/index.ts
+++ b/web/app/components/base/icons/src/vender/line/general/index.ts
@@ -1,30 +1,19 @@
-export { default as AtSign } from './AtSign'
-export { default as Bookmark } from './Bookmark'
export { default as Check } from './Check'
-export { default as CheckDone01 } from './CheckDone01'
-export { default as ChecklistSquare } from './ChecklistSquare'
+
export { default as CodeAssistant } from './CodeAssistant'
-export { default as DotsGrid } from './DotsGrid'
-export { default as Edit02 } from './Edit02'
-export { default as Edit04 } from './Edit04'
-export { default as Edit05 } from './Edit05'
-export { default as Hash02 } from './Hash02'
-export { default as InfoCircle } from './InfoCircle'
+
export { default as Link03 } from './Link03'
export { default as LinkExternal02 } from './LinkExternal02'
-export { default as LogIn04 } from './LogIn04'
+
export { default as LogOut01 } from './LogOut01'
-export { default as LogOut04 } from './LogOut04'
+
export { default as MagicEdit } from './MagicEdit'
-export { default as Menu01 } from './Menu01'
-export { default as Pin01 } from './Pin01'
+
export { default as Pin02 } from './Pin02'
export { default as Plus02 } from './Plus02'
-export { default as Refresh } from './Refresh'
+
export { default as SearchMenu } from './SearchMenu'
export { default as Settings01 } from './Settings01'
export { default as Settings04 } from './Settings04'
-export { default as Target04 } from './Target04'
-export { default as Upload03 } from './Upload03'
-export { default as UploadCloud01 } from './UploadCloud01'
+
export { default as X } from './X'
diff --git a/web/app/components/base/icons/src/vender/line/layout/index.ts b/web/app/components/base/icons/src/vender/line/layout/index.ts
index 7c12b1f58f..a6aa205faa 100644
--- a/web/app/components/base/icons/src/vender/line/layout/index.ts
+++ b/web/app/components/base/icons/src/vender/line/layout/index.ts
@@ -1,4 +1 @@
-export { default as AlignLeft01 } from './AlignLeft01'
-export { default as AlignRight01 } from './AlignRight01'
-export { default as Grid01 } from './Grid01'
export { default as LayoutGrid02 } from './LayoutGrid02'
diff --git a/web/app/components/base/icons/src/vender/line/mediaAndDevices/index.ts b/web/app/components/base/icons/src/vender/line/mediaAndDevices/index.ts
index 163c433ac8..35052d6564 100644
--- a/web/app/components/base/icons/src/vender/line/mediaAndDevices/index.ts
+++ b/web/app/components/base/icons/src/vender/line/mediaAndDevices/index.ts
@@ -1,6 +1,2 @@
-export { default as Microphone01 } from './Microphone01'
-export { default as PlayCircle } from './PlayCircle'
-export { default as SlidersH } from './SlidersH'
-export { default as Speaker } from './Speaker'
export { default as Stop } from './Stop'
export { default as StopCircle } from './StopCircle'
diff --git a/web/app/components/base/icons/src/vender/line/others/index.ts b/web/app/components/base/icons/src/vender/line/others/index.ts
index 99db66b397..0425327c6c 100644
--- a/web/app/components/base/icons/src/vender/line/others/index.ts
+++ b/web/app/components/base/icons/src/vender/line/others/index.ts
@@ -1,10 +1,8 @@
export { default as BubbleX } from './BubbleX'
-export { default as Colors } from './Colors'
+
export { default as DragHandle } from './DragHandle'
export { default as Env } from './Env'
export { default as GlobalVariable } from './GlobalVariable'
export { default as Icon3Dots } from './Icon3Dots'
export { default as LongArrowLeft } from './LongArrowLeft'
export { default as LongArrowRight } from './LongArrowRight'
-export { default as SearchMenu } from './SearchMenu'
-export { default as Tools } from './Tools'
diff --git a/web/app/components/base/icons/src/vender/line/shapes/index.ts b/web/app/components/base/icons/src/vender/line/shapes/index.ts
deleted file mode 100644
index daf43bcaf7..0000000000
--- a/web/app/components/base/icons/src/vender/line/shapes/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default as CubeOutline } from './CubeOutline'
diff --git a/web/app/components/base/icons/src/vender/line/time/index.ts b/web/app/components/base/icons/src/vender/line/time/index.ts
index 7fd91f2b2e..2187814bbf 100644
--- a/web/app/components/base/icons/src/vender/line/time/index.ts
+++ b/web/app/components/base/icons/src/vender/line/time/index.ts
@@ -1,4 +1,2 @@
export { default as ClockFastForward } from './ClockFastForward'
export { default as ClockPlay } from './ClockPlay'
-export { default as ClockPlaySlim } from './ClockPlaySlim'
-export { default as ClockRefresh } from './ClockRefresh'
diff --git a/web/app/components/base/icons/src/vender/line/users/index.ts b/web/app/components/base/icons/src/vender/line/users/index.ts
deleted file mode 100644
index 9f8a35152f..0000000000
--- a/web/app/components/base/icons/src/vender/line/users/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default as User01 } from './User01'
-export { default as Users01 } from './Users01'
diff --git a/web/app/components/base/icons/src/vender/line/weather/index.ts b/web/app/components/base/icons/src/vender/line/weather/index.ts
deleted file mode 100644
index 1a68bce765..0000000000
--- a/web/app/components/base/icons/src/vender/line/weather/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default as Stars02 } from './Stars02'
diff --git a/web/app/components/base/icons/src/vender/other/index.ts b/web/app/components/base/icons/src/vender/other/index.ts
index 0ca5f22bcf..493bac1931 100644
--- a/web/app/components/base/icons/src/vender/other/index.ts
+++ b/web/app/components/base/icons/src/vender/other/index.ts
@@ -1,4 +1,3 @@
-export { default as AnthropicText } from './AnthropicText'
export { default as Generator } from './Generator'
export { default as Group } from './Group'
export { default as HourglassShape } from './HourglassShape'
diff --git a/web/app/components/base/icons/src/vender/solid/FinanceAndECommerce/index.ts b/web/app/components/base/icons/src/vender/solid/FinanceAndECommerce/index.ts
index 777fe96845..7770962bfa 100644
--- a/web/app/components/base/icons/src/vender/solid/FinanceAndECommerce/index.ts
+++ b/web/app/components/base/icons/src/vender/solid/FinanceAndECommerce/index.ts
@@ -1,2 +1 @@
-export { default as GoldCoin } from './GoldCoin'
export { default as Scales02 } from './Scales02'
diff --git a/web/app/components/base/icons/src/vender/solid/arrows/index.ts b/web/app/components/base/icons/src/vender/solid/arrows/index.ts
index 58ce9aa8ac..d89a969bd4 100644
--- a/web/app/components/base/icons/src/vender/solid/arrows/index.ts
+++ b/web/app/components/base/icons/src/vender/solid/arrows/index.ts
@@ -1,5 +1,3 @@
export { default as ArrowDownDoubleLine } from './ArrowDownDoubleLine'
export { default as ArrowDownRoundFill } from './ArrowDownRoundFill'
export { default as ArrowUpDoubleLine } from './ArrowUpDoubleLine'
-export { default as ChevronDown } from './ChevronDown'
-export { default as HighPriority } from './HighPriority'
diff --git a/web/app/components/base/icons/src/vender/solid/communication/index.ts b/web/app/components/base/icons/src/vender/solid/communication/index.ts
index 7d2a3a5a95..2da1ac57e8 100644
--- a/web/app/components/base/icons/src/vender/solid/communication/index.ts
+++ b/web/app/components/base/icons/src/vender/solid/communication/index.ts
@@ -1,12 +1,8 @@
-export { default as AiText } from './AiText'
export { default as BubbleTextMod } from './BubbleTextMod'
export { default as ChatBot } from './ChatBot'
export { default as CuteRobot } from './CuteRobot'
-export { default as EditList } from './EditList'
+
export { default as ListSparkle } from './ListSparkle'
export { default as Logic } from './Logic'
-export { default as MessageDotsCircle } from './MessageDotsCircle'
+
export { default as MessageFast } from './MessageFast'
-export { default as MessageHeartCircle } from './MessageHeartCircle'
-export { default as MessageSmileSquare } from './MessageSmileSquare'
-export { default as Send03 } from './Send03'
diff --git a/web/app/components/base/icons/src/vender/solid/development/index.ts b/web/app/components/base/icons/src/vender/solid/development/index.ts
index f67d854beb..25eb3d2736 100644
--- a/web/app/components/base/icons/src/vender/solid/development/index.ts
+++ b/web/app/components/base/icons/src/vender/solid/development/index.ts
@@ -1,13 +1,5 @@
export { default as ApiConnection } from './ApiConnection'
export { default as ApiConnectionMod } from './ApiConnectionMod'
-export { default as BarChartSquare02 } from './BarChartSquare02'
-export { default as Container } from './Container'
-export { default as Database02 } from './Database02'
-export { default as Database03 } from './Database03'
-export { default as FileHeart02 } from './FileHeart02'
-export { default as PatternRecognition } from './PatternRecognition'
-export { default as PromptEngineering } from './PromptEngineering'
-export { default as PuzzlePiece01 } from './PuzzlePiece01'
-export { default as Semantic } from './Semantic'
+
export { default as TerminalSquare } from './TerminalSquare'
export { default as Variable02 } from './Variable02'
diff --git a/web/app/components/base/icons/src/vender/solid/editor/index.ts b/web/app/components/base/icons/src/vender/solid/editor/index.ts
index 6b1a0a1afa..8b6debe736 100644
--- a/web/app/components/base/icons/src/vender/solid/editor/index.ts
+++ b/web/app/components/base/icons/src/vender/solid/editor/index.ts
@@ -1,5 +1 @@
export { default as Brush01 } from './Brush01'
-export { default as Citations } from './Citations'
-export { default as Colors } from './Colors'
-export { default as Paragraph } from './Paragraph'
-export { default as TypeSquare } from './TypeSquare'
diff --git a/web/app/components/base/icons/src/vender/solid/education/index.ts b/web/app/components/base/icons/src/vender/solid/education/index.ts
index 2c8a3b6046..e67b631335 100644
--- a/web/app/components/base/icons/src/vender/solid/education/index.ts
+++ b/web/app/components/base/icons/src/vender/solid/education/index.ts
@@ -1,4 +1,4 @@
export { default as Beaker02 } from './Beaker02'
export { default as BubbleText } from './BubbleText'
-export { default as Heart02 } from './Heart02'
+
export { default as Unblur } from './Unblur'
diff --git a/web/app/components/base/icons/src/vender/solid/files/index.ts b/web/app/components/base/icons/src/vender/solid/files/index.ts
index fa93cd68dc..7677ba6761 100644
--- a/web/app/components/base/icons/src/vender/solid/files/index.ts
+++ b/web/app/components/base/icons/src/vender/solid/files/index.ts
@@ -1,4 +1,4 @@
export { default as File05 } from './File05'
-export { default as FileSearch02 } from './FileSearch02'
+
export { default as FileZip } from './FileZip'
export { default as Folder } from './Folder'
diff --git a/web/app/components/base/icons/src/vender/solid/general/index.ts b/web/app/components/base/icons/src/vender/solid/general/index.ts
index 4c4dd9a437..273fb7e876 100644
--- a/web/app/components/base/icons/src/vender/solid/general/index.ts
+++ b/web/app/components/base/icons/src/vender/solid/general/index.ts
@@ -1,18 +1,14 @@
-export { default as AnswerTriangle } from './AnswerTriangle'
export { default as ArrowDownRoundFill } from './ArrowDownRoundFill'
export { default as CheckCircle } from './CheckCircle'
-export { default as CheckDone01 } from './CheckDone01'
+
export { default as Download02 } from './Download02'
export { default as Edit03 } from './Edit03'
-export { default as Edit04 } from './Edit04'
+
export { default as Eye } from './Eye'
export { default as Github } from './Github'
export { default as MessageClockCircle } from './MessageClockCircle'
-export { default as PlusCircle } from './PlusCircle'
-export { default as QuestionTriangle } from './QuestionTriangle'
-export { default as SearchMd } from './SearchMd'
+
export { default as Target04 } from './Target04'
export { default as Tool03 } from './Tool03'
export { default as XCircle } from './XCircle'
export { default as ZapFast } from './ZapFast'
-export { default as ZapNarrow } from './ZapNarrow'
diff --git a/web/app/components/base/icons/src/vender/solid/layout/index.ts b/web/app/components/base/icons/src/vender/solid/layout/index.ts
deleted file mode 100644
index 73a2513d51..0000000000
--- a/web/app/components/base/icons/src/vender/solid/layout/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default as Grid01 } from './Grid01'
diff --git a/web/app/components/base/icons/src/vender/solid/mediaAndDevices/index.ts b/web/app/components/base/icons/src/vender/solid/mediaAndDevices/index.ts
index 7c313fecfb..7d1bf786e9 100644
--- a/web/app/components/base/icons/src/vender/solid/mediaAndDevices/index.ts
+++ b/web/app/components/base/icons/src/vender/solid/mediaAndDevices/index.ts
@@ -1,12 +1,3 @@
-export { default as AudioSupportIcon } from './AudioSupportIcon'
-export { default as DocumentSupportIcon } from './DocumentSupportIcon'
export { default as MagicBox } from './MagicBox'
-export { default as MagicEyes } from './MagicEyes'
-export { default as MagicWand } from './MagicWand'
-export { default as Microphone01 } from './Microphone01'
-export { default as Play } from './Play'
-export { default as Robot } from './Robot'
-export { default as Sliders02 } from './Sliders02'
-export { default as Speaker } from './Speaker'
+
export { default as StopCircle } from './StopCircle'
-export { default as VideoSupportIcon } from './VideoSupportIcon'
diff --git a/web/app/components/base/icons/src/vender/solid/shapes/index.ts b/web/app/components/base/icons/src/vender/solid/shapes/index.ts
index 2768e3949a..f25784ab02 100644
--- a/web/app/components/base/icons/src/vender/solid/shapes/index.ts
+++ b/web/app/components/base/icons/src/vender/solid/shapes/index.ts
@@ -1,3 +1 @@
export { default as Corner } from './Corner'
-export { default as Star04 } from './Star04'
-export { default as Star06 } from './Star06'
diff --git a/web/app/components/base/icons/src/vender/solid/users/index.ts b/web/app/components/base/icons/src/vender/solid/users/index.ts
index 4c969bffd7..1691fc8401 100644
--- a/web/app/components/base/icons/src/vender/solid/users/index.ts
+++ b/web/app/components/base/icons/src/vender/solid/users/index.ts
@@ -1,4 +1 @@
-export { default as User01 } from './User01'
export { default as UserEdit02 } from './UserEdit02'
-export { default as Users01 } from './Users01'
-export { default as UsersPlus } from './UsersPlus'
diff --git a/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx b/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx
index a94daf432a..8c88642476 100644
--- a/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx
+++ b/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx
@@ -11,7 +11,7 @@ const Icon = (
ref,
...props
}: React.SVGProps & {
- ref?: React.RefObject>
+ ref?: React.RefObject>
},
) =>
diff --git a/web/app/components/base/icons/src/vender/workflow/index.ts b/web/app/components/base/icons/src/vender/workflow/index.ts
index c21e865a09..c2511d3816 100644
--- a/web/app/components/base/icons/src/vender/workflow/index.ts
+++ b/web/app/components/base/icons/src/vender/workflow/index.ts
@@ -13,7 +13,7 @@ export { default as Http } from './Http'
export { default as HumanInLoop } from './HumanInLoop'
export { default as IfElse } from './IfElse'
export { default as Iteration } from './Iteration'
-export { default as IterationStart } from './IterationStart'
+
export { default as Jinja } from './Jinja'
export { default as KnowledgeBase } from './KnowledgeBase'
export { default as KnowledgeRetrieval } from './KnowledgeRetrieval'
diff --git a/web/app/components/base/icons/utils.ts b/web/app/components/base/icons/utils.ts
index 9a15a0816d..51a1d70568 100644
--- a/web/app/components/base/icons/utils.ts
+++ b/web/app/components/base/icons/utils.ts
@@ -8,7 +8,7 @@ export type AbstractNode = {
children?: AbstractNode[]
}
-export type Attrs = {
+type Attrs = {
[key: string]: string | undefined
}
diff --git a/web/app/components/base/image-gallery/style.module.css b/web/app/components/base/image-gallery/style.module.css
index 3cbe886b04..2e4c62e456 100644
--- a/web/app/components/base/image-gallery/style.module.css
+++ b/web/app/components/base/image-gallery/style.module.css
@@ -1,5 +1,3 @@
-@reference "../../../styles/globals.css";
-
.item {
max-height: 200px;
margin-right: 8px;
diff --git a/web/app/components/base/inline-delete-confirm/index.tsx b/web/app/components/base/inline-delete-confirm/index.tsx
index 529dec479d..a0e3b8eb96 100644
--- a/web/app/components/base/inline-delete-confirm/index.tsx
+++ b/web/app/components/base/inline-delete-confirm/index.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { cn } from '@/utils/classnames'
-export type InlineDeleteConfirmProps = {
+type InlineDeleteConfirmProps = {
title?: string
confirmText?: string
cancelText?: string
diff --git a/web/app/components/base/input-with-copy/index.tsx b/web/app/components/base/input-with-copy/index.tsx
index 7c2177d5d2..e85a7bd6f4 100644
--- a/web/app/components/base/input-with-copy/index.tsx
+++ b/web/app/components/base/input-with-copy/index.tsx
@@ -7,7 +7,7 @@ import { cn } from '@/utils/classnames'
import ActionButton from '../action-button'
import Tooltip from '../tooltip'
-export type InputWithCopyProps = {
+type InputWithCopyProps = {
showCopyButton?: boolean
copyValue?: string // Value to copy, defaults to input value
onCopy?: (value: string) => void // Callback when copy is triggered
diff --git a/web/app/components/base/markdown-blocks/__tests__/code-block.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/code-block.spec.tsx
index a16686801c..2eebe44af9 100644
--- a/web/app/components/base/markdown-blocks/__tests__/code-block.spec.tsx
+++ b/web/app/components/base/markdown-blocks/__tests__/code-block.spec.tsx
@@ -5,6 +5,10 @@ import { Theme } from '@/types/app'
import CodeBlock from '../code-block'
+const { mockHighlightCode } = vi.hoisted(() => ({
+ mockHighlightCode: vi.fn(),
+}))
+
type UseThemeReturn = {
theme: Theme
}
@@ -70,6 +74,10 @@ vi.mock('@/hooks/use-theme', () => ({
default: () => mockUseTheme(),
}))
+vi.mock('../shiki-highlight', () => ({
+ highlightCode: mockHighlightCode,
+}))
+
vi.mock('echarts', () => ({
getInstanceByDom: mockEcharts.getInstanceByDom,
}))
@@ -130,6 +138,11 @@ describe('CodeBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTheme.mockReturnValue({ theme: Theme.light })
+ mockHighlightCode.mockImplementation(async ({ code, language }) => (
+
+ {code}
+
+ ))
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
clientWidthSpy = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(900)
@@ -198,11 +211,13 @@ describe('CodeBlock', () => {
expect(container.querySelector('code')?.textContent).toBe('plain text')
})
- it('should render syntax-highlighted output when language is standard', () => {
+ it('should render syntax-highlighted output when language is standard', async () => {
render(const x = 1;)
expect(screen.getByText('JavaScript')).toBeInTheDocument()
- expect(document.querySelector('code.language-javascript')?.textContent).toContain('const x = 1;')
+ await waitFor(() => {
+ expect(document.querySelector('code.language-javascript')?.textContent).toContain('const x = 1;')
+ })
})
it('should format unknown language labels with capitalized fallback when language is not in map', () => {
@@ -242,13 +257,26 @@ describe('CodeBlock', () => {
expect(screen.queryByText(/Error rendering SVG/i)).not.toBeInTheDocument()
})
- it('should render syntax-highlighted output when language is standard and app theme is dark', () => {
+ it('should render syntax-highlighted output when language is standard and app theme is dark', async () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
render(const y = 2;)
expect(screen.getByText('JavaScript')).toBeInTheDocument()
- expect(document.querySelector('code.language-javascript')?.textContent).toContain('const y = 2;')
+ await waitFor(() => {
+ expect(document.querySelector('code.language-javascript')?.textContent).toContain('const y = 2;')
+ })
+ })
+
+ it('should fall back to plain code block when shiki highlighting fails', async () => {
+ mockHighlightCode.mockRejectedValueOnce(new Error('highlight failed'))
+
+ render(const z = 3;)
+
+ await waitFor(() => {
+ expect(screen.getByText('const z = 3;')).toBeInTheDocument()
+ })
+ expect(document.querySelector('code.language-javascript')).toBeNull()
})
})
diff --git a/web/app/components/base/markdown-blocks/code-block.tsx b/web/app/components/base/markdown-blocks/code-block.tsx
index 412c61d52d..67fa11da00 100644
--- a/web/app/components/base/markdown-blocks/code-block.tsx
+++ b/web/app/components/base/markdown-blocks/code-block.tsx
@@ -1,10 +1,7 @@
+import type { JSX } from 'react'
+import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
import ReactEcharts from 'echarts-for-react'
-import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import SyntaxHighlighter from 'react-syntax-highlighter'
-import {
- atelierHeathDark,
- atelierHeathLight,
-} from 'react-syntax-highlighter/dist/esm/styles/hljs'
+import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import ActionButton from '@/app/components/base/action-button'
import CopyIcon from '@/app/components/base/copy-icon'
import MarkdownMusic from '@/app/components/base/markdown-blocks/music'
@@ -14,10 +11,10 @@ import useTheme from '@/hooks/use-theme'
import dynamic from '@/next/dynamic'
import { Theme } from '@/types/app'
import SVGRenderer from '../svg-gallery' // Assumes svg-gallery.tsx is in /base directory
+import { highlightCode } from './shiki-highlight'
const Flowchart = dynamic(() => import('@/app/components/base/mermaid'), { ssr: false })
-// Available language https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_HLJS.MD
const capitalizationLanguageNameMap: Record = {
sql: 'SQL',
javascript: 'JavaScript',
@@ -64,6 +61,61 @@ const getCorrectCapitalizationLanguageName = (language: string) => {
// visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message
// or use the non-minified dev environment for full errors and additional helpful warnings.
+const ShikiCodeBlock = memo(({ code, language, theme, initial }: { code: string, language: string, theme: BundledTheme, initial?: JSX.Element }) => {
+ const [nodes, setNodes] = useState(initial)
+
+ useLayoutEffect(() => {
+ let cancelled = false
+
+ void highlightCode({
+ code,
+ language: language as BundledLanguage,
+ theme,
+ }).then((result) => {
+ if (!cancelled)
+ setNodes(result)
+ }).catch((error) => {
+ console.error('Shiki highlighting failed:', error)
+ if (!cancelled)
+ setNodes(undefined)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [code, language, theme])
+
+ if (!nodes) {
+ return (
+
+ {code}
+
+ )
+ }
+
+ return (
+
+ {nodes}
+
+ )
+})
+ShikiCodeBlock.displayName = 'ShikiCodeBlock'
+
// Define ECharts event parameter types
type EChartsEventParams = {
type: string
@@ -416,20 +468,11 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
)
default:
return (
-
- {content}
-
+
)
}
}, [children, language, isSVG, finalChartOption, props, theme, match, chartState, isDarkMode, echartsStyle, echartsOpts, handleChartReady, echartsEvents])
@@ -440,7 +483,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
return (
- {languageShowName}
+ {languageShowName}
{language === 'svg' && }
diff --git a/web/app/components/base/markdown-blocks/index.ts b/web/app/components/base/markdown-blocks/index.ts
index 73c9fdf13f..10e98c9ad9 100644
--- a/web/app/components/base/markdown-blocks/index.ts
+++ b/web/app/components/base/markdown-blocks/index.ts
@@ -6,7 +6,7 @@
export { default as AudioBlock } from './audio-block'
// Assuming these are also standalone components in this directory intended for Markdown rendering
export { default as MarkdownButton } from './button'
-export { default as CodeBlock } from './code-block'
+
export { default as MarkdownForm } from './form'
export { default as Img } from './img'
export { default as Link } from './link'
diff --git a/web/app/components/base/markdown-blocks/shiki-highlight.tsx b/web/app/components/base/markdown-blocks/shiki-highlight.tsx
new file mode 100644
index 0000000000..cfc075827f
--- /dev/null
+++ b/web/app/components/base/markdown-blocks/shiki-highlight.tsx
@@ -0,0 +1,29 @@
+import type { JSX } from 'react'
+import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
+import { toJsxRuntime } from 'hast-util-to-jsx-runtime'
+import { Fragment } from 'react'
+import { jsx, jsxs } from 'react/jsx-runtime'
+import { codeToHast } from 'shiki/bundle/web'
+
+type HighlightCodeOptions = {
+ code: string
+ language: BundledLanguage
+ theme: BundledTheme
+}
+
+export const highlightCode = async ({
+ code,
+ language,
+ theme,
+}: HighlightCodeOptions): Promise => {
+ const hast = await codeToHast(code, {
+ lang: language,
+ theme,
+ })
+
+ return toJsxRuntime(hast, {
+ Fragment,
+ jsx,
+ jsxs,
+ }) as JSX.Element
+}
diff --git a/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts
index 5e31a7afa9..4e721b214e 100644
--- a/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts
+++ b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts
@@ -15,12 +15,12 @@ export const withIconCardItemPropsSchema = z.object({
),
}).strict()
-export const directivePropsSchemas = {
+const directivePropsSchemas = {
withiconcardlist: withIconCardListPropsSchema,
withiconcarditem: withIconCardItemPropsSchema,
} as const
-export type DirectiveName = keyof typeof directivePropsSchemas
+type DirectiveName = keyof typeof directivePropsSchemas
function isDirectiveName(name: string): name is DirectiveName {
return Object.hasOwn(directivePropsSchemas, name)
diff --git a/web/app/components/base/node-status/index.tsx b/web/app/components/base/node-status/index.tsx
index 3c39fa1fb3..1616f350b0 100644
--- a/web/app/components/base/node-status/index.tsx
+++ b/web/app/components/base/node-status/index.tsx
@@ -32,7 +32,7 @@ const StatusIconMap: Record ({
usePreImportNotionPages: vi.fn(),
useInvalidPreImportNotionPages: vi.fn(),
@@ -183,7 +185,7 @@ describe('NotionPageSelector Base', () => {
const user = userEvent.setup()
render()
- await user.click(screen.getByRole('button', { name: 'Configure Notion' }))
+ await user.click(screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' }))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })
})
diff --git a/web/app/components/base/notion-page-selector/base.tsx b/web/app/components/base/notion-page-selector/base.tsx
index 261e7940e1..34926849a6 100644
--- a/web/app/components/base/notion-page-selector/base.tsx
+++ b/web/app/components/base/notion-page-selector/base.tsx
@@ -2,6 +2,7 @@ import type { DataSourceCredential } from '../../header/account-setting/data-sou
import type { NotionCredential } from './credential-selector'
import type { DataSourceNotionPageMap, DataSourceNotionWorkspace, NotionPage } from '@/models/common'
import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContextSelector } from '@/context/modal-context'
import { useInvalidPreImportNotionPages, usePreImportNotionPages } from '@/service/knowledge/use-import'
@@ -33,6 +34,7 @@ const NotionPageSelector = ({
credentialList,
onSelectCredential,
}: NotionPageSelectorProps) => {
+ const { t } = useTranslation()
const [searchValue, setSearchValue] = useState('')
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
@@ -48,27 +50,34 @@ const NotionPageSelector = ({
}
})
}, [credentialList])
- const [currentCredential, setCurrentCredential] = useState(notionCredentials[0])
+ const [selectedCredentialId, setSelectedCredentialId] = useState(() => notionCredentials[0]?.credentialId ?? '')
+ const currentCredential = useMemo(() => {
+ return notionCredentials.find(item => item.credentialId === selectedCredentialId) ?? notionCredentials[0] ?? null
+ }, [notionCredentials, selectedCredentialId])
+ const currentCredentialId = currentCredential?.credentialId ?? ''
useEffect(() => {
- const credential = notionCredentials.find(item => item.credentialId === currentCredential?.credentialId)
- if (!credential) {
- const firstCredential = notionCredentials[0]
- invalidPreImportNotionPages({ datasetId, credentialId: firstCredential.credentialId })
- setCurrentCredential(notionCredentials[0])
- onSelect([]) // Clear selected pages when changing credential
- onSelectCredential?.(firstCredential.credentialId)
+ onSelectCredential?.(currentCredentialId)
+ }, [currentCredentialId, onSelectCredential])
+
+ useEffect(() => {
+ if (!notionCredentials.length) {
+ onSelect([])
+ return
}
- else {
- onSelectCredential?.(credential?.credentialId || '')
- }
- }, [notionCredentials])
+
+ if (!selectedCredentialId || selectedCredentialId === currentCredentialId)
+ return
+
+ invalidPreImportNotionPages({ datasetId, credentialId: currentCredentialId })
+ onSelect([])
+ }, [currentCredentialId, datasetId, invalidPreImportNotionPages, notionCredentials.length, onSelect, selectedCredentialId])
const {
data: notionsPages,
isFetching: isFetchingNotionPages,
isError: isFetchingNotionPagesError,
- } = usePreImportNotionPages({ datasetId, credentialId: currentCredential.credentialId || '' })
+ } = usePreImportNotionPages({ datasetId, credentialId: currentCredentialId })
const pagesMapAndSelectedPagesId: [DataSourceNotionPageMap, Set, Set] = useMemo(() => {
const selectedPagesId = new Set()
@@ -94,28 +103,24 @@ const NotionPageSelector = ({
const defaultSelectedPagesId = useMemo(() => {
return [...Array.from(pagesMapAndSelectedPagesId[1]), ...(value || [])]
}, [pagesMapAndSelectedPagesId, value])
- const [selectedPagesId, setSelectedPagesId] = useState>(() => new Set(defaultSelectedPagesId))
-
- useEffect(() => {
- setSelectedPagesId(new Set(defaultSelectedPagesId))
- }, [defaultSelectedPagesId])
+ const selectedPagesId = useMemo(() => new Set(defaultSelectedPagesId), [defaultSelectedPagesId])
const handleSearchValueChange = useCallback((value: string) => {
setSearchValue(value)
}, [])
const handleSelectCredential = useCallback((credentialId: string) => {
- const credential = notionCredentials.find(item => item.credentialId === credentialId)!
- invalidPreImportNotionPages({ datasetId, credentialId: credential.credentialId })
- setCurrentCredential(credential)
+ if (credentialId === currentCredentialId)
+ return
+
+ invalidPreImportNotionPages({ datasetId, credentialId })
+ setSelectedCredentialId(credentialId)
onSelect([]) // Clear selected pages when changing credential
- onSelectCredential?.(credential.credentialId)
- }, [datasetId, invalidPreImportNotionPages, notionCredentials, onSelect, onSelectCredential])
+ }, [currentCredentialId, datasetId, invalidPreImportNotionPages, onSelect])
const handleSelectPages = useCallback((newSelectedPagesId: Set) => {
const selectedPages = Array.from(newSelectedPagesId).map(pageId => pagesMapAndSelectedPagesId[0][pageId])
- setSelectedPagesId(new Set(Array.from(newSelectedPagesId)))
onSelect(selectedPages)
}, [pagesMapAndSelectedPagesId, onSelect])
@@ -140,16 +145,16 @@ const NotionPageSelector = ({
@@ -168,6 +173,7 @@ const NotionPageSelector = ({
)
: (
): DataSourceNotionPage => ({
page_id: 'page-id',
page_name: 'Page name',
diff --git a/web/app/components/base/notion-page-selector/page-selector/index.tsx b/web/app/components/base/notion-page-selector/page-selector/index.tsx
index 50ac567193..6464539864 100644
--- a/web/app/components/base/notion-page-selector/page-selector/index.tsx
+++ b/web/app/components/base/notion-page-selector/page-selector/index.tsx
@@ -1,11 +1,7 @@
-import type { ListChildComponentProps } from 'react-window'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
-import { memo, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { areEqual, FixedSizeList as List } from 'react-window'
-import { cn } from '@/utils/classnames'
-import Checkbox from '../../checkbox'
-import NotionIcon from '../../notion-icon'
+import { usePageSelectorModel } from './use-page-selector-model'
+import VirtualPageList from './virtual-page-list'
type PageSelectorProps = {
value: Set
@@ -17,173 +13,7 @@ type PageSelectorProps = {
canPreview?: boolean
previewPageId?: string
onPreview?: (selectedPageId: string) => void
- isMultipleChoice?: boolean
}
-type NotionPageTreeItem = {
- children: Set
- descendants: Set
- depth: number
- ancestors: string[]
-} & DataSourceNotionPage
-type NotionPageTreeMap = Record
-type NotionPageItem = {
- expand: boolean
- depth: number
-} & DataSourceNotionPage
-
-const recursivePushInParentDescendants = (
- pagesMap: DataSourceNotionPageMap,
- listTreeMap: NotionPageTreeMap,
- current: NotionPageTreeItem,
- leafItem: NotionPageTreeItem,
-) => {
- const parentId = current.parent_id
- const pageId = current.page_id
-
- if (!parentId || !pageId)
- return
-
- if (parentId !== 'root' && pagesMap[parentId]) {
- if (!listTreeMap[parentId]) {
- const children = new Set([pageId])
- const descendants = new Set([pageId, leafItem.page_id])
- listTreeMap[parentId] = {
- ...pagesMap[parentId],
- children,
- descendants,
- depth: 0,
- ancestors: [],
- }
- }
- else {
- listTreeMap[parentId].children.add(pageId)
- listTreeMap[parentId].descendants.add(pageId)
- listTreeMap[parentId].descendants.add(leafItem.page_id)
- }
- leafItem.depth++
- leafItem.ancestors.unshift(listTreeMap[parentId].page_name)
-
- if (listTreeMap[parentId].parent_id !== 'root')
- recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap[parentId], leafItem)
- }
-}
-
-const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
- dataList: NotionPageItem[]
- handleToggle: (index: number) => void
- checkedIds: Set
- disabledCheckedIds: Set
- handleCheck: (index: number) => void
- canPreview?: boolean
- handlePreview: (index: number) => void
- listMapWithChildrenAndDescendants: NotionPageTreeMap
- searchValue: string
- previewPageId: string
- pagesMap: DataSourceNotionPageMap
-}>) => {
- const { t } = useTranslation()
- const {
- dataList,
- handleToggle,
- checkedIds,
- disabledCheckedIds,
- handleCheck,
- canPreview,
- handlePreview,
- listMapWithChildrenAndDescendants,
- searchValue,
- previewPageId,
- pagesMap,
- } = data
- const current = dataList[index]
- const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id]
- const hasChild = currentWithChildrenAndDescendants.descendants.size > 0
- const ancestors = currentWithChildrenAndDescendants.ancestors
- const breadCrumbs = ancestors.length ? [...ancestors, current.page_name] : [current.page_name]
- const disabled = disabledCheckedIds.has(current.page_id)
-
- const renderArrow = () => {
- if (hasChild) {
- return (
- handleToggle(index)}
- data-testid={`notion-page-toggle-${current.page_id}`}
- >
- {
- current.expand
- ?
- :
- }
-
- )
- }
- if (current.parent_id === 'root' || !pagesMap[current.parent_id]) {
- return (
-
- )
- }
- return (
-
- )
- }
-
- return (
-
- {
- handleCheck(index)
- }}
- id={`notion-page-checkbox-${current.page_id}`}
- />
- {!searchValue && renderArrow()}
-
-
- {current.page_name}
-
- {
- canPreview && (
- handlePreview(index)}
- data-testid={`notion-page-preview-${current.page_id}`}
- >
- {t('dataSource.notion.selector.preview', { ns: 'common' })}
-
- )
- }
- {
- searchValue && (
-
- {breadCrumbs.join(' / ')}
-
- )
- }
-
- )
-}
-const Item = memo(ItemComponent, areEqual)
const PageSelector = ({
value,
@@ -197,108 +27,25 @@ const PageSelector = ({
onPreview,
}: PageSelectorProps) => {
const { t } = useTranslation()
- const [dataList, setDataList] = useState([])
- const [localPreviewPageId, setLocalPreviewPageId] = useState('')
-
- useEffect(() => {
- setDataList(list.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]).map((item) => {
- return {
- ...item,
- expand: false,
- depth: 0,
- }
- }))
- }, [list])
-
- const searchDataList = list.filter((item) => {
- return item.page_name.includes(searchValue)
- }).map((item) => {
- return {
- ...item,
- expand: false,
- depth: 0,
- }
+ const {
+ currentPreviewPageId,
+ effectiveSearchValue,
+ rows,
+ handlePreview,
+ handleSelect,
+ handleToggle,
+ } = usePageSelectorModel({
+ checkedIds: value,
+ list,
+ onPreview,
+ onSelect,
+ pagesMap,
+ previewPageId,
+ searchValue,
+ selectionMode: 'multiple',
})
- const currentDataList = searchValue ? searchDataList : dataList
- const currentPreviewPageId = previewPageId === undefined ? localPreviewPageId : previewPageId
- const listMapWithChildrenAndDescendants = useMemo(() => {
- return list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => {
- const pageId = next.page_id
- if (!prev[pageId])
- prev[pageId] = { ...next, children: new Set(), descendants: new Set(), depth: 0, ancestors: [] }
-
- recursivePushInParentDescendants(pagesMap, prev, prev[pageId], prev[pageId])
- return prev
- }, {})
- }, [list, pagesMap])
-
- const handleToggle = (index: number) => {
- const current = dataList[index]
- const pageId = current.page_id
- const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
- const descendantsIds = Array.from(currentWithChildrenAndDescendants.descendants)
- const childrenIds = Array.from(currentWithChildrenAndDescendants.children)
- let newDataList = []
-
- if (current.expand) {
- current.expand = false
-
- newDataList = dataList.filter(item => !descendantsIds.includes(item.page_id))
- }
- else {
- current.expand = true
-
- newDataList = [
- ...dataList.slice(0, index + 1),
- ...childrenIds.map(item => ({
- ...pagesMap[item],
- expand: false,
- depth: listMapWithChildrenAndDescendants[item].depth,
- })),
- ...dataList.slice(index + 1),
- ]
- }
- setDataList(newDataList)
- }
-
- const copyValue = new Set(value)
- const handleCheck = (index: number) => {
- const current = currentDataList[index]
- const pageId = current.page_id
- const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
-
- if (copyValue.has(pageId)) {
- if (!searchValue) {
- for (const item of currentWithChildrenAndDescendants.descendants)
- copyValue.delete(item)
- }
-
- copyValue.delete(pageId)
- }
- else {
- if (!searchValue) {
- for (const item of currentWithChildrenAndDescendants.descendants)
- copyValue.add(item)
- }
-
- copyValue.add(pageId)
- }
-
- onSelect(new Set(copyValue))
- }
-
- const handlePreview = (index: number) => {
- const current = currentDataList[index]
- const pageId = current.page_id
-
- setLocalPreviewPageId(pageId)
-
- if (onPreview)
- onPreview(pageId)
- }
-
- if (!currentDataList.length) {
+ if (!rows.length) {
return (
{t('dataSource.notion.selector.noSearchResult', { ns: 'common' })}
@@ -307,29 +54,18 @@ const PageSelector = ({
}
return (
- data.dataList[index].page_id}
- itemData={{
- dataList: currentDataList,
- handleToggle,
- checkedIds: value,
- disabledCheckedIds: disabledValue,
- handleCheck,
- canPreview,
- handlePreview,
- listMapWithChildrenAndDescendants,
- searchValue,
- previewPageId: currentPreviewPageId,
- pagesMap,
- }}
- >
- {Item}
-
+
)
}
diff --git a/web/app/components/base/notion-page-selector/page-selector/page-row.tsx b/web/app/components/base/notion-page-selector/page-selector/page-row.tsx
new file mode 100644
index 0000000000..849c8507a8
--- /dev/null
+++ b/web/app/components/base/notion-page-selector/page-selector/page-row.tsx
@@ -0,0 +1,116 @@
+import type { CSSProperties } from 'react'
+import type { NotionPageRow as NotionPageRowData, NotionPageSelectionMode } from './types'
+import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
+import { memo } from 'react'
+import { useTranslation } from 'react-i18next'
+import Checkbox from '@/app/components/base/checkbox'
+import NotionIcon from '@/app/components/base/notion-icon'
+import Radio from '@/app/components/base/radio/ui'
+import { cn } from '@/utils/classnames'
+
+type NotionPageRowProps = {
+ checked: boolean
+ disabled: boolean
+ isPreviewed: boolean
+ onPreview: (pageId: string) => void
+ onSelect: (pageId: string) => void
+ onToggle: (pageId: string) => void
+ row: NotionPageRowData
+ searchValue: string
+ selectionMode: NotionPageSelectionMode
+ showPreview: boolean
+ style: CSSProperties
+}
+
+const NotionPageRow = ({
+ checked,
+ disabled,
+ isPreviewed,
+ onPreview,
+ onSelect,
+ onToggle,
+ row,
+ searchValue,
+ selectionMode,
+ showPreview,
+ style,
+}: NotionPageRowProps) => {
+ const { t } = useTranslation()
+ const pageId = row.page.page_id
+ const breadcrumbs = row.ancestors.length ? [...row.ancestors, row.page.page_name] : [row.page.page_name]
+
+ return (
+
+ {selectionMode === 'multiple'
+ ? (
+ onSelect(pageId)}
+ id={`notion-page-checkbox-${pageId}`}
+ />
+ )
+ : (
+ onSelect(pageId)}
+ />
+ )}
+ {!searchValue && row.hasChild && (
+ onToggle(pageId)}
+ data-testid={`notion-page-toggle-${pageId}`}
+ >
+ {row.expand
+ ?
+ : }
+
+ )}
+ {!searchValue && !row.hasChild && row.parentExists && (
+
+ )}
+
+
+ {row.page.page_name}
+
+ {showPreview && (
+ onPreview(pageId)}
+ data-testid={`notion-page-preview-${pageId}`}
+ >
+ {t('dataSource.notion.selector.preview', { ns: 'common' })}
+
+ )}
+ {searchValue && (
+
+ {breadcrumbs.join(' / ')}
+
+ )}
+
+ )
+}
+
+export default memo(NotionPageRow)
diff --git a/web/app/components/base/notion-page-selector/page-selector/types.ts b/web/app/components/base/notion-page-selector/page-selector/types.ts
new file mode 100644
index 0000000000..c823ac3f7f
--- /dev/null
+++ b/web/app/components/base/notion-page-selector/page-selector/types.ts
@@ -0,0 +1,21 @@
+import type { DataSourceNotionPage } from '@/models/common'
+
+export type NotionPageSelectionMode = 'multiple' | 'single'
+
+export type NotionPageTreeItem = {
+ children: Set
+ descendants: Set
+ depth: number
+ ancestors: string[]
+} & DataSourceNotionPage
+
+export type NotionPageTreeMap = Record
+
+export type NotionPageRow = {
+ page: DataSourceNotionPage
+ parentExists: boolean
+ depth: number
+ expand: boolean
+ hasChild: boolean
+ ancestors: string[]
+}
diff --git a/web/app/components/base/notion-page-selector/page-selector/use-page-selector-model.ts b/web/app/components/base/notion-page-selector/page-selector/use-page-selector-model.ts
new file mode 100644
index 0000000000..ec35ee05f2
--- /dev/null
+++ b/web/app/components/base/notion-page-selector/page-selector/use-page-selector-model.ts
@@ -0,0 +1,88 @@
+import type { NotionPageSelectionMode } from './types'
+import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
+import { startTransition, useCallback, useDeferredValue, useMemo, useState } from 'react'
+import { buildNotionPageTree, getNextSelectedPageIds, getRootPageIds, getVisiblePageRows } from './utils'
+
+type UsePageSelectorModelProps = {
+ checkedIds: Set
+ searchValue: string
+ pagesMap: DataSourceNotionPageMap
+ list: DataSourceNotionPage[]
+ onSelect: (selectedPagesId: Set) => void
+ previewPageId?: string
+ onPreview?: (selectedPageId: string) => void
+ selectionMode: NotionPageSelectionMode
+}
+
+export const usePageSelectorModel = ({
+ checkedIds,
+ searchValue,
+ pagesMap,
+ list,
+ onSelect,
+ previewPageId,
+ onPreview,
+ selectionMode,
+}: UsePageSelectorModelProps) => {
+ const deferredSearchValue = useDeferredValue(searchValue)
+ const [expandedIds, setExpandedIds] = useState>(() => new Set())
+ const [localPreviewPageId, setLocalPreviewPageId] = useState('')
+
+ const treeMap = useMemo(() => buildNotionPageTree(list, pagesMap), [list, pagesMap])
+ const rootPageIds = useMemo(() => getRootPageIds(list, pagesMap), [list, pagesMap])
+
+ const rows = useMemo(() => {
+ return getVisiblePageRows({
+ list,
+ pagesMap,
+ searchValue: deferredSearchValue,
+ treeMap,
+ rootPageIds,
+ expandedIds,
+ })
+ }, [deferredSearchValue, expandedIds, list, pagesMap, rootPageIds, treeMap])
+
+ const currentPreviewPageId = previewPageId ?? localPreviewPageId
+
+ const handleToggle = useCallback((pageId: string) => {
+ startTransition(() => {
+ setExpandedIds((currentExpandedIds) => {
+ const nextExpandedIds = new Set(currentExpandedIds)
+
+ if (nextExpandedIds.has(pageId)) {
+ nextExpandedIds.delete(pageId)
+ treeMap[pageId]?.descendants.forEach(descendantId => nextExpandedIds.delete(descendantId))
+ }
+ else {
+ nextExpandedIds.add(pageId)
+ }
+
+ return nextExpandedIds
+ })
+ })
+ }, [treeMap])
+
+ const handleSelect = useCallback((pageId: string) => {
+ onSelect(getNextSelectedPageIds({
+ checkedIds,
+ pageId,
+ searchValue: deferredSearchValue,
+ selectionMode,
+ treeMap,
+ }))
+ }, [checkedIds, deferredSearchValue, onSelect, selectionMode, treeMap])
+
+ const handlePreview = useCallback((pageId: string) => {
+ setLocalPreviewPageId(pageId)
+ onPreview?.(pageId)
+ }, [onPreview])
+
+ return {
+ currentPreviewPageId,
+ effectiveSearchValue: deferredSearchValue,
+ rows,
+ handlePreview,
+ handleSelect,
+ handleToggle,
+ }
+}
diff --git a/web/app/components/base/notion-page-selector/page-selector/utils.ts b/web/app/components/base/notion-page-selector/page-selector/utils.ts
new file mode 100644
index 0000000000..d9327ddb0f
--- /dev/null
+++ b/web/app/components/base/notion-page-selector/page-selector/utils.ts
@@ -0,0 +1,163 @@
+import type { NotionPageRow, NotionPageSelectionMode, NotionPageTreeItem, NotionPageTreeMap } from './types'
+import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
+
+export const recursivePushInParentDescendants = (
+ pagesMap: DataSourceNotionPageMap,
+ listTreeMap: NotionPageTreeMap,
+ current: NotionPageTreeItem,
+ leafItem: NotionPageTreeItem,
+) => {
+ const parentId = current.parent_id
+ const pageId = current.page_id
+
+ if (!parentId || !pageId)
+ return
+
+ if (parentId !== 'root' && pagesMap[parentId]) {
+ if (!listTreeMap[parentId]) {
+ const children = new Set([pageId])
+ const descendants = new Set([pageId, leafItem.page_id])
+ listTreeMap[parentId] = {
+ ...pagesMap[parentId],
+ children,
+ descendants,
+ depth: 0,
+ ancestors: [],
+ }
+ }
+ else {
+ listTreeMap[parentId].children.add(pageId)
+ listTreeMap[parentId].descendants.add(pageId)
+ listTreeMap[parentId].descendants.add(leafItem.page_id)
+ }
+
+ leafItem.depth++
+ leafItem.ancestors.unshift(listTreeMap[parentId].page_name)
+
+ if (listTreeMap[parentId].parent_id !== 'root')
+ recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap[parentId], leafItem)
+ }
+}
+
+export const buildNotionPageTree = (
+ list: DataSourceNotionPage[],
+ pagesMap: DataSourceNotionPageMap,
+): NotionPageTreeMap => {
+ return list.reduce((prev: NotionPageTreeMap, next) => {
+ const pageId = next.page_id
+ if (!prev[pageId])
+ prev[pageId] = { ...next, children: new Set(), descendants: new Set(), depth: 0, ancestors: [] }
+
+ recursivePushInParentDescendants(pagesMap, prev, prev[pageId], prev[pageId])
+ return prev
+ }, {})
+}
+
+export const getRootPageIds = (
+ list: DataSourceNotionPage[],
+ pagesMap: DataSourceNotionPageMap,
+) => {
+ return list
+ .filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id])
+ .map(item => item.page_id)
+}
+
+export const getVisiblePageRows = ({
+ list,
+ pagesMap,
+ searchValue,
+ treeMap,
+ rootPageIds,
+ expandedIds,
+}: {
+ list: DataSourceNotionPage[]
+ pagesMap: DataSourceNotionPageMap
+ searchValue: string
+ treeMap: NotionPageTreeMap
+ rootPageIds: string[]
+ expandedIds: Set
+}): NotionPageRow[] => {
+ if (searchValue) {
+ return list
+ .filter(item => item.page_name.includes(searchValue))
+ .map(item => ({
+ page: item,
+ parentExists: item.parent_id !== 'root' && Boolean(pagesMap[item.parent_id]),
+ depth: treeMap[item.page_id]?.depth ?? 0,
+ expand: false,
+ hasChild: (treeMap[item.page_id]?.children.size ?? 0) > 0,
+ ancestors: treeMap[item.page_id]?.ancestors ?? [],
+ }))
+ }
+
+ const rows: NotionPageRow[] = []
+
+ const visit = (pageId: string) => {
+ const current = treeMap[pageId]
+ if (!current)
+ return
+
+ const expand = expandedIds.has(pageId)
+ rows.push({
+ page: current,
+ parentExists: current.parent_id !== 'root' && Boolean(pagesMap[current.parent_id]),
+ depth: current.depth,
+ expand,
+ hasChild: current.children.size > 0,
+ ancestors: current.ancestors,
+ })
+
+ if (!expand)
+ return
+
+ current.children.forEach(visit)
+ }
+
+ rootPageIds.forEach(visit)
+
+ return rows
+}
+
+export const getNextSelectedPageIds = ({
+ checkedIds,
+ pageId,
+ searchValue,
+ selectionMode,
+ treeMap,
+}: {
+ checkedIds: Set
+ pageId: string
+ searchValue: string
+ selectionMode: NotionPageSelectionMode
+ treeMap: NotionPageTreeMap
+}) => {
+ const nextCheckedIds = new Set(checkedIds)
+ const descendants = treeMap[pageId]?.descendants ?? new Set()
+
+ if (selectionMode === 'single') {
+ if (nextCheckedIds.has(pageId)) {
+ nextCheckedIds.delete(pageId)
+ }
+ else {
+ nextCheckedIds.clear()
+ nextCheckedIds.add(pageId)
+ }
+
+ return nextCheckedIds
+ }
+
+ if (nextCheckedIds.has(pageId)) {
+ if (!searchValue)
+ descendants.forEach(item => nextCheckedIds.delete(item))
+
+ nextCheckedIds.delete(pageId)
+ return nextCheckedIds
+ }
+
+ if (!searchValue)
+ descendants.forEach(item => nextCheckedIds.add(item))
+
+ nextCheckedIds.add(pageId)
+
+ return nextCheckedIds
+}
diff --git a/web/app/components/base/notion-page-selector/page-selector/virtual-page-list.tsx b/web/app/components/base/notion-page-selector/page-selector/virtual-page-list.tsx
new file mode 100644
index 0000000000..6fec6c0b84
--- /dev/null
+++ b/web/app/components/base/notion-page-selector/page-selector/virtual-page-list.tsx
@@ -0,0 +1,93 @@
+'use client'
+
+import type { NotionPageRow, NotionPageSelectionMode } from './types'
+import { useVirtualizer } from '@tanstack/react-virtual'
+import { useRef } from 'react'
+import PageRow from './page-row'
+
+type VirtualPageListProps = {
+ checkedIds: Set
+ disabledValue: Set
+ onPreview: (pageId: string) => void
+ onSelect: (pageId: string) => void
+ onToggle: (pageId: string) => void
+ previewPageId: string
+ rows: NotionPageRow[]
+ searchValue: string
+ selectionMode: NotionPageSelectionMode
+ showPreview: boolean
+}
+
+const rowHeight = 28
+
+const VirtualPageList = ({
+ checkedIds,
+ disabledValue,
+ onPreview,
+ onSelect,
+ onToggle,
+ previewPageId,
+ rows,
+ searchValue,
+ selectionMode,
+ showPreview,
+}: VirtualPageListProps) => {
+ const scrollRef = useRef(null)
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ estimateSize: () => rowHeight,
+ getScrollElement: () => scrollRef.current,
+ overscan: 6,
+ paddingEnd: 8,
+ paddingStart: 8,
+ })
+
+ const virtualRows = rowVirtualizer.getVirtualItems()
+
+ return (
+
+
+ {virtualRows.map((virtualRow) => {
+ const row = rows[virtualRow.index]
+ const pageId = row.page.page_id
+
+ return (
+
+ )
+ })}
+
+
+ )
+}
+
+export default VirtualPageList
diff --git a/web/app/components/base/pagination/pagination.tsx b/web/app/components/base/pagination/pagination.tsx
index b258090d80..9ce128e497 100644
--- a/web/app/components/base/pagination/pagination.tsx
+++ b/web/app/components/base/pagination/pagination.tsx
@@ -26,7 +26,7 @@ const defaultState: IPagination = {
const PaginationContext: React.Context = React.createContext(defaultState)
-export const PrevButton = ({
+const PrevButton = ({
className,
children,
dataTestId,
@@ -61,7 +61,7 @@ export const PrevButton = ({
)
}
-export const NextButton = ({
+const NextButton = ({
className,
children,
dataTestId,
@@ -117,7 +117,7 @@ const TruncableElement = ({ prev }: ITruncableElementProps) => {
: null
}
-export const PageButton = ({
+const PageButton = ({
as = ,
className,
dataTestIdActive,
diff --git a/web/app/components/base/portal-to-follow-elem/index.tsx b/web/app/components/base/portal-to-follow-elem/index.tsx
index 7d4f6baa9b..932eceee2b 100644
--- a/web/app/components/base/portal-to-follow-elem/index.tsx
+++ b/web/app/components/base/portal-to-follow-elem/index.tsx
@@ -46,7 +46,7 @@ export type PortalToFollowElemOptions = {
}
/** @deprecated Use semantic overlay primitives instead. See #32767. */
-export function usePortalToFollowElem({
+function usePortalToFollowElem({
placement = 'bottom',
open: controlledOpen,
offset: offsetValue = 0,
@@ -114,7 +114,7 @@ type ContextType = ReturnType | null
const PortalToFollowElemContext = React.createContext(null)
-export function usePortalToFollowElemContext() {
+function usePortalToFollowElemContext() {
const context = React.useContext(PortalToFollowElemContext)
if (context == null)
diff --git a/web/app/components/base/premium-badge/index.tsx b/web/app/components/base/premium-badge/index.tsx
index 297e05fe42..1ffff2f7a9 100644
--- a/web/app/components/base/premium-badge/index.tsx
+++ b/web/app/components/base/premium-badge/index.tsx
@@ -66,4 +66,3 @@ const PremiumBadge: React.FC = ({
PremiumBadge.displayName = 'PremiumBadge'
export default PremiumBadge
-export { PremiumBadge, PremiumBadgeVariants }
diff --git a/web/app/components/base/prompt-editor/hooks.ts b/web/app/components/base/prompt-editor/hooks.ts
index 6984d30ee8..49fc6a8eb0 100644
--- a/web/app/components/base/prompt-editor/hooks.ts
+++ b/web/app/components/base/prompt-editor/hooks.ts
@@ -35,7 +35,7 @@ import { DELETE_QUERY_BLOCK_COMMAND } from './plugins/query-block'
import { $isQueryBlockNode } from './plugins/query-block/node'
import { registerLexicalTextEntity } from './utils'
-export type UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand) => [RefObject, boolean]
+type UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand) => [RefObject, boolean]
export const useSelectOrDelete: UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand) => {
const ref = useRef |