Merge remote-tracking branch 'origin/main' into feat/trigger

This commit is contained in:
yessenia
2025-09-25 17:14:24 +08:00
3013 changed files with 148826 additions and 44294 deletions

View File

@@ -35,7 +35,6 @@ if $api_modified; then
status=${status:-0}
if [ $status -ne 0 ]; then
echo "Ruff linter on api module error, exit code: $status"
echo "Please run 'dev/reformat' to fix the fixable linting errors."

144
web/.oxlintrc.json Normal file
View File

@@ -0,0 +1,144 @@
{
"plugins": [
"unicorn",
"typescript",
"oxc"
],
"categories": {},
"rules": {
"for-direction": "error",
"no-async-promise-executor": "error",
"no-caller": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "warn",
"no-const-assign": "warn",
"no-constant-binary-expression": "error",
"no-constant-condition": "warn",
"no-control-regex": "warn",
"no-debugger": "warn",
"no-delete-var": "warn",
"no-dupe-class-members": "warn",
"no-dupe-else-if": "warn",
"no-dupe-keys": "warn",
"no-duplicate-case": "warn",
"no-empty-character-class": "warn",
"no-empty-pattern": "warn",
"no-empty-static-block": "warn",
"no-eval": "warn",
"no-ex-assign": "warn",
"no-extra-boolean-cast": "warn",
"no-func-assign": "warn",
"no-global-assign": "warn",
"no-import-assign": "warn",
"no-invalid-regexp": "warn",
"no-irregular-whitespace": "warn",
"no-loss-of-precision": "warn",
"no-new-native-nonconstructor": "warn",
"no-nonoctal-decimal-escape": "warn",
"no-obj-calls": "warn",
"no-self-assign": "warn",
"no-setter-return": "warn",
"no-shadow-restricted-names": "warn",
"no-sparse-arrays": "warn",
"no-this-before-super": "warn",
"no-unassigned-vars": "warn",
"no-unsafe-finally": "warn",
"no-unsafe-negation": "warn",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "warn",
"no-unused-private-class-members": "warn",
"no-unused-vars": "warn",
"no-useless-backreference": "warn",
"no-useless-catch": "error",
"no-useless-escape": "warn",
"no-useless-rename": "warn",
"no-with": "warn",
"require-yield": "warn",
"use-isnan": "warn",
"valid-typeof": "warn",
"oxc/bad-array-method-on-arguments": "warn",
"oxc/bad-char-at-comparison": "warn",
"oxc/bad-comparison-sequence": "warn",
"oxc/bad-min-max-func": "warn",
"oxc/bad-object-literal-comparison": "warn",
"oxc/bad-replace-all-arg": "warn",
"oxc/const-comparisons": "warn",
"oxc/double-comparisons": "warn",
"oxc/erasing-op": "warn",
"oxc/missing-throw": "warn",
"oxc/number-arg-out-of-range": "warn",
"oxc/only-used-in-recursion": "warn",
"oxc/uninvoked-array-callback": "warn",
"typescript/await-thenable": "warn",
"typescript/no-array-delete": "warn",
"typescript/no-base-to-string": "warn",
"typescript/no-confusing-void-expression": "warn",
"typescript/no-duplicate-enum-values": "warn",
"typescript/no-duplicate-type-constituents": "warn",
"typescript/no-extra-non-null-assertion": "warn",
"typescript/no-floating-promises": "warn",
"typescript/no-for-in-array": "warn",
"typescript/no-implied-eval": "warn",
"typescript/no-meaningless-void-operator": "warn",
"typescript/no-misused-new": "warn",
"typescript/no-misused-spread": "warn",
"typescript/no-non-null-asserted-optional-chain": "warn",
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-this-alias": "warn",
"typescript/no-unnecessary-parameter-property-assignment": "warn",
"typescript/no-unsafe-declaration-merging": "warn",
"typescript/no-unsafe-unary-minus": "warn",
"typescript/no-useless-empty-export": "warn",
"typescript/no-wrapper-object-types": "warn",
"typescript/prefer-as-const": "warn",
"typescript/require-array-sort-compare": "warn",
"typescript/restrict-template-expressions": "warn",
"typescript/triple-slash-reference": "warn",
"typescript/unbound-method": "warn",
"unicorn/no-await-in-promise-methods": "warn",
"unicorn/no-empty-file": "warn",
"unicorn/no-invalid-fetch-options": "warn",
"unicorn/no-invalid-remove-event-listener": "warn",
"unicorn/no-new-array": "warn",
"unicorn/no-single-promise-in-promise-methods": "warn",
"unicorn/no-thenable": "warn",
"unicorn/no-unnecessary-await": "warn",
"unicorn/no-useless-fallback-in-spread": "warn",
"unicorn/no-useless-length-check": "warn",
"unicorn/no-useless-spread": "warn",
"unicorn/prefer-set-size": "warn",
"unicorn/prefer-string-starts-ends-with": "warn"
},
"settings": {
"jsx-a11y": {
"polymorphicPropName": null,
"components": {},
"attributes": {}
},
"next": {
"rootDir": []
},
"react": {
"formComponents": [],
"linkComponents": []
},
"jsdoc": {
"ignorePrivate": false,
"ignoreInternal": false,
"ignoreReplacesDocs": true,
"overrideReplacesDocs": true,
"augmentsExtendsReplacesDocs": false,
"implementsReplacesDocs": false,
"exemptDestructuredRootsFromChecks": false,
"tagNamePreference": {}
}
},
"env": {
"builtin": true
},
"globals": {},
"ignorePatterns": [
"**/*.js"
]
}

15
web/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
]
}

View File

