Compare commits

..

280 Commits

Author SHA1 Message Date
52b9299e43 Merge branch 'main' into jzh 2026-02-03 16:17:41 +08:00
757317f965 chore: add feat/hitl-frontend to build-push workflow triggers 2026-02-03 15:11:38 +08:00
e119d1a16c fix: do not stop when workflow paused event recieved 2026-02-03 15:08:59 +08:00
0187838a54 Merge branch 'main' into jzh 2026-02-03 14:42:30 +08:00
68c36655a7 chore: remove feat/hitl-frontend from build-push workflow triggers 2026-01-30 09:46:24 +08:00
0357530468 Merge branch 'main' into feat/hitl-frontend 2026-01-29 18:55:13 +08:00
966e308e8b test(billing, rag-pipeline): enhance tests by adding act wrapper and mocking download utility 2026-01-29 18:42:33 +08:00
e405e801ce test(billing): add human_input_email_delivery_enabled to billing utils tests; remove obsolete text generation result tests 2026-01-29 18:20:26 +08:00
3170e12b0a Merge branch 'main' into feat/hitl-frontend 2026-01-29 17:27:25 +08:00
8bbf3ef261 Merge branch 'main' into feat/hitl-frontend 2026-01-29 10:58:26 +08:00
e09bd3e977 refactor(Result): update setWorkflowProcessData to use useCallback and reset workflow process data on completion 2026-01-29 10:39:40 +08:00
b1f7602dc8 feat(humanInput): update email configuration defaults and remove validation tooltip 2026-01-28 22:43:58 +08:00
2ac20ced26 feat(humanInput): add email configuration validation and tooltip for delivery method 2026-01-28 22:22:01 +08:00
4e34fa3d70 feat(humanInput): enhance web app delivery method handling in trigger mode 2026-01-28 22:03:47 +08:00
bf161d01a8 Merge branch 'main' into feat/hitl-frontend 2026-01-28 18:29:13 +08:00
fe5a65b21c test(shortcuts-popup-plugin): add JSDOM mocks for Range methods to improve test reliability 2026-01-28 13:24:55 +08:00
b3be035c64 fix(userActions): update error messages to include dynamic max length and improve validation logic 2026-01-28 11:51:08 +08:00
91f118c82c fix(varReferencePicker): update trigger condition to use double negation for clarity 2026-01-28 11:38:52 +08:00
3eaafccff2 chore(mock): remove unused mock data file for human input form 2026-01-28 11:31:14 +08:00
9d3b4749ae fix(humanInput): wrap rate limit exceeded error message in return statement for proper rendering 2026-01-28 11:15:10 +08:00
67bbfd972b feat(humanInput): add rate limit exceeded error handling and localization support 2026-01-28 10:43:13 +08:00
1968589fdf Merge branch 'main' into feat/hitl-frontend 2026-01-28 10:19:52 +08:00
fc2acc2c53 refactor(form): streamline error handling by removing redundant expired state management 2026-01-27 21:58:15 +08:00
e945f3d439 feat(log): add paused status count to log rendering and simplify status rendering logic 2026-01-27 21:20:47 +08:00
1b0abdf642 feat(humanInput): improve form handling with site info integration and error management 2026-01-27 18:14:24 +08:00
441104ac3c fix: lnit error 2026-01-27 17:55:43 +08:00
8897633e42 feat(workflow): add human input node formatting to retain latest status 2026-01-27 17:09:00 +08:00
8a20a59d72 Merge branch 'feat/hitl-frontend' of https://github.com/langgenius/dify into feat/hitl-frontend 2026-01-27 15:57:42 +08:00
7cd4a7c1de feat: implement human input form timeout handling and enhance expiration time display 2026-01-27 15:57:13 +08:00
b503da0942 fix: validation of inputs in email sender 2026-01-27 15:35:53 +08:00
94671d42c1 refactor(workflow): optimize input variable generation in useSingleRunFormParams hook 2026-01-27 15:19:45 +08:00
8cf817a5fd fix: empty result judgement 2026-01-27 15:02:45 +08:00
3e0b3fae37 fix: test running tab switch 2026-01-27 14:45:46 +08:00
19664daeef feat(chat): enhance file handling in chat by converting file types to MIME format 2026-01-27 11:19:07 +08:00
fe5b5bc4c3 Merge branch 'main' into feat/hitl-frontend 2026-01-27 11:06:50 +08:00
07d8eaae82 refactor: simplify filteredHumanInputFormDataList computation by removing useMemo 2026-01-26 15:16:55 +08:00
e8130fc509 enhancement: add error style for wrong member in email configure of delivery methods 2026-01-26 14:38:17 +08:00
4ea3cf46fa fix: update key references in HumanInputFormList and adjust resume URL in useChat hook 2026-01-26 14:23:45 +08:00
824f139b46 fix: variable display in form content preview 2026-01-26 13:52:21 +08:00
f2d98e832f Merge branch 'feat/hitl-frontend' of https://github.com/langgenius/dify into feat/hitl-frontend 2026-01-26 12:00:37 +08:00
5f73fed1e7 fix(input-field): update regex for name validation to ensure correct format 2026-01-26 12:00:18 +08:00
2ccef3ac84 fix: human input field dragging 2026-01-26 11:47:51 +08:00
9341227cf1 fix(i18n): update Chinese translations for human input terminology 2026-01-26 11:11:39 +08:00
d9982b8dc4 Merge branch 'main' into feat/hitl-frontend 2026-01-26 10:50:25 +08:00
d3d42e3a8e refactor: rename placeholder to default in form input (#31452) 2026-01-23 18:01:44 +08:00
f73db70cec Merge branch 'main' into feat/hitl-frontend 2026-01-23 15:51:58 +08:00
bcd6e22735 fix: update key prop in UserActionItem to use index for consistent rendering 2026-01-22 17:15:58 +08:00
9aa674a04f feat: add jumpToEmailConfigModal function to enhance email configuration navigation in delivery method component 2026-01-22 16:48:29 +08:00
a4b87be5f4 Merge branch 'feat/hitl-frontend' of https://github.com/langgenius/dify into feat/hitl-frontend 2026-01-22 16:16:52 +08:00
c8c98519ad refactor: update expiration time handling in human input component with new utility functions and localization support 2026-01-22 16:16:34 +08:00
6aa452d4e3 fix: add default value for form in HITL 2026-01-22 15:41:42 +08:00
45c2167e0f fix: default value of input field in form content editor 2026-01-22 15:35:52 +08:00
b242578b86 enhancement: add email address when input blur 2026-01-22 15:35:52 +08:00
d65523aa0b save draft when delivery method changed 2026-01-22 15:35:52 +08:00
ac46cf499f refactor: update human input form handling with new hooks and improve error management 2026-01-22 14:52:06 +08:00
8f780cad4c Merge branch 'feat/hitl-frontend' of https://github.com/langgenius/dify into feat/hitl-frontend 2026-01-21 17:33:15 +08:00
0389dd459d style: update class names for consistent width handling in chat answer component 2026-01-21 17:31:46 +08:00
a19ae24adb fix: fix typo 2026-01-21 17:27:26 +08:00
42e80659b6 fix: name validation for output variable 2026-01-21 17:02:17 +08:00
59cb447e05 enhancement: add keyboard events handle 2026-01-21 16:52:49 +08:00
e63f7b2249 fix: include state snapshot in workflow event URLs for chat hooks 2026-01-21 16:45:38 +08:00
307f0d5827 fix: initialize requiredInputs state to an empty object and improve requestParamsObj handling in useSingleRunFormParams hook 2026-01-21 16:23:42 +08:00
757b1c7190 fix: display node name in form content preview 2026-01-21 16:05:03 +08:00
c42b78f2d2 fix: lint err 2026-01-21 16:05:03 +08:00
645793c48c refactor: streamline input variable handling and enhance type definitions in human input components 2026-01-21 15:44:14 +08:00
528e3400da fix: add HumanInput block type to available blocks filter logic 2026-01-21 13:54:19 +08:00
c69a26b1ca fix: correct spacing in expiration time message in Chinese localization 2026-01-21 12:51:21 +08:00
663f320d86 style: adjust layout and styling in HITL input component for improved responsiveness 2026-01-21 12:19:12 +08:00
6e9facd9b5 Merge branch 'feat/hitl-frontend' of https://github.com/langgenius/dify into feat/hitl-frontend 2026-01-21 11:32:20 +08:00
52e9342af5 feat: refactor user action handling in human input panel and update error messaging 2026-01-21 11:31:09 +08:00
0138dc45b6 fix linit error 2026-01-21 11:03:53 +08:00
aaf6e8f978 use action is for executed action text 2026-01-21 11:03:53 +08:00
55be933342 fix email input handle & email test sender UI 2026-01-21 11:03:51 +08:00
711cca01b8 Merge branch 'main' into feat/hitl-frontend 2026-01-21 10:44:23 +08:00
2b28074f4c feat: enhance email configuration and body input components with support for dynamic node variables 2026-01-21 10:30:48 +08:00
c5721184e9 refactor: implement workflow resumption logic in embedded chat and update chat wrapper functionality 2026-01-20 17:03:06 +08:00
f3ec6ad53c feat: enhance chat functionality with workflow resumption and support regeneration (#31281) 2026-01-20 16:52:04 +08:00
1014852ebd Merge branch 'main' into feat/hitl-frontend 2026-01-20 10:45:27 +08:00
5e644315e4 add tip modal for email type 2026-01-19 16:40:54 +08:00
e3a22e5027 hide workflow as tool button when DSL contains human input nodes 2026-01-19 14:16:05 +08:00
977cef5a22 Merge branch 'main' into feat/hitl-frontend 2026-01-19 13:46:07 +08:00
1353eec9ca Merge branch 'main' into feat/hitl-frontend 2026-01-19 12:40:11 +08:00
73343f03c1 Merge branch 'main' into feat/hitl-frontend 2026-01-19 10:16:27 +08:00
d401a29bd9 feat: update test email sender mutation to include inputs and adjust API endpoint for better data handling 2026-01-16 17:52:33 +08:00
3bf8f19874 fix: reduce BUTTON_TEXT_MAX_LENGTH to 20 in user action component for consistency 2026-01-16 17:12:58 +08:00
51a7ddba81 refactor: rename placeholder_values to resolved_placeholder_values and adjust expiration_time calculation in human input form 2026-01-16 16:46:09 +08:00
bd634b165d feat: enhance user action validation in human input form by adding checks for duplicate IDs, empty IDs, and empty titles; update translations accordingly 2026-01-16 15:31:13 +08:00
a298140d8f Merge branch 'main' into feat/hitl-frontend 2026-01-16 13:49:15 +08:00
3db3a18eff refactor: move getButtonStyle function to utils for better code organization in human input form 2026-01-16 13:47:34 +08:00
91c35c2f0a feat: enhance human input form handling by adding placeholder values and new workflow event handlers 2026-01-15 18:22:11 +08:00
61c7fdc614 feat: add humanInputEmailDeliveryEnabled to provider context and update related components for email delivery handling 2026-01-15 16:13:01 +08:00
88c2483192 refactor: simplify dependencies in DetailPanel and enhance workflow run components with new props for better handling of paused states 2026-01-15 11:52:33 +08:00
ca58055a39 Merge branch 'main' into feat/hitl-frontend 2026-01-15 09:58:09 +08:00
368e38d593 fix(chat): add tracing for workflow process when node status is running 2026-01-14 17:24:16 +08:00
7463bc9199 feat: integrate workflow hook to handle output variable renaming in useFormContent 2026-01-14 16:36:37 +08:00
35a707199f refactor: update email configuration modal to use selector for user email and enhance tooltip functionality in delivery method item 2026-01-14 16:28:22 +08:00
dfb25df5ec Merge branch 'main' into feat/hitl-frontend 2026-01-14 13:24:56 +08:00
cdc24696e4 add validation for output name & add correct placeholder 2026-01-13 14:41:46 +08:00
8f6a0e2d8e step run of human input params 2026-01-13 10:39:35 +08:00
20ba0de47d Merge branch 'main' into feat/hitl-frontend 2026-01-13 09:43:47 +08:00
f13ace9dd9 fix(use-available-nodes-meta-data): temporarily exclude human-input nodes from available nodes 2026-01-12 16:10:04 +08:00
23f5427349 feat(elk-layout): add support for Human Input nodes with ELK ports and enhance edge sorting 2026-01-12 16:00:48 +08:00
7deaab116a fix display of preview 2026-01-12 15:56:09 +08:00
b6db6d9305 revert regex for output variable name 2026-01-12 15:56:09 +08:00
2fa688cefd refactor(use-checklist, variable-utils): update dependencies in hooks and enhance human-input node support 2026-01-12 15:41:25 +08:00
b24fafe901 fix variable name of input field in chinese 2026-01-12 15:33:37 +08:00
c837def205 Revert "add check valid for human input"
This reverts commit d21cf87bb6.
2026-01-12 15:17:29 +08:00
1a7eac192c ci: remove unused GitHub Action file
The `workflow_run` event requires the executed workflow files exist in
the default branch.
2026-01-12 15:14:49 +08:00
d21cf87bb6 add check valid for human input 2026-01-12 15:12:28 +08:00
ef41325ad1 Merge branch 'feat/hitl-frontend' of https://github.com/langgenius/dify into feat/hitl-frontend 2026-01-12 14:52:09 +08:00
57c33d5869 refactor(draggable-plugin, hitl-input, human-input): update styles and improve layout for better UI consistency 2026-01-12 14:51:48 +08:00
4484261023 fix judgement of generate button display 2026-01-12 14:21:32 +08:00
72bc396646 step run of human input 2026-01-12 14:16:53 +08:00
68885afac6 Merge branch 'main' into feat/hitl-frontend 2026-01-12 13:47:36 +08:00
18e57096d2 Merge branch 'feat/hitl-frontend' of https://github.com/langgenius/dify into feat/hitl-frontend 2026-01-12 13:46:48 +08:00
b6c6d52725 feat(hitl-input): add readonly prop to HITL input components for enhanced user interaction control 2026-01-12 13:46:04 +08:00
bd104557ef chore(web): fix incorrect update to deploy actions 2026-01-12 11:40:38 +08:00
55b18bb79e chore(web): update deployment action 2026-01-12 11:38:23 +08:00
3f8f158b04 ci(web): add automatically deployment for HITL frontend 2026-01-12 09:43:06 +08:00
471d14f882 step run of human input 2026-01-09 18:31:56 +08:00
a280df2c07 feat(chat): implement workflow event handling and audio player management in chat hooks 2026-01-09 17:05:38 +08:00
b479a36273 feat(human-input): add formContent prop to delivery method components for enhanced email configuration 2026-01-09 15:20:07 +08:00
9136bf48f5 Merge branch 'main' into feat/hitl-frontend 2026-01-09 10:49:24 +08:00
ac70069847 Merge branch 'feat/hitl-frontend' of https://github.com/langgenius/dify into feat/hitl-frontend 2026-01-09 10:47:52 +08:00
27da1e72eb refactor: remove unused webAppLogout dependency from effect hooks and enhance chat item structure with extra content handling 2026-01-08 16:18:44 +08:00
467119d186 fix loading state in batch run 2026-01-08 15:24:02 +08:00
1cdbfa2539 human input in workflow apps 2026-01-08 14:45:05 +08:00
d3299db915 feat: add human input output structure and enhance filtering in human input form components 2026-01-07 18:07:30 +08:00
ebb816b90b fix: update condition for rendering human input form in Answer component 2026-01-07 15:46:47 +08:00
0b794ad9cc feat: extend log status to include 'paused' and update related components for improved status rendering 2026-01-07 11:48:42 +08:00
a19c0023f9 Merge branch 'main' into feat/hitl-frontend 2026-01-07 10:24:51 +08:00
c7fa7009b1 refactor: update form submission parameter from formID to formToken across chat components 2026-01-06 18:13:42 +08:00
3e1a96ad64 expand filled form content as default 2026-01-06 17:57:35 +08:00
d500a631f0 add node title in content wrapper 2026-01-06 17:57:34 +08:00
607e77e0a7 feat: add human input form divider background color and enhance chat answer component layout 2026-01-06 15:18:15 +08:00
46ec24cf8a Merge branch 'main' into feat/hitl-frontend 2026-01-06 10:41:22 +08:00
f654a7f704 feat: implement human input form handling and display components 2026-01-05 17:05:10 +08:00
cf9b72d574 Merge branch 'main' into feat/hitl-frontend 2026-01-05 15:31:08 +08:00
5a92bbdab5 feat: enhance chat component to manage multiple human input forms and their submissions 2026-01-05 15:30:17 +08:00
d0a713e117 feat: integrate human input form submission handling in chat components 2026-01-04 15:27:40 +08:00
9ec127ea8f feat: enhance human input handling by adding filled data support 2026-01-04 11:31:09 +08:00
d478822fe1 refactor: update locale hook in ExpirationTime component 2026-01-04 09:50:08 +08:00
4eac271adc Merge branch 'main' into feat/hitl-frontend 2026-01-04 09:38:08 +08:00
0f13b6b26d fix: include workflowStore in dependency array for useChat hook to ensure proper state updates 2025-12-31 16:52:22 +08:00
52ce46c364 refactor: optimize workflow variable handling and enhance node state management for resumption scenarios 2025-12-31 16:44:43 +08:00
f0f1ae0b49 fix: enhance workflow node handling by including paused state and improving human input management 2025-12-31 13:45:16 +08:00
0c69466b0f feat: add workflow resume functionality and enhance handling of HumanInput node in workflow events 2025-12-30 18:30:32 +08:00
44a688cb81 feat: implement edge source handle change functionality and enhance node interactions for HumanInput node 2025-12-30 16:05:33 +08:00
0e2b59d661 fix: update placeholder handling in ContentItem and remove unused error messages from HumanInputNode 2025-12-30 10:45:59 +08:00
501c3bcc94 chore: add i18n namespace to various components in the workflow for consistency 2025-12-30 10:18:31 +08:00
bf6a2c22eb Merge branch 'main' into feat/hitl-frontend 2025-12-30 09:34:30 +08:00
8c0365c71e Merge branch 'main' into feat/hitl-frontend 2025-12-29 18:36:35 +08:00
c0a4f3b715 refactor(human-input): reorganize hooks and improve structure for better maintainability 2025-12-29 13:57:34 +08:00
d308c34842 refactor(use-common): simplify current workspace query function by removing unnecessary body parameter 2025-12-29 13:37:33 +08:00
68615eec04 Merge branch 'main' into feat/hitl-frontend 2025-12-29 13:36:39 +08:00
eca3e23af0 Merge branch 'main' into feat/hitl-frontend 2025-12-29 12:54:17 +08:00
c716c4ccbe Merge branch 'main' into feat/hitl-frontend 2025-12-29 10:35:51 +08:00
32366774a1 chore(web): remove temporary workaround for CurrentWorkspace query 2025-12-29 09:55:29 +08:00
640661983a refactor: update feature branch naming in build-push workflow 2025-12-26 16:40:50 +08:00
f528f2eafc refactor: streamline chat operation handling and enhance workflow event management 2025-12-26 16:39:17 +08:00
0994953728 refactor: enhance email delivery method handling and improve debug mode display in HumanInputContent 2025-12-26 15:43:20 +08:00
d80167d9ec refactor: update UserActionItem component props and improve dependency management in useFormContent 2025-12-26 15:09:04 +08:00
2969a77b15 refactor: rename 'suspended' status to 'paused' across workflow components and update related styles 2025-12-26 14:50:47 +08:00
062896cb9c refactor: enhance Markdown component and update FormContentPreview props 2025-12-26 12:55:46 +08:00
5a1e6269d5 Merge branch 'main' into feat/hitl-frontend 2025-12-26 12:32:29 +08:00
e744d4de80 feat: replace timeout handling with expiration time in HumanInput form and add ExpirationTime component 2025-12-25 18:16:32 +08:00
be0f493e61 feat: enhance HumanInput node by adding a Timeout action and restructuring user action rendering 2025-12-25 16:16:24 +08:00
b8d4f60782 feat: add HumanInput block support with output variable handling and integration 2025-12-25 15:51:19 +08:00
8b65b689f7 feat: enhance human input panel with structured output and variable handling improvements 2025-12-25 14:53:19 +08:00
8b9846f52b Merge branch 'main' into feat/hitl-frontend 2025-12-25 13:43:27 +08:00
1c4c1b5cb1 refactor: streamline HITL input components by consolidating props and enhancing variable handling 2025-12-25 10:50:20 +08:00
ec0c144eb2 refactor: rename isHideNodeLabel to isShowNodeLabel for clarity in variable label component 2025-12-24 16:21:07 +08:00
afddc56bb4 refactor: replace GeneratedFormInputItem with FormInputItem across components for consistency 2025-12-24 16:01:05 +08:00
57dde4a4d8 refactor: update dependency arrays in HITL input components for improved stability 2025-12-24 13:06:52 +08:00
3c0fd213bf refactor: update human input form handling to support async submission and improve placeholder resolution 2025-12-24 12:13:32 +08:00
ddfd1cb1f5 refactor: update classnames import style across multiple components 2025-12-22 18:09:11 +08:00
138922f3f4 Merge branch 'main' into feat/hitl-frontend 2025-12-22 18:07:16 +08:00
aa9d0bd655 Merge branch 'main' into feat/hitl-frontend 2025-12-15 10:56:43 +08:00
ccef15aafa Merge branch 'main' into feat/hitl-frontend 2025-12-12 17:59:08 +08:00
202f924d99 fix type error of placeholder 2025-12-03 19:48:24 +08:00
882d2f5b4c Merge branch 'main' into tp 2025-12-02 15:33:41 +08:00
93ec42344c add info panel 2025-12-02 15:33:32 +08:00
330585ec9b merge main 2025-11-24 10:46:20 +08:00
8f2660cea8 fix markdown props 2025-11-17 14:56:45 +08:00
87f6ac78c5 Merge branch 'main' into tp 2025-11-17 10:55:31 +08:00
6c7d58aa11 merge main 2025-11-14 10:51:26 +08:00
8bca22a94b merge main 2025-10-28 19:41:33 +08:00
17aa11da79 fix workflow preview crash 2025-10-28 19:40:10 +08:00
63bbcff496 test email sender 2025-10-27 14:07:23 +08:00
f71a632cdc delivery methods config update 2025-10-27 11:55:57 +08:00
4e3bded902 Merge branch 'main' into tp 2025-10-27 10:54:00 +08:00
e75bbdbc0d add other delivery methods 2025-10-26 18:01:05 +08:00
331a5edbf9 fix: drag icon position 2025-10-20 15:57:39 +08:00
6afc99a5ad fix immer import 2025-10-20 14:25:14 +08:00
a4e2ef6b0c merge main 2025-10-20 14:21:09 +08:00
3632b473df fix: merge error 2025-10-20 11:29:52 +08:00
e35dd14c59 merge main 2025-10-13 14:41:31 +08:00
495f901798 add validation for form content 2025-09-08 11:43:51 +08:00
8703515153 human input step run when no variables 2025-09-06 12:34:25 +08:00
be3c6da654 human input step run 2025-09-05 19:59:33 +08:00
d51db3afb3 single run form data 2025-09-05 18:37:28 +08:00
22683fba3f single run form of human input 2025-09-05 16:14:48 +08:00
ed16265eee form inputs in email sender 2025-09-05 15:13:44 +08:00
463ea14d44 email sender modal 2025-09-05 15:13:44 +08:00
527736b8e4 email debug switch 2025-09-05 15:13:44 +08:00
f22dcee6d9 fix: preview i18n and ui promblem 2025-09-05 14:45:46 +08:00
9bdd7e5465 fix: note match and render problem 2025-09-05 14:25:52 +08:00
ad0e79372f feat: can show notes 2025-09-05 14:19:13 +08:00
a362114486 chore: can render note 2025-09-05 11:26:13 +08:00
79ab253c26 fix: match variable error 2025-09-05 11:06:35 +08:00
783b78cc0a chore: add custom variable 2025-09-04 18:41:48 +08:00
b1e123c3aa fix: can not choose vars 2025-09-04 16:46:43 +08:00
84709a7941 temp 2025-09-02 10:06:57 +08:00
ec07636ce9 feat: preveiw wrap 2025-08-29 16:21:39 +08:00
4d4c8b21ac chore: some tiny ui fix 2025-08-29 14:35:37 +08:00
ec8754173f feat: copy and expand 2025-08-29 14:30:57 +08:00
7920b89714 chore: hotkey tip i18n 2025-08-29 11:07:34 +08:00
19e152fd0c chore: prompt editor ui 2025-08-28 16:57:12 +08:00
076a8ecff4 feat: portal position 2025-08-27 18:46:09 +08:00
40591b2196 fix: not choose vars 2025-08-26 17:35:25 +08:00
5156b8f9c9 fix: not auto focus and popup var hide 2025-08-26 16:54:00 +08:00
286ab0d468 feat: insert the hitl config 2025-08-26 16:23:16 +08:00
71a511a470 feat: can insert hitl node by / 2025-08-26 15:51:03 +08:00
6b11973151 add workflow run events 2025-08-26 14:27:27 +08:00
949a894f03 preview tip of human input delivery methods 2025-08-26 14:27:27 +08:00
305b5da764 chore: pre popular placeholder ui 2025-08-25 17:47:45 +08:00
fda19d3f0e feat: in placeholder choose var 2025-08-25 16:29:58 +08:00
e5a2172a85 human input form display & submit in preview 2025-08-25 16:17:40 +08:00
2d89d59d74 main 2025-08-25 16:08:24 +08:00
4f56acd432 chore: fix variable change ux error 2025-08-25 15:36:40 +08:00
c1b7412465 chore: handle textare to ui 2025-08-25 15:31:21 +08:00
62b9a20115 chore: fix placeholder ux 2025-08-25 14:40:51 +08:00
5392401e60 chore: btn text 2025-08-22 18:10:53 +08:00
baa77d3cda chore: can show popup 2025-08-22 16:59:04 +08:00
a41176b66d chore: pre poplulte field 2025-08-22 16:24:45 +08:00
465e978209 feat: modify shortcutsplugin 2025-08-22 16:13:54 +08:00
a9ea8cfd1c feat: shortcut popup unit test 2025-08-21 16:30:19 +08:00
c771f4dbc7 feat: shortcut popup 2025-08-20 18:18:42 +08:00
ebbed8f863 form field 2025-08-12 14:17:08 +08:00
bdf1e9ed3b form content 2025-08-12 13:59:28 +08:00
36acd0b9dd form submit 2025-08-12 10:58:23 +08:00
a4049e1ea7 api of human input form 2025-08-12 10:21:02 +08:00
114dfe038c human input form 2025-08-11 17:58:03 +08:00
81f6344aaa feat: can update var 2025-08-08 16:16:52 +08:00
89963ecf59 feat: can remove 2025-08-08 15:08:02 +08:00
a18bcf3957 feat: pass current value to form input 2025-08-08 14:22:30 +08:00
c28720529e add suspended status 2025-08-08 11:27:11 +08:00
242826013e update validation for human input node 2025-08-08 10:38:32 +08:00
05453cb22f user action validation 2025-08-08 10:14:29 +08:00
da211d3009 timeout value validation 2025-08-08 09:42:21 +08:00
f8a249de03 email config validation 2025-08-07 20:03:53 +08:00
e2e5dedceb feat: add form content editor 2025-08-07 18:28:17 +08:00
bd7ba85471 fix input focus 2025-08-07 18:21:43 +08:00
7d5f6bc255 email input 2025-08-07 18:13:21 +08:00
fcc8789cc3 member selector 2025-08-07 18:13:21 +08:00
6da1a48cad chore: notes add form content holder 2025-08-07 17:56:37 +08:00
465ff7838a fix: move style 2025-08-07 17:25:15 +08:00
a4bf493343 chore: hanele drag ui 2025-08-07 16:56:56 +08:00
c27f20b4b7 chore: hide tree view 2025-08-07 15:06:13 +08:00
a9e6140dc6 feat: can drag and drop hitl block 2025-08-07 15:05:44 +08:00
792f28451c timeout new data structure 2025-08-07 10:44:25 +08:00
82530df38f mail body input 2025-08-06 17:04:46 +08:00
ce8325c83c update data structure 2025-08-06 17:02:40 +08:00
3ed561d943 delivery method item 2025-08-06 17:02:40 +08:00
bb8d54c48b chore: fix hitl not full 2025-08-06 16:59:46 +08:00
922aeb7c21 feat: node hitl render 2025-08-06 16:56:54 +08:00
177be06d09 files 2025-08-05 18:31:32 +08:00
736ec55f86 feat: hitl block 2025-08-05 18:31:18 +08:00
ab373197f9 feat: i18n 2025-08-05 15:14:17 +08:00
a6d2392c6c method selector 2025-08-01 15:36:02 +08:00
3371989572 method selector 2025-08-01 15:14:27 +08:00
f04daf056d delivery methods 2025-08-01 14:37:48 +08:00
fb6c8fa01f user action add 2025-08-01 14:26:21 +08:00
b02199145e user actions 2025-07-27 16:46:40 +08:00
e257455c9c feat: input filed form 2025-07-25 16:59:09 +08:00
63af5305e6 chore: template 2025-07-25 15:47:35 +08:00
95b88a0621 add timeout of human input node 2025-07-25 11:42:50 +08:00
1ab02c6e9a human input node style complete 2025-07-25 10:45:56 +08:00
1099ab5d91 update human input node 2025-07-25 08:54:43 +08:00
6485adae35 add human input node 2025-07-25 08:54:43 +08:00
246 changed files with 12008 additions and 6833 deletions

View File

@ -8,6 +8,7 @@ on:
- "build/**"
- "release/e-*"
- "hotfix/**"
- "feat/hitl-frontend"
tags:
- "*"

View File

@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.12.0"
version = "1.11.4"
requires-python = ">=3.11,<3.13"
dependencies = [

2
api/uv.lock generated
View File

@ -1368,7 +1368,7 @@ wheels = [
[[package]]
name = "dify-api"
version = "1.12.0"
version = "1.11.4"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },

View File

@ -21,7 +21,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.12.0
image: langgenius/dify-api:1.11.4
restart: always
environment:
# Use the shared environment variables.
@ -63,7 +63,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.12.0
image: langgenius/dify-api:1.11.4
restart: always
environment:
# Use the shared environment variables.
@ -102,7 +102,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.12.0
image: langgenius/dify-api:1.11.4
restart: always
environment:
# Use the shared environment variables.
@ -132,7 +132,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.12.0
image: langgenius/dify-web:1.11.4
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}

View File

@ -707,7 +707,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.12.0
image: langgenius/dify-api:1.11.4
restart: always
environment:
# Use the shared environment variables.
@ -749,7 +749,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.12.0
image: langgenius/dify-api:1.11.4
restart: always
environment:
# Use the shared environment variables.
@ -788,7 +788,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.12.0
image: langgenius/dify-api:1.11.4
restart: always
environment:
# Use the shared environment variables.
@ -818,7 +818,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.12.0
image: langgenius/dify-web:1.11.4
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}

View File

@ -35,6 +35,7 @@ export const baseProviderContextValue: ProviderContextState = {
refreshLicenseLimit: noop,
isAllowTransferWorkspace: false,
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
humanInputEmailDeliveryEnabled: false,
}
export const createMockProviderContextValue = (overrides: Partial<ProviderContextState> = {}): ProviderContextState => {

View File

@ -8,7 +8,6 @@ describe('SVG Attribute Error Reproduction', () => {
// Capture console errors
const originalError = console.error
let errorMessages: string[] = []
beforeEach(() => {
errorMessages = []
console.error = vi.fn((message) => {

View File

@ -0,0 +1,289 @@
'use client'
import type { ButtonProps } from '@/app/components/base/button'
import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { SiteInfo } from '@/models/share'
import type { HumanInputFormError } from '@/service/use-share'
import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiInformation2Fill,
} from '@remixicon/react'
import { produce } from 'immer'
import { useParams } from 'next/navigation'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time'
import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import Loading from '@/app/components/base/loading'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import useDocumentTitle from '@/hooks/use-document-title'
import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share'
import { cn } from '@/utils/classnames'
export type FormData = {
site: { site: SiteInfo }
form_content: string
inputs: FormInputItem[]
resolved_default_values: Record<string, string>
user_actions: UserAction[]
expiration_time: number
}
const FormContent = () => {
const { t } = useTranslation()
const { token } = useParams<{ token: string }>()
useDocumentTitle('')
const [inputs, setInputs] = useState<Record<string, string>>({})
const [success, setSuccess] = useState(false)
const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm()
const { data: formData, isLoading, error } = useGetHumanInputForm(token)
const expired = (error as HumanInputFormError | null)?.code === 'human_input_form_expired'
const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted'
const rateLimitExceeded = (error as HumanInputFormError | null)?.code === 'web_form_rate_limit_exceeded'
const splitByOutputVar = (content: string): string[] => {
const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g
const parts = content.split(outputVarRegex)
return parts.filter(part => part.length > 0)
}
const contentList = useMemo(() => {
if (!formData?.form_content)
return []
return splitByOutputVar(formData.form_content)
}, [formData?.form_content])
useEffect(() => {
if (!formData?.inputs)
return
const initialInputs: Record<string, string> = {}
formData.inputs.forEach((item) => {
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? formData.resolved_default_values[item.output_variable_name] || '' : item.default.value
})
setInputs(initialInputs)
}, [formData?.inputs, formData?.resolved_default_values])
// use immer
const handleInputsChange = (name: string, value: string) => {
const newInputs = produce(inputs, (draft) => {
draft[name] = value
})
setInputs(newInputs)
}
const submit = (actionID: string) => {
submitForm(
{ token, data: { inputs, action: actionID } },
{
onSuccess: () => {
setSuccess(true)
},
},
)
}
if (isLoading) {
return (
<Loading type="app" />
)
}
if (success) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="min-w-[480px] max-w-[640px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
<div className="h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiCheckboxCircleFill className="h-8 w-8 text-text-success" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.thanks', { ns: 'share' })}</div>
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.recorded', { ns: 'share' })}</div>
</div>
<div className="system-2xs-regular-uppercase shrink-0 text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
)
}
if (expired) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="min-w-[480px] max-w-[640px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.expired', { ns: 'share' })}</div>
</div>
<div className="system-2xs-regular-uppercase shrink-0 text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
)
}
if (submitted) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="min-w-[480px] max-w-[640px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.completed', { ns: 'share' })}</div>
</div>
<div className="system-2xs-regular-uppercase shrink-0 text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
)
}
if (rateLimitExceeded) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="min-w-[480px] max-w-[640px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.rateLimitExceeded', { ns: 'share' })}</div>
</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
)
}
if (!formData) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="min-w-[480px] max-w-[640px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.formNotFound', { ns: 'share' })}</div>
</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
)
}
const site = formData.site.site
return (
<div className={cn('mx-auto flex h-full w-full max-w-[720px] flex-col items-center')}>
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
<AppIcon
size="large"
iconType={site.icon_type}
icon={site.icon}
background={site.icon_background}
imageUrl={site.icon_url}
/>
<div className="system-xl-semibold grow text-text-primary">{site.title}</div>
</div>
<div className="h-0 w-full grow overflow-y-auto">
<div className="border-components-divider-subtle rounded-[20px] border bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-sm">
{contentList.map((content, index) => (
<ContentItem
key={index}
content={content}
formInputFields={formData.inputs}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className="flex flex-wrap gap-1 py-1">
{formData.user_actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(action.id)}
>
{action.title}
</Button>
))}
</div>
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
)
}
export default React.memo(FormContent)

