mirror of
https://github.com/langgenius/dify.git
synced 2026-02-28 04:15:10 +00:00
Merge remote-tracking branch 'origin/main' into feat/trigger
This commit is contained in:
@@ -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
144
web/.oxlintrc.json
Normal 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
15
web/.vscode/launch.json
vendored
Normal 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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
235
web/__tests__/goto-anything/match-action.test.ts
Normal file
235
web/__tests__/goto-anything/match-action.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
134
web/__tests__/goto-anything/scope-command-tags.test.tsx
Normal file
134
web/__tests__/goto-anything/scope-command-tags.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
212
web/__tests__/goto-anything/slash-command-modes.test.tsx
Normal file
212
web/__tests__/goto-anything/slash-command-modes.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -69,7 +69,6 @@ export default function AccountPage() {
|
||||
}
|
||||
catch (e) {
|
||||
notify({ type: 'error', message: (e as Error).message })
|
||||
setEditNameModalVisible(false)
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
152
web/app/components/app-sidebar/dataset-info/dropdown.tsx
Normal file
152
web/app/components/app-sidebar/dataset-info/dropdown.tsx
Normal 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)
|
||||
91
web/app/components/app-sidebar/dataset-info/index.tsx
Normal file
91
web/app/components/app-sidebar/dataset-info/index.tsx
Normal 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)
|
||||
30
web/app/components/app-sidebar/dataset-info/menu-item.tsx
Normal file
30
web/app/components/app-sidebar/dataset-info/menu-item.tsx
Normal 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)
|
||||
52
web/app/components/app-sidebar/dataset-info/menu.tsx
Normal file
52
web/app/components/app-sidebar/dataset-info/menu.tsx
Normal 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)
|
||||
164
web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx
Normal file
164
web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
71
web/app/components/app-sidebar/toggle-button.tsx
Normal file
71
web/app/components/app-sidebar/toggle-button.tsx
Normal 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)
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
{
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
76
web/app/components/base/action-button/index.spec.tsx
Normal file
76
web/app/components/base/action-button/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
|
||||
@@ -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} {tab.label}
|
||||
|
||||
159
web/app/components/base/app-icon/index.spec.tsx
Normal file
159
web/app/components/base/app-icon/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -682,7 +682,7 @@ export const useChat = (
|
||||
updateChatTreeNode(targetAnswerId, {
|
||||
content: chatList[index].content,
|
||||
annotation: {
|
||||
...(chatList[index].annotation || {}),
|
||||
...chatList[index].annotation,
|
||||
id: '',
|
||||
} as Annotation,
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -93,4 +93,5 @@ export type Callback = {
|
||||
|
||||
export type Feedback = {
|
||||
rating: 'like' | 'dislike' | null
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
18
web/app/components/base/effect/index.tsx
Normal file
18
web/app/components/base/effect/index.tsx
Normal 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)
|
||||
@@ -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])
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
83
web/app/components/base/form/components/field/file-types.tsx
Normal file
83
web/app/components/base/form/components/field/file-types.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -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],
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user