@@ -0,0 +1,235 @@
import type { ActionItem } from '../../app/components/goto-anything/actions/types'
// Mock the entire actions module to avoid import issues
jest.mock('../../app/components/goto-anything/actions', () => ({
matchAction: jest.fn(),
}))
jest.mock('../../app/components/goto-anything/actions/commands/registry')
// Import after mocking to get mocked version
import { matchAction } from '../../app/components/goto-anything/actions'
import { slashCommandRegistry } from '../../app/components/goto-anything/actions/commands/registry'
// Implement the actual matchAction logic for testing
const actualMatchAction = (query: string, actions: Record<string, ActionItem>) => {
const result = Object.values(actions).find((action) => {
// Special handling for slash commands
if (action.key === '/') {
// Get all registered commands from the registry
const allCommands = slashCommandRegistry.getAllCommands()
// Check if query matches any registered command
return allCommands.some((cmd) => {
const cmdPattern = `/${cmd.name}`
// For direct mode commands, don't match (keep in command selector)
if (cmd.mode === 'direct')
return false
// For submenu mode commands, match when complete command is entered
return query === cmdPattern || query.startsWith(`${cmdPattern} `)
})
}
const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`)
return reg.test(query)
})
return result
}
// Replace mock with actual implementation
;(matchAction as jest.Mock).mockImplementation(actualMatchAction)
describe('matchAction Logic', () => {
const mockActions: Record<string, ActionItem> = {
app: {
key: '@app',
shortcut: '@a',
title: 'Search Applications',
description: 'Search apps',
search: jest.fn(),
},
knowledge: {
key: '@knowledge',
shortcut: '@kb',
title: 'Search Knowledge',
description: 'Search knowledge bases',
search: jest.fn(),
},
slash: {
key: '/',
shortcut: '/',
title: 'Commands',
description: 'Execute commands',
search: jest.fn(),
},
}
beforeEach(() => {
jest.clearAllMocks()
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([
{ name: 'docs', mode: 'direct' },
{ name: 'community', mode: 'direct' },
{ name: 'feedback', mode: 'direct' },
{ name: 'account', mode: 'direct' },
{ name: 'theme', mode: 'submenu' },
{ name: 'language', mode: 'submenu' },
])
})
describe('@ Actions Matching', () => {
it('should match @app with key', () => {
const result = matchAction('@app', mockActions)
expect(result).toBe(mockActions.app)
})
it('should match @app with shortcut', () => {
const result = matchAction('@a', mockActions)
expect(result).toBe(mockActions.app)
})
it('should match @knowledge with key', () => {
const result = matchAction('@knowledge', mockActions)
expect(result).toBe(mockActions.knowledge)
})
it('should match @knowledge with shortcut @kb', () => {
const result = matchAction('@kb', mockActions)
expect(result).toBe(mockActions.knowledge)
})
it('should match with text after action', () => {
const result = matchAction('@app search term', mockActions)
expect(result).toBe(mockActions.app)
})
it('should not match partial @ actions', () => {
const result = matchAction('@ap', mockActions)
expect(result).toBeUndefined()
})
})
describe('Slash Commands Matching', () => {
describe('Direct Mode Commands', () => {
it('should not match direct mode commands', () => {
const result = matchAction('/docs', mockActions)
expect(result).toBeUndefined()
})
it('should not match direct mode with arguments', () => {
const result = matchAction('/docs something', mockActions)
expect(result).toBeUndefined()
})
it('should not match any direct mode command', () => {
expect(matchAction('/community', mockActions)).toBeUndefined()
expect(matchAction('/feedback', mockActions)).toBeUndefined()
expect(matchAction('/account', mockActions)).toBeUndefined()
})
})
describe('Submenu Mode Commands', () => {
it('should match submenu mode commands exactly', () => {
const result = matchAction('/theme', mockActions)
expect(result).toBe(mockActions.slash)
})
it('should match submenu mode with arguments', () => {
const result = matchAction('/theme dark', mockActions)
expect(result).toBe(mockActions.slash)
})
it('should match all submenu commands', () => {
expect(matchAction('/language', mockActions)).toBe(mockActions.slash)
expect(matchAction('/language en', mockActions)).toBe(mockActions.slash)
})
})
describe('Slash Without Command', () => {
it('should not match single slash', () => {
const result = matchAction('/', mockActions)
expect(result).toBeUndefined()
})
it('should not match unregistered commands', () => {
const result = matchAction('/unknown', mockActions)
expect(result).toBeUndefined()
})
})
})
describe('Edge Cases', () => {
it('should handle empty query', () => {
const result = matchAction('', mockActions)
expect(result).toBeUndefined()
})
it('should handle whitespace only', () => {
const result = matchAction(' ', mockActions)
expect(result).toBeUndefined()
})
it('should handle regular text without actions', () => {
const result = matchAction('search something', mockActions)
expect(result).toBeUndefined()
})
it('should handle special characters', () => {
const result = matchAction('#tag', mockActions)
expect(result).toBeUndefined()
})
it('should handle multiple @ or /', () => {
expect(matchAction('@@app', mockActions)).toBeUndefined()
expect(matchAction('//theme', mockActions)).toBeUndefined()
})
})
describe('Mode-based Filtering', () => {
it('should filter direct mode commands from matching', () => {
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([
{ name: 'test', mode: 'direct' },
])
const result = matchAction('/test', mockActions)
expect(result).toBeUndefined()
})
it('should allow submenu mode commands to match', () => {
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([
{ name: 'test', mode: 'submenu' },
])
const result = matchAction('/test', mockActions)
expect(result).toBe(mockActions.slash)
})
it('should treat undefined mode as submenu', () => {
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([
{ name: 'test' }, // No mode specified
])
const result = matchAction('/test', mockActions)
expect(result).toBe(mockActions.slash)
})
})
describe('Registry Integration', () => {
it('should call getAllCommands when matching slash', () => {
matchAction('/theme', mockActions)
expect(slashCommandRegistry.getAllCommands).toHaveBeenCalled()
})
it('should not call getAllCommands for @ actions', () => {
matchAction('@app', mockActions)
expect(slashCommandRegistry.getAllCommands).not.toHaveBeenCalled()
})
it('should handle empty command list', () => {
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([])
const result = matchAction('/anything', mockActions)
expect(result).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,134 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
// Type alias for search mode
type SearchMode = 'scopes' | 'commands' | null
// Mock component to test tag display logic
const TagDisplay: React.FC<{ searchMode: SearchMode }> = ({ searchMode }) => {
if (!searchMode) return null
return (
<div className="flex items-center gap-1 text-xs text-text-tertiary">
<span>{searchMode === 'scopes' ? 'SCOPES' : 'COMMANDS'}</span>
</div>
)
}
describe('Scope and Command Tags', () => {
describe('Tag Display Logic', () => {
it('should display SCOPES for @ actions', () => {
render(<TagDisplay searchMode="scopes" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument()
})
it('should display COMMANDS for / actions', () => {
render(<TagDisplay searchMode="commands" />)
expect(screen.getByText('COMMANDS')).toBeInTheDocument()
expect(screen.queryByText('SCOPES')).not.toBeInTheDocument()
})
it('should not display any tag when searchMode is null', () => {
const { container } = render(<TagDisplay searchMode={null} />)
expect(container.firstChild).toBeNull()
})
})
describe('Search Mode Detection', () => {
const getSearchMode = (query: string): SearchMode => {
if (query.startsWith('@')) return 'scopes'
if (query.startsWith('/')) return 'commands'
return null
}
it('should detect scopes mode for @ queries', () => {
expect(getSearchMode('@app')).toBe('scopes')
expect(getSearchMode('@knowledge')).toBe('scopes')
expect(getSearchMode('@plugin')).toBe('scopes')
expect(getSearchMode('@node')).toBe('scopes')
})
it('should detect commands mode for / queries', () => {
expect(getSearchMode('/theme')).toBe('commands')
expect(getSearchMode('/language')).toBe('commands')
expect(getSearchMode('/docs')).toBe('commands')
})
it('should return null for regular queries', () => {
expect(getSearchMode('')).toBe(null)
expect(getSearchMode('search term')).toBe(null)
expect(getSearchMode('app')).toBe(null)
})
it('should handle queries with spaces', () => {
expect(getSearchMode('@app search')).toBe('scopes')
expect(getSearchMode('/theme dark')).toBe('commands')
})
})
describe('Tag Styling', () => {
it('should apply correct styling classes', () => {
const { container } = render(<TagDisplay searchMode="scopes" />)
const tagContainer = container.querySelector('.flex.items-center.gap-1.text-xs.text-text-tertiary')
expect(tagContainer).toBeInTheDocument()
})
it('should use hardcoded English text', () => {
// Verify that tags are hardcoded and not using i18n
render(<TagDisplay searchMode="scopes" />)
const scopesText = screen.getByText('SCOPES')
expect(scopesText.textContent).toBe('SCOPES')
render(<TagDisplay searchMode="commands" />)
const commandsText = screen.getByText('COMMANDS')
expect(commandsText.textContent).toBe('COMMANDS')
})
})
describe('Integration with Search States', () => {
const SearchComponent: React.FC<{ query: string }> = ({ query }) => {
let searchMode: SearchMode = null
if (query.startsWith('@')) searchMode = 'scopes'
else if (query.startsWith('/')) searchMode = 'commands'
return (
<div>
<input value={query} readOnly />
<TagDisplay searchMode={searchMode} />
</div>
)
}
it('should update tag when switching between @ and /', () => {
const { rerender } = render(<SearchComponent query="@app" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
rerender(<SearchComponent query="/theme" />)
expect(screen.queryByText('SCOPES')).not.toBeInTheDocument()
expect(screen.getByText('COMMANDS')).toBeInTheDocument()
})
it('should hide tag when clearing search', () => {
const { rerender } = render(<SearchComponent query="@app" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
rerender(<SearchComponent query="" />)
expect(screen.queryByText('SCOPES')).not.toBeInTheDocument()
expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument()
})
it('should maintain correct tag during search refinement', () => {
const { rerender } = render(<SearchComponent query="@" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
rerender(<SearchComponent query="@app" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
rerender(<SearchComponent query="@app test" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,212 @@
import '@testing-library/jest-dom'
import { slashCommandRegistry } from '../../app/components/goto-anything/actions/commands/registry'
import type { SlashCommandHandler } from '../../app/components/goto-anything/actions/commands/types'
// Mock the registry
jest.mock('../../app/components/goto-anything/actions/commands/registry')
describe('Slash Command Dual-Mode System', () => {
const mockDirectCommand: SlashCommandHandler = {
name: 'docs',
description: 'Open documentation',
mode: 'direct',
execute: jest.fn(),
search: jest.fn().mockResolvedValue([
{
id: 'docs',
title: 'Documentation',
description: 'Open documentation',
type: 'command' as const,
data: { command: 'navigation.docs', args: {} },
},
]),
register: jest.fn(),
unregister: jest.fn(),
}
const mockSubmenuCommand: SlashCommandHandler = {
name: 'theme',
description: 'Change theme',
mode: 'submenu',
search: jest.fn().mockResolvedValue([
{
id: 'theme-light',
title: 'Light Theme',
description: 'Switch to light theme',
type: 'command' as const,
data: { command: 'theme.set', args: { theme: 'light' } },
},
{
id: 'theme-dark',
title: 'Dark Theme',
description: 'Switch to dark theme',
type: 'command' as const,
data: { command: 'theme.set', args: { theme: 'dark' } },
},
]),
register: jest.fn(),
unregister: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
;(slashCommandRegistry as any).findCommand = jest.fn((name: string) => {
if (name === 'docs') return mockDirectCommand
if (name === 'theme') return mockSubmenuCommand
return null
})
;(slashCommandRegistry as any).getAllCommands = jest.fn(() => [
mockDirectCommand,
mockSubmenuCommand,
])
})
describe('Direct Mode Commands', () => {
it('should execute immediately when selected', () => {
const mockSetShow = jest.fn()
const mockSetSearchQuery = jest.fn()
// Simulate command selection
const handler = slashCommandRegistry.findCommand('docs')
expect(handler?.mode).toBe('direct')
if (handler?.mode === 'direct' && handler.execute) {
handler.execute()
mockSetShow(false)
mockSetSearchQuery('')
}
expect(mockDirectCommand.execute).toHaveBeenCalled()
expect(mockSetShow).toHaveBeenCalledWith(false)
expect(mockSetSearchQuery).toHaveBeenCalledWith('')
})
it('should not enter submenu for direct mode commands', () => {
const handler = slashCommandRegistry.findCommand('docs')
expect(handler?.mode).toBe('direct')
expect(handler?.execute).toBeDefined()
})
it('should close modal after execution', () => {
const mockModalClose = jest.fn()
const handler = slashCommandRegistry.findCommand('docs')
if (handler?.mode === 'direct' && handler.execute) {
handler.execute()
mockModalClose()
}
expect(mockModalClose).toHaveBeenCalled()
})
})
describe('Submenu Mode Commands', () => {
it('should show options instead of executing immediately', async () => {
const handler = slashCommandRegistry.findCommand('theme')
expect(handler?.mode).toBe('submenu')
const results = await handler?.search('', 'en')
expect(results).toHaveLength(2)
expect(results?.[0].title).toBe('Light Theme')
expect(results?.[1].title).toBe('Dark Theme')
})
it('should not have execute function for submenu mode', () => {
const handler = slashCommandRegistry.findCommand('theme')
expect(handler?.mode).toBe('submenu')
expect(handler?.execute).toBeUndefined()
})
it('should keep modal open for selection', () => {
const mockModalClose = jest.fn()
const handler = slashCommandRegistry.findCommand('theme')
// For submenu mode, modal should not close immediately
expect(handler?.mode).toBe('submenu')
expect(mockModalClose).not.toHaveBeenCalled()
})
})
describe('Mode Detection and Routing', () => {
it('should correctly identify direct mode commands', () => {
const commands = slashCommandRegistry.getAllCommands()
const directCommands = commands.filter(cmd => cmd.mode === 'direct')
const submenuCommands = commands.filter(cmd => cmd.mode === 'submenu')
expect(directCommands).toContainEqual(expect.objectContaining({ name: 'docs' }))
expect(submenuCommands).toContainEqual(expect.objectContaining({ name: 'theme' }))
})
it('should handle missing mode property gracefully', () => {
const commandWithoutMode: SlashCommandHandler = {
name: 'test',
description: 'Test command',
search: jest.fn(),
register: jest.fn(),
unregister: jest.fn(),
}
;(slashCommandRegistry as any).findCommand = jest.fn(() => commandWithoutMode)
const handler = slashCommandRegistry.findCommand('test')
// Default behavior should be submenu when mode is not specified
expect(handler?.mode).toBeUndefined()
expect(handler?.execute).toBeUndefined()
})
})
describe('Enter Key Handling', () => {
// Helper function to simulate key handler behavior
const createKeyHandler = () => {
return (commandKey: string) => {
if (commandKey.startsWith('/')) {
const commandName = commandKey.substring(1)
const handler = slashCommandRegistry.findCommand(commandName)
if (handler?.mode === 'direct' && handler.execute) {
handler.execute()
return true // Indicates handled
}
}
return false
}
}
it('should trigger direct execution on Enter for direct mode', () => {
const keyHandler = createKeyHandler()
const handled = keyHandler('/docs')
expect(handled).toBe(true)
expect(mockDirectCommand.execute).toHaveBeenCalled()
})
it('should not trigger direct execution for submenu mode', () => {
const keyHandler = createKeyHandler()
const handled = keyHandler('/theme')
expect(handled).toBe(false)
expect(mockSubmenuCommand.search).not.toHaveBeenCalled()
})
})
describe('Command Registration', () => {
it('should register both direct and submenu commands', () => {
mockDirectCommand.register?.({})
mockSubmenuCommand.register?.({ setTheme: jest.fn() })
expect(mockDirectCommand.register).toHaveBeenCalled()
expect(mockSubmenuCommand.register).toHaveBeenCalled()
})
it('should handle unregistration for both command types', () => {
// Test unregister for direct command
mockDirectCommand.unregister?.()
expect(mockDirectCommand.unregister).toHaveBeenCalled()
// Test unregister for submenu command
mockSubmenuCommand.unregister?.()
expect(mockSubmenuCommand.unregister).toHaveBeenCalled()
// Verify both were called independently
expect(mockDirectCommand.unregister).toHaveBeenCalledTimes(1)
expect(mockSubmenuCommand.unregister).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -49,10 +49,10 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const { appDetail, setAppDetail, setAppSiderbarExpand } = useStore(useShallow(state => ({
const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({
appDetail: state.appDetail,
setAppDetail: state.setAppDetail,
setAppSiderbarExpand: state.setAppSiderbarExpand,
setAppSidebarExpand: state.setAppSidebarExpand,
})))
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false)
@@ -64,8 +64,8 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
selectedIcon: NavIcon
}>>([])
const getNavigations = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: string) => {
const navs = [
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: string) => {
const navConfig = [
...(isCurrentWorkspaceEditor
? [{
name: t('common.appMenus.promptEng'),
@@ -99,8 +99,8 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
selectedIcon: RiDashboard2Fill,
},
]
return navs
}, [])
return navConfig
}, [t])
useDocumentTitle(appDetail?.name || t('common.menus.appDetail'))
@@ -108,10 +108,10 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
if (appDetail) {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSiderbarExpand(isMobile ? mode : localeMode)
setAppSidebarExpand(isMobile ? mode : localeMode)
// TODO: consider screen size and mode
// if ((appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow') && (pathname).endsWith('workflow'))
// setAppSiderbarExpand('collapse')
// setAppSidebarExpand('collapse')
}
}, [appDetail, isMobile])
@@ -146,7 +146,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}
else {
setAppDetail({ ...res, enable_sso: false })
setNavigation(getNavigations(appId, isCurrentWorkspaceEditor, res.mode))
setNavigation(getNavigationConfig(appId, isCurrentWorkspaceEditor, res.mode))
}
}, [appDetailRes, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace])
@@ -165,7 +165,9 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
return (
<div className={cn(s.app, 'relative flex', 'overflow-hidden')}>
{appDetail && (
<AppSideBar title={appDetail.name} icon={appDetail.icon} icon_background={appDetail.icon_background as string} desc={appDetail.mode} navigation={navigation} />
<AppSideBar
navigation={navigation}
/>
)}
<div className="grow overflow-hidden bg-components-panel-bg">
{children}

View File

@@ -27,7 +27,7 @@ const I18N_PREFIX = 'app.tracing'
const Panel: FC = () => {
const { t } = useTranslation()
const pathname = usePathname()
const matched = pathname.match(/\/app\/([^/]+)/)
const matched = /\/app\/([^/]+)/.exec(pathname)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const { isCurrentWorkspaceEditor } = useAppContext()
const readOnly = !isCurrentWorkspaceEditor

View File

@@ -0,0 +1,10 @@
import React from 'react'
import CreateFromPipeline from '@/app/components/datasets/documents/create-from-pipeline'
const CreateFromPipelinePage = async () => {
return (
<CreateFromPipeline />
)
}
export default CreateFromPipelinePage

View File

@@ -1,9 +1,9 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useMemo } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { usePathname } from 'next/navigation'
import useSWR from 'swr'
import { useTranslation } from 'react-i18next'
import type { RemixiconComponentType } from '@remixicon/react'
import {
RiEqualizer2Fill,
RiEqualizer2Line,
@@ -12,188 +12,135 @@ import {
RiFocus2Fill,
RiFocus2Line,
} from '@remixicon/react'
import {
PaperClipIcon,
} from '@heroicons/react/24/outline'
import { RiApps2AddLine, RiBookOpenLine, RiInformation2Line } from '@remixicon/react'
import classNames from '@/utils/classnames'
import { fetchDatasetDetail, fetchDatasetRelatedApps } from '@/service/datasets'
import type { RelatedAppResponse } from '@/models/datasets'
import AppSideBar from '@/app/components/app-sidebar'
import Loading from '@/app/components/base/loading'
import DatasetDetailContext from '@/context/dataset-detail'
import { DataSourceType } from '@/models/datasets'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useStore } from '@/app/components/app/store'
import { useDocLink } from '@/context/i18n'
import { useAppContext } from '@/context/app-context'
import Tooltip from '@/app/components/base/tooltip'
import LinkedAppsPanel from '@/app/components/base/linked-apps-panel'
import { PipelineFill, PipelineLine } from '@/app/components/base/icons/src/vender/pipeline'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import useDocumentTitle from '@/hooks/use-document-title'
import ExtraInfo from '@/app/components/datasets/extra-info'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import cn from '@/utils/classnames'
export type IAppDetailLayoutProps = {
children: React.ReactNode
params: { datasetId: string }
}
type IExtraInfoProps = {
isMobile: boolean
relatedApps?: RelatedAppResponse
expand: boolean
}
const ExtraInfo = ({ isMobile, relatedApps, expand }: IExtraInfoProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
const hasRelatedApps = relatedApps?.data && relatedApps?.data?.length > 0
const relatedAppsTotal = relatedApps?.data?.length || 0
return <div>
{/* Related apps for desktop */}
<div className={classNames(
'transition-all duration-200 ease-in-out',
(hasRelatedApps && !isMobile)
? 'w-auto opacity-100'
: 'pointer-events-none h-0 w-0 overflow-hidden opacity-0',
)}>
<Tooltip
position='right'
noDecoration
popupContent={
<LinkedAppsPanel
relatedApps={relatedApps?.data || []}
isMobile={isMobile}
/>
}
>
<div className='system-xs-medium-uppercase inline-flex cursor-pointer items-center space-x-1 whitespace-nowrap text-text-secondary'>
<span>{relatedAppsTotal || '--'} {t('common.datasetMenus.relatedApp')}</span>
<RiInformation2Line className='h-4 w-4' />
</div>
</Tooltip>
</div>
{/* Related apps for mobile */}
<div className={classNames(
'transition-all duration-200 ease-in-out',
(hasRelatedApps && isMobile)
? 'w-auto opacity-100'
: 'pointer-events-none h-0 w-0 overflow-hidden opacity-0',
)}>
<div className={classNames('pb-2 pt-4 text-xs font-medium uppercase text-text-tertiary', 'flex items-center justify-center gap-1 whitespace-nowrap !px-0')}>
{relatedAppsTotal || '--'}
<PaperClipIcon className='h-4 w-4 text-text-secondary' />
</div>
</div>
{/* No related apps tooltip */}
<div className={classNames(
'transition-all duration-200 ease-in-out',
(!hasRelatedApps && !expand)
? 'w-auto opacity-100'
: 'pointer-events-none h-0 w-0 overflow-hidden opacity-0',
)}>
<Tooltip
position='right'
noDecoration
popupContent={
<div className='w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-4'>
<div className='inline-flex rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle p-2'>
<RiApps2AddLine className='h-4 w-4 text-text-tertiary' />
</div>
<div className='my-2 text-xs text-text-tertiary'>{t('common.datasetMenus.emptyTip')}</div>
<a
className='mt-2 inline-flex cursor-pointer items-center text-xs text-text-accent'
href={docLink('/guides/knowledge-base/integrate-knowledge-within-application')}
target='_blank' rel='noopener noreferrer'
>
<RiBookOpenLine className='mr-1 text-text-accent' />
{t('common.datasetMenus.viewDoc')}
</a>
</div>
}
>
<div className='system-xs-medium-uppercase inline-flex cursor-pointer items-center space-x-1 whitespace-nowrap text-text-secondary'>
<span>{t('common.datasetMenus.noRelatedApp')}</span>
<RiInformation2Line className='h-4 w-4' />
</div>
</Tooltip>
</div>
</div>
}
const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const {
children,
params: { datasetId },
} = props
const pathname = usePathname()
const hideSideBar = /documents\/create$/.test(pathname)
const { t } = useTranslation()
const pathname = usePathname()
const hideSideBar = pathname.endsWith('documents/create') || pathname.endsWith('documents/create-from-pipeline')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
if (v?.type === 'workflow-canvas-maximize')
setHideHeader(v.payload)
})
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { data: datasetRes, error, mutate: mutateDatasetRes } = useSWR({
url: 'fetchDatasetDetail',
datasetId,
}, apiParams => fetchDatasetDetail(apiParams.datasetId))
const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId)
const { data: relatedApps } = useSWR({
action: 'fetchDatasetRelatedApps',
datasetId,
}, apiParams => fetchDatasetRelatedApps(apiParams.datasetId))
const { data: relatedApps } = useDatasetRelatedApps(datasetId)
const isButtonDisabledWithPipeline = useMemo(() => {
if (!datasetRes)
return true
if (datasetRes.provider === 'external')
return false
if (datasetRes.runtime_mode === 'general')
return false
return !datasetRes.is_published
}, [datasetRes])
const navigation = useMemo(() => {
const baseNavigation = [
{ name: t('common.datasetMenus.hitTesting'), href: `/datasets/${datasetId}/hitTesting`, icon: RiFocus2Line, selectedIcon: RiFocus2Fill },
{ name: t('common.datasetMenus.settings'), href: `/datasets/${datasetId}/settings`, icon: RiEqualizer2Line, selectedIcon: RiEqualizer2Fill },
{
name: t('common.datasetMenus.hitTesting'),
href: `/datasets/${datasetId}/hitTesting`,
icon: RiFocus2Line,
selectedIcon: RiFocus2Fill,
disabled: isButtonDisabledWithPipeline,
},
{
name: t('common.datasetMenus.settings'),
href: `/datasets/${datasetId}/settings`,
icon: RiEqualizer2Line,
selectedIcon: RiEqualizer2Fill,
disabled: false,
},
]
if (datasetRes?.provider !== 'external') {
baseNavigation.unshift({
name: t('common.datasetMenus.pipeline'),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
})
baseNavigation.unshift({
name: t('common.datasetMenus.documents'),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
})
}
return baseNavigation
}, [datasetRes?.provider, datasetId, t])
}, [t, datasetId, isButtonDisabledWithPipeline, datasetRes?.provider])
useDocumentTitle(datasetRes?.name || t('common.menus.datasets'))
const setAppSiderbarExpand = useStore(state => state.setAppSiderbarExpand)
const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand)
useEffect(() => {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSiderbarExpand(isMobile ? mode : localeMode)
}, [isMobile, setAppSiderbarExpand])
setAppSidebarExpand(isMobile ? mode : localeMode)
}, [isMobile, setAppSidebarExpand])
if (!datasetRes && !error)
return <Loading type='app' />
return (
<div className='flex grow overflow-hidden'>
{!hideSideBar && <AppSideBar
title={datasetRes?.name || '--'}
icon={datasetRes?.icon || 'https://static.dify.ai/images/dataset-default-icon.png'}
icon_background={datasetRes?.icon_background || '#F5F5F5'}
desc={datasetRes?.description || '--'}
isExternal={datasetRes?.provider === 'external'}
navigation={navigation}
extraInfo={!isCurrentWorkspaceDatasetOperator ? mode => <ExtraInfo isMobile={mode === 'collapse'} relatedApps={relatedApps} expand={mode === 'collapse'} /> : undefined}
iconType={datasetRes?.data_source_type === DataSourceType.NOTION ? 'notion' : 'dataset'}
/>}
<div
className={cn(
'flex grow overflow-hidden',
hideHeader && isPipelineCanvas ? '' : 'rounded-t-2xl border-t border-effects-highlight',
)}
>
<DatasetDetailContext.Provider value={{
indexingTechnique: datasetRes?.indexing_technique,
dataset: datasetRes,
mutateDatasetRes: () => mutateDatasetRes(),
mutateDatasetRes,
}}>
<div className="grow overflow-hidden bg-background-default-subtle">{children}</div>
{!hideSideBar && (
<AppSideBar
navigation={navigation}
extraInfo={
!isCurrentWorkspaceDatasetOperator
? mode => <ExtraInfo relatedApps={relatedApps} expand={mode === 'expand'} documentCount={datasetRes?.document_count} />
: undefined
}
iconType='dataset'
/>
)}
<div className='grow overflow-hidden bg-background-default-subtle'>{children}</div>
</DatasetDetailContext.Provider>
</div>
)

View File

@@ -0,0 +1,11 @@
'use client'
import RagPipeline from '@/app/components/rag-pipeline'
const PipelinePage = () => {
return (
<div className='h-full w-full overflow-x-auto'>
<RagPipeline />
</div>
)
}
export default PipelinePage

View File

@@ -8,8 +8,8 @@ const Settings = async () => {
return (
<div className='h-full overflow-y-auto'>
<div className='px-6 py-3'>
<div className='system-xl-semibold mb-1 text-text-primary'>{t('title')}</div>
<div className='flex flex-col gap-y-0.5 px-6 pb-2 pt-3'>
<div className='system-xl-semibold text-text-primary'>{t('title')}</div>
<div className='system-sm-regular text-text-tertiary'>{t('desc')}</div>
</div>
<Form />

View File

@@ -1,143 +0,0 @@
'use client'
// Libraries
import { useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { useBoolean, useDebounceFn } from 'ahooks'
import { useQuery } from '@tanstack/react-query'
// Components
import ExternalAPIPanel from '../../components/datasets/external-api/external-api-panel'
import Datasets from './datasets'
import DatasetFooter from './dataset-footer'
import ApiServer from '../../components/develop/ApiServer'
import Doc from './doc'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import TagManagementModal from '@/app/components/base/tag-management'
import TagFilter from '@/app/components/base/tag-management/filter'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
// Services
import { fetchDatasetApiBaseUrl } from '@/service/datasets'
// Hooks
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { useAppContext } from '@/context/app-context'
import { useExternalApiPanel } from '@/context/external-api-panel-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
const Container = () => {
const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
const router = useRouter()
const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel()
const [includeAll, { toggle: toggleIncludeAll }] = useBoolean(false)
useDocumentTitle(t('dataset.knowledge'))
const options = useMemo(() => {
return [
{ value: 'dataset', text: t('dataset.datasets') },
...(currentWorkspace.role === 'dataset_operator' ? [] : [{ value: 'api', text: t('dataset.datasetsApi') }]),
]
}, [currentWorkspace.role, t])
const [activeTab, setActiveTab] = useTabSearchParams({
defaultTab: 'dataset',
})
const containerRef = useRef<HTMLDivElement>(null)
const { data } = useQuery(
{
queryKey: ['datasetApiBaseInfo'],
queryFn: () => fetchDatasetApiBaseUrl('/datasets/api-base-info'),
enabled: activeTab !== 'dataset',
},
)
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const [tagFilterValue, setTagFilterValue] = useState<string[]>([])
const [tagIDs, setTagIDs] = useState<string[]>([])
const { run: handleTagsUpdate } = useDebounceFn(() => {
setTagIDs(tagFilterValue)
}, { wait: 500 })
const handleTagsChange = (value: string[]) => {
setTagFilterValue(value)
handleTagsUpdate()
}
useEffect(() => {
if (currentWorkspace.role === 'normal')
return router.replace('/apps')
}, [currentWorkspace, router])
return (
<div ref={containerRef} className={`scroll-container relative flex grow flex-col overflow-y-auto rounded-t-xl outline-none ${activeTab === 'dataset' ? 'bg-background-body' : 'bg-components-panel-bg'}`}>
<div className={`sticky top-0 z-10 flex shrink-0 flex-wrap items-center justify-between gap-y-2 rounded-t-xl px-6 py-2 ${activeTab === 'api' ? 'border-b border-solid border-b-divider-regular' : ''} ${activeTab === 'dataset' ? 'bg-background-body' : 'bg-components-panel-bg'}`}>
<TabSliderNew
value={activeTab}
onChange={newActiveTab => setActiveTab(newActiveTab)}
options={options}
/>
{activeTab === 'dataset' && (
<div className='flex items-center justify-center gap-2'>
{isCurrentWorkspaceOwner && <CheckboxWithLabel
isChecked={includeAll}
onChange={toggleIncludeAll}
label={t('dataset.allKnowledge')}
labelClassName='system-md-regular text-text-secondary'
className='mr-2'
tooltip={t('dataset.allKnowledgeDescription') as string}
/>}
<TagFilter type='knowledge' value={tagFilterValue} onChange={handleTagsChange} />
<Input
showLeftIcon
showClearIcon
wrapperClassName='w-[200px]'
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
<div className="h-4 w-[1px] bg-divider-regular" />
<Button
className='shadows-shadow-xs gap-0.5'
onClick={() => setShowExternalApiPanel(true)}
>
<ApiConnectionMod className='h-4 w-4 text-components-button-secondary-text' />
<div className='system-sm-medium flex items-center justify-center gap-1 px-0.5 text-components-button-secondary-text'>{t('dataset.externalAPIPanelTitle')}</div>
</Button>
</div>
)}
{activeTab === 'api' && data && <ApiServer apiBaseUrl={data.api_base_url || ''} />}
</div>
{activeTab === 'dataset' && (
<>
<Datasets containerRef={containerRef} tags={tagIDs} keywords={searchKeywords} includeAll={includeAll} />
{!systemFeatures.branding.enabled && <DatasetFooter />}
{showTagManagementModal && (
<TagManagementModal type='knowledge' show={showTagManagementModal} />
)}
</>
)}
{activeTab === 'api' && data && <Doc apiBaseUrl={data.api_base_url || ''} />}
{showExternalApiPanel && <ExternalAPIPanel onClose={() => setShowExternalApiPanel(false)} />}
</div>
)
}
export default Container

View File

@@ -0,0 +1,10 @@
import React from 'react'
import CreateFromPipeline from '@/app/components/datasets/create-from-pipeline'
const DatasetCreation = async () => {
return (
<CreateFromPipeline />
)
}
export default DatasetCreation

View File

@@ -1,249 +0,0 @@
'use client'
import { useContext } from 'use-context-selector'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react'
import { mutate } from 'swr'
import cn from '@/utils/classnames'
import Confirm from '@/app/components/base/confirm'
import { ToastContext } from '@/app/components/base/toast'
import { checkIsUsedInApp, deleteDataset } from '@/service/datasets'
import type { DataSet } from '@/models/datasets'
import Tooltip from '@/app/components/base/tooltip'
import { Folder } from '@/app/components/base/icons/src/vender/solid/files'
import type { HtmlContentProps } from '@/app/components/base/popover'
import CustomPopover from '@/app/components/base/popover'
import Divider from '@/app/components/base/divider'
import RenameDatasetModal from '@/app/components/datasets/rename-modal'
import type { Tag } from '@/app/components/base/tag-management/constant'
import TagSelector from '@/app/components/base/tag-management/selector'
import CornerLabel from '@/app/components/base/corner-label'
import { useAppContext } from '@/context/app-context'
export type DatasetCardProps = {
dataset: DataSet
onSuccess?: () => void
}
const DatasetCard = ({
dataset,
onSuccess,
}: DatasetCardProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { push } = useRouter()
const EXTERNAL_PROVIDER = 'external' as const
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const [tags, setTags] = useState<Tag[]>(dataset.tags)
const [showRenameModal, setShowRenameModal] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [confirmMessage, setConfirmMessage] = useState<string>('')
const isExternalProvider = (provider: string): boolean => provider === EXTERNAL_PROVIDER
const detectIsUsedByApp = useCallback(async () => {
try {
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
setConfirmMessage(isUsedByApp ? t('dataset.datasetUsedByApp')! : t('dataset.deleteDatasetConfirmContent')!)
}
catch (e: any) {
const res = await e.json()
notify({ type: 'error', message: res?.message || 'Unknown error' })
}
setShowConfirmDelete(true)
}, [dataset.id, notify, t])
const onConfirmDelete = useCallback(async () => {
try {
await deleteDataset(dataset.id)
// Clear SWR cache to prevent stale data in knowledge retrieval nodes
mutate(
(key) => {
if (typeof key === 'string') return key.includes('/datasets')
if (typeof key === 'object' && key !== null)
return key.url === '/datasets' || key.url?.includes('/datasets')
return false
},
undefined,
{ revalidate: true },
)
notify({ type: 'success', message: t('dataset.datasetDeleted') })
if (onSuccess)
onSuccess()
}
catch {
}
setShowConfirmDelete(false)
}, [dataset.id, notify, onSuccess, t])
const Operations = (props: HtmlContentProps & { showDelete: boolean }) => {
const onMouseLeave = async () => {
props.onClose?.()
}
const onClickRename = async (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowRenameModal(true)
}
const onClickDelete = async (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
detectIsUsedByApp()
}
return (
<div className="relative w-full py-1" onMouseLeave={onMouseLeave}>
<div className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-base-hover' onClick={onClickRename}>
<span className='text-sm text-text-secondary'>{t('common.operation.settings')}</span>
</div>
{props.showDelete && (
<>
<Divider className="!my-1" />
<div
className='group mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-destructive-hover'
onClick={onClickDelete}
>
<span className={cn('text-sm text-text-secondary', 'group-hover:text-text-destructive')}>
{t('common.operation.delete')}
</span>
</div>
</>
)}
</div>
)
}
useEffect(() => {
setTags(dataset.tags)
}, [dataset])
return (
<>
<div
className='group relative col-span-1 flex min-h-[171px] cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg'
data-disable-nprogress={true}
onClick={(e) => {
e.preventDefault()
isExternalProvider(dataset.provider)
? push(`/datasets/${dataset.id}/hitTesting`)
: push(`/datasets/${dataset.id}/documents`)
}}
>
{isExternalProvider(dataset.provider) && <CornerLabel label='External' className='absolute right-0' labelClassName='rounded-tr-xl' />}
<div className='flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pb-3 pt-[14px]'>
<div className={cn(
'flex shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#E0EAFF] bg-[#F5F8FF] p-2.5',
!dataset.embedding_available && 'opacity-50 hover:opacity-100',
)}>
<Folder className='h-5 w-5 text-[#444CE7]' />
</div>
<div className='w-0 grow py-[1px]'>
<div className='flex items-center text-sm font-semibold leading-5 text-text-secondary'>
<div className={cn('truncate', !dataset.embedding_available && 'text-text-tertiary opacity-50 hover:opacity-100')} title={dataset.name}>{dataset.name}</div>
{!dataset.embedding_available && (
<Tooltip
popupContent={t('dataset.unavailableTip')}
>
<span className='ml-1 inline-flex w-max shrink-0 rounded-md border border-divider-regular px-1 text-xs font-normal leading-[18px] text-text-tertiary'>{t('dataset.unavailable')}</span>
</Tooltip>
)}
</div>
<div className='mt-[1px] flex items-center text-xs leading-[18px] text-text-tertiary'>
<div
className={cn('truncate', (!dataset.embedding_available || !dataset.document_count) && 'opacity-50')}
title={dataset.provider === 'external' ? `${dataset.app_count}${t('dataset.appCount')}` : `${dataset.document_count}${t('dataset.documentCount')} · ${Math.round(dataset.word_count / 1000)}${t('dataset.wordCount')} · ${dataset.app_count}${t('dataset.appCount')}`}
>
{dataset.provider === 'external'
? <>
<span>{dataset.app_count}{t('dataset.appCount')}</span>
</>
: <>
<span>{dataset.document_count}{t('dataset.documentCount')}</span>
<span className='mx-0.5 w-1 shrink-0 text-text-tertiary'>·</span>
<span>{Math.round(dataset.word_count / 1000)}{t('dataset.wordCount')}</span>
<span className='mx-0.5 w-1 shrink-0 text-text-tertiary'>·</span>
<span>{dataset.app_count}{t('dataset.appCount')}</span>
</>
}
</div>
</div>
</div>
</div>
<div
className={cn(
'mb-2 line-clamp-2 max-h-[36px] grow px-[14px] text-xs leading-normal text-text-tertiary',
!dataset.embedding_available && 'opacity-50 hover:opacity-100',
)}
title={dataset.description}>
{dataset.description}
</div>
<div className='mt-4 flex h-[42px] shrink-0 items-center pb-[6px] pl-[14px] pr-[6px] pt-1'>
<div className={cn('flex w-0 grow items-center gap-1', !dataset.embedding_available && 'opacity-50 hover:opacity-100')} onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}>
<div className={cn(
'mr-[41px] w-full grow group-hover:!mr-0',
)}>
<TagSelector
position='bl'
type='knowledge'
targetID={dataset.id}
value={tags.map(tag => tag.id)}
selectedTags={tags}
onCacheUpdate={setTags}
onChange={onSuccess}
/>
</div>
</div>
<div className='mx-1 !hidden h-[14px] w-[1px] shrink-0 bg-divider-regular group-hover:!flex' />
<div className='!hidden shrink-0 group-hover:!flex'>
<CustomPopover
htmlContent={<Operations showDelete={!isCurrentWorkspaceDatasetOperator} />}
position="br"
trigger="click"
btnElement={
<div
className='flex h-8 w-8 cursor-pointer items-center justify-center rounded-md'
>
<RiMoreFill className='h-4 w-4 text-text-secondary' />
</div>
}
btnClassName={open =>
cn(
open ? '!bg-state-base-hover !shadow-none' : '!bg-transparent',
'h-8 w-8 rounded-md border-none !p-2 hover:!bg-state-base-hover',
)
}
className={'!z-20 h-fit !w-[128px]'}
/>
</div>
</div>
</div>
{showRenameModal && (
<RenameDatasetModal
show={showRenameModal}
dataset={dataset}
onClose={() => setShowRenameModal(false)}
onSuccess={onSuccess}
/>
)}
{showConfirmDelete && (
<Confirm
title={t('dataset.deleteDatasetConfirmTitle')}
content={confirmMessage}
isShow={showConfirmDelete}
onConfirm={onConfirmDelete}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
</>
)
}
export default DatasetCard

View File

@@ -1,96 +0,0 @@
'use client'
import { useCallback, useEffect, useRef } from 'react'
import useSWRInfinite from 'swr/infinite'
import { debounce } from 'lodash-es'
import NewDatasetCard from './new-dataset-card'
import DatasetCard from './dataset-card'
import type { DataSetListResponse, FetchDatasetsParams } from '@/models/datasets'
import { fetchDatasets } from '@/service/datasets'
import { useAppContext } from '@/context/app-context'
import { useTranslation } from 'react-i18next'
const getKey = (
pageIndex: number,
previousPageData: DataSetListResponse,
tags: string[],
keyword: string,
includeAll: boolean,
) => {
if (!pageIndex || previousPageData.has_more) {
const params: FetchDatasetsParams = {
url: 'datasets',
params: {
page: pageIndex + 1,
limit: 30,
include_all: includeAll,
},
}
if (tags.length)
params.params.tag_ids = tags
if (keyword)
params.params.keyword = keyword
return params
}
return null
}
type Props = {
containerRef: React.RefObject<HTMLDivElement | null>
tags: string[]
keywords: string
includeAll: boolean
}
const Datasets = ({
containerRef,
tags,
keywords,
includeAll,
}: Props) => {
const { t } = useTranslation()
const { isCurrentWorkspaceEditor } = useAppContext()
const { data, isLoading, setSize, mutate } = useSWRInfinite(
(pageIndex: number, previousPageData: DataSetListResponse) => getKey(pageIndex, previousPageData, tags, keywords, includeAll),
fetchDatasets,
{ revalidateFirstPage: false, revalidateAll: true },
)
const loadingStateRef = useRef(false)
const anchorRef = useRef<HTMLAnchorElement>(null)
useEffect(() => {
loadingStateRef.current = isLoading
}, [isLoading, t])
const onScroll = useCallback(
debounce(() => {
if (!loadingStateRef.current && containerRef.current && anchorRef.current) {
const { scrollTop, clientHeight } = containerRef.current
const anchorOffset = anchorRef.current.offsetTop
if (anchorOffset - scrollTop - clientHeight < 100)
setSize(size => size + 1)
}
}, 50),
[setSize],
)
useEffect(() => {
const currentContainer = containerRef.current
currentContainer?.addEventListener('scroll', onScroll)
return () => {
currentContainer?.removeEventListener('scroll', onScroll)
onScroll.cancel()
}
}, [containerRef, onScroll])
return (
<nav className='grid shrink-0 grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'>
{isCurrentWorkspaceEditor && <NewDatasetCard ref={anchorRef} />}
{data?.map(({ data: datasets }) => datasets.map(dataset => (
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={mutate} />),
))}
</nav>
)
}
export default Datasets

View File

@@ -1,203 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import { RiCloseLine, RiListUnordered } from '@remixicon/react'
import TemplateEn from './template/template.en.mdx'
import TemplateZh from './template/template.zh.mdx'
import TemplateJa from './template/template.ja.mdx'
import I18n from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import cn from '@/utils/classnames'
type DocProps = {
apiBaseUrl: string
}
const Doc = ({ apiBaseUrl }: DocProps) => {
const { locale } = useContext(I18n)
const { t } = useTranslation()
const [toc, setToc] = useState<Array<{ href: string; text: string }>>([])
const [isTocExpanded, setIsTocExpanded] = useState(false)
const [activeSection, setActiveSection] = useState<string>('')
const { theme } = useTheme()
// Set initial TOC expanded state based on screen width
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 1280px)')
setIsTocExpanded(mediaQuery.matches)
}, [])
// Extract TOC from article content
useEffect(() => {
const extractTOC = () => {
const article = document.querySelector('article')
if (article) {
const headings = article.querySelectorAll('h2')
const tocItems = Array.from(headings).map((heading) => {
const anchor = heading.querySelector('a')
if (anchor) {
return {
href: anchor.getAttribute('href') || '',
text: anchor.textContent || '',
}
}
return null
}).filter((item): item is { href: string; text: string } => item !== null)
setToc(tocItems)
// Set initial active section
if (tocItems.length > 0)
setActiveSection(tocItems[0].href.replace('#', ''))
}
}
setTimeout(extractTOC, 0)
}, [locale])
// Track scroll position for active section highlighting
useEffect(() => {
const handleScroll = () => {
const scrollContainer = document.querySelector('.scroll-container')
if (!scrollContainer || toc.length === 0)
return
// Find active section based on scroll position
let currentSection = ''
toc.forEach((item) => {
const targetId = item.href.replace('#', '')
const element = document.getElementById(targetId)
if (element) {
const rect = element.getBoundingClientRect()
// Consider section active if its top is above the middle of viewport
if (rect.top <= window.innerHeight / 2)
currentSection = targetId
}
})
if (currentSection && currentSection !== activeSection)
setActiveSection(currentSection)
}
const scrollContainer = document.querySelector('.scroll-container')
if (scrollContainer) {
scrollContainer.addEventListener('scroll', handleScroll)
handleScroll() // Initial check
return () => scrollContainer.removeEventListener('scroll', handleScroll)
}
}, [toc, activeSection])
// Handle TOC item click
const handleTocClick = (e: React.MouseEvent<HTMLAnchorElement>, item: { href: string; text: string }) => {
e.preventDefault()
const targetId = item.href.replace('#', '')
const element = document.getElementById(targetId)
if (element) {
const scrollContainer = document.querySelector('.scroll-container')
if (scrollContainer) {
const headerOffset = -40
const elementTop = element.offsetTop - headerOffset
scrollContainer.scrollTo({
top: elementTop,
behavior: 'smooth',
})
}
}
}
const Template = useMemo(() => {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateZh apiBaseUrl={apiBaseUrl} />
case LanguagesSupported[7]:
return <TemplateJa apiBaseUrl={apiBaseUrl} />
default:
return <TemplateEn apiBaseUrl={apiBaseUrl} />
}
}, [apiBaseUrl, locale])
return (
<div className="flex">
<div className={`fixed right-20 top-32 z-10 transition-all duration-150 ease-out ${isTocExpanded ? 'w-[280px]' : 'w-11'}`}>
{isTocExpanded
? (
<nav className="toc flex max-h-[calc(100vh-150px)] w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-background-default-hover shadow-xl">
<div className="relative z-10 flex items-center justify-between border-b border-components-panel-border-subtle bg-background-default-hover px-4 py-2.5">
<span className="text-xs font-medium uppercase tracking-wide text-text-tertiary">
{t('appApi.develop.toc')}
</span>
<button
onClick={() => setIsTocExpanded(false)}
className="group flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-state-base-hover"
aria-label="Close"
>
<RiCloseLine className="h-3 w-3 text-text-quaternary transition-colors group-hover:text-text-secondary" />
</button>
</div>
<div className="from-components-panel-border-subtle/20 pointer-events-none absolute left-0 right-0 top-[41px] z-10 h-2 bg-gradient-to-b to-transparent"></div>
<div className="pointer-events-none absolute left-0 right-0 top-[43px] z-10 h-3 bg-gradient-to-b from-background-default-hover to-transparent"></div>
<div className="relative flex-1 overflow-y-auto px-3 py-3 pt-1">
{toc.length === 0 ? (
<div className="px-2 py-8 text-center text-xs text-text-quaternary">
{t('appApi.develop.noContent')}
</div>
) : (
<ul className="space-y-0.5">
{toc.map((item, index) => {
const isActive = activeSection === item.href.replace('#', '')
return (
<li key={index}>
<a
href={item.href}
onClick={e => handleTocClick(e, item)}
className={cn(
'group relative flex items-center rounded-md px-3 py-2 text-[13px] transition-all duration-200',
isActive
? 'bg-state-base-hover font-medium text-text-primary'
: 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
)}
>
<span
className={cn(
'mr-2 h-1.5 w-1.5 rounded-full transition-all duration-200',
isActive
? 'scale-100 bg-text-accent'
: 'scale-75 bg-components-panel-border',
)}
/>
<span className="flex-1 truncate">
{item.text}
</span>
</a>
</li>
)
})}
</ul>
)}
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-4 rounded-b-xl bg-gradient-to-t from-background-default-hover to-transparent"></div>
</nav>
)
: (
<button
onClick={() => setIsTocExpanded(true)}
className="group flex h-11 w-11 items-center justify-center rounded-full border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg transition-all duration-150 hover:bg-background-default-hover hover:shadow-xl"
aria-label="Open table of contents"
>
<RiListUnordered className="h-5 w-5 text-text-tertiary transition-colors group-hover:text-text-secondary" />
</button>
)}
</div>
<article className={cn('prose-xl prose', theme === Theme.dark && 'prose-invert')}>
{Template}
</article>
</div>
)
}
export default Doc

View File

@@ -1,41 +0,0 @@
'use client'
import { useTranslation } from 'react-i18next'
import {
RiAddLine,
RiArrowRightLine,
} from '@remixicon/react'
import Link from 'next/link'
type CreateAppCardProps = {
ref?: React.Ref<HTMLAnchorElement>
}
const CreateAppCard = ({ ref }: CreateAppCardProps) => {
const { t } = useTranslation()
return (
<div className='bg-background-default-dimm flex min-h-[160px] flex-col rounded-xl border-[0.5px]
border-components-panel-border transition-all duration-200 ease-in-out'
>
<Link ref={ref} className='group flex grow cursor-pointer items-start p-4' href='/datasets/create'>
<div className='flex items-center gap-3'>
<div className='flex h-10 w-10 items-center justify-center rounded-lg border border-dashed border-divider-regular bg-background-default-lighter
p-2 group-hover:border-solid group-hover:border-effects-highlight group-hover:bg-background-default-dodge'
>
<RiAddLine className='h-4 w-4 text-text-tertiary group-hover:text-text-accent' />
</div>
<div className='system-md-semibold text-text-secondary group-hover:text-text-accent'>{t('dataset.createDataset')}</div>
</div>
</Link>
<div className='system-xs-regular p-4 pt-0 text-text-tertiary'>{t('dataset.createDatasetIntro')}</div>
<Link className='group flex cursor-pointer items-center gap-1 rounded-b-xl border-t-[0.5px] border-divider-subtle p-4' href='/datasets/connect'>
<div className='system-xs-medium text-text-tertiary group-hover:text-text-accent'>{t('dataset.connectDataset')}</div>
<RiArrowRightLine className='h-3.5 w-3.5 text-text-tertiary group-hover:text-text-accent' />
</Link>
</div>
)
}
CreateAppCard.displayName = 'CreateAppCard'
export default CreateAppCard

View File

@@ -1,12 +1,7 @@
'use client'
import { useTranslation } from 'react-i18next'
import Container from './container'
import useDocumentTitle from '@/hooks/use-document-title'
import List from '../../components/datasets/list'
const AppList = () => {
const { t } = useTranslation()
useDocumentTitle(t('common.menus.datasets'))
return <Container />
const DatasetList = async () => {
return <List />
}
export default AppList
export default DatasetList

View File

@@ -1,11 +0,0 @@
import { create } from 'zustand'
type DatasetStore = {
showExternalApiPanel: boolean
setShowExternalApiPanel: (show: boolean) => void
}
export const useDatasetStore = create<DatasetStore>(set => ({
showExternalApiPanel: false,
setShowExternalApiPanel: show => set({ showExternalApiPanel: show }),
}))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -82,7 +82,7 @@ export default function CheckCode() {
<form action="">
<input type='text' className='hidden' />
<label htmlFor="code" className='system-md-semibold mb-1 text-text-secondary'>{t('login.checkCode.verificationCode')}</label>
<Input value={code} onChange={e => setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} />
<Input value={code} onChange={e => setVerifyCode(e.target.value)} maxLength={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') || ''} />
<Button loading={loading} disabled={loading} className='my-3 w-full' variant='primary' onClick={verify}>{t('login.checkCode.verify')}</Button>
<Countdown onResend={resendCode} />
</form>

View File

@@ -104,7 +104,7 @@ export default function CheckCode() {
<form action="">
<label htmlFor="code" className='system-md-semibold mb-1 text-text-secondary'>{t('login.checkCode.verificationCode')}</label>
<Input value={code} onChange={e => setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} />
<Input value={code} onChange={e => setVerifyCode(e.target.value)} maxLength={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') || ''} />
<Button loading={loading} disabled={loading} className='my-3 w-full' variant='primary' onClick={verify}>{t('login.checkCode.verify')}</Button>
<Countdown onResend={resendCode} />
</form>

View File

@@ -60,7 +60,7 @@ export default function MailAndCodeAuth() {
<Input id='email' type="email" value={email} placeholder={t('login.emailPlaceholder') as string} onChange={e => setEmail(e.target.value)} />
</div>
<div className='mt-3'>
<Button loading={loading} disabled={loading || !email} variant='primary' className='w-full' onClick={handleGetEMailVerificationCode}>{t('login.continueWithCode')}</Button>
<Button loading={loading} disabled={loading || !email} variant='primary' className='w-full' onClick={handleGetEMailVerificationCode}>{t('login.signup.verifyMail')}</Button>
</div>
</div>
</form>

View File

@@ -43,9 +43,9 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
const handleSaveAvatar = useCallback(async (uploadedFileId: string) => {
try {
await updateUserProfile({ url: 'account/avatar', body: { avatar: uploadedFileId } })
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
setIsShowAvatarPicker(false)
onSave?.()
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })

View File

@@ -69,7 +69,6 @@ export default function AccountPage() {
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
setEditNameModalVisible(false)
setEditing(false)
}
}

View File

@@ -71,6 +71,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
const [showImportDSLModal, setShowImportDSLModal] = useState<boolean>(false)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const [showExportWarning, setShowExportWarning] = useState(false)
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
@@ -142,9 +143,11 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
a.href = URL.createObjectURL(file)
const url = URL.createObjectURL(file)
a.href = url
a.download = `${appDetail.name}.yml`
a.click()
URL.revokeObjectURL(url)
}
catch {
notify({ type: 'error', message: t('app.exportFailed') })
@@ -158,6 +161,14 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
onExport()
return
}
setShowExportWarning(true)
}
const handleConfirmExport = async () => {
if (!appDetail)
return
setShowExportWarning(false)
try {
const workflowDraft = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`)
const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret')
@@ -328,13 +339,13 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
<div className='flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4'>
<div className='flex items-center gap-3 self-stretch'>
<AppIcon
size="large"
size='large'
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<div className='flex w-full grow flex-col items-start justify-center'>
<div className='flex flex-1 flex-col items-start justify-center overflow-hidden'>
<div className='system-md-semibold w-full truncate text-text-secondary'>{appDetail.name}</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}</div>
</div>
@@ -430,6 +441,16 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
onClose={() => setSecretEnvList([])}
/>
)}
{showExportWarning && (
<Confirm
type="info"
isShow={showExportWarning}
title={t('workflow.sidebar.exportWarning')}
content={t('workflow.sidebar.exportWarningDesc')}
onConfirm={handleConfirmExport}
onCancel={() => setShowExportWarning(false)}
/>
)}
</div>
)
}