View File

@ -0,0 +1,13 @@
'use client'
import * as React from 'react'
import FormContent from './form'
const FormPage = () => {
return (
<div className="h-full min-w-[300px] bg-chatbot-bg pb-[env(safe-area-inset-bottom)]">
<FormContent />
</div>
)
}
export default React.memo(FormPage)

View File

@ -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 (

View File

@ -31,7 +31,7 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
await webAppLogout(shareCode!)
const url = getSigninUrl()
router.replace(url)
}, [getSigninUrl, router, webAppLogout, shareCode])
}, [getSigninUrl, router, shareCode])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {

View File

@ -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' })}
</SuggestedAction>
</Tooltip>
{appDetail?.mode === AppModeEnum.WORKFLOW && (
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
<WorkflowToolConfigureButton
disabled={workflowToolDisabled}
published={!!toolPublished}

View File

@ -62,19 +62,19 @@ const AppCard = ({
{app.description}
</div>
</div>
{(canCreate || isTrialApp) && (
{canCreate && (
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
<div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', canCreate && 'grid-cols-2')}>
{canCreate && (
<Button variant="primary" onClick={() => onCreate()}>
<PlusIcon className="mr-1 h-4 w-4" />
<span className="text-xs">{t('newApp.useTemplate', { ns: 'app' })}</span>
<div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', isTrialApp && 'grid-cols-2')}>
<Button variant="primary" onClick={() => onCreate()}>
<PlusIcon className="mr-1 h-4 w-4" />
<span className="text-xs">{t('newApp.useTemplate', { ns: 'app' })}</span>
</Button>
{isTrialApp && (
<Button onClick={showTryAPPPanel(app.app_id)}>
<RiInformation2Line className="mr-1 size-4" />
<span>{t('appCard.try', { ns: 'explore' })}</span>
</Button>
)}
<Button onClick={showTryAPPPanel(app.app_id)}>
<RiInformation2Line className="mr-1 size-4" />
<span>{t('appCard.try', { ns: 'explore' })}</span>
</Button>
</div>
</div>
)}

View File

@ -1,4 +1,3 @@
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useRouter } from 'next/navigation'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
@ -14,8 +13,8 @@ import { getRedirection } from '@/utils/app-redirection'
import CreateAppModal from './index'
vi.mock('ahooks', () => ({
useDebounceFn: <T extends (...args: unknown[]) => unknown>(fn: T) => {
const run = (...args: Parameters<T>) => fn(...args)
useDebounceFn: (fn: (...args: any[]) => any) => {
const run = (...args: any[]) => fn(...args)
const cancel = vi.fn()
const flush = vi.fn()
return { run, cancel, flush }
@ -84,7 +83,7 @@ describe('CreateAppModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseRouter.mockReturnValue({ push: mockPush } as unknown as ReturnType<typeof useRouter>)
mockUseRouter.mockReturnValue({ push: mockPush } as any)
mockUseProviderContext.mockReturnValue({
plan: {
type: AppModeEnum.ADVANCED_CHAT,
@ -93,10 +92,10 @@ describe('CreateAppModal', () => {
reset: {},
},
enableBilling: true,
} as unknown as ReturnType<typeof useProviderContext>)
} as any)
mockUseAppContext.mockReturnValue({
isCurrentWorkspaceEditor: true,
} as unknown as ReturnType<typeof useAppContext>)
} as any)
mockSetItem.mockClear()
Object.defineProperty(window, 'localStorage', {
value: {
@ -119,8 +118,8 @@ describe('CreateAppModal', () => {
})
it('creates an app, notifies success, and fires callbacks', async () => {
const mockApp: Partial<App> = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }
mockCreateApp.mockResolvedValue(mockApp as App)
const mockApp = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }
mockCreateApp.mockResolvedValue(mockApp as any)
const { onClose, onSuccess } = renderModal()
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')

View File

@ -68,6 +68,7 @@ type IDrawerContext = {
}
type StatusCount = {
paused: number
success: number
failed: number
partial_success: number
@ -93,7 +94,15 @@ const statusTdRender = (statusCount: StatusCount) => {
if (!statusCount)
return null
if (statusCount.partial_success + statusCount.failed === 0) {
if (statusCount.paused > 0) {
return (
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<Indicator color="yellow" />
<span className="text-util-colors-warning-warning-600">Pending</span>
</div>
)
}
else if (statusCount.partial_success + statusCount.failed === 0) {
return (
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<Indicator color="green" />
@ -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')

View File

@ -53,6 +53,7 @@ const defaultProviderContext = {
refreshLicenseLimit: noop,
isAllowTransferWorkspace: false,
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
humanInputEmailDeliveryEnabled: false,
}
const defaultModalContext: ModalContextState = {

View File

@ -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<IGenerationItemProps> = ({
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<IGenerationItemProps> = ({
}
const [currentTab, setCurrentTab] = useState<string>('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<string, string>, action: string }) => {
if (appSourceType === AppSourceType.installedApp)
await submitHumanInputFormService(formToken, formData)
else
await submitHumanInputForm(formToken, formData)
}, [appSourceType])
return (
<>
@ -275,7 +284,24 @@ const GenerationItem: FC<IGenerationItemProps> = ({
)}
</div>
{!isError && (
<ResultTab data={workflowProcessData} content={content} currentTab={currentTab} />
<>
{currentTab === 'RESULT' && workflowProcessData.humanInputFormDataList && workflowProcessData.humanInputFormDataList.length > 0 && (
<div className="px-4 pt-4">
<HumanInputFormList
humanInputFormDataList={workflowProcessData.humanInputFormDataList}
onHumanInputFormSubmit={handleSubmitHumanInputForm}
/>
</div>
)}
{currentTab === 'RESULT' && workflowProcessData.humanInputFilledFormDataList && workflowProcessData.humanInputFilledFormDataList.length > 0 && (
<div className="px-4 pt-4">
<HumanInputFilledFormList
humanInputFilledFormDataList={workflowProcessData.humanInputFilledFormDataList}
/>
</div>
)}
<ResultTab data={workflowProcessData} content={content} currentTab={currentTab} />
</>
)}
</>
)}
@ -348,7 +374,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
)}
{isInWebApp && isError && (
<ActionButton onClick={onRetry}>
<RiReplay15Line className="h-4 w-4" />
<RiResetLeftLine className="h-4 w-4" />
</ActionButton>
)}
{isInWebApp && !isWorkflow && !isTryApp && (

View File

@ -81,6 +81,14 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
</div>
)
}
if (status === 'paused') {
return (
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<Indicator color="yellow" />
<span className="text-util-colors-warning-warning-600">Pending</span>
</div>
)
}
if (status === 'running') {
return (
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">

View File

@ -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
}

View File

@ -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',

View File

@ -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={

View File

@ -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

View File

@ -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 (
<Markdown content={content} />
)
}
if (!formInputField)
return null
return (
<div className="py-3">
{formInputField.type === 'paragraph' && (
<Textarea
className="h-[104px] sm:text-xs"
value={inputs[fieldName]}
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
/>
)}
</div>
)
}
export default React.memo(ContentItem)

View File

@ -0,0 +1,64 @@
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { useCallback, useState } from 'react'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
type ContentWrapperProps = {
nodeTitle: string
children: React.ReactNode
showExpandIcon?: boolean
className?: string
expanded?: boolean
}
const ContentWrapper = ({
nodeTitle,
children,
showExpandIcon = false,
className,
expanded = false,
}: ContentWrapperProps) => {
const [isExpanded, setIsExpanded] = useState(expanded)
const handleToggleExpand = useCallback(() => {
setIsExpanded(!isExpanded)
}, [isExpanded])
return (
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}>
<div className="flex items-center gap-2 p-2">
{/* node icon */}
<BlockIcon type={BlockEnum.HumanInput} className="shrink-0" />
{/* node name */}
<div
className="system-sm-semibold-uppercase grow truncate text-text-primary"
title={nodeTitle}
>
{nodeTitle}
</div>
{showExpandIcon && (
<div className="shrink-0 cursor-pointer" onClick={handleToggleExpand}>
{
isExpanded
? (
<RiArrowDownSLine className="size-4" />
)
: (
<RiArrowRightSLine className="size-4" />
)
}
</div>
)}
</div>
{(!showExpandIcon || isExpanded) && (
<div className="px-2 py-1">
{/* human input form content */}
{children}
</div>
)}
</div>
)
}
export default ContentWrapper

View File

@ -0,0 +1,30 @@
import type { ExecutedAction as ExecutedActionType } from './type'
import { memo } from 'react'
import { Trans } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
type ExecutedActionProps = {
executedAction: ExecutedActionType
}
const ExecutedAction = ({
executedAction,
}: ExecutedActionProps) => {
return (
<div className="flex flex-col gap-y-1 py-1">
<Divider className="mb-2 mt-1 w-[30px]" />
<div className="system-xs-regular flex items-center gap-x-1 text-text-tertiary">
<TriggerAll className="size-3.5 shrink-0" />
<Trans
i18nKey="nodes.humanInput.userActions.triggered"
ns="workflow"
components={{ strong: <span className="system-xs-medium text-text-secondary"></span> }}
values={{ actionName: executedAction.id }}
/>
</div>
</div>
)
}
export default memo(ExecutedAction)

View File

@ -0,0 +1,46 @@
'use client'
import { RiAlertFill, RiTimeLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useLocale } from '@/context/i18n'
import { cn } from '@/utils/classnames'
import { getRelativeTime, isRelativeTimeSameOrAfter } from './utils'
type ExpirationTimeProps = {
expirationTime: number
}
const ExpirationTime = ({
expirationTime,
}: ExpirationTimeProps) => {
const { t } = useTranslation()
const locale = useLocale()
const relativeTime = getRelativeTime(expirationTime, locale)
const isSameOrAfter = isRelativeTimeSameOrAfter(expirationTime)
return (
<div
className={cn(
'system-xs-regular mt-1 flex items-center gap-x-1 text-text-tertiary',
!isSameOrAfter && 'text-text-warning',
)}
>
{
isSameOrAfter
? (
<>
<RiTimeLine className="size-3.5" />
<span>{t('humanInput.expirationTimeNowOrFuture', { relativeTime, ns: 'share' })}</span>
</>
)
: (
<>
<RiAlertFill className="size-3.5" />
<span>{t('humanInput.expiredTip', { ns: 'share' })}</span>
</>
)
}
</div>
)
}
export default ExpirationTime

View File

@ -0,0 +1,61 @@
'use client'
import type { HumanInputFormProps } from './type'
import type { ButtonProps } from '@/app/components/base/button'
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
import * as React from 'react'
import { useCallback, useState } from 'react'
import Button from '@/app/components/base/button'
import ContentItem from './content-item'
import { getButtonStyle, initializeInputs, splitByOutputVar } from './utils'
const HumanInputForm = ({
formData,
onSubmit,
}: HumanInputFormProps) => {
const formToken = formData.form_token
const defaultInputs = initializeInputs(formData.inputs, formData.resolved_default_values || {})
const contentList = splitByOutputVar(formData.form_content)
const [inputs, setInputs] = useState(defaultInputs)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleInputsChange = useCallback((name: string, value: string) => {
setInputs(prev => ({
...prev,
[name]: value,
}))
}, [])
const submit = async (formToken: string, actionID: string, inputs: Record<string, string>) => {
setIsSubmitting(true)
await onSubmit?.(formToken, { inputs, action: actionID })
setIsSubmitting(false)
}
return (
<>
{contentList.map((content, index) => (
<ContentItem
key={index}
content={content}
formInputFields={formData.inputs}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className="flex flex-wrap gap-1 py-1">
{formData.actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(formToken, action.id, inputs)}
>
{action.title}
</Button>
))}
</div>
</>
)
}
export default React.memo(HumanInputForm)

View File

@ -0,0 +1,16 @@
import * as React from 'react'
import { Markdown } from '@/app/components/base/markdown'
type SubmittedContentProps = {
content: string
}
const SubmittedContent = ({
content,
}: SubmittedContentProps) => {
return (
<Markdown content={content} />
)
}
export default React.memo(SubmittedContent)

View File

@ -0,0 +1,25 @@
import type { SubmittedHumanInputContentProps } from './type'
import { useMemo } from 'react'
import ExecutedAction from './executed-action'
import SubmittedContent from './submitted-content'
export const SubmittedHumanInputContent = ({
formData,
}: SubmittedHumanInputContentProps) => {
const { rendered_content, action_id, action_text } = formData
const executedAction = useMemo(() => {
return {
id: action_id,
title: action_text,
}
}, [action_id, action_text])
return (
<>
<SubmittedContent content={rendered_content} />
{/* Executed Action */}
<ExecutedAction executedAction={executedAction} />
</>
)
}

View File

@ -0,0 +1,43 @@
import { memo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { useSelector as useAppSelector } from '@/context/app-context'
type TipsProps = {
showEmailTip: boolean
isEmailDebugMode: boolean
showDebugModeTip: boolean
}
const Tips = ({
showEmailTip,
isEmailDebugMode,
showDebugModeTip,
}: TipsProps) => {
const { t } = useTranslation()
const email = useAppSelector(s => s.userProfile.email)
return (
<>
<Divider className="!my-2 w-[30px]" />
<div className="space-y-1 pt-1">
{showEmailTip && !isEmailDebugMode && (
<div className="system-xs-regular text-text-secondary">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
)}
{showEmailTip && isEmailDebugMode && (
<div className="system-xs-regular text-text-secondary">
<Trans
i18nKey="common.humanInputEmailTipInDebugMode"
ns="workflow"
components={{ email: <span className="system-xs-semibold"></span> }}
values={{ email }}
/>
</div>
)}
{showDebugModeTip && <div className="system-xs-medium text-text-warning">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
</div>
</>
)
}
export default memo(Tips)

View File

@ -0,0 +1,31 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
export type ExecutedAction = {
id: string
title: string
}
export type UnsubmittedHumanInputContentProps = {
formData: HumanInputFormData
showEmailTip?: boolean
isEmailDebugMode?: boolean
showDebugModeTip?: boolean
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
}
export type SubmittedHumanInputContentProps = {
formData: HumanInputFilledFormData
}
export type HumanInputFormProps = {
formData: HumanInputFormData
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
}
export type ContentItemProps = {
content: string
formInputFields: FormInputItem[]
inputs: Record<string, string>
onInputChange: (name: string, value: string) => void
}

View File

@ -0,0 +1,36 @@
import type { UnsubmittedHumanInputContentProps } from './type'
import ExpirationTime from './expiration-time'
import HumanInputForm from './human-input-form'
import Tips from './tips'
export const UnsubmittedHumanInputContent = ({
formData,
showEmailTip = false,
isEmailDebugMode = false,
showDebugModeTip = false,
onSubmit,
}: UnsubmittedHumanInputContentProps) => {
const { expiration_time } = formData
return (
<>
{/* Form */}
<HumanInputForm
formData={formData}
onSubmit={onSubmit}
/>
{/* Tips */}
{(showEmailTip || showDebugModeTip) && (
<Tips
showEmailTip={showEmailTip}
isEmailDebugMode={isEmailDebugMode}
showDebugModeTip={showDebugModeTip}
/>
)}
{/* Expiration Time */}
{typeof expiration_time === 'number' && (
<ExpirationTime expirationTime={expiration_time * 1000} />
)}
</>
)
}

View File

@ -0,0 +1,64 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Locale } from '@/i18n-config'
import dayjs from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import relativeTime from 'dayjs/plugin/relativeTime'
import utc from 'dayjs/plugin/utc'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import 'dayjs/locale/en'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/ja'
dayjs.extend(utc)
dayjs.extend(relativeTime)
dayjs.extend(isSameOrAfter)
export const getButtonStyle = (style: UserActionButtonType) => {
if (style === UserActionButtonType.Primary)
return 'primary'
if (style === UserActionButtonType.Default)
return 'secondary'
if (style === UserActionButtonType.Accent)
return 'secondary-accent'
if (style === UserActionButtonType.Ghost)
return 'ghost'
}
export const splitByOutputVar = (content: string): string[] => {
const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g
const parts = content.split(outputVarRegex)
return parts.filter(part => part.length > 0)
}
export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record<string, string> = {}) => {
const initialInputs: Record<string, any> = {}
formInputs.forEach((item) => {
if (item.type === 'text-input' || item.type === 'paragraph')
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? defaultValues[item.output_variable_name] || '' : item.default.value
else
initialInputs[item.output_variable_name] = undefined
})
return initialInputs
}
const localeMap: Record<string, string> = {
'en-US': 'en',
'zh-Hans': 'zh-cn',
'ja-JP': 'ja',
}
export const getRelativeTime = (
utcTimestamp: string | number,
locale: Locale = 'en-US',
) => {
const dayjsLocale = localeMap[locale] ?? 'en'
return dayjs
.utc(utcTimestamp)
.locale(dayjsLocale)
.fromNow()
}
export const isRelativeTimeSameOrAfter = (utcTimestamp: string | number) => {
return dayjs.utc(utcTimestamp).isSameOrAfter(dayjs())
}

View File

@ -0,0 +1,32 @@
import type { HumanInputFilledFormData } from '@/types/workflow'
import ContentWrapper from './human-input-content/content-wrapper'
import { SubmittedHumanInputContent } from './human-input-content/submitted'
type HumanInputFilledFormListProps = {
humanInputFilledFormDataList: HumanInputFilledFormData[]
}
const HumanInputFilledFormList = ({
humanInputFilledFormDataList,
}: HumanInputFilledFormListProps) => {
return (
<div className="mt-2 flex flex-col gap-y-2">
{
humanInputFilledFormDataList.map(formData => (
<ContentWrapper
key={formData.node_id}
nodeTitle={formData.node_title}
showExpandIcon
>
<SubmittedHumanInputContent
key={formData.node_id}
formData={formData}
/>
</ContentWrapper>
))
}
</div>
)
}
export default HumanInputFilledFormList

View File

@ -0,0 +1,70 @@
import type { DeliveryMethod, HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
import type { Node } from '@/app/components/workflow/types'
import type { HumanInputFormData } from '@/types/workflow'
import { useMemo } from 'react'
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
import ContentWrapper from './human-input-content/content-wrapper'
import { UnsubmittedHumanInputContent } from './human-input-content/unsubmitted'
type HumanInputFormListProps = {
humanInputFormDataList: HumanInputFormData[]
onHumanInputFormSubmit?: (formToken: string, formData: { inputs: Record<string, string>, action: string }) => Promise<void>
getHumanInputNodeData?: (nodeID: string) => Node<HumanInputNodeType> | undefined
}
const HumanInputFormList = ({
humanInputFormDataList,
onHumanInputFormSubmit,
getHumanInputNodeData,
}: HumanInputFormListProps) => {
const deliveryMethodsConfig = useMemo((): Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }> => {
if (!humanInputFormDataList.length)
return {}
return humanInputFormDataList.reduce((acc, formData) => {
const deliveryMethodsConfig = getHumanInputNodeData?.(formData.node_id)?.data.delivery_methods || []
if (!deliveryMethodsConfig.length) {
acc[formData.node_id] = {
showEmailTip: false,
isEmailDebugMode: false,
showDebugModeTip: false,
}
return acc
}
const isWebappEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled)
const isEmailEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled)
const isEmailDebugMode = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.config?.debug_mode)
acc[formData.node_id] = {
showEmailTip: isEmailEnabled,
isEmailDebugMode,
showDebugModeTip: !isWebappEnabled,
}
return acc
}, {} as Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }>)
}, [getHumanInputNodeData, humanInputFormDataList])
const filteredHumanInputFormDataList = humanInputFormDataList.filter(formData => formData.display_in_ui)
return (
<div className="mt-2 flex flex-col gap-y-2">
{
filteredHumanInputFormDataList.map(formData => (
<ContentWrapper
key={formData.form_id}
nodeTitle={formData.node_title}
>
<UnsubmittedHumanInputContent
key={formData.form_id}
formData={formData}
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
onSubmit={onHumanInputFormSubmit}
/>
</ContentWrapper>
))
}
</div>
)
}
export default HumanInputFormList

View File

@ -16,8 +16,11 @@ import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
import { FileList } from '@/app/components/base/file-uploader'
import { cn } from '@/utils/classnames'
import ContentSwitch from '../content-switch'
import { useChatContext } from '../context'
import AgentContent from './agent-content'
import BasicContent from './basic-content'
import HumanInputFilledFormList from './human-input-filled-form-list'
import HumanInputFormList from './human-input-form-list'
import More from './more'
import Operation from './operation'
import SuggestedQuestions from './suggested-questions'
@ -36,6 +39,8 @@ type AnswerProps = {
appData?: AppData
noChatInput?: boolean
switchSibling?: (siblingMessageId: string) => void
hideAvatar?: boolean
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
}
const Answer: FC<AnswerProps> = ({
item,
@ -50,6 +55,8 @@ const Answer: FC<AnswerProps> = ({
appData,
noChatInput,
switchSibling,
hideAvatar,
onHumanInputFormSubmit,
}) => {
const { t } = useTranslation()
const {
@ -61,13 +68,22 @@ const Answer: FC<AnswerProps> = ({
workflowProcess,
allFiles,
message_files,
humanInputFormDataList,
humanInputFilledFormDataList,
} = item
const hasAgentThoughts = !!agent_thoughts?.length
const hasHumanInputs = !!humanInputFormDataList?.length || !!humanInputFilledFormDataList?.length
const [containerWidth, setContainerWidth] = useState(0)
const [contentWidth, setContentWidth] = useState(0)
const [humanInputFormContainerWidth, setHumanInputFormContainerWidth] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const humanInputFormContainerRef = useRef<HTMLDivElement>(null)
const {
getHumanInputNodeData,
} = useChatContext()
const getContainerWidth = () => {
if (containerRef.current)
@ -87,12 +103,23 @@ const Answer: FC<AnswerProps> = ({
getContentWidth()
}, [responding])
const getHumanInputFormContainerWidth = () => {
if (humanInputFormContainerRef.current)
setHumanInputFormContainerWidth(humanInputFormContainerRef.current?.clientWidth)
}
useEffect(() => {
if (hasHumanInputs)
getHumanInputFormContainerWidth()
}, [hasHumanInputs])
// Recalculate contentWidth when content changes (e.g., SVG preview/source toggle)
useEffect(() => {
if (!containerRef.current)
return
const resizeObserver = new ResizeObserver(() => {
getContentWidth()
getHumanInputFormContainerWidth()
})
resizeObserver.observe(containerRef.current)
return () => {
@ -115,115 +142,285 @@ const Answer: FC<AnswerProps> = ({
return (
<div className="mb-2 flex last:mb-0">
<div className="relative h-10 w-10 shrink-0">
{answerIcon || <AnswerIcon />}
{responding && (
<div className="absolute left-[-3px] top-[-3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs">
<LoadingAnim type="avatar" />
{!hideAvatar && (
<div className="relative h-10 w-10 shrink-0">
{answerIcon || <AnswerIcon />}
{responding && (
<div className="absolute left-[-3px] top-[-3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs">
<LoadingAnim type="avatar" />
</div>
)}
</div>
)}
<div className="chat-answer-container group ml-4 w-0 grow pb-4" ref={containerRef}>
{/* Block 1: Workflow Process + Human Input Forms */}
{hasHumanInputs && (
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
<div
ref={humanInputFormContainerRef}
className={cn('body-lg-regular relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary')}
>
{
!responding && contentIsEmpty && !hasAgentThoughts && (
<Operation
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - humanInputFormContainerWidth - 4}
contentWidth={humanInputFormContainerWidth}
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)
}
{/** Render workflow process */}
{
workflowProcess && (
<WorkflowProcessItem
data={workflowProcess}
item={item}
hideProcessDetail={hideProcessDetail}
readonly={hideProcessDetail && appData ? !appData.site.show_workflow_steps : undefined}
/>
)
}
{
humanInputFormDataList && humanInputFormDataList.length > 0 && (
<HumanInputFormList
humanInputFormDataList={humanInputFormDataList}
onHumanInputFormSubmit={onHumanInputFormSubmit}
getHumanInputNodeData={getHumanInputNodeData}
/>
)
}
{
humanInputFilledFormDataList && humanInputFilledFormDataList.length > 0 && (
<HumanInputFilledFormList
humanInputFilledFormDataList={humanInputFilledFormDataList}
/>
)
}
{
typeof item.siblingCount === 'number'
&& item.siblingCount > 1
&& !responding
&& contentIsEmpty
&& !hasAgentThoughts
&& (
<ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}
prevDisabled={!item.prevSibling}
nextDisabled={!item.nextSibling}
switchSibling={handleSwitchSibling}
/>
)
}
</div>
</div>
)}
</div>
<div className="chat-answer-container group ml-4 w-0 grow pb-4" ref={containerRef}>
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
<div
ref={contentRef}
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', workflowProcess && 'w-full')}
>
{
!responding && (
<Operation
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - contentWidth - 4}
contentWidth={contentWidth}
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)
}
{/** Render workflow process */}
{
workflowProcess && (
<WorkflowProcessItem
data={workflowProcess}
item={item}
hideProcessDetail={hideProcessDetail}
readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined}
/>
)
}
{
responding && contentIsEmpty && !hasAgentThoughts && (
<div className="flex h-5 w-6 items-center justify-center">
<LoadingAnim type="text" />
</div>
)
}
{
!contentIsEmpty && !hasAgentThoughts && (
<BasicContent item={item} />
)
}
{
(hasAgentThoughts) && (
<AgentContent
item={item}
responding={responding}
content={content}
/>
)
}
{
!!allFiles?.length && (
<FileList
className="my-1"
files={allFiles}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
!!message_files?.length && (
<FileList
className="my-1"
files={message_files}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
annotation?.id && annotation.authorName && (
<EditTitle
className="mt-1"
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
/>
)
}
<SuggestedQuestions item={item} />
{
!!citation?.length && !responding && (
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
)
}
{
!!(item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined) && (
<ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}
prevDisabled={!item.prevSibling}
nextDisabled={!item.nextSibling}
switchSibling={handleSwitchSibling}
/>
)
}
{/* Block 2: Response Content (when human inputs exist) */}
{hasHumanInputs && (responding || !contentIsEmpty || hasAgentThoughts) && (
<div className={cn('group relative mt-2 pr-10', chatAnswerContainerInner)}>
<div className="absolute -top-2 left-6 h-3 w-0.5 bg-chat-answer-human-input-form-divider-bg" />
<div
ref={contentRef}
className="body-lg-regular relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary"
>
{
!responding && (
<Operation
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - contentWidth - 4}
contentWidth={contentWidth}
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)
}
{
responding && contentIsEmpty && !hasAgentThoughts && (
<div className="flex h-5 w-6 items-center justify-center">
<LoadingAnim type="text" />
</div>
)
}
{
!contentIsEmpty && !hasAgentThoughts && (
<BasicContent item={item} />
)
}
{
hasAgentThoughts && (
<AgentContent
item={item}
responding={responding}
content={content}
/>
)
}
{
!!allFiles?.length && (
<FileList
className="my-1"
files={allFiles}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
!!message_files?.length && (
<FileList
className="my-1"
files={message_files}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
annotation?.id && annotation.authorName && (
<EditTitle
className="mt-1"
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
/>
)
}
<SuggestedQuestions item={item} />
{
!!citation?.length && !responding && (
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
)
}
{
typeof item.siblingCount === 'number'
&& item.siblingCount > 1
&& (
<ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}
prevDisabled={!item.prevSibling}
nextDisabled={!item.nextSibling}
switchSibling={handleSwitchSibling}
/>
)
}
</div>
</div>
</div>
)}
{/* Original single block layout (when no human inputs) */}
{!hasHumanInputs && (
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
<div
ref={contentRef}
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', workflowProcess && 'w-full')}
>
{
!responding && (
<Operation
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - contentWidth - 4}
contentWidth={contentWidth}
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)
}
{/** Render workflow process */}
{
workflowProcess && (
<WorkflowProcessItem
data={workflowProcess}
item={item}
hideProcessDetail={hideProcessDetail}
readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined}
/>
)
}
{
responding && contentIsEmpty && !hasAgentThoughts && (
<div className="flex h-5 w-6 items-center justify-center">
<LoadingAnim type="text" />
</div>
)
}
{
!contentIsEmpty && !hasAgentThoughts && (
<BasicContent item={item} />
)
}
{
hasAgentThoughts && (
<AgentContent
item={item}
responding={responding}
content={content}
/>
)
}
{
!!allFiles?.length && (
<FileList
className="my-1"
files={allFiles}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
!!message_files?.length && (
<FileList
className="my-1"
files={message_files}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
annotation?.id && annotation.authorName && (
<EditTitle
className="mt-1"
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
/>
)
}
<SuggestedQuestions item={item} />
{
!!citation?.length && !responding && (
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
)
}
{
typeof item.siblingCount === 'number'
&& item.siblingCount > 1 && (
<ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}
prevDisabled={!item.prevSibling}
nextDisabled={!item.nextSibling}
switchSibling={handleSwitchSibling}
/>
)
}
</div>
</div>
)}
<More more={more} />
</div>
</div>

View File

@ -69,6 +69,7 @@ const Operation: FC<OperationProps> = ({
feedback,
adminFeedback,
agent_thoughts,
humanInputFormDataList,
} = item
const [userLocalFeedback, setUserLocalFeedback] = useState(feedback)
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
@ -186,7 +187,7 @@ const Operation: FC<OperationProps> = ({
)}
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
>
{shouldShowUserFeedbackBar && (
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
hasUserFeedback ? 'flex' : 'hidden group-hover:flex',
@ -226,7 +227,7 @@ const Operation: FC<OperationProps> = ({
)}
</div>
)}
{shouldShowAdminFeedbackBar && (
{shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
@ -305,26 +306,28 @@ const Operation: FC<OperationProps> = ({
)}
{!isOpeningStatement && (
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex">
{(config?.text_to_speech?.enabled) && (
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
<NewAudioButton
id={id}
value={content}
voice={config?.text_to_speech?.voice}
/>
)}
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
>
<RiClipboardLine className="h-4 w-4" />
</ActionButton>
{!humanInputFormDataList?.length && (
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
>
<RiClipboardLine className="h-4 w-4" />
</ActionButton>
)}
{!noChatInput && (
<ActionButton onClick={() => onRegenerate?.(item)}>
<RiResetLeftLine className="h-4 w-4" />
</ActionButton>
)}
{(config?.supportAnnotation && config.annotation_reply?.enabled) && (
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
<AnnotationCtrlButton
appId={config?.appId || ''}
messageId={id}

View File

@ -3,6 +3,7 @@ import {
RiArrowRightSLine,
RiErrorWarningFill,
RiLoader2Line,
RiPauseCircleFill,
} from '@remixicon/react'
import {
useEffect,
@ -34,6 +35,8 @@ const WorkflowProcessItem = ({
const running = data.status === WorkflowRunningStatus.Running
const succeeded = data.status === WorkflowRunningStatus.Succeeded
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
const paused = data.status === WorkflowRunningStatus.Paused
const latestNode = data.tracing[data.tracing.length - 1]
useEffect(() => {
setCollapse(!expand)
@ -50,7 +53,10 @@ const WorkflowProcessItem = ({
running && !collapse && 'bg-background-section-burn',
succeeded && !collapse && 'bg-state-success-hover',
failed && !collapse && 'bg-state-destructive-hover',
collapse && 'bg-workflow-process-bg',
paused && !collapse && 'bg-state-warning-hover',
collapse && !failed && !paused && 'bg-workflow-process-bg',
collapse && paused && 'bg-workflow-process-paused-bg',
collapse && failed && 'bg-workflow-process-failed-bg',
)}
>
<div
@ -72,8 +78,13 @@ const WorkflowProcessItem = ({
<RiErrorWarningFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive" />
)
}
{
paused && (
<RiPauseCircleFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-warning-secondary" />
)
}
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
{t('common.workflowProcess', { ns: 'workflow' })}
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
</div>
<RiArrowRightSLine className={cn('ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
</div>

View File

@ -16,7 +16,8 @@ export type ChatContextValue = Pick<ChatProps, 'config'
| 'onAnnotationAdded'
| 'onAnnotationRemoved'
| 'disableFeedback'
| 'onFeedback'> & {
| 'onFeedback'
| 'getHumanInputNodeData'> & {
readonly?: boolean
}
@ -45,6 +46,7 @@ export const ChatContextProvider = ({
onAnnotationRemoved,
disableFeedback,
onFeedback,
getHumanInputNodeData,
}: ChatContextProviderProps) => {
return (
<ChatContext.Provider value={{
@ -62,6 +64,7 @@ export const ChatContextProvider = ({
onAnnotationRemoved,
disableFeedback,
onFeedback,
getHumanInputNodeData,
}}
>
{children}

File diff suppressed because it is too large Load Diff

View File

@ -75,6 +75,9 @@ export type ChatProps = {
noSpacing?: boolean
inputDisabled?: boolean
sidebarCollapseState?: boolean
hideAvatar?: boolean
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
getHumanInputNodeData?: (nodeID: string) => any
}
const Chat: FC<ChatProps> = ({
@ -116,6 +119,9 @@ const Chat: FC<ChatProps> = ({
noSpacing,
inputDisabled,
sidebarCollapseState,
hideAvatar,
onHumanInputFormSubmit,
getHumanInputNodeData,
}) => {
const { t } = useTranslation()
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
@ -265,6 +271,7 @@ const Chat: FC<ChatProps> = ({
onAnnotationRemoved={onAnnotationRemoved}
disableFeedback={disableFeedback}
onFeedback={onFeedback}
getHumanInputNodeData={getHumanInputNodeData}
>
<div className={cn('relative h-full', isTryApp && 'flex flex-col')}>
<div
@ -295,6 +302,8 @@ const Chat: FC<ChatProps> = ({
hideProcessDetail={hideProcessDetail}
noChatInput={noChatInput}
switchSibling={switchSibling}
hideAvatar={hideAvatar}
onHumanInputFormSubmit={onHumanInputFormSubmit}
/>
)
}
@ -306,6 +315,7 @@ const Chat: FC<ChatProps> = ({
theme={themeBuilder?.theme}
enableEdit={config?.questionEditEnable}
switchSibling={switchSibling}
hideAvatar={hideAvatar}
/>
)
})

View File

@ -32,6 +32,7 @@ type QuestionProps = {
theme: Theme | null | undefined
enableEdit?: boolean
switchSibling?: (siblingMessageId: string) => void
hideAvatar?: boolean
}
const Question: FC<QuestionProps> = ({
@ -40,6 +41,7 @@ const Question: FC<QuestionProps> = ({
theme,
enableEdit = true,
switchSibling,
hideAvatar,
}) => {
const { t } = useTranslation()
@ -174,15 +176,17 @@ const Question: FC<QuestionProps> = ({
</div>
<div className="mt-1 h-[18px]" />
</div>
<div className="h-10 w-10 shrink-0">
{
questionIcon || (
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
<User className="h-full w-full" />
</div>
)
}
</div>
{!hideAvatar && (
<div className="h-10 w-10 shrink-0">
{
questionIcon || (
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
<User className="h-full w-full" />
</div>
)
}
</div>
)}
</div>
)
}

View File

@ -2,7 +2,11 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { InputVarType } from '@/app/components/workflow/types'
import type { Annotation, MessageRating } from '@/models/log'
import type { FileResponse } from '@/types/workflow'
import type {
FileResponse,
HumanInputFilledFormData,
HumanInputFormData,
} from '@/types/workflow'
export type MessageMore = {
time: string
@ -64,6 +68,19 @@ export type CitationItem = {
word_count: number
}
export type ExtraContent
= {
type: 'human_input'
submitted: false
form_definition: HumanInputFormData
workflow_run_id: string
}
| {
type: 'human_input'
submitted: true
form_submission_data: HumanInputFilledFormData
}
export type IChatItem = {
id: string
content: string
@ -104,6 +121,10 @@ export type IChatItem = {
siblingIndex?: number
prevSibling?: string
nextSibling?: string
// for human input
humanInputFormDataList?: HumanInputFormData[]
humanInputFilledFormDataList?: HumanInputFilledFormData[]
extra_contents?: ExtraContent[]
}
export type Metadata = {

View File

@ -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'
@ -17,7 +18,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 Avatar from '../../avatar'
@ -70,9 +73,9 @@ const ChatWrapper = () => {
}, [appParams, currentConversationItem?.introduction])
const {
chatList,
setTargetMessageId,
handleSend,
handleStop,
handleSwitchSibling,
isResponding: respondingState,
suggestedQuestions,
} = useChat(
@ -130,6 +133,40 @@ const ChatWrapper = () => {
setIsResponding(respondingState)
}, [respondingState, setIsResponding])
// Resume paused workflows when chat history is loaded
useEffect(() => {
if (!appPrevChatList || appPrevChatList.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(appPrevChatList)
// 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,
@ -147,7 +184,7 @@ const ChatWrapper = () => {
isPublicAPI: appSourceType === AppSourceType.webApp,
},
)
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted])
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, handleNewConversationCompleted])
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
@ -155,6 +192,14 @@ 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, appSourceType, appId, currentConversationId, handleNewConversationCompleted])
const messageList = useMemo(() => {
if (currentConversationId || chatList.length > 1)
return chatList
@ -178,6 +223,13 @@ const ChatWrapper = () => {
}
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
if (isInstalledApp)
await submitHumanInputFormService(formToken, formData)
else
await submitHumanInputForm(formToken, formData)
}, [isInstalledApp])
const welcome = useMemo(() => {
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
if (respondingState)
@ -223,7 +275,7 @@ const ChatWrapper = () => {
</div>
</div>
)
}, [appData?.site, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
}, [chatList, respondingState, currentConversationId, collapsed, inputsForms.length, allInputsHidden, appData?.site, isMobile])
const answerIcon = isDify()
? <LogoAvatar className="relative shrink-0" />
@ -253,6 +305,7 @@ const ChatWrapper = () => {
inputsForm={inputsForms}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
onHumanInputFormSubmit={handleSubmitHumanInputForm}
chatNode={(
<>
{chatNode}
@ -266,7 +319,7 @@ const ChatWrapper = () => {
answerIcon={answerIcon}
hideProcessDetail
themeBuilder={themeBuilder}
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
switchSibling={doSwitchSibling}
inputDisabled={inputDisabled}
questionIcon={
initUserVariables?.avatar_url

View File

@ -5,7 +5,7 @@ import type {
ModelConfig,
VisionSettings,
} from '@/types/app'
import type { NodeTracing } from '@/types/workflow'
import type { HumanInputFilledFormData, HumanInputFormData, NodeTracing } from '@/types/workflow'
export type {
Inputs,
@ -67,6 +67,8 @@ export type WorkflowProcess = {
expand?: boolean // for UI
resultText?: string
files?: FileEntity[]
humanInputFormDataList?: HumanInputFormData[]
humanInputFilledFormDataList?: HumanInputFilledFormData[]
}
export type ChatItem = IChatItem & {

View File

@ -0,0 +1,6 @@
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.625 0C9.27992 0.000320434 8.93828 0.0686147 8.6196 0.200983C8.30091 0.333351 8.01142 0.527199 7.76766 0.771458C7.5239 1.01572 7.33064 1.3056 7.19893 1.62456C7.06721 1.94351 6.99962 2.28529 7 2.63037C6.99968 2.97541 7.06732 3.31714 7.19907 3.63603C7.33081 3.95493 7.52408 4.24476 7.76783 4.48897C8.01159 4.73317 8.30105 4.92698 8.61971 5.05932C8.93836 5.19165 9.27996 5.25993 9.625 5.26025H12.25V2.63037C12.2504 2.28529 12.1828 1.94351 12.0511 1.62456C11.9194 1.3056 11.7261 1.01572 11.4823 0.771458C11.2386 0.527199 10.9491 0.333351 10.6304 0.200983C10.3117 0.0686147 9.97008 0.000320434 9.625 0ZM9.625 7.01416H2.625C2.27996 7.01448 1.93836 7.08276 1.61971 7.21509C1.30106 7.34743 1.01159 7.54124 0.767835 7.78544C0.524081 8.02965 0.330814 8.31948 0.199069 8.63838C0.0673241 8.95728 -0.000319124 9.299 1.63476e-06 9.64404C-0.000383292 9.98912 0.0672126 10.3309 0.198929 10.6499C0.330645 10.9688 0.523902 11.2587 0.767662 11.503C1.01142 11.7472 1.30091 11.9411 1.6196 12.0734C1.93828 12.2058 2.27992 12.2741 2.625 12.2744H9.625C9.97008 12.2741 10.3117 12.2058 10.6304 12.0734C10.9491 11.9411 11.2386 11.7472 11.4823 11.503C11.7261 11.2587 11.9194 10.9688 12.0511 10.6499C12.1828 10.3309 12.2504 9.98912 12.25 9.64404C12.2503 9.299 12.1827 8.95728 12.0509 8.63838C11.9192 8.31948 11.7259 8.02965 11.4822 7.78544C11.2384 7.54124 10.9489 7.34743 10.6303 7.21509C10.3116 7.08276 9.97004 7.01448 9.625 7.01416Z" fill="#44BEDF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.25 9.64404C26.2503 9.299 26.1827 8.95728 26.0509 8.63838C25.9192 8.31948 25.7259 8.02965 25.4822 7.78544C25.2384 7.54124 24.9489 7.34743 24.6303 7.21509C24.3116 7.08276 23.97 7.01448 23.625 7.01416C23.28 7.01448 22.9384 7.08276 22.6197 7.21509C22.3011 7.34743 22.0116 7.54124 21.7678 7.78544C21.5241 8.02965 21.3308 8.31948 21.1991 8.63838C21.0673 8.95728 20.9997 9.299 21 9.64404V12.2744H23.625C23.9701 12.2741 24.3117 12.2058 24.6304 12.0734C24.9491 11.9411 25.2386 11.7472 25.4823 11.503C25.7261 11.2587 25.9194 10.9688 26.0511 10.6499C26.1828 10.3309 26.2504 9.98912 26.25 9.64404ZM19.25 9.64404V2.63037C19.2504 2.28529 19.1828 1.94351 19.0511 1.62456C18.9194 1.3056 18.7261 1.01572 18.4823 0.771458C18.2386 0.527199 17.9491 0.333351 17.6304 0.200983C17.3117 0.0686147 16.9701 0.000320434 16.625 0C16.2799 0.000320434 15.9383 0.0686147 15.6196 0.200983C15.3009 0.333351 15.0114 0.527199 14.7677 0.771458C14.5239 1.01572 14.3306 1.3056 14.1989 1.62456C14.0672 1.94351 13.9996 2.28529 14 2.63037V9.64404C13.9996 9.98912 14.0672 10.3309 14.1989 10.6499C14.3306 10.9688 14.5239 11.2587 14.7677 11.503C15.0114 11.7472 15.3009 11.9411 15.6196 12.0734C15.9383 12.2058 16.2799 12.2741 16.625 12.2744C16.9701 12.2741 17.3117 12.2058 17.6304 12.0734C17.9491 11.9411 18.2386 11.7472 18.4823 11.503C18.7261 11.2587 18.9194 10.9688 19.0511 10.6499C19.1828 10.3309 19.2504 9.98912 19.25 9.64404Z" fill="#2EB67D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.625 26.302C16.9701 26.3017 17.3117 26.2334 17.6304 26.101C17.9491 25.9686 18.2386 25.7748 18.4823 25.5305C18.7261 25.2863 18.9194 24.9964 19.0511 24.6774C19.1828 24.3585 19.2504 24.0167 19.25 23.6716C19.2503 23.3266 19.1827 22.9849 19.0509 22.666C18.9192 22.3471 18.7259 22.0572 18.4822 21.813C18.2384 21.5688 17.9489 21.375 17.6303 21.2427C17.3116 21.1103 16.97 21.0421 16.625 21.0417H14V23.6716C13.9996 24.0167 14.0672 24.3585 14.1989 24.6774C14.3306 24.9964 14.5239 25.2863 14.7677 25.5305C15.0114 25.7748 15.3009 25.9686 15.6196 26.101C15.9383 26.2334 16.2799 26.3017 16.625 26.302ZM16.625 19.2878H23.625C23.97 19.2875 24.3116 19.2192 24.6303 19.0869C24.9489 18.9546 25.2384 18.7608 25.4822 18.5166C25.7259 18.2723 25.9192 17.9825 26.0509 17.6636C26.1827 17.3447 26.2503 17.003 26.25 16.658C26.2504 16.3129 26.1828 15.9711 26.0511 15.6521C25.9194 15.3332 25.7261 15.0433 25.4823 14.799C25.2386 14.5548 24.9491 14.3609 24.6304 14.2286C24.3117 14.0962 23.9701 14.0279 23.625 14.0276H16.625C16.2799 14.0279 15.9383 14.0962 15.6196 14.2286C15.3009 14.3609 15.0114 14.5548 14.7677 14.799C14.5239 15.0433 14.3306 15.3332 14.1989 15.6521C14.0672 15.9711 13.9996 16.3129 14 16.658C13.9997 17.003 14.0673 17.3447 14.1991 17.6636C14.3308 17.9825 14.5241 18.2723 14.7678 18.5166C15.0116 18.7608 15.3011 18.9546 15.6197 19.0869C15.9384 19.2192 16.28 19.2875 16.625 19.2878Z" fill="#ECB22E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.22485e-06 16.658C-0.000318534 17.003 0.0673247 17.3447 0.19907 17.6636C0.330815 17.9825 0.524081 18.2723 0.767835 18.5166C1.01159 18.7608 1.30106 18.9546 1.61971 19.0869C1.93836 19.2192 2.27996 19.2875 2.625 19.2878C2.97004 19.2875 3.31164 19.2192 3.63029 19.0869C3.94895 18.9546 4.23841 18.7608 4.48217 18.5166C4.72592 18.2723 4.91919 17.9825 5.05093 17.6636C5.18268 17.3447 5.25032 17.003 5.25 16.658V14.0276H2.625C2.27988 14.0279 1.9382 14.0962 1.61948 14.2286C1.30077 14.361 1.01126 14.5549 0.76749 14.7992C0.523723 15.0435 0.330477 15.3335 0.198789 15.6525C0.0671016 15.9715 -0.000446885 16.3128 2.22485e-06 16.658ZM7 16.658V23.6716C6.99962 24.0167 7.06721 24.3585 7.19893 24.6774C7.33064 24.9964 7.5239 25.2863 7.76766 25.5305C8.01142 25.7748 8.30091 25.9686 8.6196 26.101C8.93828 26.2334 9.27992 26.3017 9.625 26.302C9.97008 26.3017 10.3117 26.2334 10.6304 26.101C10.9491 25.9686 11.2386 25.7748 11.4823 25.5305C11.7261 25.2863 11.9194 24.9964 12.0511 24.6774C12.1828 24.3585 12.2504 24.0167 12.25 23.6716V16.6584C12.2504 16.3134 12.1828 15.9716 12.0511 15.6526C11.9194 15.3337 11.7261 15.0438 11.4823 14.7995C11.2386 14.5553 10.9491 14.3614 10.6304 14.2291C10.3117 14.0967 9.97008 14.0284 9.625 14.0281C9.27992 14.0284 8.93828 14.0967 8.6196 14.2291C8.30091 14.3614 8.01142 14.5553 7.76766 14.7995C7.5239 15.0438 7.33064 15.3337 7.19893 15.6526C7.06721 15.9716 6.99962 16.3129 7 16.658Z" fill="#E01E5A"/>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@ -0,0 +1,19 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_20307_29670)">
<path opacity="0.1" d="M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z" fill="black"/>
<path opacity="0.1" d="M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z" fill="black"/>
<path d="M26.6817 10.5H20.8484L19.2267 11.8183V18.34C19.2846 19.4628 19.7715 20.5204 20.5867 21.2946C21.4019 22.0689 22.4833 22.5005 23.6075 22.5005C24.7318 22.5005 25.8131 22.0689 26.6283 21.2946C27.4436 20.5204 27.9304 19.4628 27.9884 18.34V11.8183C27.9899 11.6458 27.9572 11.4746 27.8923 11.3147C27.8273 11.1548 27.7313 11.0094 27.6098 10.8868C27.4883 10.7643 27.3437 10.667 27.1844 10.6006C27.0251 10.5342 26.8543 10.5 26.6817 10.5Z" fill="#5059C9"/>
<path d="M23.9167 9.33333C25.5275 9.33333 26.8333 8.0275 26.8333 6.41667C26.8333 4.80584 25.5275 3.5 23.9167 3.5C22.3058 3.5 21 4.80584 21 6.41667C21 8.0275 22.3058 9.33333 23.9167 9.33333Z" fill="#5059C9"/>
<path d="M10.64 10.5H20.86C21.2065 10.5 21.5389 10.6377 21.7839 10.8827C22.029 11.1278 22.1666 11.4601 22.1666 11.8067V20.4167C22.1666 22.1185 21.4906 23.7506 20.2872 24.9539C19.0839 26.1573 17.4518 26.8333 15.75 26.8333C14.0482 26.8333 12.4161 26.1573 11.2127 24.9539C10.0094 23.7506 9.33331 22.1185 9.33331 20.4167V11.8067C9.33331 11.4601 9.47098 11.1278 9.71603 10.8827C9.96107 10.6377 10.2934 10.5 10.64 10.5Z" fill="#7B83EB"/>
<path d="M16.3333 9.69477C18.4661 9.69477 20.195 7.96584 20.195 5.8331C20.195 3.70036 18.4661 1.97144 16.3333 1.97144C14.2006 1.97144 12.4717 3.70036 12.4717 5.8331C12.4717 7.96584 14.2006 9.69477 16.3333 9.69477Z" fill="#7B83EB"/>
<path opacity="0.5" d="M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z" fill="black"/>
<path opacity="0.5" d="M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z" fill="black"/>
<path d="M14.9683 5.83325H1.365C0.611131 5.83325 0 6.44438 0 7.19825V20.8016C0 21.5555 0.611131 22.1666 1.365 22.1666H14.9683C15.7222 22.1666 16.3333 21.5555 16.3333 20.8016V7.19825C16.3333 6.44438 15.7222 5.83325 14.9683 5.83325Z" fill="#4B53BC"/>
<path d="M11.8767 11.1766H9.08833V18.6666H7.25666V11.1766H4.45667V9.33325H11.8767V11.1766Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_20307_29670">
<rect width="28" height="28" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.66634 2.66675C3.66634 2.29856 3.36787 2.00008 2.99967 2.00008C2.63149 2.00008 2.33301 2.29855 2.33301 2.66675L3.66634 2.66675ZM2.99967 5.33341H2.33301C2.33301 5.51023 2.40325 5.6798 2.52827 5.80482C2.65329 5.92984 2.82286 6.00008 2.99967 6.00008V5.33341ZM5.66641 6.00008C6.03459 6.00008 6.33307 5.7016 6.33307 5.33341C6.33307 4.96523 6.03459 4.66675 5.66641 4.66675L5.66641 6.00008ZM2.41183 5.01659C2.23816 5.34125 2.36056 5.74523 2.68522 5.91889C3.00988 6.09256 3.41385 5.97016 3.58752 5.6455L2.41183 5.01659ZM3.03395 8.58915C2.99109 8.22348 2.6599 7.96175 2.29421 8.00461C1.92853 8.04748 1.66682 8.37868 1.70967 8.74435L3.03395 8.58915ZM12.0439 5.05931C12.2607 5.3569 12.6777 5.42238 12.9753 5.20557C13.2729 4.98876 13.3383 4.57176 13.1215 4.27417L12.0439 5.05931ZM5.02145 13.5907C5.34627 13.7641 5.75013 13.6413 5.92349 13.3165C6.09685 12.9917 5.97407 12.5878 5.64925 12.4145L5.02145 13.5907ZM2.33301 2.66675L2.33301 5.33341H3.66634L3.66634 2.66675L2.33301 2.66675ZM2.99967 6.00008L5.66641 6.00008L5.66641 4.66675H2.99967L2.99967 6.00008ZM3.58752 5.6455C4.43045 4.06972 6.09066 3.00008 7.99968 3.00008V1.66675C5.57951 1.66675 3.47747 3.02445 2.41183 5.01659L3.58752 5.6455ZM7.99968 3.00008C9.66128 3.00008 11.1336 3.80991 12.0439 5.05931L13.1215 4.27417C11.9711 2.69513 10.1055 1.66675 7.99968 1.66675V3.00008ZM5.64925 12.4145C4.23557 11.6599 3.22828 10.2474 3.03395 8.58915L1.70967 8.74435C1.95639 10.8495 3.23403 12.6367 5.02145 13.5907L5.64925 12.4145Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.1946 8.13826C12.9027 7.84637 12.4294 7.84638 12.1375 8.13829L9.11842 11.1574C9.01642 11.2594 8.95025 11.3917 8.92985 11.5345C8.92985 11.5345 8.92985 11.5345 8.92985 11.5345L8.78508 12.5479L9.79846 12.4031C9.94127 12.3827 10.0736 12.3165 10.1756 12.2145L13.1947 9.19548C13.4866 8.90359 13.4866 8.43027 13.1946 8.13826C13.1947 8.13827 13.1946 8.13825 13.1946 8.13826ZM11.1947 7.19548C12.0073 6.38286 13.3249 6.38286 14.1375 7.19548C14.95 8.00814 14.9501 9.32565 14.1375 10.1383L11.1184 13.1574C10.8124 13.4633 10.4154 13.6618 9.98703 13.723L8.09369 13.9935C7.88596 14.0232 7.67639 13.9533 7.52801 13.805C7.37963 13.6566 7.30977 13.447 7.33945 13.2393L7.60991 11.3459C7.67111 10.9175 7.86961 10.5205 8.17561 10.2145L11.1947 7.19548Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.528 10.8048L10.528 8.80482L11.4708 7.86201L13.4708 9.86201L12.528 10.8048Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,61 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "27",
"height": "27",
"viewBox": "0 0 27 27",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M9.625 0C9.27992 0.000320434 8.93828 0.0686147 8.6196 0.200983C8.30091 0.333351 8.01142 0.527199 7.76766 0.771458C7.5239 1.01572 7.33064 1.3056 7.19893 1.62456C7.06721 1.94351 6.99962 2.28529 7 2.63037C6.99968 2.97541 7.06732 3.31714 7.19907 3.63603C7.33081 3.95493 7.52408 4.24476 7.76783 4.48897C8.01159 4.73317 8.30105 4.92698 8.61971 5.05932C8.93836 5.19165 9.27996 5.25993 9.625 5.26025H12.25V2.63037C12.2504 2.28529 12.1828 1.94351 12.0511 1.62456C11.9194 1.3056 11.7261 1.01572 11.4823 0.771458C11.2386 0.527199 10.9491 0.333351 10.6304 0.200983C10.3117 0.0686147 9.97008 0.000320434 9.625 0ZM9.625 7.01416H2.625C2.27996 7.01448 1.93836 7.08276 1.61971 7.21509C1.30106 7.34743 1.01159 7.54124 0.767835 7.78544C0.524081 8.02965 0.330814 8.31948 0.199069 8.63838C0.0673241 8.95728 -0.000319124 9.299 1.63476e-06 9.64404C-0.000383292 9.98912 0.0672126 10.3309 0.198929 10.6499C0.330645 10.9688 0.523902 11.2587 0.767662 11.503C1.01142 11.7472 1.30091 11.9411 1.6196 12.0734C1.93828 12.2058 2.27992 12.2741 2.625 12.2744H9.625C9.97008 12.2741 10.3117 12.2058 10.6304 12.0734C10.9491 11.9411 11.2386 11.7472 11.4823 11.503C11.7261 11.2587 11.9194 10.9688 12.0511 10.6499C12.1828 10.3309 12.2504 9.98912 12.25 9.64404C12.2503 9.299 12.1827 8.95728 12.0509 8.63838C11.9192 8.31948 11.7259 8.02965 11.4822 7.78544C11.2384 7.54124 10.9489 7.34743 10.6303 7.21509C10.3116 7.08276 9.97004 7.01448 9.625 7.01416Z",
"fill": "#44BEDF"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M26.25 9.64404C26.2503 9.299 26.1827 8.95728 26.0509 8.63838C25.9192 8.31948 25.7259 8.02965 25.4822 7.78544C25.2384 7.54124 24.9489 7.34743 24.6303 7.21509C24.3116 7.08276 23.97 7.01448 23.625 7.01416C23.28 7.01448 22.9384 7.08276 22.6197 7.21509C22.3011 7.34743 22.0116 7.54124 21.7678 7.78544C21.5241 8.02965 21.3308 8.31948 21.1991 8.63838C21.0673 8.95728 20.9997 9.299 21 9.64404V12.2744H23.625C23.9701 12.2741 24.3117 12.2058 24.6304 12.0734C24.9491 11.9411 25.2386 11.7472 25.4823 11.503C25.7261 11.2587 25.9194 10.9688 26.0511 10.6499C26.1828 10.3309 26.2504 9.98912 26.25 9.64404ZM19.25 9.64404V2.63037C19.2504 2.28529 19.1828 1.94351 19.0511 1.62456C18.9194 1.3056 18.7261 1.01572 18.4823 0.771458C18.2386 0.527199 17.9491 0.333351 17.6304 0.200983C17.3117 0.0686147 16.9701 0.000320434 16.625 0C16.2799 0.000320434 15.9383 0.0686147 15.6196 0.200983C15.3009 0.333351 15.0114 0.527199 14.7677 0.771458C14.5239 1.01572 14.3306 1.3056 14.1989 1.62456C14.0672 1.94351 13.9996 2.28529 14 2.63037V9.64404C13.9996 9.98912 14.0672 10.3309 14.1989 10.6499C14.3306 10.9688 14.5239 11.2587 14.7677 11.503C15.0114 11.7472 15.3009 11.9411 15.6196 12.0734C15.9383 12.2058 16.2799 12.2741 16.625 12.2744C16.9701 12.2741 17.3117 12.2058 17.6304 12.0734C17.9491 11.9411 18.2386 11.7472 18.4823 11.503C18.7261 11.2587 18.9194 10.9688 19.0511 10.6499C19.1828 10.3309 19.2504 9.98912 19.25 9.64404Z",
"fill": "#2EB67D"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M16.625 26.302C16.9701 26.3017 17.3117 26.2334 17.6304 26.101C17.9491 25.9686 18.2386 25.7748 18.4823 25.5305C18.7261 25.2863 18.9194 24.9964 19.0511 24.6774C19.1828 24.3585 19.2504 24.0167 19.25 23.6716C19.2503 23.3266 19.1827 22.9849 19.0509 22.666C18.9192 22.3471 18.7259 22.0572 18.4822 21.813C18.2384 21.5688 17.9489 21.375 17.6303 21.2427C17.3116 21.1103 16.97 21.0421 16.625 21.0417H14V23.6716C13.9996 24.0167 14.0672 24.3585 14.1989 24.6774C14.3306 24.9964 14.5239 25.2863 14.7677 25.5305C15.0114 25.7748 15.3009 25.9686 15.6196 26.101C15.9383 26.2334 16.2799 26.3017 16.625 26.302ZM16.625 19.2878H23.625C23.97 19.2875 24.3116 19.2192 24.6303 19.0869C24.9489 18.9546 25.2384 18.7608 25.4822 18.5166C25.7259 18.2723 25.9192 17.9825 26.0509 17.6636C26.1827 17.3447 26.2503 17.003 26.25 16.658C26.2504 16.3129 26.1828 15.9711 26.0511 15.6521C25.9194 15.3332 25.7261 15.0433 25.4823 14.799C25.2386 14.5548 24.9491 14.3609 24.6304 14.2286C24.3117 14.0962 23.9701 14.0279 23.625 14.0276H16.625C16.2799 14.0279 15.9383 14.0962 15.6196 14.2286C15.3009 14.3609 15.0114 14.5548 14.7677 14.799C14.5239 15.0433 14.3306 15.3332 14.1989 15.6521C14.0672 15.9711 13.9996 16.3129 14 16.658C13.9997 17.003 14.0673 17.3447 14.1991 17.6636C14.3308 17.9825 14.5241 18.2723 14.7678 18.5166C15.0116 18.7608 15.3011 18.9546 15.6197 19.0869C15.9384 19.2192 16.28 19.2875 16.625 19.2878Z",
"fill": "#ECB22E"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M2.22485e-06 16.658C-0.000318534 17.003 0.0673247 17.3447 0.19907 17.6636C0.330815 17.9825 0.524081 18.2723 0.767835 18.5166C1.01159 18.7608 1.30106 18.9546 1.61971 19.0869C1.93836 19.2192 2.27996 19.2875 2.625 19.2878C2.97004 19.2875 3.31164 19.2192 3.63029 19.0869C3.94895 18.9546 4.23841 18.7608 4.48217 18.5166C4.72592 18.2723 4.91919 17.9825 5.05093 17.6636C5.18268 17.3447 5.25032 17.003 5.25 16.658V14.0276H2.625C2.27988 14.0279 1.9382 14.0962 1.61948 14.2286C1.30077 14.361 1.01126 14.5549 0.76749 14.7992C0.523723 15.0435 0.330477 15.3335 0.198789 15.6525C0.0671016 15.9715 -0.000446885 16.3128 2.22485e-06 16.658ZM7 16.658V23.6716C6.99962 24.0167 7.06721 24.3585 7.19893 24.6774C7.33064 24.9964 7.5239 25.2863 7.76766 25.5305C8.01142 25.7748 8.30091 25.9686 8.6196 26.101C8.93828 26.2334 9.27992 26.3017 9.625 26.302C9.97008 26.3017 10.3117 26.2334 10.6304 26.101C10.9491 25.9686 11.2386 25.7748 11.4823 25.5305C11.7261 25.2863 11.9194 24.9964 12.0511 24.6774C12.1828 24.3585 12.2504 24.0167 12.25 23.6716V16.6584C12.2504 16.3134 12.1828 15.9716 12.0511 15.6526C11.9194 15.3337 11.7261 15.0438 11.4823 14.7995C11.2386 14.5553 10.9491 14.3614 10.6304 14.2291C10.3117 14.0967 9.97008 14.0284 9.625 14.0281C9.27992 14.0284 8.93828 14.0967 8.6196 14.2291C8.30091 14.3614 8.01142 14.5553 7.76766 14.7995C7.5239 15.0438 7.33064 15.3337 7.19893 15.6526C7.06721 15.9716 6.99962 16.3129 7 16.658Z",
"fill": "#E01E5A"
},
"children": []
}
]
},
"name": "Slack"
}

View File

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Slack.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Slack'
export default Icon

View File

@ -0,0 +1,146 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "28",
"height": "28",
"viewBox": "0 0 28 28",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"clip-path": "url(#clip0_20307_29670)"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"opacity": "0.1",
"d": "M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z",
"fill": "black"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"opacity": "0.1",
"d": "M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z",
"fill": "black"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M26.6817 10.5H20.8484L19.2267 11.8183V18.34C19.2846 19.4628 19.7715 20.5204 20.5867 21.2946C21.4019 22.0689 22.4833 22.5005 23.6075 22.5005C24.7318 22.5005 25.8131 22.0689 26.6283 21.2946C27.4436 20.5204 27.9304 19.4628 27.9884 18.34V11.8183C27.9899 11.6458 27.9572 11.4746 27.8923 11.3147C27.8273 11.1548 27.7313 11.0094 27.6098 10.8868C27.4883 10.7643 27.3437 10.667 27.1844 10.6006C27.0251 10.5342 26.8543 10.5 26.6817 10.5Z",
"fill": "#5059C9"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M23.9167 9.33333C25.5275 9.33333 26.8333 8.0275 26.8333 6.41667C26.8333 4.80584 25.5275 3.5 23.9167 3.5C22.3058 3.5 21 4.80584 21 6.41667C21 8.0275 22.3058 9.33333 23.9167 9.33333Z",
"fill": "#5059C9"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M10.64 10.5H20.86C21.2065 10.5 21.5389 10.6377 21.7839 10.8827C22.029 11.1278 22.1666 11.4601 22.1666 11.8067V20.4167C22.1666 22.1185 21.4906 23.7506 20.2872 24.9539C19.0839 26.1573 17.4518 26.8333 15.75 26.8333C14.0482 26.8333 12.4161 26.1573 11.2127 24.9539C10.0094 23.7506 9.33331 22.1185 9.33331 20.4167V11.8067C9.33331 11.4601 9.47098 11.1278 9.71603 10.8827C9.96107 10.6377 10.2934 10.5 10.64 10.5Z",
"fill": "#7B83EB"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M16.3333 9.69477C18.4661 9.69477 20.195 7.96584 20.195 5.8331C20.195 3.70036 18.4661 1.97144 16.3333 1.97144C14.2006 1.97144 12.4717 3.70036 12.4717 5.8331C12.4717 7.96584 14.2006 9.69477 16.3333 9.69477Z",
"fill": "#7B83EB"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"opacity": "0.5",
"d": "M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z",
"fill": "black"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"opacity": "0.5",
"d": "M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z",
"fill": "black"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M14.9683 5.83325H1.365C0.611131 5.83325 0 6.44438 0 7.19825V20.8016C0 21.5555 0.611131 22.1666 1.365 22.1666H14.9683C15.7222 22.1666 16.3333 21.5555 16.3333 20.8016V7.19825C16.3333 6.44438 15.7222 5.83325 14.9683 5.83325Z",
"fill": "#4B53BC"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M11.8767 11.1766H9.08833V18.6666H7.25666V11.1766H4.45667V9.33325H11.8767V11.1766Z",
"fill": "white"
},
"children": []
}
]
},
{
"type": "element",
"name": "defs",
"attributes": {},
"children": [
{
"type": "element",
"name": "clipPath",
"attributes": {
"id": "clip0_20307_29670"
},
"children": [
{
"type": "element",
"name": "rect",
"attributes": {
"width": "28",
"height": "28",
"fill": "white"
},
"children": []
}
]
}
]
}
]
},
"name": "Teams"
}

View File

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Teams.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Teams'
export default Icon

View File

@ -2,3 +2,5 @@ export { default as DefaultToolIcon } from './DefaultToolIcon'
export { default as Icon3Dots } from './Icon3Dots'
export { default as Message3Fill } from './Message3Fill'
export { default as RowStruct } from './RowStruct'
export { default as Slack } from './Slack'
export { default as Teams } from './Teams'

View File

@ -0,0 +1,48 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M3.66634 2.66675C3.66634 2.29856 3.36787 2.00008 2.99967 2.00008C2.63149 2.00008 2.33301 2.29855 2.33301 2.66675L3.66634 2.66675ZM2.99967 5.33341H2.33301C2.33301 5.51023 2.40325 5.6798 2.52827 5.80482C2.65329 5.92984 2.82286 6.00008 2.99967 6.00008V5.33341ZM5.66641 6.00008C6.03459 6.00008 6.33307 5.7016 6.33307 5.33341C6.33307 4.96523 6.03459 4.66675 5.66641 4.66675L5.66641 6.00008ZM2.41183 5.01659C2.23816 5.34125 2.36056 5.74523 2.68522 5.91889C3.00988 6.09256 3.41385 5.97016 3.58752 5.6455L2.41183 5.01659ZM3.03395 8.58915C2.99109 8.22348 2.6599 7.96175 2.29421 8.00461C1.92853 8.04748 1.66682 8.37868 1.70967 8.74435L3.03395 8.58915ZM12.0439 5.05931C12.2607 5.3569 12.6777 5.42238 12.9753 5.20557C13.2729 4.98876 13.3383 4.57176 13.1215 4.27417L12.0439 5.05931ZM5.02145 13.5907C5.34627 13.7641 5.75013 13.6413 5.92349 13.3165C6.09685 12.9917 5.97407 12.5878 5.64925 12.4145L5.02145 13.5907ZM2.33301 2.66675L2.33301 5.33341H3.66634L3.66634 2.66675L2.33301 2.66675ZM2.99967 6.00008L5.66641 6.00008L5.66641 4.66675H2.99967L2.99967 6.00008ZM3.58752 5.6455C4.43045 4.06972 6.09066 3.00008 7.99968 3.00008V1.66675C5.57951 1.66675 3.47747 3.02445 2.41183 5.01659L3.58752 5.6455ZM7.99968 3.00008C9.66128 3.00008 11.1336 3.80991 12.0439 5.05931L13.1215 4.27417C11.9711 2.69513 10.1055 1.66675 7.99968 1.66675V3.00008ZM5.64925 12.4145C4.23557 11.6599 3.22828 10.2474 3.03395 8.58915L1.70967 8.74435C1.95639 10.8495 3.23403 12.6367 5.02145 13.5907L5.64925 12.4145Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M13.1946 8.13826C12.9027 7.84637 12.4294 7.84638 12.1375 8.13829L9.11842 11.1574C9.01642 11.2594 8.95025 11.3917 8.92985 11.5345C8.92985 11.5345 8.92985 11.5345 8.92985 11.5345L8.78508 12.5479L9.79846 12.4031C9.94127 12.3827 10.0736 12.3165 10.1756 12.2145L13.1947 9.19548C13.4866 8.90359 13.4866 8.43027 13.1946 8.13826C13.1947 8.13827 13.1946 8.13825 13.1946 8.13826ZM11.1947 7.19548C12.0073 6.38286 13.3249 6.38286 14.1375 7.19548C14.95 8.00814 14.9501 9.32565 14.1375 10.1383L11.1184 13.1574C10.8124 13.4633 10.4154 13.6618 9.98703 13.723L8.09369 13.9935C7.88596 14.0232 7.67639 13.9533 7.52801 13.805C7.37963 13.6566 7.30977 13.447 7.33945 13.2393L7.60991 11.3459C7.67111 10.9175 7.86961 10.5205 8.17561 10.2145L11.1947 7.19548Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M12.528 10.8048L10.528 8.80482L11.4708 7.86201L13.4708 9.86201L12.528 10.8048Z",
"fill": "currentColor"
},
"children": []
}
]
},
"name": "HumanInLoop"
}

View File

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './HumanInLoop.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'HumanInLoop'
export default Icon

View File

@ -10,6 +10,7 @@ export { default as DocsExtractor } from './DocsExtractor'
export { default as End } from './End'
export { default as Home } from './Home'
export { default as Http } from './Http'
export { default as HumanInLoop } from './HumanInLoop'
export { default as IfElse } from './IfElse'
export { default as Iteration } from './Iteration'
export { default as IterationStart } from './IterationStart'

View File

@ -18,7 +18,7 @@ export type MarkdownProps = {
content: string
className?: string
pluginInfo?: SimplePluginInfo
} & Pick<ReactMarkdownWrapperProps, 'customComponents' | 'customDisallowedElements'>
} & Pick<ReactMarkdownWrapperProps, 'customComponents' | 'customDisallowedElements' | 'rehypePlugins'>
export const Markdown = (props: MarkdownProps) => {
const { customComponents = {}, pluginInfo } = props
@ -29,7 +29,13 @@ export const Markdown = (props: MarkdownProps) => {
return (
<div className={cn('markdown-body', '!text-text-primary', props.className)}>
<ReactMarkdown pluginInfo={pluginInfo} latexContent={latexContent} customComponents={customComponents} customDisallowedElements={props.customDisallowedElements} />
<ReactMarkdown
pluginInfo={pluginInfo}
latexContent={latexContent}
customComponents={customComponents}
customDisallowedElements={props.customDisallowedElements}
rehypePlugins={props.rehypePlugins}
/>
</div>
)
}

View File

@ -22,6 +22,7 @@ export type ReactMarkdownWrapperProps = {
customDisallowedElements?: string[]
customComponents?: Record<string, React.ComponentType<any>>
pluginInfo?: SimplePluginInfo
rehypePlugins?: any// js: PluggableList[]
}
export const ReactMarkdownWrapper: FC<ReactMarkdownWrapperProps> = (props) => {
@ -55,6 +56,7 @@ export const ReactMarkdownWrapper: FC<ReactMarkdownWrapperProps> = (props) => {
tree.children.forEach(iterate)
}
},
...(props.rehypePlugins || []),
]}
urlTransform={customUrlTransform}
disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]}

View File

@ -4,6 +4,7 @@ import { SupportUploadFileTypes } from '../../workflow/types'
export const CONTEXT_PLACEHOLDER_TEXT = '{{#context#}}'
export const HISTORY_PLACEHOLDER_TEXT = '{{#histories#}}'
export const QUERY_PLACEHOLDER_TEXT = '{{#query#}}'
export const REQUEST_URL_PLACEHOLDER_TEXT = '{{#url#}}'
export const CURRENT_PLACEHOLDER_TEXT = '{{#current#}}'
export const ERROR_MESSAGE_PLACEHOLDER_TEXT = '{{#error_message#}}'
export const LAST_RUN_PLACEHOLDER_TEXT = '{{#last_run#}}'
@ -30,6 +31,12 @@ export const checkHasQueryBlock = (text: string) => {
return text.includes(QUERY_PLACEHOLDER_TEXT)
}
export const checkHasRequestURLBlock = (text: string) => {
if (!text)
return false
return text.includes(REQUEST_URL_PLACEHOLDER_TEXT)
}
/*
* {{#1711617514996.name#}} => [1711617514996, name]
* {{#1711617514996.sys.query#}} => [sys, query]

View File

@ -2,16 +2,20 @@
import type {
EditorState,
LexicalCommand,
} from 'lexical'
import type { FC } from 'react'
import type { Hotkey } from './plugins/shortcuts-popup-plugin'
import type {
ContextBlockType,
CurrentBlockType,
ErrorMessageBlockType,
ExternalToolBlockType,
HistoryBlockType,
HITLInputBlockType,
LastRunBlockType,
QueryBlockType,
RequestURLBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from './types'
@ -27,7 +31,7 @@ import {
TextNode,
} from 'lexical'
import * as React from 'react'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { cn } from '@/utils/classnames'
import {
@ -46,17 +50,23 @@ import {
CurrentBlockReplacementBlock,
} from './plugins/current-block'
import { CustomTextNode } from './plugins/custom-text/node'
import DraggableBlockPlugin from './plugins/draggable-plugin'
import {
ErrorMessageBlock,
ErrorMessageBlockNode,
ErrorMessageBlockReplacementBlock,
} from './plugins/error-message-block'
import {
HistoryBlock,
HistoryBlockNode,
HistoryBlockReplacementBlock,
} from './plugins/history-block'
import {
HITLInputBlock,
HITLInputBlockReplacementBlock,
HITLInputNode,
} from './plugins/hitl-input-block'
import {
LastRunBlock,
LastRunBlockNode,
@ -70,6 +80,12 @@ import {
QueryBlockNode,
QueryBlockReplacementBlock,
} from './plugins/query-block'
import {
RequestURLBlock,
RequestURLBlockNode,
RequestURLBlockReplacementBlock,
} from './plugins/request-url-block'
import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin'
import UpdateBlock from './plugins/update-block'
import VariableBlock from './plugins/variable-block'
import VariableValueBlock from './plugins/variable-value-block'
@ -96,14 +112,17 @@ export type PromptEditorProps = {
onFocus?: () => void
contextBlock?: ContextBlockType
queryBlock?: QueryBlockType
requestURLBlock?: RequestURLBlockType
historyBlock?: HistoryBlockType
variableBlock?: VariableBlockType
externalToolBlock?: ExternalToolBlockType
workflowVariableBlock?: WorkflowVariableBlockType
hitlInputBlock?: HITLInputBlockType
currentBlock?: CurrentBlockType
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
isSupportFileVar?: boolean
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void }> }>
}
const PromptEditor: FC<PromptEditorProps> = ({
@ -121,14 +140,17 @@ const PromptEditor: FC<PromptEditorProps> = ({
onFocus,
contextBlock,
queryBlock,
requestURLBlock,
historyBlock,
variableBlock,
externalToolBlock,
workflowVariableBlock,
hitlInputBlock,
currentBlock,
errorMessageBlock,
lastRunBlock,
isSupportFileVar,
shortcutPopups = [],
}) => {
const { eventEmitter } = useEventEmitterContextContext()
const initialConfig = {
@ -143,8 +165,10 @@ const PromptEditor: FC<PromptEditorProps> = ({
ContextBlockNode,
HistoryBlockNode,
QueryBlockNode,
RequestURLBlockNode,
WorkflowVariableBlockNode,
VariableValueBlockNode,
HITLInputNode,
CurrentBlockNode,
ErrorMessageBlockNode,
LastRunBlockNode, // LastRunBlockNode is used for error message block replacement
@ -176,9 +200,16 @@ const PromptEditor: FC<PromptEditorProps> = ({
} as any)
}, [eventEmitter, historyBlock?.history])
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
const onRef = (_floatingAnchorElem: any) => {
if (_floatingAnchorElem !== null)
setFloatingAnchorElem(_floatingAnchorElem)
}
return (
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
<div className={cn('relative', wrapperClassName)}>
<div className={cn('relative', wrapperClassName)} ref={onRef}>
<RichTextPlugin
contentEditable={(
<ContentEditable
@ -199,11 +230,17 @@ const PromptEditor: FC<PromptEditorProps> = ({
)}
ErrorBoundary={LexicalErrorBoundary}
/>
{shortcutPopups?.map(({ hotkey, Popup }, idx) => (
<ShortcutsPopupPlugin key={idx} hotkey={hotkey}>
{(closePortal, onInsert) => <Popup onClose={closePortal} onInsert={onInsert} />}
</ShortcutsPopupPlugin>
))}
<ComponentPickerBlock
triggerString="/"
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
requestURLBlock={requestURLBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
@ -217,6 +254,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
requestURLBlock={requestURLBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
@ -265,6 +303,14 @@ const PromptEditor: FC<PromptEditorProps> = ({
</>
)
}
{
hitlInputBlock?.show && (
<>
<HITLInputBlock {...hitlInputBlock} />
<HITLInputBlockReplacementBlock {...hitlInputBlock} />
</>
)
}
{
currentBlock?.show && (
<>
@ -273,6 +319,14 @@ const PromptEditor: FC<PromptEditorProps> = ({
</>
)
}
{
requestURLBlock?.show && (
<>
<RequestURLBlock {...requestURLBlock} />
<RequestURLBlockReplacementBlock {...requestURLBlock} />
</>
)
}
{
errorMessageBlock?.show && (
<>
@ -298,6 +352,9 @@ const PromptEditor: FC<PromptEditorProps> = ({
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
<UpdateBlock instanceId={instanceId} />
<HistoryPlugin />
{floatingAnchorElem && (
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
)}
{/* <TreeView /> */}
</div>
</LexicalComposer>

View File

@ -6,10 +6,12 @@ import type {
HistoryBlockType,
LastRunBlockType,
QueryBlockType,
RequestURLBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { RiGlobalLine } from '@remixicon/react'
import { $insertNodes } from 'lexical'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -27,6 +29,7 @@ import { INSERT_CONTEXT_BLOCK_COMMAND } from '../context-block'
import { $createCustomTextNode } from '../custom-text/node'
import { INSERT_HISTORY_BLOCK_COMMAND } from '../history-block'
import { INSERT_QUERY_BLOCK_COMMAND } from '../query-block'
import { INSERT_REQUEST_URL_BLOCK_COMMAND } from '../request-url-block'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block'
import { PickerBlockMenuOption } from './menu'
import { PromptMenuItem } from './prompt-option'
@ -36,6 +39,7 @@ export const usePromptOptions = (
contextBlock?: ContextBlockType,
queryBlock?: QueryBlockType,
historyBlock?: HistoryBlockType,
requestURLBlock?: RequestURLBlockType,
) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
@ -91,6 +95,30 @@ export const usePromptOptions = (
)
}
if (requestURLBlock?.show) {
promptOptions.push(new PickerBlockMenuOption({
key: t('promptEditor.requestURL.item.title', { ns: 'common' }),
group: 'request URL',
render: ({ isSelected, onSelect, onSetHighlight }) => {
return (
<PromptMenuItem
title={t('promptEditor.requestURL.item.title', { ns: 'common' })}
icon={<RiGlobalLine className="h-4 w-4 text-util-colors-violet-violet-600" />}
disabled={!requestURLBlock.selectable}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
if (!requestURLBlock?.selectable)
return
editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined)
},
}))
}
if (historyBlock?.show) {
promptOptions.push(
new PickerBlockMenuOption({
@ -272,12 +300,13 @@ export const useOptions = (
variableBlock?: VariableBlockType,
externalToolBlockType?: ExternalToolBlockType,
workflowVariableBlockType?: WorkflowVariableBlockType,
requestURLBlock?: RequestURLBlockType,
currentBlockType?: CurrentBlockType,
errorMessageBlockType?: ErrorMessageBlockType,
lastRunBlockType?: LastRunBlockType,
queryString?: string,
) => {
const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock)
const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock, requestURLBlock)
const variableOptions = useVariableOptions(variableBlock, queryString)
const externalToolOptions = useExternalToolOptions(externalToolBlockType, queryString)

View File

@ -8,6 +8,7 @@ import type {
HistoryBlockType,
LastRunBlockType,
QueryBlockType,
RequestURLBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from '../../types'
@ -44,6 +45,7 @@ type ComponentPickerProps = {
triggerString: string
contextBlock?: ContextBlockType
queryBlock?: QueryBlockType
requestURLBlock?: RequestURLBlockType
historyBlock?: HistoryBlockType
variableBlock?: VariableBlockType
externalToolBlock?: ExternalToolBlockType
@ -57,6 +59,7 @@ const ComponentPicker = ({
triggerString,
contextBlock,
queryBlock,
requestURLBlock,
historyBlock,
variableBlock,
externalToolBlock,
@ -100,6 +103,7 @@ const ComponentPicker = ({
variableBlock,
externalToolBlock,
workflowVariableBlock,
requestURLBlock,
currentBlock,
errorMessageBlock,
lastRunBlock,

View File

@ -0,0 +1,86 @@
import type { JSX } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { DraggableBlockPlugin_EXPERIMENTAL } from '@lexical/react/LexicalDraggableBlockPlugin'
import { RiDraggable } from '@remixicon/react'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/utils/classnames'
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'
function isOnMenu(element: HTMLElement): boolean {
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`)
}
const SUPPORT_DRAG_CLASS = 'support-drag'
function checkSupportDrag(element: Element | null): boolean {
if (!element)
return false
if (element.classList.contains(SUPPORT_DRAG_CLASS))
return true
if (element.querySelector(`.${SUPPORT_DRAG_CLASS}`))
return true
return !!(element.closest(`.${SUPPORT_DRAG_CLASS}`))
}
export default function DraggableBlockPlugin({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement
}): JSX.Element {
const menuRef = useRef<HTMLDivElement>(null)
const targetLineRef = useRef<HTMLDivElement>(null)
const [, setDraggableElement] = useState<HTMLElement | null>(
null,
)
const [editor] = useLexicalComposerContext()
const [isSupportDrag, setIsSupportDrag] = useState(false)
useEffect(() => {
const root = editor.getRootElement()
if (!root)
return
const onMove = (e: MouseEvent) => {
const isSupportDrag = checkSupportDrag(e.target as Element)
setIsSupportDrag(isSupportDrag)
}
root.addEventListener('mousemove', onMove)
return () => root.removeEventListener('mousemove', onMove)
}, [editor])
return (
<DraggableBlockPlugin_EXPERIMENTAL
anchorElem={anchorElem}
menuRef={menuRef as any}
targetLineRef={targetLineRef as any}
menuComponent={
isSupportDrag
? (
<div ref={menuRef} className={cn(DRAGGABLE_BLOCK_MENU_CLASSNAME, 'absolute right-2.5 top-4 cursor-grab opacity-0 will-change-transform active:cursor-move')}>
<RiDraggable className="size-3.5 text-text-tertiary" />
</div>
)
: null
}
targetLineComponent={(
<div
ref={targetLineRef}
className="pointer-events-none absolute left-[-21px] top-0 opacity-0 will-change-transform"
// style={{ width: 500 }} // width not worked here
>
<div
className="absolute -right-10 left-0 top-0 h-[2px] bg-text-accent-secondary"
>
</div>
</div>
)}
isOnMenu={isOnMenu}
onElementChanged={setDraggableElement}
/>
)
}

View File

@ -0,0 +1,170 @@
'use client'
import type { FC } from 'react'
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Type } from '@/app/components/workflow/nodes/llm/types'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { RiDeleteBinLine, RiEditLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { InputVarType } from '@/app/components/workflow/types'
import ActionButton from '../../../action-button'
import { VariableX } from '../../../icons/src/vender/workflow'
import Modal from '../../../modal'
import InputField from './input-field'
import VariableBlock from './variable-block'
type HITLInputComponentUIProps = {
nodeId: string
varName: string
formInput?: FormInputItem
onChange: (input: FormInputItem) => void
onRename: (payload: FormInputItem, oldName: string) => void
onRemove: (varName: string) => void
workflowNodesMap: WorkflowNodesMap
environmentVariables?: Var[]
conversationVariables?: Var[]
ragVariables?: Var[]
getVarType?: (payload: {
nodeId: string
valueSelector: ValueSelector
}) => Type
readonly?: boolean
}
const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
nodeId,
varName,
formInput = {
type: InputVarType.paragraph,
output_variable_name: varName,
default: {
type: 'constant',
selector: [],
value: '',
},
},
onChange,
onRename,
onRemove,
workflowNodesMap = {},
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
readonly,
}) => {
const [isShowEditModal, {
setTrue: showEditModal,
setFalse: hideEditModal,
}] = useBoolean(false)
// Lexical delegate the click make it unable to add click by the method of react
const editBtnRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const editBtn = editBtnRef.current
if (editBtn)
editBtn.addEventListener('click', showEditModal)
return () => {
if (editBtn)
editBtn.removeEventListener('click', showEditModal)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const removeBtnRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const removeBtn = removeBtnRef.current
const removeHandler = () => onRemove(varName)
if (removeBtn)
removeBtn.addEventListener('click', removeHandler)
return () => {
if (removeBtn)
removeBtn.removeEventListener('click', removeHandler)
}
}, [onRemove, varName])
const handleChange = useCallback((newPayload: FormInputItem) => {
if (varName === newPayload.output_variable_name)
onChange(newPayload)
else
onRename(newPayload, varName)
hideEditModal()
}, [hideEditModal, onChange, onRename, varName])
const isDefaultValueVariable = useMemo(() => {
return formInput.default?.type === 'variable'
}, [formInput.default?.type])
return (
<div
className="group relative flex h-8 w-full select-none items-center rounded-[8px] border-[1.5px] border-components-input-border-active bg-background-default-hover pl-1.5 pr-0.5"
>
<div className="absolute left-2.5 top-[-12px]">
<div className="absolute bottom-1 h-[1.5px] w-full bg-background-default-subtle"></div>
<div className="relative flex items-center space-x-0.5 px-1 text-text-accent-light-mode-only">
<VariableX className="size-3" />
<div className="system-xs-medium">{varName}</div>
</div>
</div>
<div className="flex w-full items-center gap-x-0.5 pr-5">
<div className="min-w-0 grow">
{/* Default Value Info */}
{isDefaultValueVariable && (
<VariableBlock
variables={formInput.default?.selector}
workflowNodesMap={workflowNodesMap}
getVarType={getVarType}
environmentVariables={environmentVariables}
conversationVariables={conversationVariables}
ragVariables={ragVariables}
/>
)}
{!isDefaultValueVariable && (
<div className="system-xs-medium max-w-full truncate text-components-input-text-filled">{formInput.default?.value}</div>
)}
</div>
{/* Actions */}
{!readonly && (
<div className="hidden h-full shrink-0 items-center space-x-1 group-hover:flex">
<div className="flex h-full items-center" ref={editBtnRef}>
<ActionButton size="s">
<RiEditLine className="size-4 text-text-tertiary" />
</ActionButton>
</div>
<div className="flex h-full items-center" ref={removeBtnRef}>
<ActionButton size="s">
<RiDeleteBinLine className="size-4 text-text-tertiary" />
</ActionButton>
</div>
</div>
)}
</div>
{isShowEditModal && (
<Modal
isShow
onClose={hideEditModal}
wrapperClassName="z-[999]"
className="max-w-[372px] !p-0"
>
<InputField
nodeId={nodeId}
isEdit
payload={formInput}
onChange={handleChange}
onCancel={hideEditModal}
/>
</Modal>
)}
</div>
)
}
export default React.memo(HITLInputComponentUI)

View File

@ -0,0 +1,86 @@
import type { FC } from 'react'
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Type } from '@/app/components/workflow/nodes/llm/types'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { useCallback } from 'react'
import { useSelectOrDelete } from '../../hooks'
import { DELETE_HITL_INPUT_BLOCK_COMMAND } from './'
import ComponentUi from './component-ui'
type HITLInputComponentProps = {
nodeKey: string
nodeId: string
varName: string
formInputs?: FormInputItem[]
onChange: (inputs: FormInputItem[]) => void
onRename: (payload: FormInputItem, oldName: string) => void
onRemove: (varName: string) => void
workflowNodesMap: WorkflowNodesMap
environmentVariables?: Var[]
conversationVariables?: Var[]
ragVariables?: Var[]
getVarType?: (payload: {
nodeId: string
valueSelector: ValueSelector
}) => Type
readonly?: boolean
}
const HITLInputComponent: FC<HITLInputComponentProps> = ({
nodeKey,
nodeId,
varName,
formInputs = [],
onChange,
onRename,
onRemove,
workflowNodesMap = {},
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
readonly,
}) => {
const [ref] = useSelectOrDelete(nodeKey, DELETE_HITL_INPUT_BLOCK_COMMAND)
const payload = formInputs.find(item => item.output_variable_name === varName)
const handleChange = useCallback((newPayload: FormInputItem) => {
if (!payload) {
onChange([...formInputs, newPayload])
return
}
if (payload?.output_variable_name !== newPayload.output_variable_name) {
onChange(produce(formInputs, (draft) => {
draft.splice(draft.findIndex(item => item.output_variable_name === payload?.output_variable_name), 1, newPayload)
}))
return
}
onChange(formInputs.map(item => item.output_variable_name === varName ? newPayload : item))
}, [formInputs, onChange, payload, varName])
return (
<div
ref={ref}
className="w-full pb-1 pt-3"
>
<ComponentUi
nodeId={nodeId}
varName={varName}
formInput={payload}
onChange={handleChange}
onRename={onRename}
onRemove={onRemove}
workflowNodesMap={workflowNodesMap}
getVarType={getVarType}
environmentVariables={environmentVariables}
conversationVariables={conversationVariables}
ragVariables={ragVariables}
readonly={readonly}
/>
</div>
)
}
export default HITLInputComponent

View File

@ -0,0 +1,89 @@
import type { TextNode } from 'lexical'
import type { HITLInputBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { $applyNodeReplacement } from 'lexical'
import {
memo,
useCallback,
useEffect,
useMemo,
} from 'react'
import { HITL_INPUT_REG } from '@/config'
import { decoratorTransform } from '../../utils'
import { CustomTextNode } from '../custom-text/node'
import { $createHITLInputNode, HITLInputNode } from './node'
const REGEX = new RegExp(HITL_INPUT_REG)
const HITLInputReplacementBlock = ({
nodeId,
formInputs,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
workflowNodesMap,
getVarType,
variables,
readonly,
}: HITLInputBlockType) => {
const [editor] = useLexicalComposerContext()
const environmentVariables = useMemo(() => variables?.find(o => o.nodeId === 'env')?.vars || [], [variables])
const conversationVariables = useMemo(() => variables?.find(o => o.nodeId === 'conversation')?.vars || [], [variables])
const ragVariables = useMemo(() => variables?.reduce<any[]>((acc, curr) => {
if (curr.nodeId === 'rag')
acc.push(...curr.vars)
else
acc.push(...curr.vars.filter(v => v.isRagVariable))
return acc
}, []), [variables])
useEffect(() => {
if (!editor.hasNodes([HITLInputNode]))
throw new Error('HITLInputNodePlugin: HITLInputNode not registered on editor')
}, [editor])
const createHITLInputBlockNode = useCallback((textNode: TextNode): HITLInputNode => {
const varName = textNode.getTextContent().split('.')[1].replace(/#\}\}$/, '')
return $applyNodeReplacement($createHITLInputNode(
varName,
nodeId,
formInputs || [],
onFormInputsChange!,
onFormInputItemRename,
onFormInputItemRemove!,
workflowNodesMap,
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
readonly,
))
}, [nodeId, formInputs, onFormInputsChange, onFormInputItemRename, onFormInputItemRemove, workflowNodesMap, getVarType, environmentVariables, conversationVariables, ragVariables, readonly])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)
if (matchArr === null)
return null
const startOffset = matchArr.index
const endOffset = startOffset + matchArr[0].length
return {
end: endOffset,
start: startOffset,
}
}, [])
useEffect(() => {
REGEX.lastIndex = 0
return mergeRegister(
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createHITLInputBlockNode)),
)
}, [])
return null
}
export default memo(HITLInputReplacementBlock)

View File

@ -0,0 +1,106 @@
import type { HITLInputBlockType } from '../../types'
import type {
HITLNodeProps,
} from './node'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
$insertNodes,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical'
import {
memo,
useEffect,
} from 'react'
import { CustomTextNode } from '../custom-text/node'
import {
$createHITLInputNode,
HITLInputNode,
} from './node'
export const INSERT_HITL_INPUT_BLOCK_COMMAND = createCommand('INSERT_HITL_INPUT_BLOCK_COMMAND')
export const DELETE_HITL_INPUT_BLOCK_COMMAND = createCommand('DELETE_HITL_INPUT_BLOCK_COMMAND')
export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP')
export type HITLInputProps = {
onInsert?: () => void
onDelete?: () => void
}
const HITLInputBlock = memo(({
onInsert,
onDelete,
workflowNodesMap,
getVarType,
readonly,
}: HITLInputBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
editor.update(() => {
editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
})
}, [editor, workflowNodesMap])
useEffect(() => {
if (!editor.hasNodes([HITLInputNode]))
throw new Error('HITLInputBlockPlugin: HITLInputBlock not registered on editor')
return mergeRegister(
editor.registerCommand(
INSERT_HITL_INPUT_BLOCK_COMMAND,
(nodeProps: HITLNodeProps) => {
const {
variableName,
nodeId,
formInputs,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
} = nodeProps
const currentHITLNode = $createHITLInputNode(
variableName,
nodeId,
formInputs,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
workflowNodesMap,
getVarType,
undefined,
undefined,
undefined,
readonly,
)
const prev = new CustomTextNode('\n')
$insertNodes([prev])
$insertNodes([currentHITLNode])
const next = new CustomTextNode('\n')
$insertNodes([next])
if (onInsert)
onInsert()
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
DELETE_HITL_INPUT_BLOCK_COMMAND,
() => {
if (onDelete)
onDelete()
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, onInsert, onDelete])
return null
})
HITLInputBlock.displayName = 'HITLInputBlock'
export { HITLInputBlock }
export { default as HITLInputBlockReplacementBlock } from './hitl-input-block-replacement-block'
export { HITLInputNode } from './node'

View File

@ -0,0 +1,153 @@
import type { FormInputItem, FormInputItemDefault } from '@/app/components/workflow/nodes/human-input/types'
import type { ValueSelector } from '@/app/components/workflow/types'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { InputVarType } from '@/app/components/workflow/types'
import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
import Button from '../../../button'
import PrePopulate from './pre-populate'
const i18nPrefix = 'nodes.humanInput.insertInputField'
type InputFieldProps = {
nodeId: string
isEdit: boolean
payload?: FormInputItem
onChange: (newPayload: FormInputItem) => void
onCancel: () => void
}
const defaultPayload: FormInputItem = {
type: InputVarType.paragraph,
output_variable_name: '',
default: { type: 'constant', selector: [], value: '' },
}
const InputField: React.FC<InputFieldProps> = ({
nodeId,
isEdit,
payload,
onChange,
onCancel,
}) => {
const { t } = useTranslation()
const [tempPayload, setTempPayload] = useState(payload || defaultPayload)
const nameValid = useMemo(() => {
const name = tempPayload.output_variable_name.trim()
if (!name)
return false
if (name.includes(' '))
return false
return /^[a-z_]\w{0,29}$/.test(name)
}, [tempPayload.output_variable_name])
const handleSave = useCallback(() => {
if (!nameValid)
return
onChange(tempPayload)
}, [nameValid, onChange, tempPayload])
const defaultValueConfig = tempPayload.default
const handleDefaultValueChange = useCallback((key: keyof FormInputItemDefault) => {
return (value: ValueSelector | string) => {
const nextValue = produce(tempPayload, (draft) => {
if (!draft.default)
draft.default = { type: 'constant', selector: [], value: '' }
if (key === 'selector') {
draft.default.type = 'variable'
draft.default.selector = value as ValueSelector
}
else if (key === 'value') {
draft.default.type = 'constant'
draft.default.value = value as string
}
else if (key === 'type') {
draft.default.type = value as 'constant' | 'variable'
}
})
setTempPayload(nextValue)
}
}, [tempPayload])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
handleSave()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [handleSave])
return (
<div className="w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-[5px]">
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
<div className="mt-3">
<div className="system-xs-medium text-text-secondary">
{t(`${i18nPrefix}.saveResponseAs`, { ns: 'workflow' })}
<span className="system-xs-regular relative text-text-destructive-secondary">*</span>
</div>
<Input
className="mt-1.5"
placeholder={t(`${i18nPrefix}.saveResponseAsPlaceholder`, { ns: 'workflow' })}
value={tempPayload.output_variable_name}
onChange={(e) => {
setTempPayload(prev => ({ ...prev, output_variable_name: e.target.value }))
}}
autoFocus
/>
{tempPayload.output_variable_name && !nameValid && (
<div className="system-xs-regular mt-1 px-1 text-text-destructive-secondary">
{t(`${i18nPrefix}.variableNameInvalid`, { ns: 'workflow' })}
</div>
)}
</div>
<div className="mt-4">
<div className="system-xs-medium mb-1.5 text-text-secondary">
{t(`${i18nPrefix}.prePopulateField`, { ns: 'workflow' })}
</div>
<PrePopulate
isVariable={defaultValueConfig?.type === 'variable'}
onIsVariableChange={(isVariable) => {
handleDefaultValueChange('type')(isVariable ? 'variable' : 'constant')
}}
nodeId={nodeId}
valueSelector={defaultValueConfig?.selector}
onValueSelectorChange={handleDefaultValueChange('selector')}
value={defaultValueConfig?.value}
onValueChange={handleDefaultValueChange('value')}
/>
</div>
<div className="mt-4 flex justify-end space-x-2">
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
{isEdit
? (
<Button
variant="primary"
onClick={handleSave}
disabled={!nameValid}
>
{t('operation.save', { ns: 'common' })}
</Button>
)
: (
<Button
className="flex"
variant="primary"
disabled={!nameValid}
onClick={handleSave}
>
<span className="mr-1">{t(`${i18nPrefix}.insert`, { ns: 'workflow' })}</span>
<span className="system-kbd mr-0.5 flex h-4 items-center rounded-[4px] bg-components-kbd-bg-white px-1">{getKeyboardKeyNameBySystem('ctrl')}</span>
<span className=" system-kbd flex h-4 items-center rounded-[4px] bg-components-kbd-bg-white px-1"></span>
</Button>
)}
</div>
</div>
)
}
export default InputField

View File

@ -0,0 +1,272 @@
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import type { GetVarType } from '../../types'
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Var } from '@/app/components/workflow/types'
import { DecoratorNode } from 'lexical'
import HILTInputBlockComponent from './component'
export type HITLNodeProps = {
variableName: string
nodeId: string
formInputs: FormInputItem[]
onFormInputsChange: (inputs: FormInputItem[]) => void
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
onFormInputItemRemove: (varName: string) => void
workflowNodesMap: WorkflowNodesMap
getVarType?: GetVarType
environmentVariables?: Var[]
conversationVariables?: Var[]
ragVariables?: Var[]
readonly?: boolean
}
export type SerializedNode = SerializedLexicalNode & HITLNodeProps
export class HITLInputNode extends DecoratorNode<React.JSX.Element> {
__variableName: string
__nodeId: string
__formInputs?: FormInputItem[]
__onFormInputsChange: (inputs: FormInputItem[]) => void
__onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
__onFormInputItemRemove: (varName: string) => void
__workflowNodesMap: WorkflowNodesMap
__getVarType?: GetVarType
__environmentVariables?: Var[]
__conversationVariables?: Var[]
__ragVariables?: Var[]
__readonly?: boolean
isIsolated(): boolean {
return true // This is necessary for drag-and-drop to work correctly
}
isTopLevel(): boolean {
return true // This is necessary for drag-and-drop to work correctly
}
static getType(): string {
return 'hitl-input-block'
}
getVariableName(): string {
const self = this.getLatest()
return self.__variableName
}
getNodeId(): string {
const self = this.getLatest()
return self.__nodeId
}
getFormInputs(): FormInputItem[] {
const self = this.getLatest()
return self.__formInputs || []
}
getOnFormInputsChange(): (inputs: FormInputItem[]) => void {
const self = this.getLatest()
return self.__onFormInputsChange
}
getOnFormInputItemRename(): (payload: FormInputItem, oldName: string) => void {
const self = this.getLatest()
return self.__onFormInputItemRename
}
getOnFormInputItemRemove(): (varName: string) => void {
const self = this.getLatest()
return self.__onFormInputItemRemove
}
getWorkflowNodesMap(): WorkflowNodesMap {
const self = this.getLatest()
return self.__workflowNodesMap
}
getGetVarType(): GetVarType | undefined {
const self = this.getLatest()
return self.__getVarType
}
getEnvironmentVariables(): Var[] {
const self = this.getLatest()
return self.__environmentVariables || []
}
getConversationVariables(): Var[] {
const self = this.getLatest()
return self.__conversationVariables || []
}
getRagVariables(): Var[] {
const self = this.getLatest()
return self.__ragVariables || []
}
getReadonly(): boolean {
const self = this.getLatest()
return self.__readonly || false
}
static clone(node: HITLInputNode): HITLInputNode {
return new HITLInputNode(
node.__variableName,
node.__nodeId,
node.__formInputs || [],
node.__onFormInputsChange,
node.__onFormInputItemRename,
node.__onFormInputItemRemove,
node.__workflowNodesMap,
node.__getVarType,
node.__environmentVariables,
node.__conversationVariables,
node.__ragVariables,
node.__readonly,
node.__key,
)
}
isInline(): boolean {
return true
}
constructor(
varName: string,
nodeId: string,
formInputs: FormInputItem[],
onFormInputsChange: (inputs: FormInputItem[]) => void,
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void,
onFormInputItemRemove: (varName: string) => void,
workflowNodesMap: WorkflowNodesMap,
getVarType?: GetVarType,
environmentVariables?: Var[],
conversationVariables?: Var[],
ragVariables?: Var[],
readonly?: boolean,
key?: NodeKey,
) {
super(key)
this.__variableName = varName
this.__nodeId = nodeId
this.__formInputs = formInputs
this.__onFormInputsChange = onFormInputsChange
this.__onFormInputItemRename = onFormInputItemRename
this.__onFormInputItemRemove = onFormInputItemRemove
this.__workflowNodesMap = workflowNodesMap
this.__getVarType = getVarType
this.__environmentVariables = environmentVariables
this.__conversationVariables = conversationVariables
this.__ragVariables = ragVariables
this.__readonly = readonly
}
createDOM(): HTMLElement {
const div = document.createElement('div')
div.classList.add('inline-flex', 'w-[calc(100%-1px)]', 'items-center', 'align-middle', 'support-drag')
return div
}
updateDOM(): false {
return false
}
decorate(): React.JSX.Element {
return (
<HILTInputBlockComponent
nodeKey={this.getKey()}
varName={this.getVariableName()}
nodeId={this.getNodeId()}
formInputs={this.getFormInputs()}
onChange={this.getOnFormInputsChange()}
onRename={this.getOnFormInputItemRename()}
onRemove={this.getOnFormInputItemRemove()}
workflowNodesMap={this.getWorkflowNodesMap()}
getVarType={this.getGetVarType()}
environmentVariables={this.getEnvironmentVariables()}
conversationVariables={this.getConversationVariables()}
ragVariables={this.getRagVariables()}
readonly={this.getReadonly()}
/>
)
}
static importJSON(serializedNode: SerializedNode): HITLInputNode {
const node = $createHITLInputNode(
serializedNode.variableName,
serializedNode.nodeId,
serializedNode.formInputs,
serializedNode.onFormInputsChange,
serializedNode.onFormInputItemRename,
serializedNode.onFormInputItemRemove,
serializedNode.workflowNodesMap,
serializedNode.getVarType,
serializedNode.environmentVariables,
serializedNode.conversationVariables,
serializedNode.ragVariables,
serializedNode.readonly,
)
return node
}
exportJSON(): SerializedNode {
return {
type: 'hitl-input-block',
version: 1,
variableName: this.getVariableName(),
nodeId: this.getNodeId(),
formInputs: this.getFormInputs(),
onFormInputsChange: this.getOnFormInputsChange(),
onFormInputItemRename: this.getOnFormInputItemRename(),
onFormInputItemRemove: this.getOnFormInputItemRemove(),
workflowNodesMap: this.getWorkflowNodesMap(),
getVarType: this.getGetVarType(),
environmentVariables: this.getEnvironmentVariables(),
conversationVariables: this.getConversationVariables(),
ragVariables: this.getRagVariables(),
readonly: this.getReadonly(),
}
}
getTextContent(): string {
return `{{#$output.${this.getVariableName()}#}}`
}
}
export function $createHITLInputNode(
variableName: string,
nodeId: string,
formInputs: FormInputItem[],
onFormInputsChange: (inputs: FormInputItem[]) => void,
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void,
onFormInputItemRemove: (varName: string) => void,
workflowNodesMap: WorkflowNodesMap,
getVarType?: GetVarType,
environmentVariables?: Var[],
conversationVariables?: Var[],
ragVariables?: Var[],
readonly?: boolean,
): HITLInputNode {
return new HITLInputNode(
variableName,
nodeId,
formInputs,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
workflowNodesMap,
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
readonly,
)
}
export function $isHITLInputNode(
node: HITLInputNode | LexicalNode | null | undefined,
): node is HITLInputNode {
return node instanceof HITLInputNode
}

View File

@ -0,0 +1,148 @@
'use client'
import type { FC } from 'react'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import { VarType } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import Textarea from '../../../textarea'
import TagLabel from './tag-label'
import TypeSwitch from './type-switch'
type Props = {
isVariable?: boolean
onIsVariableChange?: (isVariable: boolean) => void
nodeId: string
valueSelector?: ValueSelector
onValueSelectorChange?: (valueSelector: ValueSelector | string) => void
value?: string
onValueChange?: (value: string) => void
}
const i18nPrefix = 'nodes.humanInput.insertInputField'
type PlaceholderProps = {
varPickerProps: {
nodeId: string
value: ValueSelector
onChange: (valueSelector: ValueSelector | string) => void
readonly: boolean
zIndex: number
filterVar: (varPayload: Var) => boolean
isJustShowValue?: boolean
}
onTypeClick: (isVariable: boolean) => void
}
const Placeholder = ({
varPickerProps,
onTypeClick,
}: PlaceholderProps) => {
const { t } = useTranslation()
return (
<div className="system-sm-regular mt-1 h-[80px] rounded-lg bg-components-input-bg-normal px-3 pt-2 text-text-tertiary">
<div className="flex flex-wrap items-center leading-5">
<Trans
i18nKey={`${i18nPrefix}.prePopulateFieldPlaceholder`}
ns="workflow"
components={{
staticContent: <TagLabel type="edit" className="mx-1" onClick={() => onTypeClick(false)}>{t(`${i18nPrefix}.staticContent`, { ns: 'workflow' })}</TagLabel>,
variable: (
<VarReferencePicker
{...varPickerProps}
trigger={
<TagLabel type="variable" className="mx-1">{t(`${i18nPrefix}.variable`, { ns: 'workflow' })}</TagLabel>
}
/>
),
}}
/>
</div>
</div>
)
}
const PrePopulate: FC<Props> = ({
isVariable = false,
onIsVariableChange,
nodeId,
valueSelector,
onValueSelectorChange,
value,
onValueChange,
}) => {
const [onPlaceholderClicked, setOnPlaceholderClicked] = useState(false)
const handleTypeChange = useCallback((isVar: boolean) => {
setOnPlaceholderClicked(true)
onIsVariableChange?.(isVar)
}, [onIsVariableChange])
const [isFocus, setIsFocus] = useState(false)
const varPickerProps = {
nodeId,
value: valueSelector || [],
onChange: onValueSelectorChange!,
readonly: false,
zIndex: 1000000, // bigger than shortcut plugin popup
filterVar: (varPayload: Var) => {
return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
},
}
const isShowPlaceholder = !onPlaceholderClicked && (isVariable ? (!valueSelector || valueSelector.length === 0) : !value)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Tab' && !onPlaceholderClicked) {
e.preventDefault()
setOnPlaceholderClicked(true)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [onPlaceholderClicked, setOnPlaceholderClicked])
if (isShowPlaceholder)
return <Placeholder varPickerProps={varPickerProps} onTypeClick={handleTypeChange} />
if (isVariable) {
return (
<div className="relative h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal px-3 pt-2">
<VarReferencePicker
{...varPickerProps}
isJustShowValue
/>
<TypeSwitch
className="absolute bottom-1 left-1.5"
isVariable={isVariable}
onIsVariableChange={handleTypeChange}
/>
</div>
)
}
return (
<div className={cn('relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal pb-1', isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs')}>
<Textarea
value={value || ''}
className="h-[43px] min-h-[43px] rounded-none border-none bg-transparent px-3 hover:bg-transparent focus:bg-transparent focus:shadow-none"
onChange={e => onValueChange?.(e.target.value)}
onFocus={() => {
setOnPlaceholderClicked(true)
setIsFocus(true)
}}
onBlur={() => setIsFocus(false)}
autoFocus
/>
<TypeSwitch
className="absolute bottom-1 left-1.5"
isVariable={isVariable}
onIsVariableChange={handleTypeChange}
/>
</div>
)
}
export default React.memo(PrePopulate)

View File

@ -0,0 +1,32 @@
'use client'
import type { FC } from 'react'
import { RiEditLine } from '@remixicon/react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
import { Variable02 } from '../../../icons/src/vender/solid/development'
type Props = {
type: 'edit' | 'variable'
children: string
className?: string
onClick?: () => void
}
const TagLabel: FC<Props> = ({
type,
children,
className,
onClick,
}) => {
const Icon = type === 'edit' ? RiEditLine : Variable02
return (
<div
className={cn('inline-flex h-5 cursor-pointer items-center space-x-1 rounded-md bg-components-button-secondary-bg px-1 text-text-accent', className)}
onClick={onClick}
>
<Icon className="size-3.5" />
<div className="system-xs-medium ">{children}</div>
</div>
)
}
export default React.memo(TagLabel)

View File

@ -0,0 +1,27 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import { Variable02 } from '../../../icons/src/vender/solid/development'
type Props = {
className?: string
isVariable?: boolean
onIsVariableChange?: (isVariable: boolean) => void
}
const TypeSwitch: FC<Props> = ({
className,
isVariable,
onIsVariableChange,
}) => {
const { t } = useTranslation()
return (
<div className={cn('inline-flex h-6 cursor-pointer select-none items-center space-x-1 rounded-md pl-1.5 pr-2 text-text-tertiary hover:bg-components-button-ghost-bg-hover', className)} onClick={() => onIsVariableChange?.(!isVariable)}>
<Variable02 className="size-3.5" />
<div className="system-xs-medium">{t(`nodes.humanInput.insertInputField.${isVariable ? 'useConstantInstead' : 'useVarInstead'}`, { ns: 'workflow' })}</div>
</div>
)
}
export default React.memo(TypeSwitch)

View File

@ -0,0 +1,148 @@
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
COMMAND_PRIORITY_EDITOR,
} from 'lexical'
import {
memo,
useEffect,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import {
isConversationVar,
isENV,
isGlobalVar,
isRagVariableVar,
isSystemVar,
} from '@/app/components/workflow/nodes/_base/components/variable/utils'
import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
import {
VariableLabelInEditor,
} from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { isExceptionVariable } from '@/app/components/workflow/utils'
import { UPDATE_WORKFLOW_NODES_MAP } from '../workflow-variable-block'
import { HITLInputNode } from './node'
type HITLInputVariableBlockComponentProps = {
variables: string[]
workflowNodesMap: WorkflowNodesMap
environmentVariables?: Var[]
conversationVariables?: Var[]
ragVariables?: Var[]
getVarType?: (payload: {
nodeId: string
valueSelector: ValueSelector
}) => Type
}
const HITLInputVariableBlockComponent = ({
variables,
workflowNodesMap = {},
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
}: HITLInputVariableBlockComponentProps) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
const variablesLength = variables.length
const isRagVar = isRagVariableVar(variables)
const isShowAPart = variablesLength > 2 && !isRagVar
const varName = (
() => {
const isSystem = isSystemVar(variables)
const varName = variables[variablesLength - 1]
return `${isSystem ? 'sys.' : ''}${varName}`
}
)()
const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState<WorkflowNodesMap>(workflowNodesMap)
const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]]
const isException = isExceptionVariable(varName, node?.type)
const variableValid = useMemo(() => {
let variableValid = true
const isEnv = isENV(variables)
const isChatVar = isConversationVar(variables)
const isGlobal = isGlobalVar(variables)
if (isGlobal)
return true
if (isEnv) {
if (environmentVariables)
variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
}
else if (isChatVar) {
if (conversationVariables)
variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
}
else if (isRagVar) {
if (ragVariables)
variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`)
}
else {
variableValid = !!node
}
return variableValid
}, [variables, node, environmentVariables, conversationVariables, isRagVar, ragVariables])
useEffect(() => {
if (!editor.hasNodes([HITLInputNode]))
throw new Error('HITLInputNodePlugin: HITLInputNode not registered on editor')
return mergeRegister(
editor.registerCommand(
UPDATE_WORKFLOW_NODES_MAP,
(workflowNodesMap: WorkflowNodesMap) => {
setLocalWorkflowNodesMap(workflowNodesMap)
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor])
const Item = (
<VariableLabelInEditor
nodeType={node?.type}
nodeTitle={node?.title}
variables={variables}
isExceptionVariable={isException}
errorMsg={!variableValid ? t('errorMsg.invalidVariable', { ns: 'workflow' }) : undefined}
notShowFullPath={isShowAPart}
/>
)
if (!node)
return Item
return (
<Tooltip
noDecoration
popupContent={(
<VarFullPathPanel
nodeName={node.title}
path={variables.slice(1)}
varType={getVarType
? getVarType({
nodeId: variables[0],
valueSelector: variables,
})
: Type.string}
nodeType={node?.type}
/>
)}
disabled={!isShowAPart}
>
<div>{Item}</div>
</Tooltip>
)
}
export default memo(HITLInputVariableBlockComponent)

View File

@ -0,0 +1,33 @@
import type { FC } from 'react'
import { RiGlobalLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import { useSelectOrDelete } from '../../hooks'
import { DELETE_REQUEST_URL_BLOCK_COMMAND } from './index'
type RequestURLBlockComponentProps = {
nodeKey: string
}
const RequestURLBlockComponent: FC<RequestURLBlockComponentProps> = ({
nodeKey,
}) => {
const { t } = useTranslation()
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_REQUEST_URL_BLOCK_COMMAND)
return (
<div
className={cn(
'group/wrap relative mx-0.5 flex h-[18px] select-none items-center rounded-[5px] border border-components-panel-border-subtle bg-components-badge-white-to-dark px-1 hover:border-[#7839ee]',
isSelected && '!border-[#7839ee] hover:!border-[#7839ee]',
)}
ref={ref}
>
<RiGlobalLine className="mr-0.5 h-3.5 w-3.5 text-util-colors-violet-violet-600" />
<div className="system-xs-medium text-util-colors-violet-violet-600">{t('promptEditor.requestURL.item.title', { ns: 'common' })}</div>
</div>
)
}
export default RequestURLBlockComponent

View File

@ -0,0 +1,64 @@
import type { RequestURLBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
$insertNodes,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical'
import {
memo,
useEffect,
} from 'react'
import {
$createRequestURLBlockNode,
RequestURLBlockNode,
} from './node'
export const INSERT_REQUEST_URL_BLOCK_COMMAND = createCommand('INSERT_REQUEST_URL_BLOCK_COMMAND')
export const DELETE_REQUEST_URL_BLOCK_COMMAND = createCommand('DELETE_REQUEST_URL_BLOCK_COMMAND')
const RequestURLBlock = memo(({
onInsert,
onDelete,
}: RequestURLBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([RequestURLBlockNode]))
throw new Error('RequestURLBlockPlugin: RequestURLBlock not registered on editor')
return mergeRegister(
editor.registerCommand(
INSERT_REQUEST_URL_BLOCK_COMMAND,
() => {
const contextBlockNode = $createRequestURLBlockNode()
$insertNodes([contextBlockNode])
if (onInsert)
onInsert()
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
DELETE_REQUEST_URL_BLOCK_COMMAND,
() => {
if (onDelete)
onDelete()
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, onInsert, onDelete])
return null
})
RequestURLBlock.displayName = 'RequestURLBlock'
export { RequestURLBlock }
export { RequestURLBlockNode } from './node'
export { default as RequestURLBlockReplacementBlock } from './request-url-block-replacement-block'

View File

@ -0,0 +1,59 @@
import type { LexicalNode, SerializedLexicalNode } from 'lexical'
import { DecoratorNode } from 'lexical'
import RequestURLBlockComponent from './component'
export type SerializedNode = SerializedLexicalNode
export class RequestURLBlockNode extends DecoratorNode<React.JSX.Element> {
static getType(): string {
return 'request-url-block'
}
static clone(node: RequestURLBlockNode): RequestURLBlockNode {
return new RequestURLBlockNode(node.__key)
}
isInline(): boolean {
return true
}
createDOM(): HTMLElement {
const div = document.createElement('div')
div.classList.add('inline-flex', 'items-center', 'align-middle')
return div
}
updateDOM(): false {
return false
}
decorate(): React.JSX.Element {
return <RequestURLBlockComponent nodeKey={this.getKey()} />
}
static importJSON(): RequestURLBlockNode {
const node = $createRequestURLBlockNode()
return node
}
exportJSON(): SerializedNode {
return {
type: 'request-url-block',
version: 1,
}
}
getTextContent(): string {
return '{{#url#}}'
}
}
export function $createRequestURLBlockNode(): RequestURLBlockNode {
return new RequestURLBlockNode()
}
export function $isRequestURLBlockNode(
node: RequestURLBlockNode | LexicalNode | null | undefined,
): node is RequestURLBlockNode {
return node instanceof RequestURLBlockNode
}

View File

@ -0,0 +1,60 @@
import type { RequestURLBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { $applyNodeReplacement } from 'lexical'
import {
memo,
useCallback,
useEffect,
} from 'react'
import { REQUEST_URL_PLACEHOLDER_TEXT } from '../../constants'
import { decoratorTransform } from '../../utils'
import { CustomTextNode } from '../custom-text/node'
import {
$createRequestURLBlockNode,
RequestURLBlockNode,
} from '../request-url-block/node'
const REGEX = new RegExp(REQUEST_URL_PLACEHOLDER_TEXT)
const RequestURLBlockReplacementBlock = ({
onInsert,
}: RequestURLBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([RequestURLBlockNode]))
throw new Error('RequestURLBlockNodePlugin: RequestURLBlockNode not registered on editor')
}, [editor])
const createRequestURLBlockNode = useCallback((): RequestURLBlockNode => {
if (onInsert)
onInsert()
return $applyNodeReplacement($createRequestURLBlockNode())
}, [onInsert])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)
if (matchArr === null)
return null
const startOffset = matchArr.index
const endOffset = startOffset + REQUEST_URL_PLACEHOLDER_TEXT.length
return {
end: endOffset,
start: startOffset,
}
}, [])
useEffect(() => {
REGEX.lastIndex = 0
return mergeRegister(
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createRequestURLBlockNode)),
)
}, [])
return null
}
export default memo(RequestURLBlockReplacementBlock)

View File

@ -0,0 +1,134 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useState } from 'react'
import ShortcutsPopupPlugin, { SHORTCUTS_EMPTY_CONTENT } from './index'
import '@testing-library/jest-dom'
// Mock Range.getClientRects and getBoundingClientRect for JSDOM
const mockDOMRect = {
x: 100,
y: 100,
width: 100,
height: 20,
top: 100,
right: 200,
bottom: 120,
left: 100,
toJSON: () => ({}),
}
beforeAll(() => {
// Mock getClientRects on Range prototype
Range.prototype.getClientRects = vi.fn(() => {
const rectList = [mockDOMRect] as unknown as DOMRectList
Object.defineProperty(rectList, 'length', { value: 1 })
Object.defineProperty(rectList, 'item', { value: (index: number) => index === 0 ? mockDOMRect : null })
return rectList
})
// Mock getBoundingClientRect on Range prototype
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
})
const CONTAINER_ID = 'host'
const CONTENT_EDITABLE_ID = 'ce'
const MinimalEditor: React.FC<{
withContainer?: boolean
}> = ({ withContainer = true }) => {
const initialConfig = {
namespace: 'shortcuts-popup-plugin-test',
onError: (e: Error) => {
throw e
},
}
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null)
return (
<LexicalComposer initialConfig={initialConfig}>
<div data-testid={CONTAINER_ID} className="relative" ref={withContainer ? setContainerEl : undefined}>
<RichTextPlugin
contentEditable={<ContentEditable data-testid={CONTENT_EDITABLE_ID} />}
placeholder={null}
ErrorBoundary={LexicalErrorBoundary}
/>
<ShortcutsPopupPlugin
container={withContainer ? containerEl : undefined}
/>
</div>
</LexicalComposer>
)
}
describe('ShortcutsPopupPlugin', () => {
it('opens on hotkey when editor is focused', async () => {
render(<MinimalEditor />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true }) // 模拟 Ctrl+/
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('does not open when editor is not focused', async () => {
render(<MinimalEditor />)
// 未聚焦
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
it('closes on Escape', async () => {
render(<MinimalEditor />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
it('closes on click outside', async () => {
render(<MinimalEditor />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
fireEvent.mouseDown(ce)
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
it('portals into provided container when container is set', async () => {
render(<MinimalEditor withContainer />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
const host = screen.getByTestId(CONTAINER_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
expect(host).toContainElement(portalContent)
})
it('falls back to document.body when container is not provided', async () => {
render(<MinimalEditor withContainer={false} />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
expect(document.body).toContainElement(portalContent)
})
})

View File

@ -0,0 +1,305 @@
import type { LexicalCommand } from 'lexical'
import {
autoUpdate,
flip,
offset,
shift,
size,
useFloating,
} from '@floating-ui/react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$getSelection,
$isRangeSelection,
} from 'lexical'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/utils/classnames'
export const SHORTCUTS_EMPTY_CONTENT = 'shortcuts_empty_content'
// Hotkey can be:
// - string: 'mod+/'
// - string[]: ['mod', '/']
// - string[][]: [['mod', '/'], ['mod', 'shift', '/']] (any combo matches)
// - function: custom matcher
export type Hotkey = string | string[] | string[][] | ((e: KeyboardEvent) => boolean)
type ShortcutPopupPluginProps = {
hotkey?: Hotkey
children?: React.ReactNode | ((close: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void) => React.ReactNode)
className?: string
container?: Element | null
onOpen?: () => void
onClose?: () => void
}
const META_ALIASES = new Set(['meta', 'cmd', 'command'])
const CTRL_ALIASES = new Set(['ctrl'])
const ALT_ALIASES = new Set(['alt', 'option'])
const SHIFT_ALIASES = new Set(['shift'])
function matchHotkey(event: KeyboardEvent, hotkey?: Hotkey) {
if (!hotkey)
return false
if (typeof hotkey === 'function')
return hotkey(event)
const matchCombo = (tokens: string[]) => {
const parts = tokens.map(t => t.toLowerCase().trim()).filter(Boolean)
let expectedKey: string | null = null
let needMod = false
let needCtrl = false
let needMeta = false
let needAlt = false
let needShift = false
for (const p of parts) {
if (p === 'mod') {
needMod = true
continue
}
if (CTRL_ALIASES.has(p)) {
needCtrl = true
continue
}
if (META_ALIASES.has(p)) {
needMeta = true
continue
}
if (ALT_ALIASES.has(p)) {
needAlt = true
continue
}
if (SHIFT_ALIASES.has(p)) {
needShift = true
continue
}
expectedKey = p
}
if (needMod && !(event.metaKey || event.ctrlKey))
return false
if (needCtrl && !event.ctrlKey)
return false
if (needMeta && !event.metaKey)
return false
if (needAlt && !event.altKey)
return false
if (needShift && !event.shiftKey)
return false
if (expectedKey) {
const k = event.key.toLowerCase()
const normalized = k === ' ' ? 'space' : k
if (normalized !== expectedKey)
return false
}
return true
}
if (Array.isArray(hotkey)) {
const isNested = hotkey.length > 0 && Array.isArray((hotkey as unknown[])[0])
if (isNested) {
const combos = hotkey as string[][]
return combos.some(tokens => matchCombo(tokens))
}
else {
const tokens = hotkey as string[]
return matchCombo(tokens)
}
}
const tokensFromString = hotkey
.toLowerCase()
.split('+')
.map(t => t.trim())
.filter(Boolean)
return matchCombo(tokensFromString)
}
export default function ShortcutsPopupPlugin({
hotkey = 'mod+/',
children,
className,
container,
onOpen,
onClose,
}: ShortcutPopupPluginProps): React.ReactPortal | null {
const [editor] = useLexicalComposerContext()
const [open, setOpen] = useState(false)
const portalRef = useRef<HTMLDivElement | null>(null)
const lastSelectionRef = useRef<Range | null>(null)
const containerEl = useMemo(() => container ?? (typeof document !== 'undefined' ? document.body : null), [container])
const useContainer = !!containerEl && containerEl !== document.body
const { refs, floatingStyles, isPositioned } = useFloating({
placement: 'bottom-start',
middleware: [
offset(0), // fix hide cursor
shift({
padding: 8,
altBoundary: true,
}),
flip(),
size({
apply({ availableWidth, availableHeight, elements }) {
Object.assign(elements.floating.style, {
maxWidth: `${Math.min(400, availableWidth)}px`,
maxHeight: `${Math.min(300, availableHeight)}px`,
overflow: 'auto',
})
},
padding: 8,
}),
],
whileElementsMounted: autoUpdate,
})
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const domSelection = window.getSelection()
if (domSelection && domSelection.rangeCount > 0)
lastSelectionRef.current = domSelection.getRangeAt(0).cloneRange()
}
})
})
}, [editor])
const isEditorFocused = useCallback(() => {
const root = editor.getRootElement()
if (!root)
return false
return root.contains(document.activeElement)
}, [editor])
const openPortal = useCallback(() => {
const domSelection = window.getSelection()
let range: Range | null = null
if (domSelection && domSelection.rangeCount > 0)
range = domSelection.getRangeAt(0).cloneRange()
else
range = lastSelectionRef.current
if (range) {
const rects = range.getClientRects()
let rect: DOMRect | null = null
if (rects && rects.length)
rect = rects[rects.length - 1]
else
rect = range.getBoundingClientRect()
if (rect.width === 0 && rect.height === 0) {
const root = editor.getRootElement()
if (root) {
const sc = range.startContainer
const node = sc.nodeType === Node.ELEMENT_NODE
? sc as Element
: (sc.parentElement || root)
rect = node.getBoundingClientRect()
if (rect.width === 0 && rect.height === 0)
rect = root.getBoundingClientRect()
}
}
if (rect && !(rect.top === 0 && rect.left === 0 && rect.width === 0 && rect.height === 0)) {
const virtualEl = {
getBoundingClientRect() {
return rect!
},
}
refs.setReference(virtualEl as Element)
}
}
setOpen(true)
onOpen?.()
}, [onOpen])
const closePortal = useCallback(() => {
setOpen(false)
onClose?.()
}, [onClose])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (open && event.key === 'Escape') {
event.stopPropagation()
event.preventDefault()
closePortal()
return
}
if (!isEditorFocused())
return
if (matchHotkey(event, hotkey)) {
event.preventDefault()
openPortal()
}
}
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [hotkey, open, isEditorFocused, openPortal, closePortal])
useEffect(() => {
if (!open)
return
const onMouseDown = (e: MouseEvent) => {
if (!portalRef.current)
return
if (!portalRef.current.contains(e.target as Node))
closePortal()
}
document.addEventListener('mousedown', onMouseDown, false)
return () => document.removeEventListener('mousedown', onMouseDown, false)
}, [open, closePortal])
const handleInsert = useCallback((command: LexicalCommand<unknown>, params: any) => {
editor.dispatchCommand(command, params)
closePortal()
}, [editor, closePortal])
if (!open || !containerEl)
return null
return createPortal(
<div
ref={(node) => {
portalRef.current = node
refs.setFloating(node)
}}
className={cn(
useContainer ? '' : 'z-[999999]',
'absolute rounded-xl bg-components-panel-bg-blur shadow-lg',
className,
)}
style={{
...floatingStyles,
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
{typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
</div>,
containerEl,
)
}

View File

@ -40,7 +40,7 @@ const WorkflowVariableBlockReplacementBlock = ({
const nodePathString = textNode.getTextContent().slice(3, -3)
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType, variables?.find(o => o.nodeId === 'env')?.vars || [], variables?.find(o => o.nodeId === 'conversation')?.vars || [], ragVariables))
}, [onInsert, workflowNodesMap, getVarType, variables])
}, [onInsert, workflowNodesMap, getVarType, variables, ragVariables])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)

View File

@ -1,4 +1,5 @@
import type { GeneratorType } from '../../app/configuration/config/automatic/types'
import type { FormInputItem } from '../../workflow/nodes/human-input/types'
import type { Type } from '../../workflow/nodes/llm/types'
import type { Dataset } from './plugins/context-block'
import type { RoleName } from './plugins/history-block'
@ -46,6 +47,13 @@ export type HistoryBlockType = {
onEditRole?: () => void
}
export type RequestURLBlockType = {
show?: boolean
selectable?: boolean
onInsert?: () => void
onDelete?: () => void
}
export type VariableBlockType = {
show?: boolean
variables?: Option[]
@ -73,6 +81,21 @@ export type WorkflowVariableBlockType = {
onManageInputField?: () => void
}
export type HITLInputBlockType = {
show?: boolean
nodeId: string
formInputs?: FormInputItem[]
variables?: NodeOutPutVar[]
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'>>
getVarType?: GetVarType
onFormInputsChange?: (inputs: FormInputItem[]) => void
onFormInputItemRemove: (varName: string) => void
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
onInsert?: () => void
onDelete?: () => void
readonly?: boolean
}
export type MenuTextMatch = {
leadOffset: number
matchingString: string

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
import { Plan } from '../type'
import PlanComp from './index'
@ -188,7 +188,9 @@ describe('PlanComp', () => {
expect(lastCall.onCancel).toBeDefined()
// Call onConfirm to close modal
lastCall.onConfirm()
lastCall.onCancel()
act(() => {
lastCall.onConfirm()
lastCall.onCancel()
})
})
})

View File

@ -118,6 +118,7 @@ export type CurrentPlanInfoBackend = {
knowledge_pipeline: {
publish_enabled: boolean
}
human_input_email_delivery_enabled: boolean
}
export type SubscriptionItem = {

View File

@ -94,6 +94,7 @@ describe('billing utils', () => {
knowledge_pipeline: {
publish_enabled: false,
},
human_input_email_delivery_enabled: false,
...overrides,
})

View File

@ -1,262 +0,0 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import { DatasourceType } from '@/models/pipeline'
import DocumentSourceIcon from './document-source-icon'
const createMockDoc = (overrides: Record<string, unknown> = {}): SimpleDocumentDetail => ({
id: 'doc-1',
position: 1,
data_source_type: DataSourceType.FILE,
data_source_info: {},
data_source_detail_dict: {},
dataset_process_rule_id: 'rule-1',
dataset_id: 'dataset-1',
batch: 'batch-1',
name: 'test-document.txt',
created_from: 'web',
created_by: 'user-1',
created_at: Date.now(),
tokens: 100,
indexing_status: 'completed',
error: null,
enabled: true,
disabled_at: null,
disabled_by: null,
archived: false,
archived_reason: null,
archived_by: null,
archived_at: null,
updated_at: Date.now(),
doc_type: null,
doc_metadata: undefined,
doc_language: 'en',
display_status: 'available',
word_count: 100,
hit_count: 10,
doc_form: 'text_model',
...overrides,
}) as unknown as SimpleDocumentDetail
describe('DocumentSourceIcon', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const doc = createMockDoc()
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Local File Icon', () => {
it('should render FileTypeIcon for FILE data source type', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.FILE,
data_source_info: {
upload_file: { extension: 'pdf' },
},
})
const { container } = render(<DocumentSourceIcon doc={doc} fileType="pdf" />)
const icon = container.querySelector('svg, img')
expect(icon).toBeInTheDocument()
})
it('should render FileTypeIcon for localFile data source type', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.localFile,
created_from: 'rag-pipeline',
data_source_info: {
extension: 'docx',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
const icon = container.querySelector('svg, img')
expect(icon).toBeInTheDocument()
})
it('should use extension from upload_file for legacy data source', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.FILE,
created_from: 'web',
data_source_info: {
upload_file: { extension: 'txt' },
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should use fileType prop as fallback for extension', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.FILE,
created_from: 'web',
data_source_info: {},
})
const { container } = render(<DocumentSourceIcon doc={doc} fileType="csv" />)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Notion Icon', () => {
it('should render NotionIcon for NOTION data source type', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.NOTION,
created_from: 'web',
data_source_info: {
notion_page_icon: 'https://notion.so/icon.png',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render NotionIcon for onlineDocument data source type', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.onlineDocument,
created_from: 'rag-pipeline',
data_source_info: {
page: { page_icon: 'https://notion.so/icon.png' },
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should use page_icon for rag-pipeline created documents', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.NOTION,
created_from: 'rag-pipeline',
data_source_info: {
page: { page_icon: 'https://notion.so/custom-icon.png' },
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Web Crawl Icon', () => {
it('should render globe icon for WEB data source type', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.WEB,
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('mr-1.5')
expect(icon).toHaveClass('size-4')
})
it('should render globe icon for websiteCrawl data source type', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.websiteCrawl,
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
describe('Online Drive Icon', () => {
it('should render FileTypeIcon for onlineDrive data source type', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.onlineDrive,
data_source_info: {
name: 'document.xlsx',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should extract extension from file name', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.onlineDrive,
data_source_info: {
name: 'spreadsheet.xlsx',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle file name without extension', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.onlineDrive,
data_source_info: {
name: 'noextension',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle empty file name', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.onlineDrive,
data_source_info: {
name: '',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle hidden files (starting with dot)', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.onlineDrive,
data_source_info: {
name: '.gitignore',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Unknown Data Source Type', () => {
it('should return null for unknown data source type', () => {
const doc = createMockDoc({
data_source_type: 'unknown',
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeNull()
})
})
describe('Edge Cases', () => {
it('should handle undefined data_source_info', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.FILE,
data_source_info: undefined,
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should memoize the component', () => {
const doc = createMockDoc()
const { rerender, container } = render(<DocumentSourceIcon doc={doc} />)
const firstRender = container.innerHTML
rerender(<DocumentSourceIcon doc={doc} />)
expect(container.innerHTML).toBe(firstRender)
})
})
})

View File

@ -1,100 +0,0 @@
import type { FC } from 'react'
import type { LegacyDataSourceInfo, LocalFileInfo, OnlineDocumentInfo, OnlineDriveInfo, SimpleDocumentDetail } from '@/models/datasets'
import { RiGlobalLine } from '@remixicon/react'
import * as React from 'react'
import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon'
import NotionIcon from '@/app/components/base/notion-icon'
import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type'
import { DataSourceType } from '@/models/datasets'
import { DatasourceType } from '@/models/pipeline'
type DocumentSourceIconProps = {
doc: SimpleDocumentDetail
fileType?: string
}
const isLocalFile = (dataSourceType: DataSourceType | DatasourceType) => {
return dataSourceType === DatasourceType.localFile || dataSourceType === DataSourceType.FILE
}
const isOnlineDocument = (dataSourceType: DataSourceType | DatasourceType) => {
return dataSourceType === DatasourceType.onlineDocument || dataSourceType === DataSourceType.NOTION
}
const isWebsiteCrawl = (dataSourceType: DataSourceType | DatasourceType) => {
return dataSourceType === DatasourceType.websiteCrawl || dataSourceType === DataSourceType.WEB
}
const isOnlineDrive = (dataSourceType: DataSourceType | DatasourceType) => {
return dataSourceType === DatasourceType.onlineDrive
}
const isCreateFromRAGPipeline = (createdFrom: string) => {
return createdFrom === 'rag-pipeline'
}
const getFileExtension = (fileName: string): string => {
if (!fileName)
return ''
const parts = fileName.split('.')
if (parts.length <= 1 || (parts[0] === '' && parts.length === 2))
return ''
return parts[parts.length - 1].toLowerCase()
}
const DocumentSourceIcon: FC<DocumentSourceIconProps> = React.memo(({
doc,
fileType,
}) => {
if (isOnlineDocument(doc.data_source_type)) {
return (
<NotionIcon
className="mr-1.5"
type="page"
src={
isCreateFromRAGPipeline(doc.created_from)
? (doc.data_source_info as OnlineDocumentInfo).page.page_icon
: (doc.data_source_info as LegacyDataSourceInfo).notion_page_icon
}
/>
)
}
if (isLocalFile(doc.data_source_type)) {
return (
<FileTypeIcon
type={
extensionToFileType(
isCreateFromRAGPipeline(doc.created_from)
? (doc?.data_source_info as LocalFileInfo)?.extension
: ((doc?.data_source_info as LegacyDataSourceInfo)?.upload_file?.extension ?? fileType),
)
}
className="mr-1.5"
/>
)
}
if (isOnlineDrive(doc.data_source_type)) {
return (
<FileTypeIcon
type={
extensionToFileType(
getFileExtension((doc?.data_source_info as unknown as OnlineDriveInfo)?.name),
)
}
className="mr-1.5"
/>
)
}
if (isWebsiteCrawl(doc.data_source_type)) {
return <RiGlobalLine className="mr-1.5 size-4" />
}
return null
})
DocumentSourceIcon.displayName = 'DocumentSourceIcon'
export default DocumentSourceIcon

View File

@ -1,342 +0,0 @@
import type { ReactNode } from 'react'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import DocumentTableRow from './document-table-row'
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
})
const createWrapper = () => {
const queryClient = createTestQueryClient()
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<table>
<tbody>
{children}
</tbody>
</table>
</QueryClientProvider>
)
}
type LocalDoc = SimpleDocumentDetail & { percent?: number }
const createMockDoc = (overrides: Record<string, unknown> = {}): LocalDoc => ({
id: 'doc-1',
position: 1,
data_source_type: DataSourceType.FILE,
data_source_info: {},
data_source_detail_dict: {
upload_file: { name: 'test.txt', extension: 'txt' },
},
dataset_process_rule_id: 'rule-1',
dataset_id: 'dataset-1',
batch: 'batch-1',
name: 'test-document.txt',
created_from: 'web',
created_by: 'user-1',
created_at: Date.now(),
tokens: 100,
indexing_status: 'completed',
error: null,
enabled: true,
disabled_at: null,
disabled_by: null,
archived: false,
archived_reason: null,
archived_by: null,
archived_at: null,
updated_at: Date.now(),
doc_type: null,
doc_metadata: undefined,
doc_language: 'en',
display_status: 'available',
word_count: 500,
hit_count: 10,
doc_form: 'text_model',
...overrides,
}) as unknown as LocalDoc
// Helper to find the custom checkbox div (Checkbox component renders as a div, not a native checkbox)
const findCheckbox = (container: HTMLElement): HTMLElement | null => {
return container.querySelector('[class*="shadow-xs"]')
}
describe('DocumentTableRow', () => {
const defaultProps = {
doc: createMockDoc(),
index: 0,
datasetId: 'dataset-1',
isSelected: false,
isGeneralMode: true,
isQAMode: false,
embeddingAvailable: true,
selectedIds: [],
onSelectOne: vi.fn(),
onSelectedIdChange: vi.fn(),
onShowRenameModal: vi.fn(),
onUpdate: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('test-document.txt')).toBeInTheDocument()
})
it('should render index number correctly', () => {
render(<DocumentTableRow {...defaultProps} index={5} />, { wrapper: createWrapper() })
expect(screen.getByText('6')).toBeInTheDocument()
})
it('should render document name with tooltip', () => {
render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('test-document.txt')).toBeInTheDocument()
})
it('should render checkbox element', () => {
const { container } = render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
const checkbox = findCheckbox(container)
expect(checkbox).toBeInTheDocument()
})
})
describe('Selection', () => {
it('should show check icon when isSelected is true', () => {
const { container } = render(<DocumentTableRow {...defaultProps} isSelected />, { wrapper: createWrapper() })
// When selected, the checkbox should have a check icon (RiCheckLine svg)
const checkbox = findCheckbox(container)
expect(checkbox).toBeInTheDocument()
const checkIcon = checkbox?.querySelector('svg')
expect(checkIcon).toBeInTheDocument()
})
it('should not show check icon when isSelected is false', () => {
const { container } = render(<DocumentTableRow {...defaultProps} isSelected={false} />, { wrapper: createWrapper() })
const checkbox = findCheckbox(container)
expect(checkbox).toBeInTheDocument()
// When not selected, there should be no check icon inside the checkbox
const checkIcon = checkbox?.querySelector('svg')
expect(checkIcon).not.toBeInTheDocument()
})
it('should call onSelectOne when checkbox is clicked', () => {
const onSelectOne = vi.fn()
const { container } = render(<DocumentTableRow {...defaultProps} onSelectOne={onSelectOne} />, { wrapper: createWrapper() })
const checkbox = findCheckbox(container)
if (checkbox) {
fireEvent.click(checkbox)
expect(onSelectOne).toHaveBeenCalledWith('doc-1')
}
})
it('should stop propagation when checkbox container is clicked', () => {
const { container } = render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
// Click the div containing the checkbox (which has stopPropagation)
const checkboxContainer = container.querySelector('td')?.querySelector('div')
if (checkboxContainer) {
fireEvent.click(checkboxContainer)
expect(mockPush).not.toHaveBeenCalled()
}
})
})
describe('Row Navigation', () => {
it('should navigate to document detail on row click', () => {
render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
const row = screen.getByRole('row')
fireEvent.click(row)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1')
})
it('should navigate with correct datasetId and documentId', () => {
render(
<DocumentTableRow
{...defaultProps}
datasetId="custom-dataset"
doc={createMockDoc({ id: 'custom-doc' })}
/>,
{ wrapper: createWrapper() },
)
const row = screen.getByRole('row')
fireEvent.click(row)
expect(mockPush).toHaveBeenCalledWith('/datasets/custom-dataset/documents/custom-doc')
})
})
describe('Word Count Display', () => {
it('should display word count less than 1000 as is', () => {
const doc = createMockDoc({ word_count: 500 })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByText('500')).toBeInTheDocument()
})
it('should display word count 1000 or more in k format', () => {
const doc = createMockDoc({ word_count: 1500 })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByText('1.5k')).toBeInTheDocument()
})
it('should display 0 with empty style when word_count is 0', () => {
const doc = createMockDoc({ word_count: 0 })
const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
const zeroCells = container.querySelectorAll('.text-text-tertiary')
expect(zeroCells.length).toBeGreaterThan(0)
})
it('should handle undefined word_count', () => {
const doc = createMockDoc({ word_count: undefined as unknown as number })
const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(container).toBeInTheDocument()
})
})
describe('Hit Count Display', () => {
it('should display hit count less than 1000 as is', () => {
const doc = createMockDoc({ hit_count: 100 })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByText('100')).toBeInTheDocument()
})
it('should display hit count 1000 or more in k format', () => {
const doc = createMockDoc({ hit_count: 2500 })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByText('2.5k')).toBeInTheDocument()
})
it('should display 0 with empty style when hit_count is 0', () => {
const doc = createMockDoc({ hit_count: 0 })
const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
const zeroCells = container.querySelectorAll('.text-text-tertiary')
expect(zeroCells.length).toBeGreaterThan(0)
})
})
describe('Chunking Mode', () => {
it('should render ChunkingModeLabel with general mode', () => {
render(<DocumentTableRow {...defaultProps} isGeneralMode isQAMode={false} />, { wrapper: createWrapper() })
// ChunkingModeLabel should be rendered
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should render ChunkingModeLabel with QA mode', () => {
render(<DocumentTableRow {...defaultProps} isGeneralMode={false} isQAMode />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
})
describe('Summary Status', () => {
it('should render SummaryStatus when summary_index_status is present', () => {
const doc = createMockDoc({ summary_index_status: 'completed' })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should not render SummaryStatus when summary_index_status is absent', () => {
const doc = createMockDoc({ summary_index_status: undefined })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
})
describe('Rename Action', () => {
it('should call onShowRenameModal when rename button is clicked', () => {
const onShowRenameModal = vi.fn()
const { container } = render(
<DocumentTableRow {...defaultProps} onShowRenameModal={onShowRenameModal} />,
{ wrapper: createWrapper() },
)
// Find the rename button by finding the RiEditLine icon's parent
const renameButtons = container.querySelectorAll('.cursor-pointer.rounded-md')
if (renameButtons.length > 0) {
fireEvent.click(renameButtons[0])
expect(onShowRenameModal).toHaveBeenCalledWith(defaultProps.doc)
expect(mockPush).not.toHaveBeenCalled()
}
})
})
describe('Operations', () => {
it('should pass selectedIds to Operations component', () => {
render(<DocumentTableRow {...defaultProps} selectedIds={['doc-1', 'doc-2']} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should pass onSelectedIdChange to Operations component', () => {
const onSelectedIdChange = vi.fn()
render(<DocumentTableRow {...defaultProps} onSelectedIdChange={onSelectedIdChange} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
})
describe('Document Source Icon', () => {
it('should render with FILE data source type', () => {
const doc = createMockDoc({ data_source_type: DataSourceType.FILE })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should render with NOTION data source type', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.NOTION,
data_source_info: { notion_page_icon: 'icon.png' },
})
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should render with WEB data source type', () => {
const doc = createMockDoc({ data_source_type: DataSourceType.WEB })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle document with very long name', () => {
const doc = createMockDoc({ name: `${'a'.repeat(500)}.txt` })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should handle document with special characters in name', () => {
const doc = createMockDoc({ name: '<script>test</script>.txt' })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByText('<script>test</script>.txt')).toBeInTheDocument()
})
it('should memoize the component', () => {
const wrapper = createWrapper()
const { rerender } = render(<DocumentTableRow {...defaultProps} />, { wrapper })
rerender(<DocumentTableRow {...defaultProps} />)
expect(screen.getByRole('row')).toBeInTheDocument()
})
})
})

View File

@ -1,152 +0,0 @@
import type { FC } from 'react'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { RiEditLine } from '@remixicon/react'
import { pick } from 'es-toolkit/object'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Tooltip from '@/app/components/base/tooltip'
import ChunkingModeLabel from '@/app/components/datasets/common/chunking-mode-label'
import Operations from '@/app/components/datasets/documents/components/operations'
import SummaryStatus from '@/app/components/datasets/documents/detail/completed/common/summary-status'
import StatusItem from '@/app/components/datasets/documents/status-item'
import useTimestamp from '@/hooks/use-timestamp'
import { DataSourceType } from '@/models/datasets'
import { formatNumber } from '@/utils/format'
import DocumentSourceIcon from './document-source-icon'
import { renderTdValue } from './utils'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
type DocumentTableRowProps = {
doc: LocalDoc
index: number
datasetId: string
isSelected: boolean
isGeneralMode: boolean
isQAMode: boolean
embeddingAvailable: boolean
selectedIds: string[]
onSelectOne: (docId: string) => void
onSelectedIdChange: (ids: string[]) => void
onShowRenameModal: (doc: LocalDoc) => void
onUpdate: () => void
}
const renderCount = (count: number | undefined) => {
if (!count)
return renderTdValue(0, true)
if (count < 1000)
return count
return `${formatNumber((count / 1000).toFixed(1))}k`
}
const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
doc,
index,
datasetId,
isSelected,
isGeneralMode,
isQAMode,
embeddingAvailable,
selectedIds,
onSelectOne,
onSelectedIdChange,
onShowRenameModal,
onUpdate,
}) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const router = useRouter()
const isFile = doc.data_source_type === DataSourceType.FILE
const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : ''
const handleRowClick = useCallback(() => {
router.push(`/datasets/${datasetId}/documents/${doc.id}`)
}, [router, datasetId, doc.id])
const handleCheckboxClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
}, [])
const handleRenameClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
onShowRenameModal(doc)
}, [doc, onShowRenameModal])
return (
<tr
className="h-8 cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover"
onClick={handleRowClick}
>
<td className="text-left align-middle text-xs text-text-tertiary">
<div className="flex items-center" onClick={handleCheckboxClick}>
<Checkbox
className="mr-2 shrink-0"
checked={isSelected}
onCheck={() => onSelectOne(doc.id)}
/>
{index + 1}
</div>
</td>
<td>
<div className="group mr-6 flex max-w-[460px] items-center hover:mr-0">
<div className="flex shrink-0 items-center">
<DocumentSourceIcon doc={doc} fileType={fileType} />
</div>
<Tooltip popupContent={doc.name}>
<span className="grow-1 truncate text-sm">{doc.name}</span>
</Tooltip>
{doc.summary_index_status && (
<div className="ml-1 hidden shrink-0 group-hover:flex">
<SummaryStatus status={doc.summary_index_status} />
</div>
)}
<div className="hidden shrink-0 group-hover:ml-auto group-hover:flex">
<Tooltip popupContent={t('list.table.rename', { ns: 'datasetDocuments' })}>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={handleRenameClick}
>
<RiEditLine className="h-4 w-4 text-text-tertiary" />
</div>
</Tooltip>
</div>
</div>
</td>
<td>
<ChunkingModeLabel
isGeneralMode={isGeneralMode}
isQAMode={isQAMode}
/>
</td>
<td>{renderCount(doc.word_count)}</td>
<td>{renderCount(doc.hit_count)}</td>
<td className="text-[13px] text-text-secondary">
{formatTime(doc.created_at, t('dateTimeFormat', { ns: 'datasetHitTesting' }) as string)}
</td>
<td>
<StatusItem status={doc.display_status} />
</td>
<td>
<Operations
selectedIds={selectedIds}
onSelectedIdChange={onSelectedIdChange}
embeddingAvailable={embeddingAvailable}
datasetId={datasetId}
detail={pick(doc, ['name', 'enabled', 'archived', 'id', 'data_source_type', 'doc_form', 'display_status'])}
onUpdate={onUpdate}
/>
</td>
</tr>
)
})
DocumentTableRow.displayName = 'DocumentTableRow'
export default DocumentTableRow

View File

@ -1,4 +0,0 @@
export { default as DocumentSourceIcon } from './document-source-icon'
export { default as DocumentTableRow } from './document-table-row'
export { default as SortHeader } from './sort-header'
export { renderTdValue } from './utils'

View File

@ -1,124 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import SortHeader from './sort-header'
describe('SortHeader', () => {
const defaultProps = {
field: 'name' as const,
label: 'File Name',
currentSortField: null,
sortOrder: 'desc' as const,
onSort: vi.fn(),
}
describe('rendering', () => {
it('should render the label', () => {
render(<SortHeader {...defaultProps} />)
expect(screen.getByText('File Name')).toBeInTheDocument()
})
it('should render the sort icon', () => {
const { container } = render(<SortHeader {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
describe('inactive state', () => {
it('should have disabled text color when not active', () => {
const { container } = render(<SortHeader {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-disabled')
})
it('should not be rotated when not active', () => {
const { container } = render(<SortHeader {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).not.toHaveClass('rotate-180')
})
})
describe('active state', () => {
it('should have tertiary text color when active', () => {
const { container } = render(
<SortHeader {...defaultProps} currentSortField="name" />,
)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-tertiary')
})
it('should not be rotated when active and desc', () => {
const { container } = render(
<SortHeader {...defaultProps} currentSortField="name" sortOrder="desc" />,
)
const icon = container.querySelector('svg')
expect(icon).not.toHaveClass('rotate-180')
})
it('should be rotated when active and asc', () => {
const { container } = render(
<SortHeader {...defaultProps} currentSortField="name" sortOrder="asc" />,
)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('rotate-180')
})
})
describe('interaction', () => {
it('should call onSort when clicked', () => {
const onSort = vi.fn()
render(<SortHeader {...defaultProps} onSort={onSort} />)
fireEvent.click(screen.getByText('File Name'))
expect(onSort).toHaveBeenCalledWith('name')
})
it('should call onSort with correct field', () => {
const onSort = vi.fn()
render(<SortHeader {...defaultProps} field="word_count" onSort={onSort} />)
fireEvent.click(screen.getByText('File Name'))
expect(onSort).toHaveBeenCalledWith('word_count')
})
})
describe('different fields', () => {
it('should work with word_count field', () => {
render(
<SortHeader
{...defaultProps}
field="word_count"
label="Words"
currentSortField="word_count"
/>,
)
expect(screen.getByText('Words')).toBeInTheDocument()
})
it('should work with hit_count field', () => {
render(
<SortHeader
{...defaultProps}
field="hit_count"
label="Hit Count"
currentSortField="hit_count"
/>,
)
expect(screen.getByText('Hit Count')).toBeInTheDocument()
})
it('should work with created_at field', () => {
render(
<SortHeader
{...defaultProps}
field="created_at"
label="Upload Time"
currentSortField="created_at"
/>,
)
expect(screen.getByText('Upload Time')).toBeInTheDocument()
})
})
})

View File

@ -1,44 +0,0 @@
import type { FC } from 'react'
import type { SortField, SortOrder } from '../hooks'
import { RiArrowDownLine } from '@remixicon/react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
type SortHeaderProps = {
field: Exclude<SortField, null>
label: string
currentSortField: SortField
sortOrder: SortOrder
onSort: (field: SortField) => void
}
const SortHeader: FC<SortHeaderProps> = React.memo(({
field,
label,
currentSortField,
sortOrder,
onSort,
}) => {
const isActive = currentSortField === field
const isDesc = isActive && sortOrder === 'desc'
return (
<div
className="flex cursor-pointer items-center hover:text-text-secondary"
onClick={() => onSort(field)}
>
{label}
<RiArrowDownLine
className={cn(
'ml-0.5 h-3 w-3 transition-all',
isActive ? 'text-text-tertiary' : 'text-text-disabled',
isActive && !isDesc ? 'rotate-180' : '',
)}
/>
</div>
)
})
SortHeader.displayName = 'SortHeader'
export default SortHeader

View File

@ -1,90 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { renderTdValue } from './utils'
describe('renderTdValue', () => {
describe('Rendering', () => {
it('should render string value correctly', () => {
const { container } = render(<>{renderTdValue('test value')}</>)
expect(screen.getByText('test value')).toBeInTheDocument()
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
})
it('should render number value correctly', () => {
const { container } = render(<>{renderTdValue(42)}</>)
expect(screen.getByText('42')).toBeInTheDocument()
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
})
it('should render zero correctly', () => {
const { container } = render(<>{renderTdValue(0)}</>)
expect(screen.getByText('0')).toBeInTheDocument()
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
})
})
describe('Null and undefined handling', () => {
it('should render dash for null value', () => {
render(<>{renderTdValue(null)}</>)
expect(screen.getByText('-')).toBeInTheDocument()
})
it('should render dash for null value with empty style', () => {
const { container } = render(<>{renderTdValue(null, true)}</>)
expect(screen.getByText('-')).toBeInTheDocument()
expect(container.querySelector('div')).toHaveClass('text-text-tertiary')
})
})
describe('Empty style', () => {
it('should apply text-text-tertiary class when isEmptyStyle is true', () => {
const { container } = render(<>{renderTdValue('value', true)}</>)
expect(container.querySelector('div')).toHaveClass('text-text-tertiary')
})
it('should apply text-text-secondary class when isEmptyStyle is false', () => {
const { container } = render(<>{renderTdValue('value', false)}</>)
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
})
it('should apply text-text-secondary class when isEmptyStyle is not provided', () => {
const { container } = render(<>{renderTdValue('value')}</>)
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
})
})
describe('Edge Cases', () => {
it('should handle empty string', () => {
render(<>{renderTdValue('')}</>)
// Empty string should still render but with no visible text
const div = document.querySelector('div')
expect(div).toBeInTheDocument()
})
it('should handle large numbers', () => {
render(<>{renderTdValue(1234567890)}</>)
expect(screen.getByText('1234567890')).toBeInTheDocument()
})
it('should handle negative numbers', () => {
render(<>{renderTdValue(-42)}</>)
expect(screen.getByText('-42')).toBeInTheDocument()
})
it('should handle special characters in string', () => {
render(<>{renderTdValue('<script>alert("xss")</script>')}</>)
expect(screen.getByText('<script>alert("xss")</script>')).toBeInTheDocument()
})
it('should handle unicode characters', () => {
render(<>{renderTdValue('Test Unicode: \u4E2D\u6587')}</>)
expect(screen.getByText('Test Unicode: \u4E2D\u6587')).toBeInTheDocument()
})
it('should handle very long strings', () => {
const longString = 'a'.repeat(1000)
render(<>{renderTdValue(longString)}</>)
expect(screen.getByText(longString)).toBeInTheDocument()
})
})
})

View File

@ -1,16 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@/utils/classnames'
import s from '../../../style.module.css'
export const renderTdValue = (value: string | number | null, isEmptyStyle = false): ReactNode => {
const className = cn(
isEmptyStyle ? 'text-text-tertiary' : 'text-text-secondary',
s.tdValue,
)
return (
<div className={className}>
{value ?? '-'}
</div>
)
}

View File

@ -1,4 +0,0 @@
export { useDocumentActions } from './use-document-actions'
export { useDocumentSelection } from './use-document-selection'
export { useDocumentSort } from './use-document-sort'
export type { SortField, SortOrder } from './use-document-sort'

View File

@ -1,438 +0,0 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DocumentActionType } from '@/models/datasets'
import * as useDocument from '@/service/knowledge/use-document'
import { useDocumentActions } from './use-document-actions'
vi.mock('@/service/knowledge/use-document')
const mockUseDocumentArchive = vi.mocked(useDocument.useDocumentArchive)
const mockUseDocumentSummary = vi.mocked(useDocument.useDocumentSummary)
const mockUseDocumentEnable = vi.mocked(useDocument.useDocumentEnable)
const mockUseDocumentDisable = vi.mocked(useDocument.useDocumentDisable)
const mockUseDocumentDelete = vi.mocked(useDocument.useDocumentDelete)
const mockUseDocumentBatchRetryIndex = vi.mocked(useDocument.useDocumentBatchRetryIndex)
const mockUseDocumentDownloadZip = vi.mocked(useDocument.useDocumentDownloadZip)
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const createWrapper = () => {
const queryClient = createTestQueryClient()
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
describe('useDocumentActions', () => {
const mockMutateAsync = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Setup all mocks with default values
const createMockMutation = () => ({
mutateAsync: mockMutateAsync,
isPending: false,
isError: false,
isSuccess: false,
isIdle: true,
data: undefined,
error: null,
mutate: vi.fn(),
reset: vi.fn(),
status: 'idle' as const,
variables: undefined,
context: undefined,
failureCount: 0,
failureReason: null,
submittedAt: 0,
})
mockUseDocumentArchive.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentArchive>)
mockUseDocumentSummary.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentSummary>)
mockUseDocumentEnable.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentEnable>)
mockUseDocumentDisable.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentDisable>)
mockUseDocumentDelete.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentDelete>)
mockUseDocumentBatchRetryIndex.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentBatchRetryIndex>)
mockUseDocumentDownloadZip.mockReturnValue({
...createMockMutation(),
isPending: false,
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
})
describe('handleAction', () => {
it('should call archive mutation when archive action is triggered', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleAction(DocumentActionType.archive)()
})
expect(mockMutateAsync).toHaveBeenCalledWith({
datasetId: 'ds1',
documentIds: ['doc1'],
})
})
it('should call onUpdate on successful action', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleAction(DocumentActionType.enable)()
})
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
})
})
it('should call onClearSelection on delete action', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleAction(DocumentActionType.delete)()
})
await waitFor(() => {
expect(onClearSelection).toHaveBeenCalled()
})
})
})
describe('handleBatchReIndex', () => {
it('should call retry index mutation', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1', 'doc2'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchReIndex()
})
expect(mockMutateAsync).toHaveBeenCalledWith({
datasetId: 'ds1',
documentIds: ['doc1', 'doc2'],
})
})
it('should call onClearSelection on success', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchReIndex()
})
await waitFor(() => {
expect(onClearSelection).toHaveBeenCalled()
expect(onUpdate).toHaveBeenCalled()
})
})
})
describe('handleBatchDownload', () => {
it('should not proceed when already downloading', async () => {
mockUseDocumentDownloadZip.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: ['doc1'],
onUpdate: vi.fn(),
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchDownload()
})
expect(mockMutateAsync).not.toHaveBeenCalled()
})
it('should call download mutation with downloadable ids', async () => {
const mockBlob = new Blob(['test'])
mockMutateAsync.mockResolvedValue(mockBlob)
mockUseDocumentDownloadZip.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1', 'doc2'],
downloadableSelectedIds: ['doc1'],
onUpdate: vi.fn(),
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchDownload()
})
expect(mockMutateAsync).toHaveBeenCalledWith({
datasetId: 'ds1',
documentIds: ['doc1'],
})
})
})
describe('isDownloadingZip', () => {
it('should reflect isPending state from mutation', () => {
mockUseDocumentDownloadZip.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: [],
downloadableSelectedIds: [],
onUpdate: vi.fn(),
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
expect(result.current.isDownloadingZip).toBe(true)
})
})
describe('error handling', () => {
it('should show error toast when handleAction fails', async () => {
mockMutateAsync.mockRejectedValue(new Error('Action failed'))
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleAction(DocumentActionType.archive)()
})
// onUpdate should not be called on error
expect(onUpdate).not.toHaveBeenCalled()
})
it('should show error toast when handleBatchReIndex fails', async () => {
mockMutateAsync.mockRejectedValue(new Error('Re-index failed'))
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchReIndex()
})
// onUpdate and onClearSelection should not be called on error
expect(onUpdate).not.toHaveBeenCalled()
expect(onClearSelection).not.toHaveBeenCalled()
})
it('should show error toast when handleBatchDownload fails', async () => {
mockMutateAsync.mockRejectedValue(new Error('Download failed'))
mockUseDocumentDownloadZip.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: ['doc1'],
onUpdate: vi.fn(),
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchDownload()
})
// Mutation was called but failed
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should show error toast when handleBatchDownload returns null blob', async () => {
mockMutateAsync.mockResolvedValue(null)
mockUseDocumentDownloadZip.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: ['doc1'],
onUpdate: vi.fn(),
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchDownload()
})
// Mutation was called but returned null
expect(mockMutateAsync).toHaveBeenCalled()
})
})
describe('all action types', () => {
it('should handle summary action', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleAction(DocumentActionType.summary)()
})
expect(mockMutateAsync).toHaveBeenCalled()
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
})
})
it('should handle disable action', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleAction(DocumentActionType.disable)()
})
expect(mockMutateAsync).toHaveBeenCalled()
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
})
})
})
})

View File

@ -1,126 +0,0 @@
import type { CommonResponse } from '@/models/common'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { DocumentActionType } from '@/models/datasets'
import {
useDocumentArchive,
useDocumentBatchRetryIndex,
useDocumentDelete,
useDocumentDisable,
useDocumentDownloadZip,
useDocumentEnable,
useDocumentSummary,
} from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import { downloadBlob } from '@/utils/download'
type UseDocumentActionsOptions = {
datasetId: string
selectedIds: string[]
downloadableSelectedIds: string[]
onUpdate: () => void
onClearSelection: () => void
}
/**
* Generate a random ZIP filename for bulk document downloads.
* We intentionally avoid leaking dataset info in the exported archive name.
*/
const generateDocsZipFileName = (): string => {
const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
? crypto.randomUUID()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
return `${randomPart}-docs.zip`
}
export const useDocumentActions = ({
datasetId,
selectedIds,
downloadableSelectedIds,
onUpdate,
onClearSelection,
}: UseDocumentActionsOptions) => {
const { t } = useTranslation()
const { mutateAsync: archiveDocument } = useDocumentArchive()
const { mutateAsync: generateSummary } = useDocumentSummary()
const { mutateAsync: enableDocument } = useDocumentEnable()
const { mutateAsync: disableDocument } = useDocumentDisable()
const { mutateAsync: deleteDocument } = useDocumentDelete()
const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex()
const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip()
type SupportedActionType
= | typeof DocumentActionType.archive
| typeof DocumentActionType.summary
| typeof DocumentActionType.enable
| typeof DocumentActionType.disable
| typeof DocumentActionType.delete
const actionMutationMap = useMemo(() => ({
[DocumentActionType.archive]: archiveDocument,
[DocumentActionType.summary]: generateSummary,
[DocumentActionType.enable]: enableDocument,
[DocumentActionType.disable]: disableDocument,
[DocumentActionType.delete]: deleteDocument,
} as const), [archiveDocument, generateSummary, enableDocument, disableDocument, deleteDocument])
const handleAction = useCallback((actionName: SupportedActionType) => {
return async () => {
const opApi = actionMutationMap[actionName]
if (!opApi)
return
const [e] = await asyncRunSafe<CommonResponse>(
opApi({ datasetId, documentIds: selectedIds }),
)
if (!e) {
if (actionName === DocumentActionType.delete)
onClearSelection()
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
onUpdate()
}
else {
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}
}
}, [actionMutationMap, datasetId, selectedIds, onClearSelection, onUpdate, t])
const handleBatchReIndex = useCallback(async () => {
const [e] = await asyncRunSafe<CommonResponse>(
retryIndexDocument({ datasetId, documentIds: selectedIds }),
)
if (!e) {
onClearSelection()
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
onUpdate()
}
else {
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}
}, [retryIndexDocument, datasetId, selectedIds, onClearSelection, onUpdate, t])
const handleBatchDownload = useCallback(async () => {
if (isDownloadingZip)
return
const [e, blob] = await asyncRunSafe(
requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds }),
)
if (e || !blob) {
Toast.notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
return
}
downloadBlob({ data: blob, fileName: generateDocsZipFileName() })
}, [datasetId, downloadableSelectedIds, isDownloadingZip, requestDocumentsZip, t])
return {
handleAction,
handleBatchReIndex,
handleBatchDownload,
isDownloadingZip,
}
}

View File

@ -1,317 +0,0 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import { useDocumentSelection } from './use-document-selection'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
const createMockDocument = (overrides: Partial<LocalDoc> = {}): LocalDoc => ({
id: 'doc1',
name: 'Test Document',
data_source_type: DataSourceType.FILE,
data_source_info: {},
data_source_detail_dict: {},
word_count: 100,
hit_count: 10,
created_at: 1000000,
position: 1,
doc_form: 'text_model',
enabled: true,
archived: false,
display_status: 'available',
created_from: 'api',
...overrides,
} as LocalDoc)
describe('useDocumentSelection', () => {
describe('isAllSelected', () => {
it('should return false when documents is empty', () => {
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: [],
selectedIds: [],
onSelectedIdChange,
}),
)
expect(result.current.isAllSelected).toBe(false)
})
it('should return true when all documents are selected', () => {
const docs = [
createMockDocument({ id: 'doc1' }),
createMockDocument({ id: 'doc2' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1', 'doc2'],
onSelectedIdChange,
}),
)
expect(result.current.isAllSelected).toBe(true)
})
it('should return false when not all documents are selected', () => {
const docs = [
createMockDocument({ id: 'doc1' }),
createMockDocument({ id: 'doc2' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1'],
onSelectedIdChange,
}),
)
expect(result.current.isAllSelected).toBe(false)
})
})
describe('isSomeSelected', () => {
it('should return false when no documents are selected', () => {
const docs = [createMockDocument({ id: 'doc1' })]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: [],
onSelectedIdChange,
}),
)
expect(result.current.isSomeSelected).toBe(false)
})
it('should return true when some documents are selected', () => {
const docs = [
createMockDocument({ id: 'doc1' }),
createMockDocument({ id: 'doc2' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1'],
onSelectedIdChange,
}),
)
expect(result.current.isSomeSelected).toBe(true)
})
})
describe('onSelectAll', () => {
it('should select all documents when none are selected', () => {
const docs = [
createMockDocument({ id: 'doc1' }),
createMockDocument({ id: 'doc2' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: [],
onSelectedIdChange,
}),
)
act(() => {
result.current.onSelectAll()
})
expect(onSelectedIdChange).toHaveBeenCalledWith(['doc1', 'doc2'])
})
it('should deselect all when all are selected', () => {
const docs = [
createMockDocument({ id: 'doc1' }),
createMockDocument({ id: 'doc2' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1', 'doc2'],
onSelectedIdChange,
}),
)
act(() => {
result.current.onSelectAll()
})
expect(onSelectedIdChange).toHaveBeenCalledWith([])
})
it('should add to existing selection when some are selected', () => {
const docs = [
createMockDocument({ id: 'doc1' }),
createMockDocument({ id: 'doc2' }),
createMockDocument({ id: 'doc3' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1'],
onSelectedIdChange,
}),
)
act(() => {
result.current.onSelectAll()
})
expect(onSelectedIdChange).toHaveBeenCalledWith(['doc1', 'doc2', 'doc3'])
})
})
describe('onSelectOne', () => {
it('should add document to selection when not selected', () => {
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: [],
selectedIds: [],
onSelectedIdChange,
}),
)
act(() => {
result.current.onSelectOne('doc1')
})
expect(onSelectedIdChange).toHaveBeenCalledWith(['doc1'])
})
it('should remove document from selection when already selected', () => {
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: [],
selectedIds: ['doc1', 'doc2'],
onSelectedIdChange,
}),
)
act(() => {
result.current.onSelectOne('doc1')
})
expect(onSelectedIdChange).toHaveBeenCalledWith(['doc2'])
})
})
describe('hasErrorDocumentsSelected', () => {
it('should return false when no error documents are selected', () => {
const docs = [
createMockDocument({ id: 'doc1', display_status: 'available' }),
createMockDocument({ id: 'doc2', display_status: 'error' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1'],
onSelectedIdChange,
}),
)
expect(result.current.hasErrorDocumentsSelected).toBe(false)
})
it('should return true when an error document is selected', () => {
const docs = [
createMockDocument({ id: 'doc1', display_status: 'available' }),
createMockDocument({ id: 'doc2', display_status: 'error' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc2'],
onSelectedIdChange,
}),
)
expect(result.current.hasErrorDocumentsSelected).toBe(true)
})
})
describe('downloadableSelectedIds', () => {
it('should return only FILE type documents from selection', () => {
const docs = [
createMockDocument({ id: 'doc1', data_source_type: DataSourceType.FILE }),
createMockDocument({ id: 'doc2', data_source_type: DataSourceType.NOTION }),
createMockDocument({ id: 'doc3', data_source_type: DataSourceType.FILE }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1', 'doc2', 'doc3'],
onSelectedIdChange,
}),
)
expect(result.current.downloadableSelectedIds).toEqual(['doc1', 'doc3'])
})
it('should return empty array when no FILE documents selected', () => {
const docs = [
createMockDocument({ id: 'doc1', data_source_type: DataSourceType.NOTION }),
createMockDocument({ id: 'doc2', data_source_type: DataSourceType.WEB }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1', 'doc2'],
onSelectedIdChange,
}),
)
expect(result.current.downloadableSelectedIds).toEqual([])
})
})
describe('clearSelection', () => {
it('should call onSelectedIdChange with empty array', () => {
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: [],
selectedIds: ['doc1', 'doc2'],
onSelectedIdChange,
}),
)
act(() => {
result.current.clearSelection()
})
expect(onSelectedIdChange).toHaveBeenCalledWith([])
})
})
})

View File

@ -1,66 +0,0 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { uniq } from 'es-toolkit/array'
import { useCallback, useMemo } from 'react'
import { DataSourceType } from '@/models/datasets'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
type UseDocumentSelectionOptions = {
documents: LocalDoc[]
selectedIds: string[]
onSelectedIdChange: (selectedIds: string[]) => void
}
export const useDocumentSelection = ({
documents,
selectedIds,
onSelectedIdChange,
}: UseDocumentSelectionOptions) => {
const isAllSelected = useMemo(() => {
return documents.length > 0 && documents.every(doc => selectedIds.includes(doc.id))
}, [documents, selectedIds])
const isSomeSelected = useMemo(() => {
return documents.some(doc => selectedIds.includes(doc.id))
}, [documents, selectedIds])
const onSelectAll = useCallback(() => {
if (isAllSelected)
onSelectedIdChange([])
else
onSelectedIdChange(uniq([...selectedIds, ...documents.map(doc => doc.id)]))
}, [isAllSelected, documents, onSelectedIdChange, selectedIds])
const onSelectOne = useCallback((docId: string) => {
onSelectedIdChange(
selectedIds.includes(docId)
? selectedIds.filter(id => id !== docId)
: [...selectedIds, docId],
)
}, [selectedIds, onSelectedIdChange])
const hasErrorDocumentsSelected = useMemo(() => {
return documents.some(doc => selectedIds.includes(doc.id) && doc.display_status === 'error')
}, [documents, selectedIds])
const downloadableSelectedIds = useMemo(() => {
const selectedSet = new Set(selectedIds)
return documents
.filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE)
.map(doc => doc.id)
}, [documents, selectedIds])
const clearSelection = useCallback(() => {
onSelectedIdChange([])
}, [onSelectedIdChange])
return {
isAllSelected,
isSomeSelected,
onSelectAll,
onSelectOne,
hasErrorDocumentsSelected,
downloadableSelectedIds,
clearSelection,
}
}

View File

@ -1,340 +0,0 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { useDocumentSort } from './use-document-sort'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
const createMockDocument = (overrides: Partial<LocalDoc> = {}): LocalDoc => ({
id: 'doc1',
name: 'Test Document',
data_source_type: 'upload_file',
data_source_info: {},
data_source_detail_dict: {},
word_count: 100,
hit_count: 10,
created_at: 1000000,
position: 1,
doc_form: 'text_model',
enabled: true,
archived: false,
display_status: 'available',
created_from: 'api',
...overrides,
} as LocalDoc)
describe('useDocumentSort', () => {
describe('initial state', () => {
it('should return null sortField initially', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
expect(result.current.sortField).toBeNull()
expect(result.current.sortOrder).toBe('desc')
})
it('should return documents unchanged when no sort is applied', () => {
const docs = [
createMockDocument({ id: 'doc1', name: 'B' }),
createMockDocument({ id: 'doc2', name: 'A' }),
]
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
expect(result.current.sortedDocuments).toEqual(docs)
})
})
describe('handleSort', () => {
it('should set sort field when called', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortField).toBe('name')
expect(result.current.sortOrder).toBe('desc')
})
it('should toggle sort order when same field is clicked twice', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortOrder).toBe('desc')
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortOrder).toBe('asc')
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortOrder).toBe('desc')
})
it('should reset to desc when different field is selected', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortOrder).toBe('asc')
act(() => {
result.current.handleSort('word_count')
})
expect(result.current.sortField).toBe('word_count')
expect(result.current.sortOrder).toBe('desc')
})
it('should not change state when null is passed', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort(null)
})
expect(result.current.sortField).toBeNull()
})
})
describe('sorting documents', () => {
const docs = [
createMockDocument({ id: 'doc1', name: 'Banana', word_count: 200, hit_count: 5, created_at: 3000 }),
createMockDocument({ id: 'doc2', name: 'Apple', word_count: 100, hit_count: 10, created_at: 1000 }),
createMockDocument({ id: 'doc3', name: 'Cherry', word_count: 300, hit_count: 1, created_at: 2000 }),
]
it('should sort by name descending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
const names = result.current.sortedDocuments.map(d => d.name)
expect(names).toEqual(['Cherry', 'Banana', 'Apple'])
})
it('should sort by name ascending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
act(() => {
result.current.handleSort('name')
})
const names = result.current.sortedDocuments.map(d => d.name)
expect(names).toEqual(['Apple', 'Banana', 'Cherry'])
})
it('should sort by word_count descending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('word_count')
})
const counts = result.current.sortedDocuments.map(d => d.word_count)
expect(counts).toEqual([300, 200, 100])
})
it('should sort by hit_count ascending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('hit_count')
})
act(() => {
result.current.handleSort('hit_count')
})
const counts = result.current.sortedDocuments.map(d => d.hit_count)
expect(counts).toEqual([1, 5, 10])
})
it('should sort by created_at descending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('created_at')
})
const times = result.current.sortedDocuments.map(d => d.created_at)
expect(times).toEqual([3000, 2000, 1000])
})
})
describe('status filtering', () => {
const docs = [
createMockDocument({ id: 'doc1', display_status: 'available' }),
createMockDocument({ id: 'doc2', display_status: 'error' }),
createMockDocument({ id: 'doc3', display_status: 'available' }),
]
it('should not filter when statusFilterValue is empty', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
expect(result.current.sortedDocuments.length).toBe(3)
})
it('should not filter when statusFilterValue is all', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: 'all',
remoteSortValue: '',
}),
)
expect(result.current.sortedDocuments.length).toBe(3)
})
})
describe('remoteSortValue reset', () => {
it('should reset sort state when remoteSortValue changes', () => {
const { result, rerender } = renderHook(
({ remoteSortValue }) =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue,
}),
{ initialProps: { remoteSortValue: 'initial' } },
)
act(() => {
result.current.handleSort('name')
})
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortField).toBe('name')
expect(result.current.sortOrder).toBe('asc')
rerender({ remoteSortValue: 'changed' })
expect(result.current.sortField).toBeNull()
expect(result.current.sortOrder).toBe('desc')
})
})
describe('edge cases', () => {
it('should handle documents with missing values', () => {
const docs = [
createMockDocument({ id: 'doc1', name: undefined as unknown as string, word_count: undefined }),
createMockDocument({ id: 'doc2', name: 'Test', word_count: 100 }),
]
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortedDocuments.length).toBe(2)
})
it('should handle empty documents array', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortedDocuments).toEqual([])
})
})
})

View File

@ -1,102 +0,0 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { useCallback, useMemo, useRef, useState } from 'react'
import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter'
export type SortField = 'name' | 'word_count' | 'hit_count' | 'created_at' | null
export type SortOrder = 'asc' | 'desc'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
type UseDocumentSortOptions = {
documents: LocalDoc[]
statusFilterValue: string
remoteSortValue: string
}
export const useDocumentSort = ({
documents,
statusFilterValue,
remoteSortValue,
}: UseDocumentSortOptions) => {
const [sortField, setSortField] = useState<SortField>(null)
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const prevRemoteSortValueRef = useRef(remoteSortValue)
// Reset sort when remote sort changes
if (prevRemoteSortValueRef.current !== remoteSortValue) {
prevRemoteSortValueRef.current = remoteSortValue
setSortField(null)
setSortOrder('desc')
}
const handleSort = useCallback((field: SortField) => {
if (field === null)
return
if (sortField === field) {
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
}
else {
setSortField(field)
setSortOrder('desc')
}
}, [sortField])
const sortedDocuments = useMemo(() => {
let filteredDocs = documents
if (statusFilterValue && statusFilterValue !== 'all') {
filteredDocs = filteredDocs.filter(doc =>
typeof doc.display_status === 'string'
&& normalizeStatusForQuery(doc.display_status) === statusFilterValue,
)
}
if (!sortField)
return filteredDocs
const sortedDocs = [...filteredDocs].sort((a, b) => {
let aValue: string | number
let bValue: string | number
switch (sortField) {
case 'name':
aValue = a.name?.toLowerCase() || ''
bValue = b.name?.toLowerCase() || ''
break
case 'word_count':
aValue = a.word_count || 0
bValue = b.word_count || 0
break
case 'hit_count':
aValue = a.hit_count || 0
bValue = b.hit_count || 0
break
case 'created_at':
aValue = a.created_at
bValue = b.created_at
break
default:
return 0
}
if (sortField === 'name') {
const result = (aValue as string).localeCompare(bValue as string)
return sortOrder === 'asc' ? result : -result
}
else {
const result = (aValue as number) - (bValue as number)
return sortOrder === 'asc' ? result : -result
}
})
return sortedDocs
}, [documents, sortField, sortOrder, statusFilterValue])
return {
sortField,
sortOrder,
handleSort,
sortedDocuments,
}
}

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