From 7d1ad7e03ad008c810dfa6851729d0d9b0046ca8 Mon Sep 17 00:00:00 2001 From: CrabSAMA <40541269+CrabSAMA@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:57:46 +0800 Subject: [PATCH 01/14] refactor: unified shortcut keys display using component (#31713) --- .../components/app-sidebar/toggle-button.tsx | 15 ++------------- web/app/components/app/app-publisher/index.tsx | 11 +++-------- .../components/app/create-app-modal/index.tsx | 8 +++----- .../app/create-from-dsl-modal/index.tsx | 8 +++----- .../detail/completed/common/action-buttons.tsx | 10 ++++------ .../explore/create-app-modal/index.tsx | 8 +++----- web/app/components/goto-anything/index.tsx | 12 +++--------- .../rag-pipeline-header/publisher/popup.tsx | 11 +++-------- .../components/rag-pipeline-header/run-mode.tsx | 11 ++--------- .../workflow-onboarding-modal/index.tsx | 5 ++--- web/app/components/workflow/header/run-mode.tsx | 11 ++--------- .../workflow/header/version-history-button.tsx | 14 +++----------- .../edit-card/advanced-actions.tsx | 17 +++-------------- web/app/components/workflow/shortcuts-name.tsx | 6 +++++- 14 files changed, 41 insertions(+), 106 deletions(-) diff --git a/web/app/components/app-sidebar/toggle-button.tsx b/web/app/components/app-sidebar/toggle-button.tsx index a6bdee4f78..cbfbeee452 100644 --- a/web/app/components/app-sidebar/toggle-button.tsx +++ b/web/app/components/app-sidebar/toggle-button.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next' import { cn } from '@/utils/classnames' import Button from '../base/button' import Tooltip from '../base/tooltip' -import { getKeyboardKeyNameBySystem } from '../workflow/utils' +import ShortcutsName from '../workflow/shortcuts-name' type TooltipContentProps = { expand: boolean @@ -20,18 +20,7 @@ const TooltipContent = ({ return (
{expand ? t('sidebar.collapseSidebar', { ns: 'layout' }) : t('sidebar.expandSidebar', { ns: 'layout' })} -
- { - TOGGLE_SHORTCUT.map(key => ( - - {getKeyboardKeyNameBySystem(key)} - - )) - } -
+
) } diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 0a026a680b..0fc364cb7e 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -49,7 +49,8 @@ import Divider from '../../base/divider' import Loading from '../../base/loading' import Toast from '../../base/toast' import Tooltip from '../../base/tooltip' -import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../../workflow/utils' +import ShortcutsName from '../../workflow/shortcuts-name' +import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' import AccessControl from '../app-access-control' import PublishWithMultipleModel from './publish-with-multiple-model' import SuggestedAction from './suggested-action' @@ -345,13 +346,7 @@ const AppPublisher = ({ : (
{t('common.publishUpdate', { ns: 'workflow' })} -
- {PUBLISH_SHORTCUT.map(key => ( - - {getKeyboardKeyNameBySystem(key)} - - ))} -
+
) } diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index e2b50cf030..66c7bce80c 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { AppIconSelection } from '../../base/app-icon-picker' -import { RiArrowRightLine, RiArrowRightSLine, RiCommandLine, RiCornerDownLeftLine, RiExchange2Fill } from '@remixicon/react' +import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' import Image from 'next/image' @@ -29,6 +29,7 @@ import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' import { basePath } from '@/utils/var' import AppIconPicker from '../../base/app-icon-picker' +import ShortcutsName from '../../workflow/shortcuts-name' type CreateAppProps = { onSuccess: () => void @@ -269,10 +270,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index 838e9cc03f..04d8b1e754 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { MouseEventHandler } from 'react' -import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react' +import { RiCloseLine } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' import { noop } from 'es-toolkit/function' import { useRouter } from 'next/navigation' @@ -28,6 +28,7 @@ import { } from '@/service/apps' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import ShortcutsName from '../../workflow/shortcuts-name' import Uploader from './uploader' type CreateFromDSLModalProps = { @@ -298,10 +299,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS className="gap-1" > {t('newApp.Create', { ns: 'app' })} -
- - -
+ diff --git a/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx b/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx index efb9848494..a0cbfea147 100644 --- a/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx @@ -4,7 +4,8 @@ import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' -import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' +import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils' import { ChunkingMode } from '@/models/datasets' import { useDocumentContext } from '../../context' @@ -54,7 +55,7 @@ const ActionButtons: FC = ({ >
{t('operation.cancel', { ns: 'common' })} - ESC +
{(isParentChildParagraphMode && actionType === 'edit' && !isChildChunk && showRegenerationButton) @@ -76,10 +77,7 @@ const ActionButtons: FC = ({ >
{t('operation.save', { ns: 'common' })} -
- {getKeyboardKeyNameBySystem('ctrl')} - S -
+
diff --git a/web/app/components/explore/create-app-modal/index.tsx b/web/app/components/explore/create-app-modal/index.tsx index 9bffcc6c69..cfe59fb7f3 100644 --- a/web/app/components/explore/create-app-modal/index.tsx +++ b/web/app/components/explore/create-app-modal/index.tsx @@ -1,6 +1,6 @@ 'use client' import type { AppIconType } from '@/types/app' -import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react' +import { RiCloseLine } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' import { noop } from 'es-toolkit/function' import * as React from 'react' @@ -17,6 +17,7 @@ import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { useProviderContext } from '@/context/provider-context' import { AppModeEnum } from '@/types/app' import AppIconPicker from '../../base/app-icon-picker' +import ShortcutsName from '../../workflow/shortcuts-name' export type CreateAppModalProps = { show: boolean @@ -198,10 +199,7 @@ const CreateAppModal = ({ onClick={handleSubmit} > {!isEditModal ? t('operation.create', { ns: 'common' }) : t('operation.save', { ns: 'common' })} -
- - -
+ diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index d34176e4c7..733e1d3162 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -12,7 +12,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' -import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app/components/workflow/utils/common' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' +import { getKeyboardKeyCodeBySystem, isEventTargetInputArea } from '@/app/components/workflow/utils/common' import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' import { useGetLanguage } from '@/context/i18n' import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace' @@ -356,14 +357,7 @@ const GotoAnything: FC = ({ )} -
- - {isMac() ? '⌘' : 'Ctrl'} - - - K - -
+ diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index 0cdc9a0327..c66b293d8a 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -28,11 +28,12 @@ import { useToastContext } from '@/app/components/base/toast' import { useChecklistBeforePublish, } from '@/app/components/workflow/hooks' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' import { useStore, useWorkflowStore, } from '@/app/components/workflow/store' -import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils' +import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useDocLink } from '@/context/i18n' import { useModalContextSelector } from '@/context/modal-context' @@ -261,13 +262,7 @@ const Popup = () => { : (
{t('common.publishUpdate', { ns: 'workflow' })} -
- {PUBLISH_SHORTCUT.map(key => ( - - {getKeyboardKeyNameBySystem(key)} - - ))} -
+
) } diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx index 00c531004f..81389e51b4 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx @@ -4,9 +4,9 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' -import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils' import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' import { useEventEmitterContextContext } from '@/context/event-emitter' import { cn } from '@/utils/classnames' @@ -78,14 +78,7 @@ const RunMode = ({ )} { !isDisabled && ( -
-
- {getKeyboardKeyNameBySystem('alt')} -
-
- R -
-
+ ) } diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx index c483abfb0b..16bae51246 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx @@ -7,6 +7,7 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import Modal from '@/app/components/base/modal' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' import { BlockEnum } from '@/app/components/workflow/types' import StartNodeSelectionPanel from './start-node-selection-panel' @@ -75,9 +76,7 @@ const WorkflowOnboardingModal: FC = ({ {isShow && (
{t('onboarding.escTip.press', { ns: 'workflow' })} - - {t('onboarding.escTip.key', { ns: 'workflow' })} - + {t('onboarding.escTip.toDismiss', { ns: 'workflow' })}
)} diff --git a/web/app/components/workflow/header/run-mode.tsx b/web/app/components/workflow/header/run-mode.tsx index 1a101bc6d2..74bc5bc80a 100644 --- a/web/app/components/workflow/header/run-mode.tsx +++ b/web/app/components/workflow/header/run-mode.tsx @@ -7,9 +7,9 @@ import { trackEvent } from '@/app/components/base/amplitude' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import { useToastContext } from '@/app/components/base/toast' import { useWorkflowRun, useWorkflowRunValidation, useWorkflowStartRun } from '@/app/components/workflow/hooks' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' import { useStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' -import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils' import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' import { useEventEmitterContextContext } from '@/context/event-emitter' import { cn } from '@/utils/classnames' @@ -143,14 +143,7 @@ const RunMode = ({ > {text ?? t('common.run', { ns: 'workflow' })} -
-
- {getKeyboardKeyNameBySystem('alt')} -
-
- R -
-
+ ) diff --git a/web/app/components/workflow/header/version-history-button.tsx b/web/app/components/workflow/header/version-history-button.tsx index 32e72dc184..b98dfeea76 100644 --- a/web/app/components/workflow/header/version-history-button.tsx +++ b/web/app/components/workflow/header/version-history-button.tsx @@ -8,7 +8,8 @@ import useTheme from '@/hooks/use-theme' import { cn } from '@/utils/classnames' import Button from '../../base/button' import Tooltip from '../../base/tooltip' -import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../utils' +import ShortcutsName from '../shortcuts-name' +import { getKeyboardKeyCodeBySystem } from '../utils' type VersionHistoryButtonProps = { onClick: () => Promise | unknown @@ -23,16 +24,7 @@ const PopupContent = React.memo(() => {
{t('common.versionHistory', { ns: 'workflow' })}
-
- {VERSION_HISTORY_SHORTCUT.map(key => ( - - {getKeyboardKeyNameBySystem(key)} - - ))} -
+ ) }) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx index 536277b9e2..8aad824008 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx @@ -3,7 +3,8 @@ import { useKeyPress } from 'ahooks' import * as React from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' -import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' +import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils' type AdvancedActionsProps = { isConfirmDisabled: boolean @@ -11,15 +12,6 @@ type AdvancedActionsProps = { onConfirm: () => void } -const Key = (props: { keyName: string }) => { - const { keyName } = props - return ( - - {keyName} - - ) -} - const AdvancedActions: FC = ({ isConfirmDisabled, onCancel, @@ -48,10 +40,7 @@ const AdvancedActions: FC = ({ onClick={onConfirm} > {t('operation.confirm', { ns: 'common' })} -
- - -
+ ) diff --git a/web/app/components/workflow/shortcuts-name.tsx b/web/app/components/workflow/shortcuts-name.tsx index d0ce007f61..3d21cff316 100644 --- a/web/app/components/workflow/shortcuts-name.tsx +++ b/web/app/components/workflow/shortcuts-name.tsx @@ -6,11 +6,13 @@ type ShortcutsNameProps = { keys: string[] className?: string textColor?: 'default' | 'secondary' + bgColor?: 'gray' | 'white' } const ShortcutsName = ({ keys, className, textColor = 'default', + bgColor = 'gray', }: ShortcutsNameProps) => { return (
From 25ac69afc5ac9324079be5f0d02b2a2b03dcc784 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:58:10 +0800 Subject: [PATCH 02/14] docs: relocate frontend docs for agents and human (#31714) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .agents/skills/component-refactoring/SKILL.md | 2 +- .agents/skills/frontend-testing/SKILL.md | 4 +- .../frontend-testing/references/workflow.md | 2 +- AGENTS.md | 33 +----------- CONTRIBUTING.md | 2 +- web/AGENTS.md | 6 ++- web/README.md | 2 + web/docs/lint.md | 51 +++++++++++++++++++ web/{testing/testing.md => docs/test.md} | 4 +- web/eslint-suppressions.json | 5 -- web/scripts/analyze-component.js | 4 +- 11 files changed, 69 insertions(+), 46 deletions(-) create mode 100644 web/docs/lint.md rename web/{testing/testing.md => docs/test.md} (99%) diff --git a/.agents/skills/component-refactoring/SKILL.md b/.agents/skills/component-refactoring/SKILL.md index 7006c382c8..140e0ef434 100644 --- a/.agents/skills/component-refactoring/SKILL.md +++ b/.agents/skills/component-refactoring/SKILL.md @@ -480,4 +480,4 @@ const useButtonState = () => { ### Related Skills - `frontend-testing` - For testing refactored components -- `web/testing/testing.md` - Testing specification +- `web/docs/test.md` - Testing specification diff --git a/.agents/skills/frontend-testing/SKILL.md b/.agents/skills/frontend-testing/SKILL.md index 0716c81ef7..280fcb6341 100644 --- a/.agents/skills/frontend-testing/SKILL.md +++ b/.agents/skills/frontend-testing/SKILL.md @@ -7,7 +7,7 @@ description: Generate Vitest + React Testing Library tests for Dify frontend com This skill enables Claude to generate high-quality, comprehensive frontend tests for the Dify project following established conventions and best practices. -> **⚠️ Authoritative Source**: This skill is derived from `web/testing/testing.md`. Use Vitest mock/timer APIs (`vi.*`). +> **⚠️ Authoritative Source**: This skill is derived from `web/docs/test.md`. Use Vitest mock/timer APIs (`vi.*`). ## When to Apply This Skill @@ -309,7 +309,7 @@ For more detailed information, refer to: ### Primary Specification (MUST follow) -- **`web/testing/testing.md`** - The canonical testing specification. This skill is derived from this document. +- **`web/docs/test.md`** - The canonical testing specification. This skill is derived from this document. ### Reference Examples in Codebase diff --git a/.agents/skills/frontend-testing/references/workflow.md b/.agents/skills/frontend-testing/references/workflow.md index 009c3e013b..bc4ed8285a 100644 --- a/.agents/skills/frontend-testing/references/workflow.md +++ b/.agents/skills/frontend-testing/references/workflow.md @@ -4,7 +4,7 @@ This guide defines the workflow for generating tests, especially for complex com ## Scope Clarification -This guide addresses **multi-file workflow** (how to process multiple test files). For coverage requirements within a single test file, see `web/testing/testing.md` § Coverage Goals. +This guide addresses **multi-file workflow** (how to process multiple test files). For coverage requirements within a single test file, see `web/docs/test.md` § Coverage Goals. | Scope | Rule | |-------|------| diff --git a/AGENTS.md b/AGENTS.md index 7d96ac3a6d..51fa6e4527 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Dify is an open-source platform for developing LLM applications with an intuitiv The codebase is split into: - **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design -- **Frontend Web** (`/web`): Next.js 15 application using TypeScript and React 19 +- **Frontend Web** (`/web`): Next.js application using TypeScript and React - **Docker deployment** (`/docker`): Containerized deployment configurations ## Backend Workflow @@ -18,36 +18,7 @@ The codebase is split into: ## Frontend Workflow -```bash -cd web -pnpm lint:fix -pnpm type-check:tsgo -pnpm test -``` - -### Frontend Linting - -ESLint is used for frontend code quality. Available commands: - -```bash -# Lint all files (report only) -pnpm lint - -# Lint and auto-fix issues -pnpm lint:fix - -# Lint specific files or directories -pnpm lint:fix app/components/base/button/ -pnpm lint:fix app/components/base/button/index.tsx - -# Lint quietly (errors only, no warnings) -pnpm lint:quiet - -# Check code complexity -pnpm lint:complexity -``` - -**Important**: Always run `pnpm lint:fix` before committing. The pre-commit hook runs `lint-staged` which only lints staged files. +- Read `web/AGENTS.md` for details ## Testing & Quality Practices diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 20a7d6c6f6..d7f007af67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ How we prioritize: For setting up the frontend service, please refer to our comprehensive [guide](https://github.com/langgenius/dify/blob/main/web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly. -**Testing**: All React components must have comprehensive test coverage. See [web/testing/testing.md](https://github.com/langgenius/dify/blob/main/web/testing/testing.md) for the canonical frontend testing guidelines and follow every requirement described there. +**Testing**: All React components must have comprehensive test coverage. See [web/docs/test.md](https://github.com/langgenius/dify/blob/main/web/docs/test.md) for the canonical frontend testing guidelines and follow every requirement described there. #### Backend diff --git a/web/AGENTS.md b/web/AGENTS.md index 7362cd51db..5dd41b8a3c 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -1,5 +1,9 @@ +## Frontend Workflow + +- Refer to the `./docs/test.md` and `./docs/lint.md` for detailed frontend workflow instructions. + ## Automated Test Generation -- Use `web/testing/testing.md` as the canonical instruction set for generating frontend automated tests. +- Use `./docs/test.md` as the canonical instruction set for generating frontend automated tests. - When proposing or saving tests, re-read that document and follow every requirement. - All frontend tests MUST also comply with the `frontend-testing` skill. Treat the skill as a mandatory constraint, not optional guidance. diff --git a/web/README.md b/web/README.md index 9c731a081a..64039709dc 100644 --- a/web/README.md +++ b/web/README.md @@ -107,6 +107,8 @@ Open [http://localhost:6006](http://localhost:6006) with your browser to see the If your IDE is VSCode, rename `web/.vscode/settings.example.json` to `web/.vscode/settings.json` for lint code setting. +Then follow the [Lint Documentation](./docs/lint.md) to lint the code. + ## Test We use [Vitest](https://vitest.dev/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) for Unit Testing. diff --git a/web/docs/lint.md b/web/docs/lint.md new file mode 100644 index 0000000000..051f9e6ecd --- /dev/null +++ b/web/docs/lint.md @@ -0,0 +1,51 @@ +# Lint Guide + +We use ESLint and Typescript to maintain code quality and consistency across the project. + +## ESLint + +### Common Flags + +**File/folder targeting**: Append paths to lint specific files or directories. + +```sh +pnpm eslint [options] file.js [file.js] [dir] +``` + +**`--cache`**: Caches lint results for faster subsequent runs. Keep this enabled by default; only disable when you encounter unexpected lint results. + +**`--concurrency`**: Enables multi-threaded linting. Use `--concurrency=auto` or experiment with specific numbers to find the optimal setting for your machine. Keep this enabled when linting multiple files. + +- [ESLint multi-thread linting blog post](https://eslint.org/blog/2025/08/multithread-linting/) + +**`--fix`**: Automatically fixes auto-fixable rule violations. Always review the diff before committing to ensure no unintended changes. + +**`--quiet`**: Suppresses warnings and only shows errors. Useful when you want to reduce noise from existing issues. + +**`--suppress-all`**: Temporarily suppresses error-level violations and records them, allowing CI to pass. Treat this as an escape hatch—fix these errors when time permits. + +**`--prune-suppressions`**: Removes outdated suppressions after you've fixed the underlying errors. + +- [ESLint bulk suppressions blog post](https://eslint.org/blog/2025/04/introducing-bulk-suppressions/) + +### Type-Aware Linting + +Some ESLint rules require type information, such as [no-leaked-conditional-rendering](https://www.eslint-react.xyz/docs/rules/no-leaked-conditional-rendering). However, [typed linting via typescript-eslint](https://typescript-eslint.io/getting-started/typed-linting) is too slow for practical use, so we use [TSSLint](https://github.com/johnsoncodehk/tsslint) instead. + +```sh +pnpm lint:tss +``` + +This command lints the entire project and is intended for final verification before committing or pushing changes. + +## Type Check + +You should be able to see suggestions from TypeScript in your editor for all open files. + +However, it can be useful to run the TypeScript 7 command-line (tsgo) to type check all files: + +```sh +pnpm type-check:tsgo +``` + +Prefer using `tsgo` for type checking as it is significantly faster than the standard TypeScript compiler. Only fall back to `pnpm type-check` (which uses `tsc`) if you encounter unexpected results. diff --git a/web/testing/testing.md b/web/docs/test.md similarity index 99% rename from web/testing/testing.md rename to web/docs/test.md index 47341e445e..cac0e0e351 100644 --- a/web/testing/testing.md +++ b/web/docs/test.md @@ -360,11 +360,11 @@ describe('ComponentName', () => { let mockPortalOpenState = false vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open, ...props }: any) => { + PortalToFollowElem: ({ children, open, ...props }) => { mockPortalOpenState = open || false // Update shared state return
{children}
}, - PortalToFollowElemContent: ({ children }: any) => { + PortalToFollowElemContent: ({ children }) => { // ✅ Matches actual: returns null when open is false if (!mockPortalOpenState) return null diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 6193a8ad4e..63f10d238c 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -4318,11 +4318,6 @@ "count": 10 } }, - "testing/testing.md": { - "ts/no-explicit-any": { - "count": 2 - } - }, "types/app.ts": { "ts/no-explicit-any": { "count": 1 diff --git a/web/scripts/analyze-component.js b/web/scripts/analyze-component.js index b09301503c..2fdff2f3d0 100755 --- a/web/scripts/analyze-component.js +++ b/web/scripts/analyze-component.js @@ -337,7 +337,7 @@ Test file under review: ${testPath} Checklist (ensure every item is addressed in your review): -- Confirm the tests satisfy all requirements listed above and in web/testing/TESTING.md. +- Confirm the tests satisfy all requirements listed above and in web/docs/test.md. - Verify Arrange → Act → Assert structure, mocks, and cleanup follow project conventions. - Ensure all detected component features (state, effects, routing, API, events, etc.) are exercised, including edge cases and error paths. - Check coverage of prop variations, null/undefined inputs, and high-priority workflows implied by usage score. @@ -382,7 +382,7 @@ Examples: # Review existing test pnpm analyze-component app/components/base/button/index.tsx --review -For complete testing guidelines, see: web/testing/testing.md +For complete testing guidelines, see: web/docs/test.md `) } From 8aeef36e2d16c9b9ba41088aee937d0348b5cbec Mon Sep 17 00:00:00 2001 From: yihong Date: Thu, 29 Jan 2026 18:17:40 +0800 Subject: [PATCH 03/14] feat: use xdist to make make test faster (#30824) Signed-off-by: yihong0618 --- .github/workflows/api-tests.yml | 1 + Makefile | 2 +- api/pyproject.toml | 1 + api/tests/unit_tests/conftest.py | 17 +++++++++++++ .../console/app/test_app_response_models.py | 7 ++++++ api/uv.lock | 24 +++++++++++++++++++ dev/pytest/pytest_unit_tests.sh | 10 ++++++-- 7 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 190e00d9fe..52e3272f99 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -72,6 +72,7 @@ jobs: OPENDAL_FS_ROOT: /tmp/dify-storage run: | uv run --project api pytest \ + -n auto \ --timeout "${PYTEST_TIMEOUT:-180}" \ api/tests/integration_tests/workflow \ api/tests/integration_tests/tools \ diff --git a/Makefile b/Makefile index 20cede9a5e..984e8676ee 100644 --- a/Makefile +++ b/Makefile @@ -80,7 +80,7 @@ test: echo "Target: $(TARGET_TESTS)"; \ uv run --project api --dev pytest $(TARGET_TESTS); \ else \ - uv run --project api --dev dev/pytest/pytest_unit_tests.sh; \ + PYTEST_XDIST_ARGS="-n auto" uv run --project api --dev dev/pytest/pytest_unit_tests.sh; \ fi @echo "✅ Tests complete" diff --git a/api/pyproject.toml b/api/pyproject.toml index 575c1434c5..af2dba6fac 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -175,6 +175,7 @@ dev = [ # "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved. "sseclient-py>=1.8.0", "pytest-timeout>=2.4.0", + "pytest-xdist>=3.8.0", ] ############################################################ diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py index c5e1576186..e3c1a617f7 100644 --- a/api/tests/unit_tests/conftest.py +++ b/api/tests/unit_tests/conftest.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch import pytest from flask import Flask +from sqlalchemy import create_engine # Getting the absolute path of the current file's directory ABS_PATH = os.path.dirname(os.path.abspath(__file__)) @@ -36,6 +37,7 @@ import sys sys.path.insert(0, PROJECT_DIR) +from core.db.session_factory import configure_session_factory, session_factory from extensions import ext_redis @@ -102,3 +104,18 @@ def reset_secret_key(): yield finally: dify_config.SECRET_KEY = original + + +@pytest.fixture(scope="session") +def _unit_test_engine(): + engine = create_engine("sqlite:///:memory:") + yield engine + engine.dispose() + + +@pytest.fixture(autouse=True) +def _configure_session_factory(_unit_test_engine): + try: + session_factory.get_session_maker() + except RuntimeError: + configure_session_factory(_unit_test_engine, expire_on_commit=False) diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py index 40eb59a8f4..c557605916 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py @@ -31,6 +31,13 @@ def _load_app_module(): def schema_model(self, name, schema): self.models[name] = schema + return schema + + def model(self, name, model_dict=None, **kwargs): + """Register a model with the namespace (flask-restx compatibility).""" + if model_dict is not None: + self.models[name] = model_dict + return model_dict def _decorator(self, obj): return obj diff --git a/api/uv.lock b/api/uv.lock index 7808c16a8c..a3ad292168 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1479,6 +1479,7 @@ dev = [ { name = "pytest-env" }, { name = "pytest-mock" }, { name = "pytest-timeout" }, + { name = "pytest-xdist" }, { name = "ruff" }, { name = "scipy-stubs" }, { name = "sseclient-py" }, @@ -1678,6 +1679,7 @@ dev = [ { name = "pytest-env", specifier = "~=1.1.3" }, { name = "pytest-mock", specifier = "~=3.14.0" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = "~=0.14.0" }, { name = "scipy-stubs", specifier = ">=1.15.3.0" }, { name = "sseclient-py", specifier = ">=1.8.0" }, @@ -1896,6 +1898,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/d8/2a1c638d9e0aa7e269269a1a1bf423ddd94267f1a01bbe3ad03432b67dd4/eval_type_backport-0.3.0-py3-none-any.whl", hash = "sha256:975a10a0fe333c8b6260d7fdb637698c9a16c3a9e3b6eb943fee6a6f67a37fe8", size = 6061, upload-time = "2025-11-13T20:56:49.499Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "faker" version = "38.2.0" @@ -5141,6 +5152,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-calamine" version = "0.5.4" diff --git a/dev/pytest/pytest_unit_tests.sh b/dev/pytest/pytest_unit_tests.sh index 496cb40952..7c39a48bf4 100755 --- a/dev/pytest/pytest_unit_tests.sh +++ b/dev/pytest/pytest_unit_tests.sh @@ -5,6 +5,12 @@ SCRIPT_DIR="$(dirname "$(realpath "$0")")" cd "$SCRIPT_DIR/../.." PYTEST_TIMEOUT="${PYTEST_TIMEOUT:-20}" +PYTEST_XDIST_ARGS="${PYTEST_XDIST_ARGS:--n auto}" -# libs -pytest --timeout "${PYTEST_TIMEOUT}" api/tests/unit_tests +# Run most tests in parallel (excluding controllers which have import conflicts with xdist) +# Controller tests have module-level side effects (Flask route registration) that cause +# race conditions when imported concurrently by multiple pytest-xdist workers. +pytest --timeout "${PYTEST_TIMEOUT}" ${PYTEST_XDIST_ARGS} api/tests/unit_tests --ignore=api/tests/unit_tests/controllers + +# Run controller tests sequentially to avoid import race conditions +pytest --timeout "${PYTEST_TIMEOUT}" api/tests/unit_tests/controllers From c27df884170b318b05ddd19b5c55a959f2649c53 Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 29 Jan 2026 19:40:47 +0800 Subject: [PATCH 04/14] feat: try app support review (#31716) --- web/app/components/apps/index.tsx | 1 + web/app/components/explore/app-card/index.tsx | 12 +++++------- web/app/components/explore/app-list/index.tsx | 1 + web/app/components/explore/try-app/index.tsx | 16 +++++++++++++++- web/app/components/explore/try-app/tab.tsx | 4 +++- web/i18n/en-US/explore.json | 2 +- 6 files changed, 26 insertions(+), 10 deletions(-) diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index 255bfbf9c5..3be8492489 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -105,6 +105,7 @@ const Apps = () => { {isShowTryAppPanel && ( {isExplore && (canCreate || isTrialApp) && ( + +
+
+
+
{t('chat.poweredBy', { ns: 'share' })}
+ +
+
+ + + ) +} + +export default React.memo(FormContent) diff --git a/web/app/(humanInputLayout)/form/[token]/page.tsx b/web/app/(humanInputLayout)/form/[token]/page.tsx new file mode 100644 index 0000000000..a7e2305b2b --- /dev/null +++ b/web/app/(humanInputLayout)/form/[token]/page.tsx @@ -0,0 +1,13 @@ +'use client' +import * as React from 'react' +import FormContent from './form' + +const FormPage = () => { + return ( +
+ +
+ ) +} + +export default React.memo(FormPage) diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx index 113f3b5680..c874990448 100644 --- a/web/app/(shareLayout)/components/authenticated-layout.tsx +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -47,7 +47,7 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { await webAppLogout(shareCode!) const url = getSigninUrl() router.replace(url) - }, [getSigninUrl, router, webAppLogout, shareCode]) + }, [getSigninUrl, router, shareCode]) if (appInfoError) { return ( diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index 9f89a03993..a2b847f74f 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -31,7 +31,7 @@ const Splash: FC = ({ children }) => { await webAppLogout(shareCode!) const url = getSigninUrl() router.replace(url) - }, [getSigninUrl, router, webAppLogout, shareCode]) + }, [getSigninUrl, router, shareCode]) const [isLoading, setIsLoading] = useState(true) useEffect(() => { diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 0fc364cb7e..1348e3111f 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -115,6 +115,7 @@ export type AppPublisherProps = { missingStartNode?: boolean hasTriggerNode?: boolean // Whether workflow currently contains any trigger nodes (used to hide missing-start CTA when triggers exist). startNodeLimitExceeded?: boolean + hasHumanInputNode?: boolean } const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] @@ -138,13 +139,14 @@ const AppPublisher = ({ missingStartNode = false, hasTriggerNode = false, startNodeLimitExceeded = false, + hasHumanInputNode = false, }: AppPublisherProps) => { const { t } = useTranslation() const [published, setPublished] = useState(false) const [open, setOpen] = useState(false) const [showAppAccessControl, setShowAppAccessControl] = useState(false) - const [isAppAccessSet, setIsAppAccessSet] = useState(true) + const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) const appDetail = useAppStore(state => state.appDetail) @@ -161,6 +163,13 @@ const AppPublisher = ({ const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) const openAsyncWindow = useAsyncWindowOpen() + const isAppAccessSet = useMemo(() => { + if (appDetail && appAccessSubjects) { + return !(appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0) + } + return true + }, [appAccessSubjects, appDetail]) + const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp]) const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission]) @@ -171,25 +180,13 @@ const AppPublisher = ({ return t('noUserInputNode', { ns: 'app' }) if (noAccessPermission) return t('noAccessPermission', { ns: 'app' }) - }, [missingStartNode, noAccessPermission, publishedAt]) + }, [missingStartNode, noAccessPermission, publishedAt, t]) useEffect(() => { if (systemFeatures.webapp_auth.enabled && open && appDetail) refetch() }, [open, appDetail, refetch, systemFeatures]) - useEffect(() => { - if (appDetail && appAccessSubjects) { - if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0) - setIsAppAccessSet(false) - else - setIsAppAccessSet(true) - } - else { - setIsAppAccessSet(true) - } - }, [appAccessSubjects, appDetail]) - const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => { try { await onPublish?.(params) @@ -461,7 +458,7 @@ const AppPublisher = ({ {t('common.accessAPIReference', { ns: 'workflow' })} - {appDetail?.mode === AppModeEnum.WORKFLOW && ( + {appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && ( { if (!statusCount) return null - if (statusCount.partial_success + statusCount.failed === 0) { + if (statusCount.paused > 0) { + return ( +
+ + Pending +
+ ) + } + else if (statusCount.partial_success + statusCount.failed === 0) { return (
@@ -296,7 +305,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { if (abortControllerRef.current === controller) abortControllerRef.current = null } - }, [detail.id, hasMore, timezone, t, appDetail, detail?.model_config?.configs?.introduction]) + }, [detail.id, hasMore, timezone, t, appDetail]) // Derive chatItemTree, threadChatItems, and oldestAnswerIdRef from allChatItems useEffect(() => { @@ -411,7 +420,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) }) return false } - }, [allChatItems, appDetail?.id, t]) + }, [allChatItems, appDetail?.id, notify, t]) const fetchInitiated = useRef(false) @@ -504,7 +513,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { finally { setIsLoading(false) } - }, [detail.id, hasMore, isLoading, timezone, t, appDetail, detail?.model_config?.configs?.introduction]) + }, [detail.id, hasMore, isLoading, timezone, t, appDetail]) const handleScroll = useCallback(() => { const scrollableDiv = document.getElementById('scrollableDiv') diff --git a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx index 17857ec702..54763907df 100644 --- a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx +++ b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx @@ -53,6 +53,7 @@ const defaultProviderContext = { refreshLicenseLimit: noop, isAllowTransferWorkspace: false, isAllowPublishAsCustomKnowledgePipelineTemplate: false, + humanInputEmailDeliveryEnabled: false, } const defaultModalContext: ModalContextState = { diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index c39282a022..22358805a7 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -8,7 +8,7 @@ import { RiClipboardLine, RiFileList3Line, RiPlayList2Line, - RiReplay15Line, + RiResetLeftLine, RiSparklingFill, RiSparklingLine, RiThumbDownLine, @@ -18,10 +18,12 @@ import { useBoolean } from 'ahooks' import copy from 'copy-to-clipboard' import { useParams } from 'next/navigation' import * as React from 'react' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' +import HumanInputFilledFormList from '@/app/components/base/chat/chat/answer/human-input-filled-form-list' +import HumanInputFormList from '@/app/components/base/chat/chat/answer/human-input-form-list' import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process' import { useChatContext } from '@/app/components/base/chat/chat/context' import Loading from '@/app/components/base/loading' @@ -29,7 +31,8 @@ import { Markdown } from '@/app/components/base/markdown' import NewAudioButton from '@/app/components/base/new-audio-button' import Toast from '@/app/components/base/toast' import { fetchTextGenerationMessage } from '@/service/debug' -import { AppSourceType, fetchMoreLikeThis, updateFeedback } from '@/service/share' +import { AppSourceType, fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share' +import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' import { cn } from '@/utils/classnames' import ResultTab from './result-tab' @@ -121,7 +124,7 @@ const GenerationItem: FC = ({ const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false) const childProps = { - isInWebApp: true, + isInWebApp, content: completionRes, messageId: childMessageId, depth: depth + 1, @@ -202,16 +205,22 @@ const GenerationItem: FC = ({ } const [currentTab, setCurrentTab] = useState('DETAIL') - const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length + const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0) const switchTab = async (tab: string) => { setCurrentTab(tab) } useEffect(() => { - if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length) + if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0)) switchTab('RESULT') else switchTab('DETAIL') - }, [workflowProcessData?.files?.length, workflowProcessData?.resultText]) + }, [workflowProcessData?.files?.length, workflowProcessData?.resultText, workflowProcessData?.humanInputFormDataList, workflowProcessData?.humanInputFilledFormDataList]) + const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record, action: string }) => { + if (appSourceType === AppSourceType.installedApp) + await submitHumanInputFormService(formToken, formData) + else + await submitHumanInputForm(formToken, formData) + }, [appSourceType]) return ( <> @@ -275,7 +284,24 @@ const GenerationItem: FC = ({ )}
{!isError && ( - + <> + {currentTab === 'RESULT' && workflowProcessData.humanInputFormDataList && workflowProcessData.humanInputFormDataList.length > 0 && ( +
+ +
+ )} + {currentTab === 'RESULT' && workflowProcessData.humanInputFilledFormDataList && workflowProcessData.humanInputFilledFormDataList.length > 0 && ( +
+ +
+ )} + + )} )} @@ -348,7 +374,7 @@ const GenerationItem: FC = ({ )} {isInWebApp && isError && ( - + )} {isInWebApp && !isWorkflow && !isTryApp && ( diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx index b9597c8ea1..262efad781 100644 --- a/web/app/components/app/workflow-log/list.tsx +++ b/web/app/components/app/workflow-log/list.tsx @@ -81,6 +81,14 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { ) } + if (status === 'paused') { + return ( +
+ + Pending +
+ ) + } if (status === 'running') { return (
diff --git a/web/app/components/base/action-button/index.css b/web/app/components/base/action-button/index.css index 3c1a10b86f..4ede34aeb5 100644 --- a/web/app/components/base/action-button/index.css +++ b/web/app/components/base/action-button/index.css @@ -26,6 +26,10 @@ @apply p-0.5 w-6 h-6 rounded-lg } + .action-btn-s { + @apply w-5 h-5 rounded-[6px] + } + .action-btn-xs { @apply p-0 w-4 h-4 rounded } diff --git a/web/app/components/base/action-button/index.tsx b/web/app/components/base/action-button/index.tsx index c91d472087..d182193b00 100644 --- a/web/app/components/base/action-button/index.tsx +++ b/web/app/components/base/action-button/index.tsx @@ -18,6 +18,7 @@ const actionButtonVariants = cva( variants: { size: { xs: 'action-btn-xs', + s: 'action-btn-s', m: 'action-btn-m', l: 'action-btn-l', xl: 'action-btn-xl', diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 38a3f6c6b2..304425b9a7 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -2,6 +2,7 @@ import type { FileEntity } from '../../file-uploader/types' import type { ChatConfig, ChatItem, + ChatItemInTree, OnSend, } from '../types' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -16,7 +17,9 @@ import { fetchSuggestedQuestions, getUrl, stopChatMessageResponding, + submitHumanInputForm, } from '@/service/share' +import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' import { TransferMethod } from '@/types/app' import { cn } from '@/utils/classnames' import { formatBooleanInputs } from '@/utils/model-config' @@ -73,9 +76,9 @@ const ChatWrapper = () => { }, [appParams, currentConversationItem?.introduction]) const { chatList, - setTargetMessageId, handleSend, handleStop, + handleSwitchSibling, isResponding: respondingState, suggestedQuestions, } = useChat( @@ -122,8 +125,11 @@ const ChatWrapper = () => { if (fileIsUploading) return true + + if (chatList.some(item => item.isAnswer && item.humanInputFormDataList && item.humanInputFormDataList.length > 0)) + return true return false - }, [inputsFormValue, inputsForms, allInputsHidden]) + }, [allInputsHidden, inputsForms, chatList, inputsFormValue]) useEffect(() => { if (currentChatInstanceRef.current) @@ -134,6 +140,40 @@ const ChatWrapper = () => { setIsResponding(respondingState) }, [respondingState, setIsResponding]) + // Resume paused workflows when chat history is loaded + useEffect(() => { + if (!appPrevChatTree || appPrevChatTree.length === 0) + return + + // Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first) + let lastPausedNode: ChatItemInTree | undefined + const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => { + nodes.forEach((node) => { + // DFS: recurse to children first + if (node.children && node.children.length > 0) + findLastPausedWorkflow(node.children) + + // Track the last node with humanInputFormDataList + if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0) + lastPausedNode = node + }) + } + + findLastPausedWorkflow(appPrevChatTree) + + // Only resume the last paused workflow + if (lastPausedNode) { + handleSwitchSibling( + lastPausedNode.id, + { + onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), + onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, + isPublicAPI: appSourceType === AppSourceType.webApp, + }, + ) + } + }, []) + const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { const data: any = { query: message, @@ -149,10 +189,10 @@ const ChatWrapper = () => { { onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted, - isPublicAPI: !isInstalledApp, + isPublicAPI: appSourceType === AppSourceType.webApp, }, ) - }, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId]) + }, [inputsForms, currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, isHistoryConversation, handleNewConversationCompleted]) const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => { const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! @@ -160,12 +200,27 @@ const ChatWrapper = () => { doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) }, [chatList, doSend]) + const doSwitchSibling = useCallback((siblingMessageId: string) => { + handleSwitchSibling(siblingMessageId, { + onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), + onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, + isPublicAPI: appSourceType === AppSourceType.webApp, + }) + }, [handleSwitchSibling, currentConversationId, handleNewConversationCompleted, appSourceType, appId]) + const messageList = useMemo(() => { if (currentConversationId || chatList.length > 1) return chatList // Without messages we are in the welcome screen, so hide the opening statement from chatlist return chatList.filter(item => !item.isOpeningStatement) - }, [chatList]) + }, [chatList, currentConversationId]) + + const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => { + if (isInstalledApp) + await submitHumanInputFormService(formToken, formData) + else + await submitHumanInputForm(formToken, formData) + }, [isInstalledApp]) const [collapsed, setCollapsed] = useState(!!currentConversationId) @@ -274,6 +329,7 @@ const ChatWrapper = () => { inputsForm={inputsForms} onRegenerate={doRegenerate} onStopResponding={handleStop} + onHumanInputFormSubmit={handleSubmitHumanInputForm} chatNode={( <> {chatNode} @@ -286,7 +342,7 @@ const ChatWrapper = () => { answerIcon={answerIcon} hideProcessDetail themeBuilder={themeBuilder} - switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)} + switchSibling={doSwitchSibling} inputDisabled={inputDisabled} sidebarCollapseState={sidebarCollapseState} questionIcon={ diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index ad1de38d07..da344a9789 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -1,3 +1,4 @@ +import type { ExtraContent } from '../chat/type' import type { Callback, ChatConfig, @@ -9,6 +10,7 @@ import type { AppData, ConversationItem, } from '@/models/share' +import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow' import { useLocalStorageState } from 'ahooks' import { noop } from 'es-toolkit/function' import { produce } from 'immer' @@ -57,6 +59,24 @@ function getFormattedChatList(messages: any[]) { parentMessageId: item.parent_message_id || undefined, }) const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [] + const humanInputFormDataList: HumanInputFormData[] = [] + const humanInputFilledFormDataList: HumanInputFilledFormData[] = [] + let workflowRunId = '' + if (item.status === 'paused') { + item.extra_contents?.forEach((content: ExtraContent) => { + if (content.type === 'human_input' && !content.submitted) { + humanInputFormDataList.push(content.form_definition) + workflowRunId = content.workflow_run_id + } + }) + } + else if (item.status === 'normal') { + item.extra_contents?.forEach((content: ExtraContent) => { + if (content.type === 'human_input' && content.submitted) { + humanInputFilledFormDataList.push(content.form_submission_data) + } + }) + } newChatList.push({ id: item.id, content: item.answer, @@ -66,6 +86,9 @@ function getFormattedChatList(messages: any[]) { citation: item.retriever_resources, message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))), parentMessageId: `question-${item.id}`, + humanInputFormDataList, + humanInputFilledFormDataList, + workflow_run_id: workflowRunId, }) }) return newChatList diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx new file mode 100644 index 0000000000..3ed777d41e --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx @@ -0,0 +1,54 @@ +import type { ContentItemProps } from './type' +import * as React from 'react' +import { useMemo } from 'react' +import { Markdown } from '@/app/components/base/markdown' +import Textarea from '@/app/components/base/textarea' + +const ContentItem = ({ + content, + formInputFields, + inputs, + onInputChange, +}: ContentItemProps) => { + const isInputField = (field: string) => { + const outputVarRegex = /\{\{#\$output\.[^#]+#\}\}/ + return outputVarRegex.test(field) + } + + const extractFieldName = (str: string): string => { + const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/ + const match = str.match(outputVarRegex) + return match ? match[1] : '' + } + + const fieldName = useMemo(() => { + return extractFieldName(content) + }, [content]) + + const formInputField = useMemo(() => { + return formInputFields.find(field => field.output_variable_name === fieldName) + }, [formInputFields, fieldName]) + + if (!isInputField(content)) { + return ( + + ) + } + + if (!formInputField) + return null + + return ( +
+ {formInputField.type === 'paragraph' && ( +