View File

@@ -1,47 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '../base/app-icon'
const DatasetSvg = <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M0.833497 5.13481C0.833483 4.69553 0.83347 4.31654 0.858973 4.0044C0.88589 3.67495 0.94532 3.34727 1.10598 3.03195C1.34567 2.56155 1.72812 2.17909 2.19852 1.93941C2.51384 1.77875 2.84152 1.71932 3.17097 1.6924C3.48312 1.6669 3.86209 1.66691 4.30137 1.66693L7.62238 1.66684C8.11701 1.66618 8.55199 1.66561 8.95195 1.80356C9.30227 1.92439 9.62134 2.12159 9.88607 2.38088C10.1883 2.67692 10.3823 3.06624 10.603 3.50894L11.3484 5.00008H14.3679C15.0387 5.00007 15.5924 5.00006 16.0434 5.03691C16.5118 5.07518 16.9424 5.15732 17.3468 5.36339C17.974 5.68297 18.4839 6.19291 18.8035 6.82011C19.0096 7.22456 19.0917 7.65515 19.13 8.12356C19.1668 8.57455 19.1668 9.12818 19.1668 9.79898V13.5345C19.1668 14.2053 19.1668 14.7589 19.13 15.2099C19.0917 15.6784 19.0096 16.1089 18.8035 16.5134C18.4839 17.1406 17.974 17.6505 17.3468 17.9701C16.9424 18.1762 16.5118 18.2583 16.0434 18.2966C15.5924 18.3334 15.0387 18.3334 14.3679 18.3334H5.63243C4.96163 18.3334 4.40797 18.3334 3.95698 18.2966C3.48856 18.2583 3.05798 18.1762 2.65353 17.9701C2.02632 17.6505 1.51639 17.1406 1.19681 16.5134C0.990734 16.1089 0.908597 15.6784 0.870326 15.2099C0.833478 14.7589 0.833487 14.2053 0.833497 13.5345V5.13481ZM7.51874 3.33359C8.17742 3.33359 8.30798 3.34447 8.4085 3.37914C8.52527 3.41942 8.63163 3.48515 8.71987 3.57158C8.79584 3.64598 8.86396 3.7579 9.15852 4.34704L9.48505 5.00008L2.50023 5.00008C2.50059 4.61259 2.50314 4.34771 2.5201 4.14012C2.5386 3.91374 2.57 3.82981 2.59099 3.7886C2.67089 3.6318 2.79837 3.50432 2.95517 3.42442C2.99638 3.40343 3.08031 3.37203 3.30669 3.35353C3.54281 3.33424 3.85304 3.33359 4.3335 3.33359H7.51874Z" fill="#444CE7" />
</svg>
type Props = {
isExternal?: boolean
name: string
description: string
expand: boolean
extraInfo?: React.ReactNode
}
const DatasetInfo: FC<Props> = ({
name,
description,
isExternal,
expand,
extraInfo,
}) => {
const { t } = useTranslation()
return (
<div className='pl-1 pt-1'>
<div className='mr-3 shrink-0'>
<AppIcon innerIcon={DatasetSvg} className='!border-[0.5px] !border-indigo-100 !bg-indigo-25' />
</div>
<div className={`transition-all duration-200 ease-in-out ${
expand
? 'mt-2 w-auto opacity-100'
: 'pointer-events-none h-0 w-0 overflow-hidden opacity-0'
}`}>
<div className='system-md-semibold truncate whitespace-nowrap text-text-secondary'>
{name}
</div>
<div className='system-2xs-medium-uppercase mt-1 whitespace-nowrap text-text-tertiary'>{isExternal ? t('dataset.externalTag') : t('dataset.localDocs')}</div>
<div className='system-xs-regular my-3 whitespace-nowrap text-text-tertiary first-letter:capitalize'>{description}</div>
</div>
{extraInfo}
</div>
)
}
export default React.memo(DatasetInfo)

View File

@@ -0,0 +1,152 @@
import React, { useCallback, useState } from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
import ActionButton from '../../base/action-button'
import { RiMoreFill } from '@remixicon/react'
import cn from '@/utils/classnames'
import Menu from './menu'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import type { DataSet } from '@/models/datasets'
import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { useInvalid } from '@/service/use-base'
import { useExportPipelineDSL } from '@/service/use-pipeline'
import Toast from '../../base/toast'
import { useTranslation } from 'react-i18next'
import RenameDatasetModal from '../../datasets/rename-modal'
import { checkIsUsedInApp, deleteDataset } from '@/service/datasets'
import Confirm from '../../base/confirm'
import { useRouter } from 'next/navigation'
type DropDownProps = {
expand: boolean
}
const DropDown = ({
expand,
}: DropDownProps) => {
const { t } = useTranslation()
const { replace } = useRouter()
const [open, setOpen] = useState(false)
const [showRenameModal, setShowRenameModal] = useState(false)
const [confirmMessage, setConfirmMessage] = useState<string>('')
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const handleTrigger = useCallback(() => {
setOpen(prev => !prev)
}, [])
const invalidDatasetList = useInvalidDatasetList()
const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id])
const refreshDataset = useCallback(() => {
invalidDatasetList()
invalidDatasetDetail()
}, [invalidDatasetDetail, invalidDatasetList])
const openRenameModal = useCallback(() => {
setShowRenameModal(true)
handleTrigger()
}, [handleTrigger])
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
const handleExportPipeline = useCallback(async (include = false) => {
const { pipeline_id, name } = dataset
if (!pipeline_id)
return
handleTrigger()
try {
const { data } = await exportPipelineConfig({
pipelineId: pipeline_id,
include,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `${name}.pipeline`
a.click()
URL.revokeObjectURL(url)
}
catch {
Toast.notify({ type: 'error', message: t('app.exportFailed') })
}
}, [dataset, exportPipelineConfig, handleTrigger, t])
const detectIsUsedByApp = useCallback(async () => {
try {
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
setConfirmMessage(isUsedByApp ? t('dataset.datasetUsedByApp')! : t('dataset.deleteDatasetConfirmContent')!)
setShowConfirmDelete(true)
}
catch (e: any) {
const res = await e.json()
Toast.notify({ type: 'error', message: res?.message || 'Unknown error' })
}
finally {
handleTrigger()
}
}, [dataset.id, handleTrigger, t])
const onConfirmDelete = useCallback(async () => {
try {
await deleteDataset(dataset.id)
Toast.notify({ type: 'success', message: t('dataset.datasetDeleted') })
invalidDatasetList()
replace('/datasets')
}
finally {
setShowConfirmDelete(false)
}
}, [dataset.id, replace, invalidDatasetList, t])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement={expand ? 'bottom-end' : 'right'}
offset={expand ? {
mainAxis: 4,
crossAxis: 10,
} : {
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<ActionButton className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md')}>
<RiMoreFill className='size-4' />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[60]'>
<Menu
showDelete={!isCurrentWorkspaceDatasetOperator}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
/>
</PortalToFollowElemContent>
{showRenameModal && (
<RenameDatasetModal
show={showRenameModal}
dataset={dataset!}
onClose={() => setShowRenameModal(false)}
onSuccess={refreshDataset}
/>
)}
{showConfirmDelete && (
<Confirm
title={t('dataset.deleteDatasetConfirmTitle')}
content={confirmMessage}
isShow={showConfirmDelete}
onConfirm={onConfirmDelete}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
</PortalToFollowElem>
)
}
export default React.memo(DropDown)

View File

@@ -0,0 +1,91 @@
'use client'
import type { FC } from 'react'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '../../base/app-icon'
import Effect from '../../base/effect'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import type { DataSet } from '@/models/datasets'
import { DOC_FORM_TEXT } from '@/models/datasets'
import { useKnowledge } from '@/hooks/use-knowledge'
import cn from '@/utils/classnames'
import Dropdown from './dropdown'
type DatasetInfoProps = {
expand: boolean
}
const DatasetInfo: FC<DatasetInfoProps> = ({
expand,
}) => {
const { t } = useTranslation()
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const iconInfo = dataset.icon_info || {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
}
const isExternalProvider = dataset.provider === 'external'
const isPipelinePublished = useMemo(() => {
return dataset.runtime_mode === 'rag_pipeline' && dataset.is_published
}, [dataset.runtime_mode, dataset.is_published])
const { formatIndexingTechniqueAndMethod } = useKnowledge()
return (
<div className={cn('relative flex flex-col', expand ? '' : 'p-1')}>
{expand && (
<Effect className='-left-5 top-[-22px] opacity-15' />
)}
<div className='flex flex-col gap-2 p-2'>
<div className='flex items-center gap-1'>
<div className={cn(!expand && '-ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
</div>
{expand && (
<div className='ml-auto'>
<Dropdown expand />
</div>
)}
</div>
{!expand && (
<div className='-mb-2 -mt-1 flex items-center justify-center'>
<Dropdown expand={false} />
</div>
)}
{expand && (
<div className='flex flex-col gap-y-1 pb-0.5'>
<div
className='system-md-semibold truncate text-text-secondary'
title={dataset.name}
>
{dataset.name}
</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>
{isExternalProvider && t('dataset.externalTag')}
{!isExternalProvider && isPipelinePublished && dataset.doc_form && dataset.indexing_technique && (
<div className='flex items-center gap-x-2'>
<span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</span>
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
</div>
)}
</div>
{!!dataset.description && (
<p className='system-xs-regular line-clamp-3 text-text-tertiary first-letter:capitalize'>
{dataset.description}
</p>
)}
</div>
)}
</div>
</div>
)
}
export default React.memo(DatasetInfo)

View File

@@ -0,0 +1,30 @@
import React from 'react'
import type { RemixiconComponentType } from '@remixicon/react'
type MenuItemProps = {
name: string
Icon: RemixiconComponentType
handleClick?: () => void
}
const MenuItem = ({
Icon,
name,
handleClick,
}: MenuItemProps) => {
return (
<div
className='flex items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleClick?.()
}}
>
<Icon className='size-4 text-text-tertiary' />
<span className='system-md-regular px-1 text-text-secondary'>{name}</span>
</div>
)
}
export default React.memo(MenuItem)

View File

@@ -0,0 +1,52 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import MenuItem from './menu-item'
import { RiDeleteBinLine, RiEditLine, RiFileDownloadLine } from '@remixicon/react'
import Divider from '../../base/divider'
type MenuProps = {
showDelete: boolean
openRenameModal: () => void
handleExportPipeline: () => void
detectIsUsedByApp: () => void
}
const Menu = ({
showDelete,
openRenameModal,
handleExportPipeline,
detectIsUsedByApp,
}: MenuProps) => {
const { t } = useTranslation()
return (
<div className='flex w-[200px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'>
<div className='flex flex-col p-1'>
<MenuItem
Icon={RiEditLine}
name={t('common.operation.edit')}
handleClick={openRenameModal}
/>
<MenuItem
Icon={RiFileDownloadLine}
name={t('datasetPipeline.operations.exportPipeline')}
handleClick={handleExportPipeline}
/>
</div>
{showDelete && (
<>
<Divider type='horizontal' className='my-0 bg-divider-subtle' />
<div className='flex flex-col p-1'>
<MenuItem
Icon={RiDeleteBinLine}
name={t('common.operation.delete')}
handleClick={detectIsUsedByApp}
/>
</div>
</>
)}
</div>
)
}
export default React.memo(Menu)

View File

@@ -0,0 +1,164 @@
import React, { useCallback, useRef, useState } from 'react'
import {
RiMenuLine,
} from '@remixicon/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import AppIcon from '../base/app-icon'
import Divider from '../base/divider'
import NavLink from './navLink'
import type { NavIcon } from './navLink'
import cn from '@/utils/classnames'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import Effect from '../base/effect'
import Dropdown from './dataset-info/dropdown'
import type { DataSet } from '@/models/datasets'
import { DOC_FORM_TEXT } from '@/models/datasets'
import { useKnowledge } from '@/hooks/use-knowledge'
import { useTranslation } from 'react-i18next'
import { useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import ExtraInfo from '../datasets/extra-info'
type DatasetSidebarDropdownProps = {
navigation: Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
disabled?: boolean
}>
}
const DatasetSidebarDropdown = ({
navigation,
}: DatasetSidebarDropdownProps) => {
const { t } = useTranslation()
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const { data: relatedApps } = useDatasetRelatedApps(dataset.id)
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
const iconInfo = dataset.icon_info || {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
}
const isExternalProvider = dataset.provider === 'external'
const { formatIndexingTechniqueAndMethod } = useKnowledge()
if (!dataset)
return null
return (
<>
<div className='fixed left-2 top-2 z-20'>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: -41,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-sm hover:bg-background-default-hover',
open && 'bg-background-default-hover',
)}
>
<AppIcon
size='small'
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
<RiMenuLine className='size-4 text-text-tertiary' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-50'>
<div className='relative w-[216px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg'>
<Effect className='-left-5 top-[-22px] opacity-15' />
<div className='flex flex-col gap-y-2 p-4'>
<div className='flex items-center justify-between'>
<AppIcon
size='medium'
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
<Dropdown expand />
</div>
<div className='flex flex-col gap-y-1 pb-0.5'>
<div
className='system-md-semibold truncate text-text-secondary'
title={dataset.name}
>
{dataset.name}
</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>
{isExternalProvider && t('dataset.externalTag')}
{!isExternalProvider && dataset.doc_form && dataset.indexing_technique && (
<div className='flex items-center gap-x-2'>
<span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</span>
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
</div>
)}
</div>
</div>
{!!dataset.description && (
<p className='system-xs-regular line-clamp-3 text-text-tertiary first-letter:capitalize'>
{dataset.description}
</p>
)}
</div>
<div className='px-4 py-2'>
<Divider
type='horizontal'
bgStyle='gradient'
className='my-0 h-px bg-gradient-to-r from-divider-subtle to-background-gradient-mask-transparent'
/>
</div>
<nav className='flex min-h-[200px] grow flex-col gap-y-0.5 px-3 py-2'>
{navigation.map((item, index) => {
return (
<NavLink
key={index}
mode='expand'
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
</nav>
<ExtraInfo
relatedApps={relatedApps}
expand
documentCount={dataset.document_count}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
</>
)
}
export default DatasetSidebarDropdown

View File

@@ -1,10 +1,8 @@
import React, { useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import { RiLayoutLeft2Line, RiLayoutRight2Line } from '@remixicon/react'
import NavLink from './navLink'
import type { NavIcon } from './navLink'
import AppBasic from './basic'
import AppInfo from './app-info'
import DatasetInfo from './dataset-info'
import AppSidebarDropdown from './app-sidebar-dropdown'
@@ -12,39 +10,48 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import cn from '@/utils/classnames'
import Divider from '../base/divider'
import { useHover, useKeyPress } from 'ahooks'
import ToggleButton from './toggle-button'
import { getKeyboardKeyCodeBySystem } from '../workflow/utils'
import DatasetSidebarDropdown from './dataset-sidebar-dropdown'
export type IAppDetailNavProps = {
iconType?: 'app' | 'dataset' | 'notion'
title: string
desc: string
isExternal?: boolean
icon: string
icon_background: string | null
iconType?: 'app' | 'dataset'
navigation: Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
}
const AppDetailNav = ({ title, desc, isExternal, icon, icon_background, navigation, extraInfo, iconType = 'app' }: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSiderbarExpand } = useAppStore(useShallow(state => ({
const AppDetailNav = ({
navigation,
extraInfo,
iconType = 'app',
}: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
setAppSiderbarExpand: state.setAppSiderbarExpand,
setAppSidebarExpand: state.setAppSidebarExpand,
})))
const sidebarRef = React.useRef<HTMLDivElement>(null)
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const expand = appSidebarExpand === 'expand'
const handleToggle = (state: string) => {
setAppSiderbarExpand(state === 'expand' ? 'collapse' : 'expand')
}
const handleToggle = useCallback(() => {
setAppSidebarExpand(appSidebarExpand === 'expand' ? 'collapse' : 'expand')
}, [appSidebarExpand, setAppSidebarExpand])
// // Check if the current path is a workflow canvas & fullscreen
const isHoveringSidebar = useHover(sidebarRef)
// Check if the current path is a workflow canvas & fullscreen
const pathname = usePathname()
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()
@@ -57,9 +64,14 @@ const AppDetailNav = ({ title, desc, isExternal, icon, icon_background, navigati
useEffect(() => {
if (appSidebarExpand) {
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
setAppSiderbarExpand(appSidebarExpand)
setAppSidebarExpand(appSidebarExpand)
}
}, [appSidebarExpand, setAppSiderbarExpand])
}, [appSidebarExpand, setAppSidebarExpand])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
e.preventDefault()
handleToggle()
}, { exactMatch: true, useCapture: true })
if (inWorkflowCanvas && hideHeader) {
return (
@@ -69,76 +81,74 @@ const AppDetailNav = ({ title, desc, isExternal, icon, icon_background, navigati
)
}
if (isPipelineCanvas && hideHeader) {
return (
<div className='flex w-0 shrink-0'>
<DatasetSidebarDropdown navigation={navigation} />
</div>
)
}
return (
<div
className={`
flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all
${expand ? 'w-[216px]' : 'w-14'}
`}
ref={sidebarRef}
className={cn(
'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all',
expand ? 'w-[216px]' : 'w-14',
)}
>
<div
className={`
shrink-0
${expand ? 'p-2' : 'p-1'}
`}
className={cn(
'shrink-0',
expand ? 'p-2' : 'p-1',
)}
>
{iconType === 'app' && (
<AppInfo expand={expand} />
)}
{iconType === 'dataset' && (
<DatasetInfo
name={title}
description={desc}
isExternal={isExternal}
expand={expand}
extraInfo={extraInfo && extraInfo(appSidebarExpand)}
/>
)}
{!['app', 'dataset'].includes(iconType) && (
<AppBasic
mode={appSidebarExpand}
iconType={iconType}
icon={icon}
icon_background={icon_background}
name={title}
type={desc}
isExternal={isExternal}
/>
{iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
<div className='px-4'>
<div className={cn('mx-auto mt-1 h-px bg-divider-subtle', !expand && 'w-6')} />
<div className='relative px-4 py-2'>
<Divider
type='horizontal'
bgStyle={expand ? 'gradient' : 'solid'}
className={cn(
'my-0 h-px',
expand
? 'bg-gradient-to-r from-divider-subtle to-background-gradient-mask-transparent'
: 'bg-divider-subtle',
)}
/>
{!isMobile && isHoveringSidebar && (
<ToggleButton
className='absolute -right-3 top-[-3.5px] z-20'
expand={expand}
handleToggle={handleToggle}
/>
)}
</div>
<nav
className={`
grow space-y-1
${expand ? 'p-4' : 'px-2.5 py-4'}
`}
className={cn(
'flex grow flex-col gap-y-0.5',
expand ? 'px-3 py-2' : 'p-3',
)}
>
{navigation.map((item, index) => {
return (
<NavLink key={index} mode={appSidebarExpand} iconMap={{ selected: item.selectedIcon, normal: item.icon }} name={item.name} href={item.href} />
<NavLink
key={index}
mode={appSidebarExpand}
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
</nav>
{
!isMobile && (
<div
className="shrink-0 px-4 py-3"
>
<div
className='flex h-6 w-6 cursor-pointer items-center justify-center'
onClick={() => handleToggle(appSidebarExpand)}
>
{
expand
? <RiLayoutRight2Line className='h-5 w-5 text-components-menu-item-text' />
: <RiLayoutLeft2Line className='h-5 w-5 text-components-menu-item-text' />
}
</div>
</div>
)
}
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
</div>
)
}

View File

@@ -25,7 +25,7 @@ const MockIcon = ({ className }: { className?: string }) => (
<svg className={className} data-testid="nav-icon" />
)
describe('NavLink Text Animation Issues', () => {
describe('NavLink Animation and Layout Issues', () => {
const mockProps: NavLinkProps = {
name: 'Orchestrate',
href: '/app/123/workflow',
@@ -61,108 +61,129 @@ describe('NavLink Text Animation Issues', () => {
const textElement = screen.getByText('Orchestrate')
expect(textElement).toBeInTheDocument()
expect(textElement).toHaveClass('opacity-0')
expect(textElement).toHaveClass('w-0')
expect(textElement).toHaveClass('max-w-0')
expect(textElement).toHaveClass('overflow-hidden')
// Icon should still be present
expect(screen.getByTestId('nav-icon')).toBeInTheDocument()
// Check padding in collapse mode
// Check consistent padding in collapse mode
const linkElement = screen.getByTestId('nav-link')
expect(linkElement).toHaveClass('px-2.5')
expect(linkElement).toHaveClass('pl-3')
expect(linkElement).toHaveClass('pr-1')
// Switch to expand mode - this is where the squeeze effect occurs
// Switch to expand mode - should have smooth text transition
rerender(<NavLink {...mockProps} mode="expand" />)
// Text should now appear
// Text should now be visible with opacity animation
expect(screen.getByText('Orchestrate')).toBeInTheDocument()
// Check padding change - this contributes to the squeeze effect
expect(linkElement).toHaveClass('px-3')
// Check padding remains consistent - no layout shift
expect(linkElement).toHaveClass('pl-3')
expect(linkElement).toHaveClass('pr-1')
// The bug: text appears abruptly without smooth transition
// This test documents the current behavior that causes the squeeze effect
// Fixed: text now uses max-width animation instead of abrupt show/hide
const expandedTextElement = screen.getByText('Orchestrate')
expect(expandedTextElement).toBeInTheDocument()
expect(expandedTextElement).toHaveClass('max-w-none')
expect(expandedTextElement).toHaveClass('opacity-100')
// In a properly animated version, we would expect:
// The fix provides:
// - Opacity transition from 0 to 1
// - Width transition from 0 to auto
// - No layout shift from padding changes
// - Max-width transition from 0 to none (prevents squashing)
// - No layout shift from consistent padding
})
it('should maintain icon position consistency during text appearance', () => {
it('should maintain icon position consistency using wrapper div', () => {
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
const iconElement = screen.getByTestId('nav-icon')
const initialIconClasses = iconElement.className
const iconWrapper = iconElement.parentElement
// Icon should have mr-0 in collapse mode
expect(iconElement).toHaveClass('mr-0')
// Icon wrapper should have -ml-1 micro-adjustment in collapse mode for centering
expect(iconWrapper).toHaveClass('-ml-1')
rerender(<NavLink {...mockProps} mode="expand" />)
const expandedIconClasses = iconElement.className
// In expand mode, wrapper should not have the micro-adjustment
const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement
expect(expandedIconWrapper).not.toHaveClass('-ml-1')
// Icon should have mr-2 in expand mode - this shift contributes to the squeeze effect
expect(iconElement).toHaveClass('mr-2')
// Icon itself maintains consistent classes - no margin changes
expect(iconElement).toHaveClass('h-4')
expect(iconElement).toHaveClass('w-4')
expect(iconElement).toHaveClass('shrink-0')
console.log('Collapsed icon classes:', initialIconClasses)
console.log('Expanded icon classes:', expandedIconClasses)
// This margin change causes the icon to shift when text appears
// This wrapper approach eliminates the icon margin shift issue
})
it('should document the abrupt text rendering issue', () => {
it('should provide smooth text transition with max-width animation', () => {
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
// Text is present in DOM but hidden via CSS classes
// Text is always in DOM but controlled via CSS classes
const collapsedText = screen.getByText('Orchestrate')
expect(collapsedText).toBeInTheDocument()
expect(collapsedText).toHaveClass('opacity-0')
expect(collapsedText).toHaveClass('pointer-events-none')
expect(collapsedText).toHaveClass('max-w-0')
expect(collapsedText).toHaveClass('overflow-hidden')
rerender(<NavLink {...mockProps} mode="expand" />)
// Text suddenly appears in DOM - no transition
expect(screen.getByText('Orchestrate')).toBeInTheDocument()
// Text smoothly transitions to visible state
const expandedText = screen.getByText('Orchestrate')
expect(expandedText).toBeInTheDocument()
expect(expandedText).toHaveClass('opacity-100')
expect(expandedText).toHaveClass('max-w-none')
// The issue: {mode === 'expand' && name} causes abrupt show/hide
// instead of smooth opacity/width transition
// Fixed: Always present in DOM with smooth CSS transitions
// instead of abrupt conditional rendering
})
})
describe('Layout Shift Issues', () => {
it('should detect padding differences causing layout shifts', () => {
describe('Layout Consistency Improvements', () => {
it('should maintain consistent padding across all states', () => {
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
const linkElement = screen.getByTestId('nav-link')
// Collapsed state padding
expect(linkElement).toHaveClass('px-2.5')
// Consistent padding in collapsed state
expect(linkElement).toHaveClass('pl-3')
expect(linkElement).toHaveClass('pr-1')
rerender(<NavLink {...mockProps} mode="expand" />)
// Expanded state padding - different value causes layout shift
expect(linkElement).toHaveClass('px-3')
// Same padding in expanded state - no layout shift
expect(linkElement).toHaveClass('pl-3')
expect(linkElement).toHaveClass('pr-1')
// This 2px difference (10px vs 12px) contributes to the squeeze effect
// This consistency eliminates the layout shift issue
})
it('should detect icon margin changes causing shifts', () => {
it('should use wrapper-based icon positioning instead of margin changes', () => {
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
const iconElement = screen.getByTestId('nav-icon')
const iconWrapper = iconElement.parentElement
// Collapsed: no right margin
expect(iconElement).toHaveClass('mr-0')
// Collapsed: wrapper has micro-adjustment for centering
expect(iconWrapper).toHaveClass('-ml-1')
// Icon itself has consistent classes
expect(iconElement).toHaveClass('h-4')
expect(iconElement).toHaveClass('w-4')
expect(iconElement).toHaveClass('shrink-0')
rerender(<NavLink {...mockProps} mode="expand" />)
// Expanded: 8px right margin (mr-2)
expect(iconElement).toHaveClass('mr-2')
const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement
// This sudden margin appearance causes the squeeze effect
// Expanded: no wrapper adjustment needed
expect(expandedIconWrapper).not.toHaveClass('-ml-1')
// Icon classes remain consistent - no margin shifts
expect(iconElement).toHaveClass('h-4')
expect(iconElement).toHaveClass('w-4')
expect(iconElement).toHaveClass('shrink-0')
})
})
@@ -172,7 +193,7 @@ describe('NavLink Text Animation Issues', () => {
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
let linkElement = screen.getByTestId('nav-link')
expect(linkElement).not.toHaveClass('bg-state-accent-active')
expect(linkElement).not.toHaveClass('bg-components-menu-item-bg-active')
// Test with active state (when href matches current segment)
const activeProps = {
@@ -183,7 +204,63 @@ describe('NavLink Text Animation Issues', () => {
rerender(<NavLink {...activeProps} mode="expand" />)
linkElement = screen.getByTestId('nav-link')
expect(linkElement).toHaveClass('bg-state-accent-active')
expect(linkElement).toHaveClass('bg-components-menu-item-bg-active')
expect(linkElement).toHaveClass('text-text-accent-light-mode-only')
})
})
describe('Text Animation Classes', () => {
it('should have proper text classes in collapsed mode', () => {
render(<NavLink {...mockProps} mode="collapse" />)
const textElement = screen.getByText('Orchestrate')
expect(textElement).toHaveClass('overflow-hidden')
expect(textElement).toHaveClass('whitespace-nowrap')
expect(textElement).toHaveClass('transition-all')
expect(textElement).toHaveClass('duration-200')
expect(textElement).toHaveClass('ease-in-out')
expect(textElement).toHaveClass('ml-0')
expect(textElement).toHaveClass('max-w-0')
expect(textElement).toHaveClass('opacity-0')
})
it('should have proper text classes in expanded mode', () => {
render(<NavLink {...mockProps} mode="expand" />)
const textElement = screen.getByText('Orchestrate')
expect(textElement).toHaveClass('overflow-hidden')
expect(textElement).toHaveClass('whitespace-nowrap')
expect(textElement).toHaveClass('transition-all')
expect(textElement).toHaveClass('duration-200')
expect(textElement).toHaveClass('ease-in-out')
expect(textElement).toHaveClass('ml-2')
expect(textElement).toHaveClass('max-w-none')
expect(textElement).toHaveClass('opacity-100')
})
})
describe('Disabled State', () => {
it('should render as button when disabled', () => {
render(<NavLink {...mockProps} mode="expand" disabled={true} />)
const buttonElement = screen.getByRole('button')
expect(buttonElement).toBeInTheDocument()
expect(buttonElement).toBeDisabled()
expect(buttonElement).toHaveClass('cursor-not-allowed')
expect(buttonElement).toHaveClass('opacity-30')
})
it('should maintain consistent styling in disabled state', () => {
render(<NavLink {...mockProps} mode="collapse" disabled={true} />)
const buttonElement = screen.getByRole('button')
expect(buttonElement).toHaveClass('pl-3')
expect(buttonElement).toHaveClass('pr-1')
const iconWrapper = screen.getByTestId('nav-icon').parentElement
expect(iconWrapper).toHaveClass('-ml-1')
})
})
})

View File

@@ -1,15 +1,15 @@
'use client'
import React from 'react'
import { useSelectedLayoutSegment } from 'next/navigation'
import Link from 'next/link'
import classNames from '@/utils/classnames'
import type { RemixiconComponentType } from '@remixicon/react'
export type NavIcon = React.ComponentType<
React.PropsWithoutRef<React.ComponentProps<'svg'>> & {
title?: string | undefined
titleId?: string | undefined
}> | RemixiconComponentType
React.PropsWithoutRef<React.ComponentProps<'svg'>> & {
title?: string | undefined
titleId?: string | undefined
}> | RemixiconComponentType
export type NavLinkProps = {
name: string
@@ -19,14 +19,16 @@ export type NavLinkProps = {
normal: NavIcon
}
mode?: string
disabled?: boolean
}
export default function NavLink({
const NavLink = ({
name,
href,
iconMap,
mode = 'expand',
}: NavLinkProps) {
disabled = false,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
let res = segment?.toLowerCase()
@@ -39,30 +41,59 @@ export default function NavLink({
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const renderIcon = () => (
<div className={classNames(mode !== 'expand' && '-ml-1')}>
<NavIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
</div>
)
if (disabled) {
return (
<button
key={name}
type='button'
disabled
className={classNames(
'system-sm-medium flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover',
'pl-3 pr-1',
)}
title={mode === 'collapse' ? name : ''}
aria-disabled
>
{renderIcon()}
<span
className={classNames(
'overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0',
)}
>
{name}
</span>
</button>
)
}
return (
<Link
key={name}
href={href}
className={classNames(
isActive ? 'bg-state-accent-active font-semibold text-text-accent' : 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover',
'group flex h-9 items-center rounded-md py-2 text-sm font-normal',
mode === 'expand' ? 'px-3' : 'px-2.5',
isActive
? 'system-sm-semibold border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only'
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover',
'flex h-8 items-center rounded-lg pl-3 pr-1',
)}
title={mode === 'collapse' ? name : ''}
>
<NavIcon
className={classNames(
'h-4 w-4 shrink-0',
mode === 'expand' ? 'mr-2' : 'mr-0',
)}
aria-hidden="true"
/>
{renderIcon()}
<span
className={classNames(
'whitespace-nowrap transition-all duration-200 ease-in-out',
'overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
mode === 'expand'
? 'w-auto opacity-100'
: 'pointer-events-none w-0 overflow-hidden opacity-0',
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0',
)}
>
{name}
@@ -70,3 +101,5 @@ export default function NavLink({
</Link>
)
}
export default React.memo(NavLink)

View File

@@ -0,0 +1,71 @@
import React from 'react'
import Button from '../base/button'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import Tooltip from '../base/tooltip'
import { useTranslation } from 'react-i18next'
import { getKeyboardKeyNameBySystem } from '../workflow/utils'
type TooltipContentProps = {
expand: boolean
}
const TOGGLE_SHORTCUT = ['ctrl', 'B']
const TooltipContent = ({
expand,
}: TooltipContentProps) => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-1'>
<span className='system-xs-medium px-0.5 text-text-secondary'>{expand ? t('layout.sidebar.collapseSidebar') : t('layout.sidebar.expandSidebar')}</span>
<div className='flex items-center gap-x-0.5'>
{
TOGGLE_SHORTCUT.map(key => (
<span
key={key}
className='system-kbd inline-flex items-center justify-center rounded-[4px] bg-components-kbd-bg-gray px-1 text-text-tertiary'
>
{getKeyboardKeyNameBySystem(key)}
</span>
))
}
</div>
</div>
)
}
type ToggleButtonProps = {
expand: boolean
handleToggle: () => void
className?: string
}
const ToggleButton = ({
expand,
handleToggle,
className,
}: ToggleButtonProps) => {
return (
<Tooltip
popupContent={<TooltipContent expand={expand} />}
popupClassName='p-1.5 rounded-lg'
position='right'
>
<Button
size='small'
onClick={handleToggle}
className={cn('rounded-full px-1', className)}
>
{
expand
? <RiArrowLeftSLine className='size-4' />
: <RiArrowRightSLine className='size-4' />
}
</Button>
</Tooltip>
)
}
export default React.memo(ToggleButton)

View File

@@ -60,9 +60,11 @@ const HeaderOptions: FC<Props> = ({
const a = document.createElement('a')
const content = listTransformer(list).join('\n')
const file = new Blob([content], { type: 'application/jsonl' })
a.href = URL.createObjectURL(file)
const url = URL.createObjectURL(file)
a.href = url
a.download = `annotations-${locale}.jsonl`
a.click()
URL.revokeObjectURL(url)
}
const fetchList = async () => {

View File

@@ -18,7 +18,7 @@ import {
RiVerifiedBadgeLine,
} from '@remixicon/react'
import { useKeyPress } from 'ahooks'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../../workflow/utils'
import Toast from '../../base/toast'
import type { ModelAndParameter } from '../configuration/debug/types'
import Divider from '../../base/divider'
@@ -66,7 +66,7 @@ export type AppPublisherProps = {
onRefreshData?: () => void
}
const PUBLISH_SHORTCUT = ['', '⇧', 'P']
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
const AppPublisher = ({
disabled = false,
@@ -250,7 +250,7 @@ const AppPublisher = ({
<div className='flex gap-0.5'>
{PUBLISH_SHORTCUT.map(key => (
<span key={key} className='system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white text-text-primary-on-surface'>
{key}
{getKeyboardKeyNameBySystem(key)}
</span>
))}
</div>

View File

@@ -54,7 +54,7 @@ const TypeSelector: FC<Props> = ({
<InputVarTypeIcon type={selectedItem?.value as InputVarType} className='size-4 shrink-0 text-text-secondary' />
<span
className={`
ml-1.5 ${!selectedItem?.name && 'text-components-input-text-placeholder'}
ml-1.5 text-components-input-text-filled ${!selectedItem?.name && 'text-components-input-text-placeholder'}
`}
>
{selectedItem?.name}

View File

@@ -45,7 +45,7 @@ const ConfigVision: FC = () => {
if (draft.file) {
draft.file.enabled = (draft.file.allowed_file_types?.length ?? 0) > 0
draft.file.image = {
...(draft.file.image || {}),
...draft.file.image,
enabled: value,
}
}

View File

@@ -50,6 +50,7 @@ export type IGetAutomaticResProps = {
onFinished: (res: GenRes) => void
flowId?: string
nodeId?: string
editorId?: string
currentPrompt?: string
isBasicMode?: boolean
}
@@ -76,6 +77,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
onClose,
flowId,
nodeId,
editorId,
currentPrompt,
isBasicMode,
onFinished,
@@ -132,7 +134,8 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
},
]
const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>(`improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}`}`)
// eslint-disable-next-line sonarjs/no-nested-template-literals, sonarjs/no-nested-conditional
const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>(`improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`)
const instruction = instructionFromSessionStorage || ''
const [ideaOutput, setIdeaOutput] = useState<string>('')
@@ -166,7 +169,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
return true
}
const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false)
const storageKey = `${flowId}${isBasicMode ? '' : `-${nodeId}`}`
const storageKey = `${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`
const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = useGenData({
storageKey,
})

View File

@@ -8,16 +8,13 @@ import {
import { useTranslation } from 'react-i18next'
import SettingsModal from '../settings-modal'
import type { DataSet } from '@/models/datasets'
import { DataSourceType } from '@/models/datasets'
import FileIcon from '@/app/components/base/file-icon'
import { Folder } from '@/app/components/base/icons/src/vender/solid/files'
import { Globe06 } from '@/app/components/base/icons/src/vender/solid/mapsAndTravel'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import Drawer from '@/app/components/base/drawer'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Badge from '@/app/components/base/badge'
import { useKnowledge } from '@/hooks/use-knowledge'
import cn from '@/utils/classnames'
import AppIcon from '@/app/components/base/app-icon'
type ItemProps = {
className?: string
@@ -47,33 +44,26 @@ const Item: FC<ItemProps> = ({
const [isDeleting, setIsDeleting] = useState(false)
const iconInfo = config.icon_info || {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
}
return (
<div className={cn(
'group relative mb-1 flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover',
isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover',
)}>
<div className='flex w-0 grow items-center space-x-1.5'>
{
config.data_source_type === DataSourceType.FILE && (
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#E0EAFF] bg-[#F5F8FF]'>
<Folder className='h-4 w-4 text-[#444CE7]' />
</div>
)
}
{
config.data_source_type === DataSourceType.NOTION && (
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#EAECF5]'>
<FileIcon type='notion' className='h-4 w-4' />
</div>
)
}
{
config.data_source_type === DataSourceType.WEB && (
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-blue-100 bg-[#F5FAFF]'>
<Globe06 className='h-4 w-4 text-blue-600' />
</div>
)
}
<AppIcon
size='tiny'
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_type === 'image' ? undefined : iconInfo.icon_background}
imageUrl={iconInfo.icon_type === 'image' ? iconInfo.icon_url : undefined}
/>
<div className='system-sm-medium w-0 grow truncate text-text-secondary' title={config.name}>{config.name}</div>
</div>
<div className='ml-2 hidden shrink-0 items-center space-x-1 group-hover:flex'>

View File

@@ -21,10 +21,12 @@ type Value = {
type WeightedScoreProps = {
value: Value
onChange: (value: Value) => void
readonly?: boolean
}
const WeightedScore = ({
value,
onChange = noop,
readonly = false,
}: WeightedScoreProps) => {
const { t } = useTranslation()
@@ -37,8 +39,9 @@ const WeightedScore = ({
min={0}
step={0.1}
value={value.value[0]}
onChange={v => onChange({ value: [v, (10 - v * 10) / 10] })}
onChange={v => !readonly && onChange({ value: [v, (10 - v * 10) / 10] })}
trackClassName='weightedScoreSliderTrack'
disabled={readonly}
/>
<div className='mt-3 flex justify-between'>
<div className='system-xs-semibold-uppercase flex w-[90px] shrink-0 items-center text-util-colors-blue-light-blue-light-500'>

View File

@@ -4,7 +4,6 @@ import React, { useRef, useState } from 'react'
import { useGetState, useInfiniteScroll } from 'ahooks'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import TypeIcon from '../type-icon'
import Modal from '@/app/components/base/modal'
import type { DataSet } from '@/models/datasets'
import Button from '@/app/components/base/button'
@@ -13,6 +12,7 @@ import Loading from '@/app/components/base/loading'
import Badge from '@/app/components/base/badge'
import { useKnowledge } from '@/hooks/use-knowledge'
import cn from '@/utils/classnames'
import AppIcon from '@/app/components/base/app-icon'
export type ISelectDataSetProps = {
isShow: boolean
@@ -88,6 +88,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
const handleSelect = () => {
onSelect(selected)
}
return (
<Modal
isShow={isShow}
@@ -132,7 +133,13 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
>
<div className='mr-1 flex items-center overflow-hidden'>
<div className={cn('mr-2', !item.embedding_available && 'opacity-30')}>
<TypeIcon type="upload_file" size='md' />
<AppIcon
size='tiny'
iconType={item.icon_info.icon_type}
icon={item.icon_info.icon}
background={item.icon_info.icon_type === 'image' ? undefined : item.icon_info.icon_background}
imageUrl={item.icon_info.icon_type === 'image' ? item.icon_info.icon_url : undefined}
/>
</div>
<div className={cn('max-w-[200px] truncate text-[13px] font-medium text-text-secondary', !item.embedding_available && '!max-w-[120px] opacity-30')}>{item.name}</div>
{!item.embedding_available && (

View File

@@ -6,7 +6,7 @@ import { isEqual } from 'lodash-es'
import { RiCloseLine } from '@remixicon/react'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import cn from '@/utils/classnames'
import IndexMethodRadio from '@/app/components/datasets/settings/index-method-radio'
import IndexMethod from '@/app/components/datasets/settings/index-method'
import Divider from '@/app/components/base/divider'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
@@ -31,6 +31,7 @@ import {
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fetchMembers } from '@/service/common'
import type { Member } from '@/models/common'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { useDocLink } from '@/context/i18n'
type SettingsModalProps = {
@@ -73,6 +74,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
const [indexMethod, setIndexMethod] = useState(currentDataset.indexing_technique)
const [retrievalConfig, setRetrievalConfig] = useState(localeCurrentDataset?.retrieval_model_dict as RetrievalConfig)
const [keywordNumber, setKeywordNumber] = useState(currentDataset.keyword_number ?? 10)
const handleValueChange = (type: string, value: string) => {
setLocaleCurrentDataset({ ...localeCurrentDataset, [type]: value })
@@ -124,6 +126,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
description,
permission,
indexing_technique: indexMethod,
keyword_number: keywordNumber,
retrieval_model: {
...retrievalConfig,
score_threshold: retrievalConfig.score_threshold_enabled ? retrievalConfig.score_threshold : 0,
@@ -245,17 +248,18 @@ const SettingsModal: FC<SettingsModalProps> = ({
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.indexMethod')}</div>
</div>
<div className='grow'>
<IndexMethodRadio
disable={!localeCurrentDataset?.embedding_available}
<IndexMethod
disabled={!localeCurrentDataset?.embedding_available}
value={indexMethod}
onChange={v => setIndexMethod(v!)}
docForm={currentDataset.doc_form}
onChange={setIndexMethod}
currentValue={currentDataset.indexing_technique}
keywordNumber={keywordNumber}
onKeywordNumberChange={setKeywordNumber}
/>
</div>
</div>
)}
{indexMethod === 'high_quality' && (
{indexMethod === IndexingType.QUALIFIED && (
<div className={cn(rowClass)}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.embeddingModel')}</div>
@@ -334,7 +338,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
</div>
</div>
<div>
{indexMethod === 'high_quality'
{indexMethod === IndexingType.QUALIFIED
? (
<RetrievalMethodConfig
value={retrievalConfig}

View File

@@ -94,9 +94,9 @@ const Configuration: FC = () => {
const { notify } = useContext(ToastContext)
const { isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const { appDetail, showAppConfigureFeaturesModal, setAppSiderbarExpand, setShowAppConfigureFeaturesModal } = useAppStore(useShallow(state => ({
const { appDetail, showAppConfigureFeaturesModal, setAppSidebarExpand, setShowAppConfigureFeaturesModal } = useAppStore(useShallow(state => ({
appDetail: state.appDetail,
setAppSiderbarExpand: state.setAppSiderbarExpand,
setAppSidebarExpand: state.setAppSidebarExpand,
showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal,
setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal,
})))
@@ -464,6 +464,7 @@ const Configuration: FC = () => {
provider,
modelId,
completionParams,
isAdvancedMode,
)
if (Object.keys(removedDetails).length)
Toast.notify({ type: 'warning', message: `${t('common.modelProvider.parametersInvalidRemoved')}: ${Object.entries(removedDetails).map(([k, reason]) => `${k} (${reason})`).join(', ')}` })
@@ -842,7 +843,7 @@ const Configuration: FC = () => {
{ id: `${Date.now()}-no-repeat`, model: '', provider: '', parameters: {} },
],
)
setAppSiderbarExpand('collapse')
setAppSidebarExpand('collapse')
}
if (isLoading || isLoadingCurrentWorkspace || !currentWorkspace.id) {
@@ -850,84 +851,83 @@ const Configuration: FC = () => {
<Loading type='area' />
</div>
}
const value = {
appId,
isAPIKeySet,
isTrailFinished: false,
mode,
modelModeType,
promptMode,
isAdvancedMode,
isAgent,
isOpenAI,
isFunctionCall,
collectionList,
setPromptMode,
canReturnToSimpleMode,
setCanReturnToSimpleMode,
chatPromptConfig,
completionPromptConfig,
currentAdvancedPrompt,
setCurrentAdvancedPrompt,
conversationHistoriesRole: completionPromptConfig.conversation_histories_role,
showHistoryModal,
setConversationHistoriesRole,
hasSetBlockStatus,
conversationId,
introduction,
setIntroduction,
suggestedQuestions,
setSuggestedQuestions,
setConversationId,
controlClearChatMessage,
setControlClearChatMessage,
prevPromptConfig,
setPrevPromptConfig,
moreLikeThisConfig,
setMoreLikeThisConfig,
suggestedQuestionsAfterAnswerConfig,
setSuggestedQuestionsAfterAnswerConfig,
speechToTextConfig,
setSpeechToTextConfig,
textToSpeechConfig,
setTextToSpeechConfig,
citationConfig,
setCitationConfig,
annotationConfig,
setAnnotationConfig,
moderationConfig,
setModerationConfig,
externalDataToolsConfig,
setExternalDataToolsConfig,
formattingChanged,
setFormattingChanged,
inputs,
setInputs,
query,
setQuery,
completionParams,
setCompletionParams,
modelConfig,
setModelConfig,
showSelectDataSet,
dataSets,
setDataSets,
datasetConfigs,
datasetConfigsRef,
setDatasetConfigs,
hasSetContextVar,
isShowVisionConfig,
visionConfig,
setVisionConfig: handleSetVisionConfig,
isAllowVideoUpload,
isShowDocumentConfig,
isShowAudioConfig,
rerankSettingModalOpen,
setRerankSettingModalOpen,
}
return (
<ConfigContext.Provider value={{
appId,
isAPIKeySet,
isTrailFinished: false,
mode,
modelModeType,
promptMode,
isAdvancedMode,
isAgent,
isOpenAI,
isFunctionCall,
collectionList,
setPromptMode,
canReturnToSimpleMode,
setCanReturnToSimpleMode,
chatPromptConfig,
completionPromptConfig,
currentAdvancedPrompt,
setCurrentAdvancedPrompt,
conversationHistoriesRole: completionPromptConfig.conversation_histories_role,
showHistoryModal,
setConversationHistoriesRole,
hasSetBlockStatus,
conversationId,
introduction,
setIntroduction,
suggestedQuestions,
setSuggestedQuestions,
setConversationId,
controlClearChatMessage,
setControlClearChatMessage,
prevPromptConfig,
setPrevPromptConfig,
moreLikeThisConfig,
setMoreLikeThisConfig,
suggestedQuestionsAfterAnswerConfig,
setSuggestedQuestionsAfterAnswerConfig,
speechToTextConfig,
setSpeechToTextConfig,
textToSpeechConfig,
setTextToSpeechConfig,
citationConfig,
setCitationConfig,
annotationConfig,
setAnnotationConfig,
moderationConfig,
setModerationConfig,
externalDataToolsConfig,
setExternalDataToolsConfig,
formattingChanged,
setFormattingChanged,
inputs,
setInputs,
query,
setQuery,
completionParams,
setCompletionParams,
modelConfig,
setModelConfig,
showSelectDataSet,
dataSets,
setDataSets,
datasetConfigs,
datasetConfigsRef,
setDatasetConfigs,
hasSetContextVar,
isShowVisionConfig,
visionConfig,
setVisionConfig: handleSetVisionConfig,
isAllowVideoUpload,
isShowDocumentConfig,
isShowAudioConfig,
rerankSettingModalOpen,
setRerankSettingModalOpen,
}}
>
<ConfigContext.Provider value={value}>
<FeaturesProvider features={featuresData}>
<MittProvider>
<div className="flex h-full flex-col">

View File

@@ -153,7 +153,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
return
}
if (localeData.variable && !/[a-zA-Z_]\w{0,29}/g.test(localeData.variable)) {
if (localeData.variable && !/^[a-zA-Z_]\w{0,29}$/.test(localeData.variable)) {
notify({ type: 'error', message: t('appDebug.varKeyError.notValid', { key: t('appDebug.feature.tools.modal.variableName.title') }) })
return
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
@@ -35,14 +35,15 @@ type CreateAppProps = {
onSuccess: () => void
onClose: () => void
onCreateFromTemplate?: () => void
defaultAppMode?: AppMode
}
function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps) {
function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppProps) {
const { t } = useTranslation()
const { push } = useRouter()
const { notify } = useContext(ToastContext)
const [appMode, setAppMode] = useState<AppMode>('advanced-chat')
const [appMode, setAppMode] = useState<AppMode>(defaultAppMode || 'advanced-chat')
const [appIcon, setAppIcon] = useState<AppIconSelection>({ type: 'emoji', icon: '🤖', background: '#FFEAD5' })
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [name, setName] = useState('')
@@ -55,6 +56,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
const isCreatingRef = useRef(false)
useEffect(() => {
if (appMode === 'chat' || appMode === 'agent-chat' || appMode === 'completion')
setIsAppTypeExpanded(true)
}, [appMode])
const onCreate = useCallback(async () => {
if (!appMode) {
notify({ type: 'error', message: t('app.newApp.appTypeRequired') })
@@ -264,7 +270,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
type CreateAppDialogProps = CreateAppProps & {
show: boolean
}
const CreateAppModal = ({ show, onClose, onSuccess, onCreateFromTemplate }: CreateAppDialogProps) => {
const CreateAppModal = ({ show, onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppDialogProps) => {
return (
<FullScreenModal
overflowVisible
@@ -272,7 +278,7 @@ const CreateAppModal = ({ show, onClose, onSuccess, onCreateFromTemplate }: Crea
open={show}
onClose={onClose}
>
<CreateApp onClose={onClose} onSuccess={onSuccess} onCreateFromTemplate={onCreateFromTemplate} />
<CreateApp onClose={onClose} onSuccess={onSuccess} onCreateFromTemplate={onCreateFromTemplate} defaultAppMode={defaultAppMode} />
</FullScreenModal>
)
}

View File

@@ -17,12 +17,16 @@ export type Props = {
file: File | undefined
updateFile: (file?: File) => void
className?: string
accept?: string
displayName?: string
}
const Uploader: FC<Props> = ({
file,
updateFile,
className,
accept = '.yaml,.yml',
displayName = 'YAML',
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
@@ -95,9 +99,9 @@ const Uploader: FC<Props> = ({
<input
ref={fileUploader}
style={{ display: 'none' }}
type="file"
id="fileUploader"
accept='.yaml,.yml'
type='file'
id='fileUploader'
accept={accept}
onChange={fileChangeHandle}
/>
<div ref={dropRef}>
@@ -116,12 +120,12 @@ const Uploader: FC<Props> = ({
{file && (
<div className={cn('group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', ' hover:bg-components-panel-on-panel-item-bg-hover')}>
<div className='flex items-center justify-center p-3'>
<YamlIcon className="h-6 w-6 shrink-0" />
<YamlIcon className='h-6 w-6 shrink-0' />
</div>
<div className='flex grow flex-col items-start gap-0.5 py-1 pr-2'>
<span className='font-inter max-w-[calc(100%_-_30px)] overflow-hidden text-ellipsis whitespace-nowrap text-[12px] font-medium leading-4 text-text-secondary'>{file.name}</span>
<div className='font-inter flex h-3 items-center gap-1 self-stretch text-[10px] font-medium uppercase leading-3 text-text-tertiary'>
<span>YAML</span>
<span>{displayName}</span>
<span className='text-text-quaternary'>·</span>
<span>{formatFileSize(file.size)}</span>
</div>

View File

@@ -15,7 +15,7 @@ type State = {
type Action = {
setAppDetail: (appDetail?: App & Partial<AppSSO>) => void
setAppSiderbarExpand: (state: string) => void
setAppSidebarExpand: (state: string) => void
setCurrentLogItem: (item?: IChatItem) => void
setCurrentLogModalActiveTab: (tab: string) => void
setShowPromptLogModal: (showPromptLogModal: boolean) => void
@@ -28,7 +28,7 @@ export const useStore = create<State & Action>(set => ({
appDetail: undefined,
setAppDetail: appDetail => set(() => ({ appDetail })),
appSidebarExpand: '',
setAppSiderbarExpand: appSidebarExpand => set(() => ({ appSidebarExpand })),
setAppSidebarExpand: appSidebarExpand => set(() => ({ appSidebarExpand })),
currentLogItem: undefined,
currentLogModalActiveTab: 'DETAIL',
setCurrentLogItem: currentLogItem => set(() => ({ currentLogItem })),

View File

@@ -103,7 +103,7 @@ export const AppTypeIcon = React.memo(({ type, className, wrapperClassName, styl
return null
})
function AppTypeSelectTrigger({ values }: { values: AppSelectorProps['value'] }) {
function AppTypeSelectTrigger({ values }: { readonly values: AppSelectorProps['value'] }) {
const { t } = useTranslation()
if (!values || values.length === 0) {
return <div className={cn(

View File

@@ -3,6 +3,7 @@ import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import Run from '@/app/components/workflow/run'
import { useStore } from '@/app/components/app/store'
type ILogDetail = {
runID: string
@@ -11,6 +12,7 @@ type ILogDetail = {
const DetailPanel: FC<ILogDetail> = ({ runID, onClose }) => {
const { t } = useTranslation()
const appDetail = useStore(state => state.appDetail)
return (
<div className='relative flex grow flex-col pt-3'>
@@ -18,7 +20,10 @@ const DetailPanel: FC<ILogDetail> = ({ runID, onClose }) => {
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</span>
<h1 className='system-xl-semibold shrink-0 px-4 py-1 text-text-primary'>{t('appLog.runDetail.workflowTitle')}</h1>
<Run runID={runID}/>
<Run
runDetailUrl={runID ? `/apps/${appDetail?.id}/workflow-runs/${runID}` : ''}
tracingListUrl={runID ? `/apps/${appDetail?.id}/workflow-runs/${runID}/node-executions` : ''}
/>
</div>
)
}

View File

@@ -33,6 +33,19 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
const { t } = useTranslation()
return (
<div className='mb-2 flex flex-row flex-wrap gap-2'>
<Chip
value={queryParams.status || 'all'}
onSelect={(item) => {
setQueryParams({ ...queryParams, status: item.value as string })
}}
onClear={() => setQueryParams({ ...queryParams, status: 'all' })}
items={[{ value: 'all', name: 'All' },
{ value: 'succeeded', name: 'Success' },
{ value: 'failed', name: 'Fail' },
{ value: 'stopped', name: 'Stop' },
{ value: 'partial-succeeded', name: 'Partial Success' },
]}
/>
<Chip
className='min-w-[150px]'
panelClassName='w-[270px]'
@@ -44,19 +57,6 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
onClear={() => setQueryParams({ ...queryParams, period: '9' })}
items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))}
/>
<Chip
value={queryParams.status || 'all'}
onSelect={(item) => {
setQueryParams({ ...queryParams, status: item.value as string })
}}
onClear={() => setQueryParams({ ...queryParams, status: 'all' })}
items={[{ value: 'all', name: 'All' },
{ value: 'succeeded', name: 'Success' },
{ value: 'partial-succeeded', name: 'Partial Success' },
{ value: 'failed', name: 'Failure' },
{ value: 'stopped', name: 'Stop' },
]}
/>
<Input
wrapperClassName='w-[200px]'
showLeftIcon

View File

@@ -159,9 +159,11 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
a.href = URL.createObjectURL(file)
const url = URL.createObjectURL(file)
a.href = url
a.download = `${app.name}.yml`
a.click()
URL.revokeObjectURL(url)
}
catch {
notify({ type: 'error', message: t('app.exportFailed') })
@@ -257,7 +259,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
}
return (
<div className="relative flex w-full flex-col py-1" onMouseLeave={onMouseLeave}>
<button className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickSettings}>
<button type="button" className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickSettings}>
<span className='system-sm-regular text-text-secondary'>{t('app.editApp')}</span>
</button>
<Divider className="my-1" />
@@ -279,12 +281,21 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
</>
)}
{
(isGettingUserCanAccessApp || !userCanAccessApp?.result) ? null : <>
<Divider className="my-1" />
<button className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickInstalledApp}>
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
</button>
</>
(!systemFeatures.webapp_auth.enabled)
? <>
<Divider className="my-1" />
<button className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickInstalledApp}>
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
</button>
</>
: !(isGettingUserCanAccessApp || !userCanAccessApp?.result) && (
<>
<Divider className="my-1" />
<button className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickInstalledApp}>
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
</button>
</>
)
}
<Divider className="my-1" />
{

View File

@@ -211,14 +211,14 @@ const List = () => {
{(data && data[0].total > 0)
? <div className='relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
{isCurrentWorkspaceEditor
&& <NewAppCard ref={newAppCardRef} onSuccess={mutate} />}
&& <NewAppCard ref={newAppCardRef} onSuccess={mutate} selectedAppType={activeTab} />}
{data.map(({ data: apps }) => apps.map(app => (
<AppCard key={app.id} app={app} onRefresh={mutate} />
)))}
</div>
: <div className='relative grid grow grid-cols-1 content-start gap-4 overflow-hidden px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
{isCurrentWorkspaceEditor
&& <NewAppCard ref={newAppCardRef} className='z-10' onSuccess={mutate} />}
&& <NewAppCard ref={newAppCardRef} className='z-10' onSuccess={mutate} selectedAppType={activeTab} />}
<Empty />
</div>}

View File

@@ -26,12 +26,14 @@ export type CreateAppCardProps = {
className?: string
onSuccess?: () => void
ref: React.RefObject<HTMLDivElement | null>
selectedAppType?: string
}
const CreateAppCard = ({
ref,
className,
onSuccess,
selectedAppType,
}: CreateAppCardProps) => {
const { t } = useTranslation()
const { onPlanInfoChanged } = useProviderContext()
@@ -86,6 +88,7 @@ const CreateAppCard = ({
setShowNewAppTemplateDialog(true)
setShowNewAppModal(false)
}}
defaultAppMode={selectedAppType !== 'all' ? selectedAppType as any : undefined}
/>
)}
{showNewAppTemplateDialog && (

View File

@@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/react'
import { ActionButton, ActionButtonState } from './index'
describe('ActionButton', () => {
test('renders button with default props', () => {
render(<ActionButton>Click me</ActionButton>)
const button = screen.getByRole('button', { name: 'Click me' })
expect(button).toBeInTheDocument()
expect(button.classList.contains('action-btn')).toBe(true)
expect(button.classList.contains('action-btn-m')).toBe(true)
})
test('renders button with xs size', () => {
render(<ActionButton size='xs'>Small Button</ActionButton>)
const button = screen.getByRole('button', { name: 'Small Button' })
expect(button.classList.contains('action-btn-xs')).toBe(true)
})
test('renders button with l size', () => {
render(<ActionButton size='l'>Large Button</ActionButton>)
const button = screen.getByRole('button', { name: 'Large Button' })
expect(button.classList.contains('action-btn-l')).toBe(true)
})
test('renders button with xl size', () => {
render(<ActionButton size='xl'>Extra Large Button</ActionButton>)
const button = screen.getByRole('button', { name: 'Extra Large Button' })
expect(button.classList.contains('action-btn-xl')).toBe(true)
})
test('applies correct state classes', () => {
const { rerender } = render(
<ActionButton state={ActionButtonState.Destructive}>Destructive</ActionButton>,
)
let button = screen.getByRole('button', { name: 'Destructive' })
expect(button.classList.contains('action-btn-destructive')).toBe(true)
rerender(<ActionButton state={ActionButtonState.Active}>Active</ActionButton>)
button = screen.getByRole('button', { name: 'Active' })
expect(button.classList.contains('action-btn-active')).toBe(true)
rerender(<ActionButton state={ActionButtonState.Disabled}>Disabled</ActionButton>)
button = screen.getByRole('button', { name: 'Disabled' })
expect(button.classList.contains('action-btn-disabled')).toBe(true)
rerender(<ActionButton state={ActionButtonState.Hover}>Hover</ActionButton>)
button = screen.getByRole('button', { name: 'Hover' })
expect(button.classList.contains('action-btn-hover')).toBe(true)
})
test('applies custom className', () => {
render(<ActionButton className='custom-class'>Custom Class</ActionButton>)
const button = screen.getByRole('button', { name: 'Custom Class' })
expect(button.classList.contains('custom-class')).toBe(true)
})
test('applies custom style', () => {
render(
<ActionButton styleCss={{ color: 'red', backgroundColor: 'blue' }}>
Custom Style
</ActionButton>,
)
const button = screen.getByRole('button', { name: 'Custom Style' })
expect(button).toHaveStyle({
color: 'red',
backgroundColor: 'blue',
})
})
test('forwards additional button props', () => {
render(<ActionButton disabled data-testid='test-button'>Disabled Button</ActionButton>)
const button = screen.getByRole('button', { name: 'Disabled Button' })
expect(button).toBeDisabled()
expect(button).toHaveAttribute('data-testid', 'test-button')
})
})

View File

@@ -32,6 +32,7 @@ export type ActionButtonProps = {
size?: 'xs' | 's' | 'm' | 'l' | 'xl'
state?: ActionButtonState
styleCss?: CSSProperties
ref?: React.Ref<HTMLButtonElement>
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof actionButtonVariants>
function getActionButtonState(state: ActionButtonState) {
@@ -49,24 +50,22 @@ function getActionButtonState(state: ActionButtonState) {
}
}
const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
({ className, size, state = ActionButtonState.Default, styleCss, children, ...props }, ref) => {
return (
<button
type='button'
className={classNames(
actionButtonVariants({ className, size }),
getActionButtonState(state),
)}
ref={ref}
style={styleCss}
{...props}
>
{children}
</button>
)
},
)
const ActionButton = ({ className, size, state = ActionButtonState.Default, styleCss, children, ref, ...props }: ActionButtonProps) => {
return (
<button
type='button'
className={classNames(
actionButtonVariants({ className, size }),
getActionButtonState(state),
)}
ref={ref}
style={styleCss}
{...props}
>
{children}
</button>
)
}
ActionButton.displayName = 'ActionButton'
export default ActionButton

View File

@@ -33,7 +33,7 @@ const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, obs
if (time < 1)
return `${(time * 1000).toFixed(3)} ms`
if (time > 60)
return `${Number.parseInt(Math.round(time / 60).toString())} m ${(time % 60).toFixed(3)} s`
return `${Math.floor(time / 60)} m ${(time % 60).toFixed(3)} s`
return `${time.toFixed(3)} s`
}

View File

@@ -5,7 +5,6 @@ import type { Area } from 'react-easy-crop'
import Modal from '../modal'
import Divider from '../divider'
import Button from '../button'
import { ImagePlus } from '../icons/src/vender/line/images'
import { useLocalFileUploader } from '../image-uploader/hooks'
import EmojiPickerInner from '../emoji-picker/Inner'
import type { OnImageInput } from './ImageInput'
@@ -16,6 +15,7 @@ import type { AppIconType, ImageFile } from '@/types/app'
import cn from '@/utils/classnames'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
import { noop } from 'lodash-es'
import { RiImageCircleAiLine } from '@remixicon/react'
export type AppIconEmojiSelection = {
type: 'emoji'
@@ -46,7 +46,7 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
const tabs = [
{ key: 'emoji', label: t('app.iconPicker.emoji'), icon: <span className="text-lg">🤖</span> },
{ key: 'image', label: t('app.iconPicker.image'), icon: <ImagePlus /> },
{ key: 'image', label: t('app.iconPicker.image'), icon: <RiImageCircleAiLine className='size-4' /> },
]
const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
@@ -119,10 +119,10 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
{tabs.map(tab => (
<button
key={tab.key}
className={`
flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 text-sm font-medium
${activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active shadow-md'}
`}
className={cn(
'system-sm-medium flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 text-text-tertiary',
activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
)}
onClick={() => setActiveTab(tab.key as AppIconType)}
>
{tab.icon} &nbsp; {tab.label}

View File

@@ -0,0 +1,159 @@
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import AppIcon from './index'
// Mock emoji-mart initialization
jest.mock('emoji-mart', () => ({
init: jest.fn(),
}))
// Mock emoji data
jest.mock('@emoji-mart/data', () => ({}))
// Mock the ahooks useHover hook
jest.mock('ahooks', () => ({
useHover: jest.fn(() => false),
}))
describe('AppIcon', () => {
beforeEach(() => {
// Mock custom element
if (!customElements.get('em-emoji')) {
customElements.define('em-emoji', class extends HTMLElement {
constructor() {
super()
}
// Mock basic functionality
connectedCallback() {
this.innerHTML = '🤖'
}
})
}
// Reset mocks
require('ahooks').useHover.mockReset().mockReturnValue(false)
})
it('renders default emoji when no icon or image is provided', () => {
render(<AppIcon />)
const emojiElement = document.querySelector('em-emoji')
expect(emojiElement).toBeInTheDocument()
expect(emojiElement?.getAttribute('id')).toBe('🤖')
})
it('renders with custom emoji when icon is provided', () => {
render(<AppIcon icon='smile' />)
const emojiElement = document.querySelector('em-emoji')
expect(emojiElement).toBeInTheDocument()
expect(emojiElement?.getAttribute('id')).toBe('smile')
})
it('renders image when iconType is image and imageUrl is provided', () => {
render(<AppIcon iconType='image' imageUrl='test-image.jpg' />)
const imgElement = screen.getByAltText('app icon')
expect(imgElement).toBeInTheDocument()
expect(imgElement).toHaveAttribute('src', 'test-image.jpg')
})
it('renders innerIcon when provided', () => {
render(<AppIcon innerIcon={<div data-testid='inner-icon'>Custom Icon</div>} />)
const innerIcon = screen.getByTestId('inner-icon')
expect(innerIcon).toBeInTheDocument()
})
it('applies size classes correctly', () => {
const { container: xsContainer } = render(<AppIcon size='xs' />)
expect(xsContainer.firstChild).toHaveClass('w-4 h-4 rounded-[4px]')
const { container: tinyContainer } = render(<AppIcon size='tiny' />)
expect(tinyContainer.firstChild).toHaveClass('w-6 h-6 rounded-md')
const { container: smallContainer } = render(<AppIcon size='small' />)
expect(smallContainer.firstChild).toHaveClass('w-8 h-8 rounded-lg')
const { container: mediumContainer } = render(<AppIcon size='medium' />)
expect(mediumContainer.firstChild).toHaveClass('w-9 h-9 rounded-[10px]')
const { container: largeContainer } = render(<AppIcon size='large' />)
expect(largeContainer.firstChild).toHaveClass('w-10 h-10 rounded-[10px]')
const { container: xlContainer } = render(<AppIcon size='xl' />)
expect(xlContainer.firstChild).toHaveClass('w-12 h-12 rounded-xl')
const { container: xxlContainer } = render(<AppIcon size='xxl' />)
expect(xxlContainer.firstChild).toHaveClass('w-14 h-14 rounded-2xl')
})
it('applies rounded class when rounded=true', () => {
const { container } = render(<AppIcon rounded />)
expect(container.firstChild).toHaveClass('rounded-full')
})
it('applies custom background color', () => {
const { container } = render(<AppIcon background='#FF5500' />)
expect(container.firstChild).toHaveStyle('background: #FF5500')
})
it('uses default background color when no background is provided for non-image icons', () => {
const { container } = render(<AppIcon />)
expect(container.firstChild).toHaveStyle('background: #FFEAD5')
})
it('does not apply background style for image icons', () => {
const { container } = render(<AppIcon iconType='image' imageUrl='test.jpg' background='#FF5500' />)
// Should not have the background style from the prop
expect(container.firstChild).not.toHaveStyle('background: #FF5500')
})
it('calls onClick handler when clicked', () => {
const handleClick = jest.fn()
const { container } = render(<AppIcon onClick={handleClick} />)
fireEvent.click(container.firstChild!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('applies custom className', () => {
const { container } = render(<AppIcon className='custom-class' />)
expect(container.firstChild).toHaveClass('custom-class')
})
it('does not display edit icon when showEditIcon=false', () => {
render(<AppIcon />)
const editIcon = screen.queryByRole('svg')
expect(editIcon).not.toBeInTheDocument()
})
it('displays edit icon when showEditIcon=true and hovering', () => {
// Mock the useHover hook to return true for this test
require('ahooks').useHover.mockReturnValue(true)
render(<AppIcon showEditIcon />)
const editIcon = document.querySelector('svg')
expect(editIcon).toBeInTheDocument()
})
it('does not display edit icon when showEditIcon=true but not hovering', () => {
// useHover returns false by default from our mock setup
render(<AppIcon showEditIcon />)
const editIcon = document.querySelector('svg')
expect(editIcon).not.toBeInTheDocument()
})
it('handles conditional isValidImageIcon check correctly', () => {
// Case 1: Valid image icon
const { rerender } = render(
<AppIcon iconType='image' imageUrl='test.jpg' />,
)
expect(screen.getByAltText('app icon')).toBeInTheDocument()
// Case 2: Invalid - missing image URL
rerender(<AppIcon iconType='image' imageUrl={null} />)
expect(screen.queryByAltText('app icon')).not.toBeInTheDocument()
// Case 3: Invalid - wrong icon type
rerender(<AppIcon iconType='emoji' imageUrl='test.jpg' />)
expect(screen.queryByAltText('app icon')).not.toBeInTheDocument()
})
})

View File

@@ -1,12 +1,13 @@
'use client'
import React from 'react'
import type { FC } from 'react'
import { type FC, useRef } from 'react'
import { init } from 'emoji-mart'
import data from '@emoji-mart/data'
import { cva } from 'class-variance-authority'
import type { AppIconType } from '@/types/app'
import classNames from '@/utils/classnames'
import { useHover } from 'ahooks'
import { RiEditLine } from '@remixicon/react'
init({ data })
@@ -20,20 +21,21 @@ export type AppIconProps = {
className?: string
innerIcon?: React.ReactNode
coverElement?: React.ReactNode
showEditIcon?: boolean
onClick?: () => void
}
const appIconVariants = cva(
'flex items-center justify-center relative text-lg rounded-lg grow-0 shrink-0 overflow-hidden leading-none',
'flex items-center justify-center relative grow-0 shrink-0 overflow-hidden leading-none border-[0.5px] border-divider-regular',
{
variants: {
size: {
xs: 'w-4 h-4 text-xs',
tiny: 'w-6 h-6 text-base',
small: 'w-8 h-8 text-xl',
medium: 'w-9 h-9 text-[22px]',
large: 'w-10 h-10 text-[24px]',
xl: 'w-12 h-12 text-[28px]',
xxl: 'w-14 h-14 text-[32px]',
xs: 'w-4 h-4 text-xs rounded-[4px]',
tiny: 'w-6 h-6 text-base rounded-md',
small: 'w-8 h-8 text-xl rounded-lg',
medium: 'w-9 h-9 text-[22px] rounded-[10px]',
large: 'w-10 h-10 text-[24px] rounded-[10px]',
xl: 'w-12 h-12 text-[28px] rounded-xl',
xxl: 'w-14 h-14 text-[32px] rounded-2xl',
},
rounded: {
true: 'rounded-full',
@@ -44,6 +46,46 @@ const appIconVariants = cva(
rounded: false,
},
})
const EditIconWrapperVariants = cva(
'absolute left-0 top-0 z-10 flex items-center justify-center bg-background-overlay-alt',
{
variants: {
size: {
xs: 'w-4 h-4 rounded-[4px]',
tiny: 'w-6 h-6 rounded-md',
small: 'w-8 h-8 rounded-lg',
medium: 'w-9 h-9 rounded-[10px]',
large: 'w-10 h-10 rounded-[10px]',
xl: 'w-12 h-12 rounded-xl',
xxl: 'w-14 h-14 rounded-2xl',
},
rounded: {
true: 'rounded-full',
},
},
defaultVariants: {
size: 'medium',
rounded: false,
},
})
const EditIconVariants = cva(
'text-text-primary-on-surface',
{
variants: {
size: {
xs: 'size-3',
tiny: 'size-3.5',
small: 'size-5',
medium: 'size-[22px]',
large: 'size-6',
xl: 'size-7',
xxl: 'size-8',
},
},
defaultVariants: {
size: 'medium',
},
})
const AppIcon: FC<AppIconProps> = ({
size = 'medium',
rounded = false,
@@ -55,21 +97,35 @@ const AppIcon: FC<AppIconProps> = ({
innerIcon,
coverElement,
onClick,
showEditIcon = false,
}) => {
const isValidImageIcon = iconType === 'image' && imageUrl
const Icon = (icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />
const wrapperRef = useRef<HTMLSpanElement>(null)
const isHovering = useHover(wrapperRef)
return <span
className={classNames(appIconVariants({ size, rounded }), className)}
style={{ background: isValidImageIcon ? undefined : (background || '#FFEAD5') }}
onClick={onClick}
>
{isValidImageIcon
? <img src={imageUrl} className="h-full w-full" alt="app icon" />
: (innerIcon || ((icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />))
}
{coverElement}
</span>
return (
<span
ref={wrapperRef}
className={classNames(appIconVariants({ size, rounded }), className)}
style={{ background: isValidImageIcon ? undefined : (background || '#FFEAD5') }}
onClick={onClick}
>
{
isValidImageIcon
? <img src={imageUrl} className='h-full w-full' alt='app icon' />
: (innerIcon || Icon)
}
{
showEditIcon && isHovering && (
<div className={EditIconWrapperVariants({ size, rounded })}>
<RiEditLine className={EditIconVariants({ size })} />
</div>
)
}
{coverElement}
</span>
)
}
export default React.memo(AppIcon)

View File

@@ -1,5 +1,5 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import cn from '@/utils/classnames'
export type AvatarProps = {
@@ -27,6 +27,12 @@ const Avatar = ({
onError?.(true)
}
// after uploaded, api would first return error imgs url: '.../files//file-preview/...'. Then return the right url, Which caused not show the avatar
useEffect(() => {
if(avatar && imgError)
setImgError(false)
}, [avatar])
if (avatar && !imgError) {
return (
<img

View File

@@ -60,6 +60,7 @@
@apply
border-[0.5px]
shadow-xs
backdrop-blur-[5px]
bg-components-button-secondary-bg
border-components-button-secondary-border
hover:bg-components-button-secondary-bg-hover
@@ -69,6 +70,7 @@
.btn-secondary.btn-disabled {
@apply
backdrop-blur-sm
bg-components-button-secondary-bg-disabled
border-components-button-secondary-border-disabled
text-components-button-secondary-text-disabled;

View File

@@ -35,27 +35,26 @@ export type ButtonProps = {
loading?: boolean
styleCss?: CSSProperties
spinnerClassName?: string
ref?: React.Ref<HTMLButtonElement>
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, destructive, loading, styleCss, children, spinnerClassName, ...props }, ref) => {
return (
<button
type='button'
className={classNames(
buttonVariants({ variant, size, className }),
destructive && 'btn-destructive',
)}
ref={ref}
style={styleCss}
{...props}
>
{children}
{loading && <Spinner loading={loading} className={classNames('!ml-1 !h-3 !w-3 !border-2 !text-white', spinnerClassName)} />}
</button>
)
},
)
const Button = ({ className, variant, size, destructive, loading, styleCss, children, spinnerClassName, ref, ...props }: ButtonProps) => {
return (
<button
type='button'
className={classNames(
buttonVariants({ variant, size, className }),
destructive && 'btn-destructive',
)}
ref={ref}
style={styleCss}
{...props}
>
{children}
{loading && <Spinner loading={loading} className={classNames('!ml-1 !h-3 !w-3 !border-2 !text-white', spinnerClassName)} />}
</button>
)
}
Button.displayName = 'Button'
export default Button

View File

@@ -52,6 +52,10 @@ const ChatWrapper = () => {
allInputsHidden,
initUserVariables,
} = useChatWithHistoryContext()
// Semantic variable for better code readability
const isHistoryConversation = !!currentConversationId
const appConfig = useMemo(() => {
const config = appParams || {}
@@ -62,9 +66,9 @@ const ChatWrapper = () => {
fileUploadConfig: (config as any).system_parameters,
},
supportFeedback: true,
opening_statement: currentConversationId ? currentConversationItem?.introduction : (config as any).opening_statement,
opening_statement: isHistoryConversation ? currentConversationItem?.introduction : (config as any).opening_statement,
} as ChatConfig
}, [appParams, currentConversationItem?.introduction, currentConversationId])
}, [appParams, currentConversationItem?.introduction, isHistoryConversation])
const {
chatList,
setTargetMessageId,
@@ -75,7 +79,7 @@ const ChatWrapper = () => {
} = useChat(
appConfig,
{
inputs: (currentConversationId ? currentConversationInputs : newConversationInputs) as any,
inputs: (isHistoryConversation ? currentConversationInputs : newConversationInputs) as any,
inputsForm: inputsForms,
},
appPrevChatTree,
@@ -83,7 +87,7 @@ const ChatWrapper = () => {
clearChatList,
setClearChatList,
)
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
const inputsFormValue = isHistoryConversation ? currentConversationInputs : newConversationInputsRef?.current
const inputDisabled = useMemo(() => {
if (allInputsHidden)
return false
@@ -132,7 +136,7 @@ const ChatWrapper = () => {
const data: any = {
query: message,
files,
inputs: formatBooleanInputs(inputsForms, currentConversationId ? currentConversationInputs : newConversationInputs),
inputs: formatBooleanInputs(inputsForms, isHistoryConversation ? currentConversationInputs : newConversationInputs),
conversation_id: currentConversationId,
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
}
@@ -142,11 +146,11 @@ const ChatWrapper = () => {
data,
{
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp,
},
)
}, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId])
}, [chatList, handleNewConversationCompleted, handleSend, isHistoryConversation, currentConversationInputs, newConversationInputs, isInstalledApp, appId])
const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => {
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
@@ -159,31 +163,30 @@ const ChatWrapper = () => {
}, [chatList, doSend])
const messageList = useMemo(() => {
if (currentConversationId)
return chatList
// Always filter out opening statement from message list as it's handled separately in welcome component
return chatList.filter(item => !item.isOpeningStatement)
}, [chatList, currentConversationId])
}, [chatList])
const [collapsed, setCollapsed] = useState(!!currentConversationId)
const [collapsed, setCollapsed] = useState(isHistoryConversation)
const chatNode = useMemo(() => {
if (allInputsHidden || !inputsForms.length)
return null
if (isMobile) {
if (!currentConversationId)
if (!isHistoryConversation)
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
return null
}
else {
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
}
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
}, [inputsForms.length, isMobile, isHistoryConversation, collapsed, allInputsHidden])
const welcome = useMemo(() => {
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
if (respondingState)
return null
if (currentConversationId)
if (isHistoryConversation)
return null
if (!welcomeMessage)
return null
@@ -224,7 +227,7 @@ const ChatWrapper = () => {
</div>
</div>
)
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, isHistoryConversation, inputsForms.length, respondingState, allInputsHidden])
const answerIcon = (appData?.site && appData.site.use_icon_as_answer_icon)
? <AnswerIcon
@@ -248,7 +251,7 @@ const ChatWrapper = () => {
chatFooterClassName='pb-4'
chatFooterInnerClassName={`mx-auto w-full max-w-[768px] ${isMobile ? 'px-2' : 'px-4'}`}
onSend={doSend}
inputs={currentConversationId ? currentConversationInputs as any : newConversationInputs}
inputs={isHistoryConversation ? currentConversationInputs as any : newConversationInputs}
inputsForm={inputsForms}
onRegenerate={doRegenerate}
onStopResponding={handleStop}

View File

@@ -122,19 +122,31 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
setLocaleFromProps()
}, [appData])
const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(false)
const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
try {
const localState = localStorage.getItem('webappSidebarCollapse')
return localState === 'collapsed'
}
catch (e) {
// localStorage may be disabled in private browsing mode or by security settings
// fallback to default value
return false
}
}
return false
})
const handleSidebarCollapse = useCallback((state: boolean) => {
if (appId) {
setSidebarCollapseState(state)
localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded')
try {
localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded')
}
catch (e) {
// localStorage may be disabled, continue without persisting state
}
}
}, [appId, setSidebarCollapseState])
useEffect(() => {
if (appId) {
const localState = localStorage.getItem('webappSidebarCollapse')
setSidebarCollapseState(localState === 'collapsed')
}
}, [appId])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
defaultValue: {},
})
@@ -215,7 +227,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}
}
if (item.number) {
const convertedNumber = Number(initInputs[item.number.variable]) ?? undefined
const convertedNumber = Number(initInputs[item.number.variable])
return {
...item.number,
default: convertedNumber || item.default || item.number.default,
@@ -508,7 +520,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [mutateAppConversationData, handleConversationIdInfoChange])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, appId)
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.success') })
}, [isInstalledApp, appId, t, notify])

View File

@@ -47,6 +47,11 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
}, [site, customConfig, themeBuilder])
useEffect(() => {
if (!isSidebarCollapsed)
setShowSidePanel(false)
}, [isSidebarCollapsed])
useDocumentTitle(site?.title || 'Chat')
return (
@@ -76,7 +81,7 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
onMouseEnter={() => setShowSidePanel(true)}
onMouseLeave={() => setShowSidePanel(false)}
>
<Sidebar isPanel />
<Sidebar isPanel panelVisible={showSidePanel} />
</div>
)}
<div className={cn('flex h-full flex-col overflow-hidden border-[0,5px] border-components-panel-border-subtle bg-chatbot-bg', isMobile ? 'rounded-t-2xl' : 'rounded-2xl')}>

View File

@@ -23,9 +23,10 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
type Props = {
isPanel?: boolean
panelVisible?: boolean
}
const Sidebar = ({ isPanel }: Props) => {
const Sidebar = ({ isPanel, panelVisible }: Props) => {
const { t } = useTranslation()
const {
isInstalledApp,
@@ -138,7 +139,12 @@ const Sidebar = ({ isPanel }: Props) => {
)}
</div>
<div className='flex shrink-0 items-center justify-between p-3'>
<MenuDropdown hideLogout={isInstalledApp} placement='top-start' data={appData?.site} />
<MenuDropdown
hideLogout={isInstalledApp}
placement='top-start'
data={appData?.site}
forceClose={isPanel && !panelVisible}
/>
{/* powered by */}
<div className='shrink-0'>
{!appData?.custom_config?.remove_webapp_brand && (

View File

@@ -20,6 +20,8 @@ import EditReplyModal from '@/app/components/app/annotation/edit-annotation-moda
import Log from '@/app/components/base/chat/chat/log'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import NewAudioButton from '@/app/components/base/new-audio-button'
import Modal from '@/app/components/base/modal/modal'
import Textarea from '@/app/components/base/textarea'
import cn from '@/utils/classnames'
type OperationProps = {
@@ -32,6 +34,7 @@ type OperationProps = {
hasWorkflowProcess: boolean
noChatInput?: boolean
}
const Operation: FC<OperationProps> = ({
item,
question,
@@ -52,6 +55,8 @@ const Operation: FC<OperationProps> = ({
onRegenerate,
} = useChatContext()
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
const [isShowFeedbackModal, setIsShowFeedbackModal] = useState(false)
const [feedbackContent, setFeedbackContent] = useState('')
const {
id,
isOpeningStatement,
@@ -70,14 +75,29 @@ const Operation: FC<OperationProps> = ({
return messageContent
}, [agent_thoughts, messageContent])
const handleFeedback = async (rating: 'like' | 'dislike' | null) => {
const handleFeedback = async (rating: 'like' | 'dislike' | null, content?: string) => {
if (!config?.supportFeedback || !onFeedback)
return
await onFeedback?.(id, { rating })
await onFeedback?.(id, { rating, content })
setLocalFeedback({ rating })
}
const handleThumbsDown = () => {
setIsShowFeedbackModal(true)
}
const handleFeedbackSubmit = async () => {
await handleFeedback('dislike', feedbackContent)
setFeedbackContent('')
setIsShowFeedbackModal(false)
}
const handleFeedbackCancel = () => {
setFeedbackContent('')
setIsShowFeedbackModal(false)
}
const operationWidth = useMemo(() => {
let width = 0
if (!isOpeningStatement)
@@ -153,7 +173,7 @@ const Operation: FC<OperationProps> = ({
<ActionButton onClick={() => handleFeedback('like')}>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
<ActionButton onClick={() => handleFeedback('dislike')}>
<ActionButton onClick={handleThumbsDown}>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
</>
@@ -188,6 +208,32 @@ const Operation: FC<OperationProps> = ({
createdAt={annotation?.created_at}
onRemove={() => onAnnotationRemoved?.(index)}
/>
{isShowFeedbackModal && (
<Modal
title={t('common.feedback.title') || 'Provide Feedback'}
subTitle={t('common.feedback.subtitle') || 'Please tell us what went wrong with this response'}
onClose={handleFeedbackCancel}
onConfirm={handleFeedbackSubmit}
onCancel={handleFeedbackCancel}
confirmButtonText={t('common.operation.submit') || 'Submit'}
cancelButtonText={t('common.operation.cancel') || 'Cancel'}
>
<div className='space-y-3'>
<div>
<label className='system-sm-semibold mb-2 block text-text-secondary'>
{t('common.feedback.content') || 'Feedback Content'}
</label>
<Textarea
value={feedbackContent}
onChange={e => setFeedbackContent(e.target.value)}
placeholder={t('common.feedback.placeholder') || 'Please describe what went wrong or how we can improve...'}
rows={4}
className='w-full'
/>
</div>
</div>
</Modal>
)}
</>
)
}

View File

@@ -682,7 +682,7 @@ export const useChat = (
updateChatTreeNode(targetAnswerId, {
content: chatList[index].content,
annotation: {
...(chatList[index].annotation || {}),
...chatList[index].annotation,
id: '',
} as Annotation,
})

View File

@@ -188,7 +188,7 @@ export const useEmbeddedChatbot = () => {
}
}
if (item.number) {
const convertedNumber = Number(initInputs[item.number.variable]) ?? undefined
const convertedNumber = Number(initInputs[item.number.variable])
return {
...item.number,
default: convertedNumber || item.default || item.number.default,
@@ -390,7 +390,7 @@ export const useEmbeddedChatbot = () => {
}, [mutateAppConversationData, handleConversationIdInfoChange])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, appId)
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.success') })
}, [isInstalledApp, appId, t, notify])

View File

@@ -93,4 +93,5 @@ export type Callback = {
export type Feedback = {
rating: 'like' | 'dislike' | null
content?: string | null
}

View File

@@ -43,6 +43,16 @@ async function getProcessedInputsFromUrlParams(): Promise<Record<string, any>> {
async function getProcessedSystemVariablesFromUrlParams(): Promise<Record<string, any>> {
const urlParams = new URLSearchParams(window.location.search)
const redirectUrl = urlParams.get('redirect_url')
if (redirectUrl) {
const decodedRedirectUrl = decodeURIComponent(redirectUrl)
const queryString = decodedRedirectUrl.split('?')[1]
if (queryString) {
const redirectParams = new URLSearchParams(queryString)
for (const [key, value] of redirectParams.entries())
urlParams.set(key, value)
}
}
const systemVariables: Record<string, any> = {}
const entriesArray = Array.from(urlParams.entries())
await Promise.all(

View File

@@ -5,7 +5,7 @@ import IndeterminateIcon from './assets/indeterminate-icon'
type CheckboxProps = {
id?: string
checked?: boolean
onCheck?: () => void
onCheck?: (event: React.MouseEvent<HTMLDivElement>) => void
className?: string
disabled?: boolean
indeterminate?: boolean
@@ -35,10 +35,10 @@ const Checkbox = ({
disabled && disabledClassName,
className,
)}
onClick={() => {
onClick={(event) => {
if (disabled)
return
onCheck?.()
onCheck?.(event)
}}
data-testid={`checkbox-${id}`}
>

View File

@@ -18,8 +18,8 @@ const ContentDialog = ({
return (
<Transition
show={show}
as="div"
className="absolute left-0 top-0 z-20 box-border h-full w-full p-2"
as='div'
className='absolute left-0 top-0 z-30 box-border h-full w-full p-2'
>
<TransitionChild>
<div

View File

@@ -10,8 +10,8 @@ type CornerLabelProps = {
const CornerLabel: React.FC<CornerLabelProps> = ({ label, className, labelClassName }) => {
return (
<div className={cn('group/corner-label inline-flex items-start', className)}>
<Corner className='h-5 w-[13px] text-background-section group-hover/corner-label:text-background-section-burn' />
<div className={cn('flex items-center gap-0.5 bg-background-section py-1 pr-2 group-hover/corner-label:bg-background-section-burn', labelClassName)}>
<Corner className='h-5 w-[13px] text-background-section-burn' />
<div className={cn('flex items-center gap-0.5 bg-background-section-burn py-1 pr-2', labelClassName)}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{label}</div>
</div>
</div>

View File

@@ -43,7 +43,14 @@ const DatePicker = ({
const [view, setView] = useState(ViewType.date)
const containerRef = useRef<HTMLDivElement>(null)
const isInitial = useRef(true)
const inputValue = useRef(value ? value.tz(timezone) : undefined).current
// Normalize the value to ensure that all subsequent uses are Day.js objects.
const normalizedValue = useMemo(() => {
if (!value) return undefined
return dayjs.isDayjs(value) ? value.tz(timezone) : dayjs(value).tz(timezone)
}, [value, timezone])
const inputValue = useRef(normalizedValue).current
const defaultValue = useRef(getDateWithTimezone({ timezone })).current
const [currentDate, setCurrentDate] = useState(inputValue || defaultValue)
@@ -69,8 +76,8 @@ const DatePicker = ({
return
}
clearMonthMapCache()
if (value) {
const newValue = getDateWithTimezone({ date: value, timezone })
if (normalizedValue) {
const newValue = getDateWithTimezone({ date: normalizedValue, timezone })
setCurrentDate(newValue)
setSelectedDate(newValue)
onChange(newValue)
@@ -89,9 +96,9 @@ const DatePicker = ({
}
setView(ViewType.date)
setIsOpen(true)
if (value) {
setCurrentDate(value)
setSelectedDate(value)
if (normalizedValue) {
setCurrentDate(normalizedValue)
setSelectedDate(normalizedValue)
}
}
@@ -193,7 +200,7 @@ const DatePicker = ({
}
const timeFormat = needTimePicker ? t('time.dateFormats.displayWithTime') : t('time.dateFormats.display')
const displayValue = value?.format(timeFormat) || ''
const displayValue = normalizedValue?.format(timeFormat) || ''
const displayTime = selectedDate?.format('hh:mm A') || '--:-- --'
const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
@@ -205,7 +212,7 @@ const DatePicker = ({
>
<PortalToFollowElemTrigger className={triggerWrapClassName}>
{renderTrigger ? (renderTrigger({
value,
value: normalizedValue,
selectedDate,
isOpen,
handleClear,

View File

@@ -1,5 +1,6 @@
import type { FC } from 'react'
import { useState } from 'react'
import cn from '@/utils/classnames'
import {
RiMoreFill,
} from '@remixicon/react'
@@ -8,6 +9,8 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import ActionButton from '@/app/components/base/action-button'
import type { ActionButtonProps } from '@/app/components/base/action-button'
export type Item = {
value: string | number
@@ -18,14 +21,20 @@ type DropdownProps = {
secondItems?: Item[]
onSelect: (item: Item) => void
renderTrigger?: (open: boolean) => React.ReactNode
triggerProps?: ActionButtonProps
popupClassName?: string
itemClassName?: string
secondItemClassName?: string
}
const Dropdown: FC<DropdownProps> = ({
items,
onSelect,
secondItems,
renderTrigger,
triggerProps,
popupClassName,
itemClassName,
secondItemClassName,
}) => {
const [open, setOpen] = useState(false)
@@ -45,14 +54,15 @@ const Dropdown: FC<DropdownProps> = ({
renderTrigger
? renderTrigger(open)
: (
<div
className={`
flex h-6 w-6 cursor-pointer items-center justify-center rounded-md
${open && 'bg-divider-regular'}
`}
<ActionButton
{...triggerProps}
className={cn(
open && 'bg-divider-regular',
triggerProps?.className,
)}
>
<RiMoreFill className='h-4 w-4 text-text-tertiary' />
</div>
</ActionButton>
)
}
</PortalToFollowElemTrigger>
@@ -65,7 +75,10 @@ const Dropdown: FC<DropdownProps> = ({
items.map(item => (
<div
key={item.value}
className='flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover'
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover',
itemClassName,
)}
onClick={() => handleSelect(item)}
>
{item.text}
@@ -87,7 +100,10 @@ const Dropdown: FC<DropdownProps> = ({
secondItems.map(item => (
<div
key={item.value}
className='flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover'
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover',
secondItemClassName,
)}
onClick={() => handleSelect(item)}
>
{item.text}

View File

@@ -0,0 +1,18 @@
import React from 'react'
import cn from '@/utils/classnames'
type EffectProps = {
className?: string
}
const Effect = ({
className,
}: EffectProps) => {
return (
<div
className={cn('absolute size-[112px] rounded-full bg-util-colors-blue-brand-blue-brand-500 blur-[80px]', className)}
/>
)
}
export default React.memo(Effect)

View File

@@ -83,9 +83,7 @@ const OpeningSettingModal = ({
}, [handleSave, hideConfirmAddVar])
const autoAddVar = useCallback(() => {
onAutoAddPromptVariable?.([
...notIncludeKeys.map(key => getNewVar(key, 'string')),
])
onAutoAddPromptVariable?.(notIncludeKeys.map(key => getNewVar(key, 'string')))
hideConfirmAddVar()
handleSave(true)
}, [handleSave, hideConfirmAddVar, notIncludeKeys, onAutoAddPromptVariable])

View File

@@ -29,6 +29,11 @@ export type SensitiveWordAvoidance = EnabledOrDisabled & {
config?: any
}
export enum PreviewMode {
NewPage = 'new_page',
CurrentPage = 'current_page',
}
export type FileUpload = {
image?: EnabledOrDisabled & {
detail?: Resolution
@@ -56,6 +61,10 @@ export type FileUpload = {
allowed_file_upload_methods?: TransferMethod[]
number_limits?: number
fileUploadConfig?: FileUploadConfigResponse
preview_config?: {
mode?: PreviewMode
file_type_list?: string[]
}
} & EnabledOrDisabled
export type AnnotationReplyConfig = {

View File

@@ -66,7 +66,7 @@ const FileListInLog = ({ fileList, isExpanded = false, noBorder = false, noPaddi
<div key={id} className='rounded-md border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1.5 shadow-xs'>
<FileTypeIcon
type={getFileAppearanceType(name, type)}
size='md'
size='lg'
/>
</div>
</Tooltip>

View File

@@ -69,13 +69,14 @@ const FILE_TYPE_ICON_MAP = {
}
type FileTypeIconProps = {
type: FileAppearanceType
size?: 'sm' | 'lg' | 'md'
size?: 'sm' | 'md' | 'lg' | 'xl'
className?: string
}
const SizeMap = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
sm: 'size-4',
md: 'size-[18px]',
lg: 'size-5',
xl: 'size-6',
}
const FileTypeIcon = ({
type,

View File

@@ -23,6 +23,7 @@ import cn from '@/utils/classnames'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import { PreviewMode } from '@/app/components/base/features/types'
type FileInAttachmentItemProps = {
file: FileEntity
@@ -31,6 +32,7 @@ type FileInAttachmentItemProps = {
onRemove?: (fileId: string) => void
onReUpload?: (fileId: string) => void
canPreview?: boolean
previewMode?: PreviewMode
}
const FileInAttachmentItem = ({
file,
@@ -39,6 +41,7 @@ const FileInAttachmentItem = ({
onRemove,
onReUpload,
canPreview,
previewMode = PreviewMode.CurrentPage,
}: FileInAttachmentItemProps) => {
const { id, name, type, progress, supportFileType, base64Url, url, isRemote } = file
const ext = getFileExtension(name, type, isRemote)
@@ -49,7 +52,13 @@ const FileInAttachmentItem = ({
<div className={cn(
'flex h-12 items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pr-3 shadow-xs',
progress === -1 && 'border-state-destructive-border bg-state-destructive-hover',
)}>
canPreview && previewMode === PreviewMode.NewPage && 'cursor-pointer',
)}
onClick={() => {
if (canPreview && previewMode === PreviewMode.NewPage)
window.open(url || base64Url || '', '_blank')
}}
>
<div className='flex h-12 w-12 items-center justify-center'>
{
isImageFile && (
@@ -63,7 +72,7 @@ const FileInAttachmentItem = ({
!isImageFile && (
<FileTypeIcon
type={getFileAppearanceType(name, type)}
size='lg'
size='xl'
/>
)
}

View File

@@ -106,6 +106,8 @@ const FileUploaderInAttachment = ({
showDownloadAction={false}
onRemove={() => handleRemoveFile(file.id)}
onReUpload={() => handleReUploadFile(file.id)}
canPreview={fileConfig.preview_config?.file_type_list?.includes(file.type)}
previewMode={fileConfig.preview_config?.mode}
/>
))
}
@@ -114,7 +116,7 @@ const FileUploaderInAttachment = ({
)
}
type FileUploaderInAttachmentWrapperProps = {
export type FileUploaderInAttachmentWrapperProps = {
value?: FileEntity[]
onChange: (files: FileEntity[]) => void
fileConfig: FileUpload

View File

@@ -14,17 +14,18 @@ import { useStore } from '@tanstack/react-form'
import {
isValidElement,
memo,
useCallback,
useMemo,
} from 'react'
const getInputType = (type: FormTypeEnum) => {
const getExtraProps = (type: FormTypeEnum) => {
switch (type) {
case FormTypeEnum.secretInput:
return 'password'
return { type: 'password', autoComplete: 'new-password' }
case FormTypeEnum.textNumber:
return 'number'
return { type: 'number' }
default:
return 'text'
return { type: 'text' }
}
}
@@ -36,6 +37,7 @@ export type BaseFieldProps = {
formSchema: FormSchema
field: AnyFieldApi
disabled?: boolean
onChange?: (field: string, value: any) => void
}
const BaseField = ({
@@ -46,6 +48,7 @@ const BaseField = ({
formSchema,
field,
disabled: propsDisabled,
onChange,
}: BaseFieldProps) => {
const renderI18nObject = useRenderI18nObject()
const {
@@ -54,9 +57,7 @@ const BaseField = ({
placeholder,
options,
labelClassName: formLabelClassName,
show_on = [],
disabled: formSchemaDisabled,
showRadioUI,
type: formItemType,
dynamicSelectParams,
} = formSchema
@@ -86,11 +87,8 @@ const BaseField = ({
variables.add(condition.variable)
}
for (const condition of show_on || [])
variables.add(condition.variable)
return Array.from(variables)
}, [options, show_on])
}, [options])
const watchedValues = useStore(field.form.store, (s) => {
const result: Record<string, any> = {}
@@ -138,20 +136,10 @@ const BaseField = ({
}))
}, [dynamicOptionsData, renderI18nObject])
const show = useMemo(() => {
return show_on.every((condition) => {
return watchedValues[condition.variable] === condition.value
})
}, [watchedValues, show_on])
const booleanRadioValue = useMemo(() => {
if (value === null || value === undefined)
return undefined
return value ? 1 : 0
}, [value])
if (!show)
return null
const handleChange = useCallback((value: any) => {
field.handleChange(value)
onChange?.(field.name, value)
}, [field, onChange])
return (
<div className={cn(fieldClassName)}>
@@ -171,11 +159,13 @@ const BaseField = ({
name={field.name}
className={cn(inputClassName)}
value={value || ''}
onChange={e => field.handleChange(e.target.value)}
onChange={(e) => {
handleChange(e.target.value)
}}
onBlur={field.handleBlur}
disabled={disabled}
placeholder={memorizedPlaceholder}
type={getInputType(formItemType)}
{...getExtraProps(formItemType)}
/>
)
}
@@ -183,11 +173,14 @@ const BaseField = ({
formItemType === FormTypeEnum.select && (
<PureSelect
value={value}
onChange={v => field.handleChange(v)}
onChange={v => handleChange(v)}
disabled={disabled}
placeholder={memorizedPlaceholder}
options={memorizedOptions}
triggerPopupSameWidth
popupProps={{
className: 'max-h-[320px] overflow-y-auto',
}}
/>
)
}
@@ -222,9 +215,16 @@ const BaseField = ({
disabled && 'cursor-not-allowed opacity-50',
inputClassName,
)}
onClick={() => !disabled && field.handleChange(option.value)}
onClick={() => !disabled && handleChange(option.value)}
>
{showRadioUI && <RadioE isChecked={value === option.value} />}
{
formSchema.showRadioUI && (
<RadioE
className='mr-2'
isChecked={value === option.value}
/>
)
}
{option.label}
</div>
))
@@ -235,12 +235,12 @@ const BaseField = ({
{
formItemType === FormTypeEnum.boolean && (
<Radio.Group
className='flex w-fit items-center gap-1'
value={booleanRadioValue}
onChange={val => field.handleChange(val === 1)}
className='flex w-fit items-center'
value={value}
onChange={v => field.handleChange(v)}
>
<Radio value={1}>True</Radio>
<Radio value={0}>False</Radio>
<Radio value={true} className='!mr-1'>True</Radio>
<Radio value={false}>False</Radio>
</Radio.Group>
)
}

View File

@@ -8,7 +8,10 @@ import type {
AnyFieldApi,
AnyFormApi,
} from '@tanstack/react-form'
import { useForm } from '@tanstack/react-form'
import {
useForm,
useStore,
} from '@tanstack/react-form'
import type {
FormRef,
FormSchema,
@@ -32,6 +35,7 @@ export type BaseFormProps = {
ref?: FormRef
disabled?: boolean
formFromProps?: AnyFormApi
onChange?: (field: string, value: any) => void
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void
preventDefaultSubmit?: boolean
} & Pick<BaseFieldProps, 'fieldClassName' | 'labelClassName' | 'inputContainerClassName' | 'inputClassName'>
@@ -47,6 +51,7 @@ const BaseForm = ({
ref,
disabled,
formFromProps,
onChange,
onSubmit,
preventDefaultSubmit = false,
}: BaseFormProps) => {
@@ -67,6 +72,19 @@ const BaseForm = ({
const { getFormValues } = useGetFormValues(form, formSchemas)
const { getValidators } = useGetValidators()
const showOnValues = useStore(form.store, (s: any) => {
const result: Record<string, any> = {}
formSchemas.forEach((schema) => {
const { show_on } = schema
if (show_on?.length) {
show_on.forEach((condition) => {
result[condition.variable] = s.values[condition.variable]
})
}
})
return result
})
useImperativeHandle(ref, () => {
return {
getForm() {
@@ -91,19 +109,29 @@ const BaseForm = ({
inputContainerClassName={inputContainerClassName}
inputClassName={inputClassName}
disabled={disabled}
onChange={onChange}
/>
)
}
return null
}, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled])
}, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange])
const renderFieldWrapper = useCallback((formSchema: FormSchema) => {
const validators = getValidators(formSchema)
const {
name,
show_on = [],
} = formSchema
const show = show_on?.every((condition) => {
const conditionValue = showOnValues[condition.variable]
return conditionValue === condition.value
})
if (!show)
return null
return (
<form.Field
key={name}
@@ -113,7 +141,7 @@ const BaseForm = ({
{renderField}
</form.Field>
)
}, [renderField, form, getValidators])
}, [renderField, form, getValidators, showOnValues])
if (!formSchemas?.length)
return null

View File

@@ -0,0 +1,41 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import type { CustomSelectProps, Option } from '../../../select/custom'
import CustomSelect from '../../../select/custom'
import type { LabelProps } from '../label'
import Label from '../label'
type CustomSelectFieldProps<T extends Option> = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
options: T[]
className?: string
} & Omit<CustomSelectProps<T>, 'options' | 'value' | 'onChange'>
const CustomSelectField = <T extends Option>({
label,
labelOptions,
options,
className,
...selectProps
}: CustomSelectFieldProps<T>) => {
const field = useFieldContext<string>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
<CustomSelect<T>
value={field.state.value}
options={options}
onChange={value => field.handleChange(value)}
{...selectProps}
/>
</div>
)
}
export default CustomSelectField

View File

@@ -0,0 +1,83 @@
import cn from '@/utils/classnames'
import type { LabelProps } from '../label'
import { useFieldContext } from '../..'
import Label from '../label'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import FileTypeItem from '@/app/components/workflow/nodes/_base/components/file-type-item'
import { useCallback } from 'react'
type FieldValue = {
allowedFileTypes: string[],
allowedFileExtensions: string[]
}
type FileTypesFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
}
const FileTypesField = ({
label,
labelOptions,
className,
}: FileTypesFieldProps) => {
const field = useFieldContext<FieldValue>()
const handleSupportFileTypeChange = useCallback((type: SupportUploadFileTypes) => {
let newAllowFileTypes = [...field.state.value.allowedFileTypes]
if (type === SupportUploadFileTypes.custom) {
if (!newAllowFileTypes.includes(SupportUploadFileTypes.custom))
newAllowFileTypes = [SupportUploadFileTypes.custom]
else
newAllowFileTypes = newAllowFileTypes.filter(v => v !== type)
}
else {
newAllowFileTypes = newAllowFileTypes.filter(v => v !== SupportUploadFileTypes.custom)
if (newAllowFileTypes.includes(type))
newAllowFileTypes = newAllowFileTypes.filter(v => v !== type)
else
newAllowFileTypes.push(type)
}
field.handleChange({
...field.state.value,
allowedFileTypes: newAllowFileTypes,
})
}, [field])
const handleCustomFileTypesChange = useCallback((customFileTypes: string[]) => {
field.handleChange({
...field.state.value,
allowedFileExtensions: customFileTypes,
})
}, [field])
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
{
[SupportUploadFileTypes.document, SupportUploadFileTypes.image, SupportUploadFileTypes.audio, SupportUploadFileTypes.video].map((type: SupportUploadFileTypes) => (
<FileTypeItem
key={type}
type={type as SupportUploadFileTypes.image | SupportUploadFileTypes.document | SupportUploadFileTypes.audio | SupportUploadFileTypes.video}
selected={field.state.value.allowedFileTypes.includes(type)}
onToggle={handleSupportFileTypeChange}
/>
))
}
<FileTypeItem
type={SupportUploadFileTypes.custom}
selected={field.state.value.allowedFileTypes.includes(SupportUploadFileTypes.custom)}
onToggle={handleSupportFileTypeChange}
customFileTypes={field.state.value.allowedFileExtensions}
onCustomFileTypesChange={handleCustomFileTypesChange}
/>
</div>
)
}
export default FileTypesField

View File

@@ -0,0 +1,40 @@
import React from 'react'
import { useFieldContext } from '../..'
import type { LabelProps } from '../label'
import Label from '../label'
import cn from '@/utils/classnames'
import type { FileUploaderInAttachmentWrapperProps } from '../../../file-uploader/file-uploader-in-attachment'
import FileUploaderInAttachmentWrapper from '../../../file-uploader/file-uploader-in-attachment'
import type { FileEntity } from '../../../file-uploader/types'
type FileUploaderFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
} & Omit<FileUploaderInAttachmentWrapperProps, 'value' | 'onChange'>
const FileUploaderField = ({
label,
labelOptions,
className,
...inputProps
}: FileUploaderFieldProps) => {
const field = useFieldContext<FileEntity[]>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
<FileUploaderInAttachmentWrapper
value={field.state.value}
onChange={value => field.handleChange(value)}
{...inputProps}
/>
</div>
)
}
export default FileUploaderField

View File

@@ -0,0 +1,52 @@
import { InputTypeEnum } from './types'
import { PipelineInputVarType } from '@/models/pipeline'
import { useTranslation } from 'react-i18next'
import {
RiAlignLeft,
RiCheckboxLine,
RiFileCopy2Line,
RiFileTextLine,
RiHashtag,
RiListCheck3,
RiTextSnippet,
} from '@remixicon/react'
const i18nFileTypeMap: Record<string, string> = {
'number': 'number',
'file': 'single-file',
'file-list': 'multi-files',
}
const INPUT_TYPE_ICON = {
[PipelineInputVarType.textInput]: RiTextSnippet,
[PipelineInputVarType.paragraph]: RiAlignLeft,
[PipelineInputVarType.number]: RiHashtag,
[PipelineInputVarType.select]: RiListCheck3,
[PipelineInputVarType.checkbox]: RiCheckboxLine,
[PipelineInputVarType.singleFile]: RiFileTextLine,
[PipelineInputVarType.multiFiles]: RiFileCopy2Line,
}
const DATA_TYPE = {
[PipelineInputVarType.textInput]: 'string',
[PipelineInputVarType.paragraph]: 'string',
[PipelineInputVarType.number]: 'number',
[PipelineInputVarType.select]: 'string',
[PipelineInputVarType.checkbox]: 'boolean',
[PipelineInputVarType.singleFile]: 'file',
[PipelineInputVarType.multiFiles]: 'array[file]',
}
export const useInputTypeOptions = (supportFile: boolean) => {
const { t } = useTranslation()
const options = supportFile ? InputTypeEnum.options : InputTypeEnum.exclude(['file', 'file-list']).options
return options.map((value) => {
return {
value,
label: t(`appDebug.variableConfig.${i18nFileTypeMap[value] || value}`),
Icon: INPUT_TYPE_ICON[value],
type: DATA_TYPE[value],
}
})
}

View File

@@ -0,0 +1,64 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../../..'
import type { CustomSelectProps } from '../../../../select/custom'
import CustomSelect from '../../../../select/custom'
import type { LabelProps } from '../../label'
import Label from '../../label'
import { useCallback } from 'react'
import Trigger from './trigger'
import type { FileTypeSelectOption, InputType } from './types'
import { useInputTypeOptions } from './hooks'
import Option from './option'
type InputTypeSelectFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
supportFile: boolean
className?: string
} & Omit<CustomSelectProps<FileTypeSelectOption>, 'options' | 'value' | 'onChange' | 'CustomTrigger' | 'CustomOption'>
const InputTypeSelectField = ({
label,
labelOptions,
supportFile,
className,
...customSelectProps
}: InputTypeSelectFieldProps) => {
const field = useFieldContext<InputType>()
const inputTypeOptions = useInputTypeOptions(supportFile)
const renderTrigger = useCallback((option: FileTypeSelectOption | undefined, open: boolean) => {
return <Trigger option={option} open={open} />
}, [])
const renderOption = useCallback((option: FileTypeSelectOption) => {
return <Option option={option} />
}, [])
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
<CustomSelect<FileTypeSelectOption>
value={field.state.value}
options={inputTypeOptions}
onChange={value => field.handleChange(value as InputType)}
triggerProps={{
className: 'gap-x-0.5',
}}
popupProps={{
className: 'w-[368px]',
wrapperClassName: 'z-[9999999]',
itemClassName: 'gap-x-1',
}}
CustomTrigger={renderTrigger}
CustomOption={renderOption}
{...customSelectProps}
/>
</div>
)
}
export default InputTypeSelectField

View File

@@ -0,0 +1,21 @@
import React from 'react'
import type { FileTypeSelectOption } from './types'
import Badge from '@/app/components/base/badge'
type OptionProps = {
option: FileTypeSelectOption
}
const Option = ({
option,
}: OptionProps) => {
return (
<>
<option.Icon className='h-4 w-4 shrink-0 text-text-tertiary' />
<span className='grow px-1'>{option.label}</span>
<Badge text={option.type} uppercase={false} />
</>
)
}
export default React.memo(Option)

View File

@@ -0,0 +1,42 @@
import React from 'react'
import Badge from '@/app/components/base/badge'
import cn from '@/utils/classnames'
import { RiArrowDownSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import type { FileTypeSelectOption } from './types'
type TriggerProps = {
option: FileTypeSelectOption | undefined
open: boolean
}
const Trigger = ({
option,
open,
}: TriggerProps) => {
const { t } = useTranslation()
return (
<>
{option ? (
<>
<option.Icon className='h-4 w-4 shrink-0 text-text-tertiary' />
<span className='grow p-1'>{option.label}</span>
<div className='pr-0.5'>
<Badge text={option.type} uppercase={false} />
</div>
</>
) : (
<span className='grow p-1'>{t('common.placeholder.select')}</span>
)}
<RiArrowDownSLine
className={cn(
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
)}
/>
</>
)
}
export default React.memo(Trigger)

View File

@@ -0,0 +1,21 @@
import type { RemixiconComponentType } from '@remixicon/react'
import { z } from 'zod'
export const InputTypeEnum = z.enum([
'text-input',
'paragraph',
'number',
'select',
'checkbox',
'file',
'file-list',
])
export type InputType = z.infer<typeof InputTypeEnum>
export type FileTypeSelectOption = {
value: InputType
label: string
Icon: RemixiconComponentType
type: string
}

View File

@@ -0,0 +1,39 @@
import {
memo,
} from 'react'
import PromptEditor from '@/app/components/base/prompt-editor'
import cn from '@/utils/classnames'
import Placeholder from './placeholder'
type MixedVariableTextInputProps = {
editable?: boolean
value?: string
onChange?: (text: string) => void
}
const MixedVariableTextInput = ({
editable = true,
value = '',
onChange,
}: MixedVariableTextInputProps) => {
return (
<PromptEditor
wrapperClassName={cn(
'rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
)}
className='caret:text-text-accent'
editable={editable}
value={value}
workflowVariableBlock={{
show: true,
variables: [],
workflowNodesMap: {},
}}
placeholder={<Placeholder />}
onChange={onChange}
/>
)
}
export default memo(MixedVariableTextInput)

Some files were not shown because too many files have changed in this diff Show More