Merge branch 'feat/support-free-try-app' into deploy/dev
Some checks are pending
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions

This commit is contained in:
Joel 2026-01-12 13:49:28 +08:00
commit b34d09649b
159 changed files with 6426 additions and 791 deletions

View File

@ -5,5 +5,18 @@
"typescript-lsp@claude-plugins-official": true,
"pyright-lsp@claude-plugins-official": true,
"ralph-loop@claude-plugins-official": true
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "npx -y block-no-verify@1.1.1"
}
]
}
]
}
}

1
.nvmrc
View File

@ -1 +0,0 @@
22.11.0

View File

@ -189,7 +189,7 @@ storage = [
"opendal~=0.46.0",
"oss2==2.18.5",
"supabase~=2.18.1",
"tos~=2.7.1",
"tos~=2.9.0",
]
############################################################

53
api/uv.lock generated
View File

@ -1731,7 +1731,7 @@ storage = [
{ name = "opendal", specifier = "~=0.46.0" },
{ name = "oss2", specifier = "==2.18.5" },
{ name = "supabase", specifier = "~=2.18.1" },
{ name = "tos", specifier = "~=2.7.1" },
{ name = "tos", specifier = "~=2.9.0" },
]
tools = [
{ name = "cloudscraper", specifier = "~=1.2.71" },
@ -6148,7 +6148,7 @@ wheels = [
[[package]]
name = "tos"
version = "2.7.2"
version = "2.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "crcmod" },
@ -6156,8 +6156,9 @@ dependencies = [
{ name = "pytz" },
{ name = "requests" },
{ name = "six" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0c/01/f811af86f1f80d5f289be075c3b281e74bf3fe081cfbe5cfce44954d2c3a/tos-2.7.2.tar.gz", hash = "sha256:3c31257716785bca7b2cac51474ff32543cda94075a7b7aff70d769c15c7b7ed", size = 123407, upload-time = "2024-10-16T15:59:08.634Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/b3/13451226f564f88d9db2323e9b7eabcced792a0ad5ee1e333751a7634257/tos-2.9.0.tar.gz", hash = "sha256:861cfc348e770f099f911cb96b2c41774ada6c9c51b7a89d97e0c426074dd99e", size = 157071, upload-time = "2026-01-06T04:13:08.921Z" }
[[package]]
name = "tqdm"
@ -7146,31 +7147,31 @@ wheels = [
[[package]]
name = "wrapt"
version = "1.17.3"
version = "1.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" }
sdist = { url = "https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", size = 53972, upload-time = "2023-11-09T06:33:30.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" },
{ url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" },
{ url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" },
{ url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" },
{ url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" },
{ url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" },
{ url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" },
{ url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" },
{ url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" },
{ url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" },
{ url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" },
{ url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" },
{ url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" },
{ url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" },
{ url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" },
{ url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" },
{ url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" },
{ url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" },
{ url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" },
{ url = "https://files.pythonhosted.org/packages/fd/03/c188ac517f402775b90d6f312955a5e53b866c964b32119f2ed76315697e/wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09", size = 37313, upload-time = "2023-11-09T06:31:52.168Z" },
{ url = "https://files.pythonhosted.org/packages/0f/16/ea627d7817394db04518f62934a5de59874b587b792300991b3c347ff5e0/wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d", size = 38164, upload-time = "2023-11-09T06:31:53.522Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a7/f1212ba098f3de0fd244e2de0f8791ad2539c03bef6c05a9fcb03e45b089/wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389", size = 80890, upload-time = "2023-11-09T06:31:55.247Z" },
{ url = "https://files.pythonhosted.org/packages/b7/96/bb5e08b3d6db003c9ab219c487714c13a237ee7dcc572a555eaf1ce7dc82/wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060", size = 73118, upload-time = "2023-11-09T06:31:57.023Z" },
{ url = "https://files.pythonhosted.org/packages/6e/52/2da48b35193e39ac53cfb141467d9f259851522d0e8c87153f0ba4205fb1/wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1", size = 80746, upload-time = "2023-11-09T06:31:58.686Z" },
{ url = "https://files.pythonhosted.org/packages/11/fb/18ec40265ab81c0e82a934de04596b6ce972c27ba2592c8b53d5585e6bcd/wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3", size = 85668, upload-time = "2023-11-09T06:31:59.992Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ef/0ecb1fa23145560431b970418dce575cfaec555ab08617d82eb92afc7ccf/wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956", size = 78556, upload-time = "2023-11-09T06:32:01.942Z" },
{ url = "https://files.pythonhosted.org/packages/25/62/cd284b2b747f175b5a96cbd8092b32e7369edab0644c45784871528eb852/wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d", size = 85712, upload-time = "2023-11-09T06:32:03.686Z" },
{ url = "https://files.pythonhosted.org/packages/e5/a7/47b7ff74fbadf81b696872d5ba504966591a3468f1bc86bca2f407baef68/wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362", size = 35327, upload-time = "2023-11-09T06:32:05.284Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c3/0084351951d9579ae83a3d9e38c140371e4c6b038136909235079f2e6e78/wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89", size = 37523, upload-time = "2023-11-09T06:32:07.17Z" },
{ url = "https://files.pythonhosted.org/packages/92/17/224132494c1e23521868cdd57cd1e903f3b6a7ba6996b7b8f077ff8ac7fe/wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b", size = 37614, upload-time = "2023-11-09T06:32:08.859Z" },
{ url = "https://files.pythonhosted.org/packages/6a/d7/cfcd73e8f4858079ac59d9db1ec5a1349bc486ae8e9ba55698cc1f4a1dff/wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36", size = 38316, upload-time = "2023-11-09T06:32:10.719Z" },
{ url = "https://files.pythonhosted.org/packages/7e/79/5ff0a5c54bda5aec75b36453d06be4f83d5cd4932cc84b7cb2b52cee23e2/wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73", size = 86322, upload-time = "2023-11-09T06:32:12.592Z" },
{ url = "https://files.pythonhosted.org/packages/c4/81/e799bf5d419f422d8712108837c1d9bf6ebe3cb2a81ad94413449543a923/wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809", size = 79055, upload-time = "2023-11-09T06:32:14.394Z" },
{ url = "https://files.pythonhosted.org/packages/62/62/30ca2405de6a20448ee557ab2cd61ab9c5900be7cbd18a2639db595f0b98/wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b", size = 87291, upload-time = "2023-11-09T06:32:16.201Z" },
{ url = "https://files.pythonhosted.org/packages/49/4e/5d2f6d7b57fc9956bf06e944eb00463551f7d52fc73ca35cfc4c2cdb7aed/wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81", size = 90374, upload-time = "2023-11-09T06:32:18.052Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/c2c21b44ff5b9bf14a83252a8b973fb84923764ff63db3e6dfc3895cf2e0/wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9", size = 83896, upload-time = "2023-11-09T06:32:19.533Z" },
{ url = "https://files.pythonhosted.org/packages/14/26/93a9fa02c6f257df54d7570dfe8011995138118d11939a4ecd82cb849613/wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c", size = 91738, upload-time = "2023-11-09T06:32:20.989Z" },
{ url = "https://files.pythonhosted.org/packages/a2/5b/4660897233eb2c8c4de3dc7cefed114c61bacb3c28327e64150dc44ee2f6/wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc", size = 35568, upload-time = "2023-11-09T06:32:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/5c/cc/8297f9658506b224aa4bd71906447dea6bb0ba629861a758c28f67428b91/wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8", size = 37653, upload-time = "2023-11-09T06:32:24.533Z" },
{ url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362, upload-time = "2023-11-09T06:33:28.271Z" },
]
[[package]]

1
web/.nvmrc Normal file
View File

@ -0,0 +1 @@
22.21.1

View File

@ -11,6 +11,16 @@ Before starting the web frontend service, please make sure the following environ
- [Node.js](https://nodejs.org) >= v22.11.x
- [pnpm](https://pnpm.io) v10.x
> [!TIP]
> It is recommended to install and enable Corepack to manage package manager versions automatically:
>
> ```bash
> npm install -g corepack
> corepack enable
> ```
>
> Learn more: [Corepack](https://github.com/nodejs/corepack#readme)
First, install the dependencies:
```bash

View File

@ -274,9 +274,9 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
</div>
)}
{hasVar && (
<div className="mt-1 px-3 pb-3">
<div className={cn('mt-1 grid px-3 pb-3')}>
<ReactSortable
className="space-y-1"
className={cn('grid-col-1 grid space-y-1', readonly && 'grid-cols-2 gap-1 space-y-0')}
list={promptVariablesWithIds}
setList={(list) => { onPromptVariablesChange?.(list.map(item => item.variable)) }}
handle=".handle"

View File

@ -39,7 +39,7 @@ const VarItem: FC<ItemProps> = ({
const [isDeleting, setIsDeleting] = useState(false)
return (
<div className={cn('group relative mb-1 flex h-[34px] w-full items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2.5 pr-3 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover', readonly && 'cursor-not-allowed opacity-30', className)}>
<div className={cn('group relative mb-1 flex h-[34px] w-full items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2.5 pr-3 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover', readonly && 'cursor-not-allowed', className)}>
<VarIcon className={cn('mr-1 h-4 w-4 shrink-0 text-text-accent', canDrag && 'group-hover:opacity-0')} />
{canDrag && (
<RiDraggable className="absolute left-3 top-3 hidden h-3 w-3 cursor-pointer text-text-tertiary group-hover:block" />

View File

@ -1,5 +1,6 @@
'use client'
import type { FC } from 'react'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback } from 'react'
@ -10,14 +11,17 @@ import { useFeatures, useFeaturesStore } from '@/app/components/base/features/ho
import { Vision } from '@/app/components/base/icons/src/vender/features'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
// import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import ConfigContext from '@/context/debug-configuration'
import { Resolution } from '@/types/app'
import { cn } from '@/utils/classnames'
import ParamConfig from './param-config'
const ConfigVision: FC = () => {
const { t } = useTranslation()
const { isShowVisionConfig, isAllowVideoUpload } = useContext(ConfigContext)
const { isShowVisionConfig, isAllowVideoUpload, readonly } = useContext(ConfigContext)
const file = useFeatures(s => s.features.file)
const featuresStore = useFeaturesStore()
@ -54,7 +58,7 @@ const ConfigVision: FC = () => {
setFeatures(newFeatures)
}, [featuresStore, isAllowVideoUpload])
if (!isShowVisionConfig)
if (!isShowVisionConfig || (readonly && !isImageEnabled))
return null
return (
@ -75,37 +79,55 @@ const ConfigVision: FC = () => {
/>
</div>
<div className="flex shrink-0 items-center">
{/* <div className='mr-2 flex items-center gap-0.5'>
<div className='text-text-tertiary system-xs-medium-uppercase'>{t('appDebug.vision.visionSettings.resolution')}</div>
<Tooltip
popupContent={
<div className='w-[180px]' >
{t('appDebug.vision.visionSettings.resolutionTooltip').split('\n').map(item => (
<div key={item}>{item}</div>
))}
</div>
}
/>
</div> */}
{/* <div className='flex items-center gap-1'>
<OptionCard
title={t('appDebug.vision.visionSettings.high')}
selected={file?.image?.detail === Resolution.high}
onSelect={() => handleChange(Resolution.high)}
/>
<OptionCard
title={t('appDebug.vision.visionSettings.low')}
selected={file?.image?.detail === Resolution.low}
onSelect={() => handleChange(Resolution.low)}
/>
</div> */}
<ParamConfig />
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-regular"></div>
<Switch
defaultValue={isImageEnabled}
onChange={handleChange}
size="md"
/>
{readonly
? (
<>
<div className="mr-2 flex items-center gap-0.5">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('vision.visionSettings.resolution', { ns: 'appDebug' })}</div>
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => (
<div key={item}>{item}</div>
))}
</div>
)}
/>
</div>
<div className="flex items-center gap-1">
<OptionCard
title={t('vision.visionSettings.high', { ns: 'appDebug' })}
selected={file?.image?.detail === Resolution.high}
onSelect={noop}
className={cn(
'cursor-not-allowed rounded-lg px-3 hover:shadow-none',
file?.image?.detail !== Resolution.high && 'hover:border-components-option-card-option-border',
)}
/>
<OptionCard
title={t('vision.visionSettings.low', { ns: 'appDebug' })}
selected={file?.image?.detail === Resolution.low}
onSelect={noop}
className={cn(
'cursor-not-allowed rounded-lg px-3 hover:shadow-none',
file?.image?.detail !== Resolution.low && 'hover:border-components-option-card-option-border',
)}
/>
</div>
</>
)
: (
<>
<ParamConfig />
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-regular"></div>
<Switch
defaultValue={isImageEnabled}
onChange={handleChange}
size="md"
/>
</>
)}
</div>
</div>
)

View File

@ -40,7 +40,7 @@ type AgentToolWithMoreInfo = AgentTool & { icon: any, collection?: Collection }
const AgentTools: FC = () => {
const { t } = useTranslation()
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
const { modelConfig, setModelConfig } = useContext(ConfigContext)
const { readonly, modelConfig, setModelConfig } = useContext(ConfigContext)
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
@ -168,10 +168,10 @@ const AgentTools: FC = () => {
{tools.filter(item => !!item.enabled).length}
/
{tools.length}
&nbsp;
&nbsp;
{t('agent.tools.enabled', { ns: 'appDebug' })}
</div>
{tools.length < MAX_TOOLS_NUM && (
{tools.length < MAX_TOOLS_NUM && !readonly && (
<>
<div className="ml-3 mr-1 h-3.5 w-px bg-divider-regular"></div>
<ToolPicker
@ -190,7 +190,7 @@ const AgentTools: FC = () => {
</div>
)}
>
<div className="grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2">
<div className={cn('grid grid-cols-1 items-center gap-1 2xl:grid-cols-2', readonly && 'cursor-not-allowed grid-cols-2')}>
{tools.map((item: AgentTool & { icon: any, collection?: Collection }, index) => (
<div
key={index}
@ -215,7 +215,7 @@ const AgentTools: FC = () => {
>
<span className="system-xs-medium pr-1.5 text-text-secondary">{getProviderShowName(item)}</span>
<span className="text-text-tertiary">{item.tool_label}</span>
{!item.isDeleted && (
{!item.isDeleted && !readonly && (
<Tooltip
popupContent={(
<div className="w-[180px]">
@ -260,7 +260,7 @@ const AgentTools: FC = () => {
</div>
</div>
)}
{!item.isDeleted && (
{!item.isDeleted && !readonly && (
<div className="mr-2 hidden items-center gap-1 group-hover:flex">
{!item.notAuthor && (
<Tooltip
@ -299,7 +299,7 @@ const AgentTools: FC = () => {
{!item.notAuthor && (
<Switch
defaultValue={item.isDeleted ? false : item.enabled}
disabled={item.isDeleted}
disabled={item.isDeleted || readonly}
size="md"
onChange={(enabled) => {
const newModelConfig = produce(modelConfig, (draft) => {
@ -313,6 +313,7 @@ const AgentTools: FC = () => {
{item.notAuthor && (
<Button
variant="secondary"
disabled={readonly}
size="small"
onClick={() => {
setCurrentTool(item)

View File

@ -17,7 +17,7 @@ const ConfigAudio: FC = () => {
const { t } = useTranslation()
const file = useFeatures(s => s.features.file)
const featuresStore = useFeaturesStore()
const { isShowAudioConfig } = useContext(ConfigContext)
const { isShowAudioConfig, readonly } = useContext(ConfigContext)
const isAudioEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.audio) ?? false
@ -45,7 +45,7 @@ const ConfigAudio: FC = () => {
setFeatures(newFeatures)
}, [featuresStore])
if (!isShowAudioConfig)
if (!isShowAudioConfig || (readonly && !isAudioEnabled))
return null
return (
@ -65,14 +65,16 @@ const ConfigAudio: FC = () => {
)}
/>
</div>
<div className="flex shrink-0 items-center">
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
<Switch
defaultValue={isAudioEnabled}
onChange={handleChange}
size="md"
/>
</div>
{!readonly && (
<div className="flex shrink-0 items-center">
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
<Switch
defaultValue={isAudioEnabled}
onChange={handleChange}
size="md"
/>
</div>
)}
</div>
)
}

View File

@ -17,7 +17,7 @@ const ConfigDocument: FC = () => {
const { t } = useTranslation()
const file = useFeatures(s => s.features.file)
const featuresStore = useFeaturesStore()
const { isShowDocumentConfig } = useContext(ConfigContext)
const { isShowDocumentConfig, readonly } = useContext(ConfigContext)
const isDocumentEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.document) ?? false
@ -45,7 +45,7 @@ const ConfigDocument: FC = () => {
setFeatures(newFeatures)
}, [featuresStore])
if (!isShowDocumentConfig)
if (!isShowDocumentConfig || (readonly && !isDocumentEnabled))
return null
return (
@ -65,14 +65,16 @@ const ConfigDocument: FC = () => {
)}
/>
</div>
<div className="flex shrink-0 items-center">
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
<Switch
defaultValue={isDocumentEnabled}
onChange={handleChange}
size="md"
/>
</div>
{!readonly && (
<div className="flex shrink-0 items-center">
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
<Switch
defaultValue={isDocumentEnabled}
onChange={handleChange}
size="md"
/>
</div>
)}
</div>
)
}

View File

@ -18,6 +18,7 @@ import ConfigDocument from './config-document'
const Config: FC = () => {
const {
readonly,
mode,
isAdvancedMode,
modelModeType,
@ -27,6 +28,7 @@ const Config: FC = () => {
modelConfig,
setModelConfig,
setPrevPromptConfig,
dataSets,
} = useContext(ConfigContext)
const isChatApp = [AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes(mode)
const formattingChangedDispatcher = useFormattingChangedDispatcher()
@ -65,19 +67,27 @@ const Config: FC = () => {
promptTemplate={promptTemplate}
promptVariables={promptVariables}
onChange={handlePromptChange}
readonly={readonly}
/>
{/* Variables */}
<ConfigVar
promptVariables={promptVariables}
onPromptVariablesChange={handlePromptVariablesNameChange}
/>
{!(readonly && promptVariables.length === 0) && (
<ConfigVar
promptVariables={promptVariables}
onPromptVariablesChange={handlePromptVariablesNameChange}
readonly={readonly}
/>
)}
{/* Dataset */}
<DatasetConfig />
{!(readonly && dataSets.length === 0) && (
<DatasetConfig
readonly={readonly}
hideMetadataFilter={readonly}
/>
)}
{/* Tools */}
{isAgent && (
{isAgent && !(readonly && modelConfig.agentConfig.tools.length === 0) && (
<AgentTools />
)}
@ -88,7 +98,7 @@ const Config: FC = () => {
<ConfigAudio />
{/* Chat History */}
{isAdvancedMode && isChatApp && modelModeType === ModelModeType.completion && (
{!readonly && isAdvancedMode && isChatApp && modelModeType === ModelModeType.completion && (
<HistoryPanel
showWarning={!hasSetBlockStatus.history}
onShowEditModal={showHistoryModal}

View File

@ -30,6 +30,7 @@ const Item: FC<ItemProps> = ({
config,
onSave,
onRemove,
readonly = false,
editable = true,
}) => {
const media = useBreakpoints()
@ -56,6 +57,7 @@ const Item: FC<ItemProps> = ({
<div className={cn(
'group relative mb-1 flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover',
isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover',
readonly && 'cursor-not-allowed',
)}
>
<div className="flex w-0 grow items-center space-x-1.5">
@ -70,7 +72,7 @@ const Item: FC<ItemProps> = ({
</div>
<div className="ml-2 hidden shrink-0 items-center space-x-1 group-hover:flex">
{
editable && (
editable && !readonly && (
<ActionButton
onClick={(e) => {
e.stopPropagation()
@ -81,14 +83,18 @@ const Item: FC<ItemProps> = ({
</ActionButton>
)
}
<ActionButton
onClick={() => onRemove(config.id)}
state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default}
onMouseEnter={() => setIsDeleting(true)}
onMouseLeave={() => setIsDeleting(false)}
>
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary', isDeleting && 'text-text-destructive')} />
</ActionButton>
{
!readonly && (
<ActionButton
onClick={() => onRemove(config.id)}
state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default}
onMouseEnter={() => setIsDeleting(true)}
onMouseLeave={() => setIsDeleting(false)}
>
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary', isDeleting && 'text-text-destructive')} />
</ActionButton>
)
}
</div>
{
config.indexing_technique && (
@ -107,11 +113,13 @@ const Item: FC<ItemProps> = ({
)
}
<Drawer isOpen={showSettingsModal} onClose={() => setShowSettingsModal(false)} footer={null} mask={isMobile} panelClassName="mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl">
<SettingsModal
currentDataset={config}
onCancel={() => setShowSettingsModal(false)}
onSave={handleSave}
/>
{showSettingsModal && (
<SettingsModal
currentDataset={config}
onCancel={() => setShowSettingsModal(false)}
onSave={handleSave}
/>
)}
</Drawer>
</div>
)

View File

@ -30,6 +30,7 @@ import {
import { useSelector as useAppContextSelector } from '@/context/app-context'
import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { hasEditPermissionForDataset } from '@/utils/permission'
import FeaturePanel from '../base/feature-panel'
import OperationBtn from '../base/operation-btn'
@ -38,7 +39,11 @@ import CardItem from './card-item'
import ContextVar from './context-var'
import ParamsConfig from './params-config'
const DatasetConfig: FC = () => {
type Props = {
readonly?: boolean
hideMetadataFilter?: boolean
}
const DatasetConfig: FC<Props> = ({ readonly, hideMetadataFilter }) => {
const { t } = useTranslation()
const userProfile = useAppContextSelector(s => s.userProfile)
const {
@ -259,17 +264,19 @@ const DatasetConfig: FC = () => {
className="mt-2"
title={t('feature.dataSet.title', { ns: 'appDebug' })}
headerRight={(
<div className="flex items-center gap-1">
{!isAgent && <ParamsConfig disabled={!hasData} selectedDatasets={dataSet} />}
<OperationBtn type="add" onClick={showSelectDataSet} />
</div>
!readonly && (
<div className="flex items-center gap-1">
{!isAgent && <ParamsConfig disabled={!hasData} selectedDatasets={dataSet} />}
<OperationBtn type="add" onClick={showSelectDataSet} />
</div>
)
)}
hasHeaderBottomBorder={!hasData}
noBodySpacing
>
{hasData
? (
<div className="mt-1 flex flex-wrap justify-between px-3 pb-3">
<div className={cn('mt-1 grid grid-cols-1 px-3 pb-3', readonly && 'grid-cols-2 gap-1')}>
{formattedDataset.map(item => (
<CardItem
key={item.id}
@ -277,6 +284,7 @@ const DatasetConfig: FC = () => {
onRemove={onRemove}
onSave={handleSave}
editable={item.editable}
readonly={readonly}
/>
))}
</div>
@ -287,27 +295,29 @@ const DatasetConfig: FC = () => {
</div>
)}
<div className="border-t border-t-divider-subtle py-2">
<MetadataFilter
metadataList={metadataList}
selectedDatasetsLoaded
metadataFilterMode={datasetConfigs.metadata_filtering_mode}
metadataFilteringConditions={datasetConfigs.metadata_filtering_conditions}
handleAddCondition={handleAddCondition}
handleMetadataFilterModeChange={handleMetadataFilterModeChange}
handleRemoveCondition={handleRemoveCondition}
handleToggleConditionLogicalOperator={handleToggleConditionLogicalOperator}
handleUpdateCondition={handleUpdateCondition}
metadataModelConfig={datasetConfigs.metadata_model_config}
handleMetadataModelChange={handleMetadataModelChange}
handleMetadataCompletionParamsChange={handleMetadataCompletionParamsChange}
isCommonVariable
availableCommonStringVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.string || item.type === MetadataFilteringVariableType.select)}
availableCommonNumberVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.number)}
/>
</div>
{!hideMetadataFilter && (
<div className="border-t border-t-divider-subtle py-2">
<MetadataFilter
metadataList={metadataList}
selectedDatasetsLoaded
metadataFilterMode={datasetConfigs.metadata_filtering_mode}
metadataFilteringConditions={datasetConfigs.metadata_filtering_conditions}
handleAddCondition={handleAddCondition}
handleMetadataFilterModeChange={handleMetadataFilterModeChange}
handleRemoveCondition={handleRemoveCondition}
handleToggleConditionLogicalOperator={handleToggleConditionLogicalOperator}
handleUpdateCondition={handleUpdateCondition}
metadataModelConfig={datasetConfigs.metadata_model_config}
handleMetadataModelChange={handleMetadataModelChange}
handleMetadataCompletionParamsChange={handleMetadataCompletionParamsChange}
isCommonVariable
availableCommonStringVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.string || item.type === MetadataFilteringVariableType.select)}
availableCommonNumberVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.number)}
/>
</div>
)}
{mode === AppModeEnum.COMPLETION && dataSet.length > 0 && (
{!readonly && mode === AppModeEnum.COMPLETION && dataSet.length > 0 && (
<ContextVar
value={selectedContextVar?.key}
options={promptVariablesToSelect}

View File

@ -19,7 +19,7 @@ const ChatUserInput = ({
inputs,
}: Props) => {
const { t } = useTranslation()
const { modelConfig, setInputs } = useContext(ConfigContext)
const { modelConfig, setInputs, readonly } = useContext(ConfigContext)
const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => {
return key && key?.trim() && name && name?.trim()
@ -89,6 +89,7 @@ const ChatUserInput = ({
placeholder={name}
autoFocus={index === 0}
maxLength={max_length || DEFAULT_VALUE_MAX_LEN}
readOnly={readonly}
/>
)}
{type === 'paragraph' && (
@ -97,6 +98,7 @@ const ChatUserInput = ({
placeholder={name}
value={inputs[key] ? `${inputs[key]}` : ''}
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
readOnly={readonly}
/>
)}
{type === 'select' && (
@ -106,6 +108,7 @@ const ChatUserInput = ({
onSelect={(i) => { handleInputValueChange(key, i.value as string) }}
items={(options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
disabled={readonly}
/>
)}
{type === 'number' && (
@ -116,6 +119,7 @@ const ChatUserInput = ({
placeholder={name}
autoFocus={index === 0}
maxLength={max_length || DEFAULT_VALUE_MAX_LEN}
readOnly={readonly}
/>
)}
{type === 'checkbox' && (
@ -124,6 +128,7 @@ const ChatUserInput = ({
value={!!inputs[key]}
required={required}
onChange={(value) => { handleInputValueChange(key, value) }}
readonly={readonly}
/>
)}
</div>

View File

@ -15,6 +15,7 @@ import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context'
import { AppSourceType } from '@/service/share'
import { promptVariablesToUserInputsForm } from '@/utils/model-config'
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
@ -130,11 +131,11 @@ const TextGenerationItem: FC<TextGenerationItemProps> = ({
return (
<TextGeneration
appSourceType={AppSourceType.webApp}
className="flex h-full flex-col overflow-y-auto border-none"
content={completion}
isLoading={!completion && isResponding}
isResponding={isResponding}
isInstalledApp={false}
siteInfo={null}
messageId={messageId}
isError={false}

View File

@ -39,6 +39,7 @@ const DebugWithSingleModel = (
) => {
const { userProfile } = useAppContext()
const {
readonly,
modelConfig,
appId,
inputs,
@ -150,6 +151,7 @@ const DebugWithSingleModel = (
return (
<Chat
readonly={readonly}
config={config}
chatList={chatList}
isResponding={isResponding}

View File

@ -38,6 +38,7 @@ import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context'
import { sendCompletionMessage } from '@/service/debug'
import { AppSourceType } from '@/service/share'
import { AppModeEnum, ModelModeType, TransferMethod } from '@/types/app'
import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config'
import GroupName from '../base/group-name'
@ -72,6 +73,7 @@ const Debug: FC<IDebug> = ({
}) => {
const { t } = useTranslation()
const {
readonly,
appId,
mode,
modelModeType,
@ -416,25 +418,33 @@ const Debug: FC<IDebug> = ({
}
{mode !== AppModeEnum.COMPLETION && (
<>
<TooltipPlus
popupContent={t('operation.refresh', { ns: 'common' })}
>
<ActionButton onClick={clearConversation}>
<RefreshCcw01 className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
{varList.length > 0 && (
<div className="relative ml-1 mr-2">
{
!readonly && (
<TooltipPlus
popupContent={t('panel.userInputField', { ns: 'workflow' })}
popupContent={t('operation.refresh', { ns: 'common' })}
>
<ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => setExpanded(!expanded)}>
<RiEqualizer2Line className="h-4 w-4" />
<ActionButton onClick={clearConversation}>
<RefreshCcw01 className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
{expanded && <div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />}
</div>
)}
)
}
{
varList.length > 0 && (
<div className="relative ml-1 mr-2">
<TooltipPlus
popupContent={t('panel.userInputField', { ns: 'workflow' })}
>
<ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => !readonly && setExpanded(!expanded)}>
<RiEqualizer2Line className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
{expanded && <div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />}
</div>
)
}
</>
)}
</div>
@ -444,19 +454,21 @@ const Debug: FC<IDebug> = ({
<ChatUserInput inputs={inputs} />
</div>
)}
{mode === AppModeEnum.COMPLETION && (
<PromptValuePanel
appType={mode as AppModeEnum}
onSend={handleSendTextCompletion}
inputs={inputs}
visionConfig={{
...features.file! as VisionSettings,
transfer_methods: features.file!.allowed_file_upload_methods || [],
image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit,
}}
onVisionFilesChange={setCompletionFiles}
/>
)}
{
mode === AppModeEnum.COMPLETION && (
<PromptValuePanel
appType={mode as AppModeEnum}
onSend={handleSendTextCompletion}
inputs={inputs}
visionConfig={{
...features.file! as VisionSettings,
transfer_methods: features.file!.allowed_file_upload_methods || [],
image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit,
}}
onVisionFilesChange={setCompletionFiles}
/>
)
}
</div>
{
debugWithMultipleModel && (
@ -510,12 +522,12 @@ const Debug: FC<IDebug> = ({
<div className="mx-4 mt-3"><GroupName name={t('result', { ns: 'appDebug' })} /></div>
<div className="mx-3 mb-8">
<TextGeneration
appSourceType={AppSourceType.webApp}
className="mt-2"
content={completionRes}
isLoading={!completionRes && isResponding}
isShowTextToSpeech={textToSpeechConfig.enabled && !!text2speechDefaultModel}
isResponding={isResponding}
isInstalledApp={false}
messageId={messageId}
isError={false}
onRetry={noop}
@ -550,13 +562,15 @@ const Debug: FC<IDebug> = ({
</div>
)
}
{isShowFormattingChangeConfirm && (
<FormattingChanged
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
)}
{!isAPIKeySet && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
{
isShowFormattingChangeConfirm && (
<FormattingChanged
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
)
}
{!isAPIKeySet && !readonly && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
</>
)
}

View File

@ -41,7 +41,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
onVisionFilesChange,
}) => {
const { t } = useTranslation()
const { modelModeType, modelConfig, setInputs, mode, isAdvancedMode, completionPromptConfig, chatPromptConfig } = useContext(ConfigContext)
const { readonly, modelModeType, modelConfig, setInputs, mode, isAdvancedMode, completionPromptConfig, chatPromptConfig } = useContext(ConfigContext)
const [userInputFieldCollapse, setUserInputFieldCollapse] = useState(false)
const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => {
return key && key?.trim() && name && name?.trim()
@ -79,12 +79,12 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
if (isAdvancedMode) {
if (modelModeType === ModelModeType.chat)
return chatPromptConfig.prompt.every(({ text }) => !text)
return chatPromptConfig?.prompt.every(({ text }) => !text)
return !completionPromptConfig.prompt?.text
}
else { return !modelConfig.configs.prompt_template }
}, [chatPromptConfig.prompt, completionPromptConfig.prompt?.text, isAdvancedMode, mode, modelConfig.configs.prompt_template, modelModeType])
}, [chatPromptConfig?.prompt, completionPromptConfig.prompt?.text, isAdvancedMode, mode, modelConfig.configs.prompt_template, modelModeType])
const handleInputValueChange = (key: string, value: string | boolean) => {
if (!(key in promptVariableObj))
@ -143,6 +143,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
placeholder={name}
autoFocus={index === 0}
maxLength={max_length || DEFAULT_VALUE_MAX_LEN}
readOnly={readonly}
/>
)}
{type === 'paragraph' && (
@ -151,6 +152,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
placeholder={name}
value={inputs[key] ? `${inputs[key]}` : ''}
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
readOnly={readonly}
/>
)}
{type === 'select' && (
@ -161,6 +163,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
items={(options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
bgClassName="bg-gray-50"
disabled={readonly}
/>
)}
{type === 'number' && (
@ -171,6 +174,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
placeholder={name}
autoFocus={index === 0}
maxLength={max_length || DEFAULT_VALUE_MAX_LEN}
readOnly={readonly}
/>
)}
{type === 'checkbox' && (
@ -179,6 +183,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
value={!!inputs[key]}
required={required}
onChange={(value) => { handleInputValueChange(key, value) }}
readonly={readonly}
/>
)}
</div>
@ -197,6 +202,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
url: fileItem.url,
upload_file_id: fileItem.fileId,
})))}
disabled={readonly}
/>
</div>
</div>
@ -205,12 +211,12 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
)}
{!userInputFieldCollapse && (
<div className="flex justify-between border-t border-divider-subtle p-4 pt-3">
<Button className="w-[72px]" onClick={onClear}>{t('operation.clear', { ns: 'common' })}</Button>
<Button className="w-[72px]" disabled={readonly} onClick={onClear}>{t('operation.clear', { ns: 'common' })}</Button>
{canNotRun && (
<Tooltip popupContent={t('otherError.promptNoBeEmpty', { ns: 'appDebug' })}>
<Button
variant="primary"
disabled={canNotRun}
disabled={canNotRun || readonly}
onClick={() => onSend?.()}
className="w-[96px]"
>
@ -222,7 +228,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
{!canNotRun && (
<Button
variant="primary"
disabled={canNotRun}
disabled={canNotRun || readonly}
onClick={() => onSend?.()}
className="w-[96px]"
>
@ -238,6 +244,8 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
showFileUpload={false}
isChatMode={appType !== AppModeEnum.COMPLETION}
onFeatureBarClick={setShowAppConfigureFeaturesModal}
disabled={readonly}
hideEditEntrance={readonly}
/>
</div>
</>

View File

@ -10,6 +10,7 @@ vi.mock('@heroicons/react/20/solid', () => ({
}))
const mockApp: App = {
can_trial: true,
app: {
id: 'test-app-id',
mode: AppModeEnum.CHAT,

View File

@ -1,9 +1,14 @@
'use client'
import type { App } from '@/models/explore'
import { PlusIcon } from '@heroicons/react/20/solid'
import { RiInformation2Line } from '@remixicon/react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import AppListContext from '@/context/app-list-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { cn } from '@/utils/classnames'
import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
@ -20,6 +25,14 @@ const AppCard = ({
}: AppCardProps) => {
const { t } = useTranslation()
const { app: appBasicInfo } = app
const { systemFeatures } = useGlobalPublicStore()
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel)
const showTryAPPPanel = useCallback((appId: string) => {
return () => {
setShowTryAppPanel?.(true, { appId, app })
}
}, [setShowTryAppPanel, app.category])
return (
<div className={cn('group relative flex h-[132px] cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 shadow-xs hover:shadow-lg')}>
<div className="flex shrink-0 grow-0 items-center gap-3 pb-2">
@ -51,11 +64,17 @@ const AppCard = ({
</div>
{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('flex h-8 w-full items-center space-x-2')}>
<Button variant="primary" className="grow" onClick={() => onCreate()}>
<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>
)}
</div>
</div>
)}

View File

@ -39,6 +39,7 @@ import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useTimestamp from '@/hooks/use-timestamp'
import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log'
import { AppSourceType } from '@/service/share'
import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
@ -638,12 +639,12 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
</div>
</div>
<TextGeneration
appSourceType={AppSourceType.webApp}
className="mt-2"
content={detail.message.answer}
messageId={detail.message.id}
isError={false}
onRetry={noop}
isInstalledApp={false}
supportFeedback
feedback={detail.message.feedbacks.find((item: any) => item.from_source === 'admin')}
onFeedback={feedback => onFeedback(detail.message.id, feedback)}

View File

@ -29,7 +29,7 @@ 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 { fetchMoreLikeThis, updateFeedback } from '@/service/share'
import { AppSourceType, fetchMoreLikeThis, updateFeedback } from '@/service/share'
import { cn } from '@/utils/classnames'
import ResultTab from './result-tab'
@ -53,7 +53,7 @@ export type IGenerationItemProps = {
onFeedback?: (feedback: FeedbackType) => void
onSave?: (messageId: string) => void
isMobile?: boolean
isInstalledApp: boolean
appSourceType: AppSourceType
installedAppId?: string
taskId?: string
controlClearMoreLikeThis?: number
@ -87,7 +87,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
onSave,
depth = 1,
isMobile,
isInstalledApp,
appSourceType,
installedAppId,
taskId,
controlClearMoreLikeThis,
@ -100,6 +100,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
const { t } = useTranslation()
const params = useParams()
const isTop = depth === 1
const isTryApp = appSourceType === AppSourceType.tryApp
const [completionRes, setCompletionRes] = useState('')
const [childMessageId, setChildMessageId] = useState<string | null>(null)
const [childFeedback, setChildFeedback] = useState<FeedbackType>({
@ -113,7 +114,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal)
const handleFeedback = async (childFeedback: FeedbackType) => {
await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, isInstalledApp, installedAppId)
await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, appSourceType, installedAppId)
setChildFeedback(childFeedback)
}
@ -131,7 +132,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
onSave,
isShowTextToSpeech,
isMobile,
isInstalledApp,
appSourceType,
installedAppId,
controlClearMoreLikeThis,
isWorkflow,
@ -145,7 +146,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
return
}
startQuerying()
const res: any = await fetchMoreLikeThis(messageId as string, isInstalledApp, installedAppId)
const res: any = await fetchMoreLikeThis(messageId as string, appSourceType, installedAppId)
setCompletionRes(res.answer)
setChildFeedback({
rating: null,
@ -310,7 +311,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
)}
{/* action buttons */}
<div className="absolute bottom-1 right-2 flex items-center">
{!isInWebApp && !isInstalledApp && !isResponding && (
{!isInWebApp && (appSourceType !== AppSourceType.installedApp) && !isResponding && (
<div className="ml-1 flex 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">
<ActionButton disabled={isError || !messageId} onClick={handleOpenLogModal}>
<RiFileList3Line className="h-4 w-4" />
@ -319,12 +320,12 @@ const GenerationItem: FC<IGenerationItemProps> = ({
</div>
)}
<div className="ml-1 flex 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">
{moreLikeThis && (
{moreLikeThis && !isTryApp && (
<ActionButton state={depth === MAX_DEPTH ? ActionButtonState.Disabled : ActionButtonState.Default} disabled={depth === MAX_DEPTH} onClick={handleMoreLikeThis}>
<RiSparklingLine className="h-4 w-4" />
</ActionButton>
)}
{isShowTextToSpeech && (
{isShowTextToSpeech && !isTryApp && (
<NewAudioButton
id={messageId!}
voice={config?.text_to_speech?.voice}
@ -350,13 +351,13 @@ const GenerationItem: FC<IGenerationItemProps> = ({
<RiReplay15Line className="h-4 w-4" />
</ActionButton>
)}
{isInWebApp && !isWorkflow && (
{isInWebApp && !isWorkflow && !isTryApp && (
<ActionButton disabled={isError || !messageId} onClick={() => { onSave?.(messageId as string) }}>
<RiBookmark3Line className="h-4 w-4" />
</ActionButton>
)}
</div>
{(supportFeedback || isInWebApp) && !isWorkflow && !isError && messageId && (
{(supportFeedback || isInWebApp) && !isWorkflow && !isTryApp && !isError && messageId && (
<div className="ml-1 flex 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">
{!feedback?.rating && (
<>

View File

@ -1,7 +1,17 @@
'use client'
import type { CreateAppModalProps } from '../explore/create-app-modal'
import type { CurrentTryAppParams } from '@/context/explore-context'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEducationInit } from '@/app/education-apply/hooks'
import AppListContext from '@/context/app-list-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { useImportDSL } from '@/hooks/use-import-dsl'
import { DSLImportMode } from '@/models/app'
import { fetchAppDetail } from '@/service/explore'
import DSLConfirmModal from '../app/create-from-dsl-modal/dsl-confirm-modal'
import CreateAppModal from '../explore/create-app-modal'
import TryApp from '../explore/try-app'
import List from './list'
const Apps = () => {
@ -10,10 +20,124 @@ const Apps = () => {
useDocumentTitle(t('menus.apps', { ns: 'common' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<CurrentTryAppParams | undefined>(undefined)
const currApp = currentTryAppParams?.app
const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false)
const hideTryAppPanel = useCallback(() => {
setIsShowTryAppPanel(false)
}, [])
const setShowTryAppPanel = (showTryAppPanel: boolean, params?: CurrentTryAppParams) => {
if (showTryAppPanel)
setCurrentTryAppParams(params)
else
setCurrentTryAppParams(undefined)
setIsShowTryAppPanel(showTryAppPanel)
}
const [isShowCreateModal, setIsShowCreateModal] = useState(false)
const handleShowFromTryApp = useCallback(() => {
setIsShowCreateModal(true)
}, [])
const [controlRefreshList, setControlRefreshList] = useState(0)
const [controlHideCreateFromTemplatePanel, setControlHideCreateFromTemplatePanel] = useState(0)
const onSuccess = useCallback(() => {
setControlRefreshList(prev => prev + 1)
setControlHideCreateFromTemplatePanel(prev => prev + 1)
}, [])
const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false)
const {
handleImportDSL,
handleImportDSLConfirm,
versions,
isFetching,
} = useImportDSL()
const onConfirmDSL = useCallback(async () => {
await handleImportDSLConfirm({
onSuccess,
})
}, [handleImportDSLConfirm, onSuccess])
const onCreate: CreateAppModalProps['onConfirm'] = async ({
name,
icon_type,
icon,
icon_background,
description,
}) => {
hideTryAppPanel()
const { export_data } = await fetchAppDetail(
currApp?.app.id as string,
)
const payload = {
mode: DSLImportMode.YAML_CONTENT,
yaml_content: export_data,
name,
icon_type,
icon,
icon_background,
description,
}
await handleImportDSL(payload, {
onSuccess: () => {
setIsShowCreateModal(false)
},
onPending: () => {
setShowDSLConfirmModal(true)
},
})
}
return (
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List />
</div>
<AppListContext.Provider value={{
currentApp: currentTryAppParams,
isShowTryAppPanel,
setShowTryAppPanel,
controlHideCreateFromTemplatePanel,
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List controlRefreshList={controlRefreshList} />
{isShowTryAppPanel && (
<TryApp
appId={currentTryAppParams?.appId || ''}
category={currentTryAppParams?.app?.category}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}
/>
)}
{
showDSLConfirmModal && (
<DSLConfirmModal
versions={versions}
onCancel={() => setShowDSLConfirmModal(false)}
onConfirm={onConfirmDSL}
confirmDisabled={isFetching}
/>
)
}
{isShowCreateModal && (
<CreateAppModal
appIconType={currApp?.app.icon_type || 'emoji'}
appIcon={currApp?.app.icon || ''}
appIconBackground={currApp?.app.icon_background || ''}
appIconUrl={currApp?.app.icon_url}
appName={currApp?.app.name || ''}
appDescription={currApp?.app.description || ''}
show
onConfirm={onCreate}
confirmDisabled={isFetching}
onHide={() => setIsShowCreateModal(false)}
/>
)}
</div>
</AppListContext.Provider>
)
}

View File

@ -1,5 +1,6 @@
'use client'
import type { FC } from 'react'
import {
RiApps2Line,
RiDragDropLine,
@ -29,6 +30,7 @@ import { CheckModal } from '@/hooks/use-pay'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { isServer } from '@/utils/client'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import Empty from './empty'
@ -54,7 +56,12 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false,
})
const List = () => {
type Props = {
controlRefreshList?: number
}
const List: FC<Props> = ({
controlRefreshList = 0,
}) => {
const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
const router = useRouter()
@ -71,7 +78,7 @@ const List = () => {
// 1) Normalize legacy/incorrect query params like ?mode=discover -> ?category=all
useEffect(() => {
// avoid running on server
if (typeof window === 'undefined')
if (isServer)
return
const mode = searchParams.get('mode')
if (!mode)
@ -139,6 +146,13 @@ const List = () => {
refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
useEffect(() => {
if (controlRefreshList > 0) {
refetch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlRefreshList])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <RiApps2Line className="mr-1 h-[14px] w-[14px]" /> },

View File

@ -6,10 +6,12 @@ import {
useSearchParams,
} from 'next/navigation'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import AppListContext from '@/context/app-list-context'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
@ -55,6 +57,12 @@ const CreateAppCard = ({
return undefined
}, [dslUrl])
const controlHideCreateFromTemplatePanel = useContextSelector(AppListContext, ctx => ctx.controlHideCreateFromTemplatePanel)
useEffect(() => {
if (controlHideCreateFromTemplatePanel > 0)
setShowNewAppTemplateDialog(false)
}, [controlHideCreateFromTemplatePanel])
return (
<div
ref={ref}

View File

@ -51,11 +51,15 @@ function getActionButtonState(state: ActionButtonState) {
}
}
const ActionButton = ({ className, size, state = ActionButtonState.Default, styleCss, children, ref, ...props }: ActionButtonProps) => {
const ActionButton = ({ className, size, state = ActionButtonState.Default, styleCss, children, ref, disabled, ...props }: ActionButtonProps) => {
return (
<button
type="button"
className={cn(actionButtonVariants({ className, size }), getActionButtonState(state))}
className={cn(
actionButtonVariants({ className, size }),
getActionButtonState(state),
disabled && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
)}
ref={ref}
style={styleCss}
{...props}

View File

@ -0,0 +1,59 @@
import {
RiCloseLine,
RiInformation2Fill,
} from '@remixicon/react'
import { cva } from 'class-variance-authority'
import {
memo,
} from 'react'
import { cn } from '@/utils/classnames'
type Props = {
type?: 'info'
message: string
onHide: () => void
className?: string
}
const bgVariants = cva(
'',
{
variants: {
type: {
info: 'from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent',
},
},
},
)
const Alert: React.FC<Props> = ({
type = 'info',
message,
onHide,
className,
}) => {
return (
<div className={cn('pointer-events-none w-full', className)}>
<div
className="relative flex space-x-1 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg"
>
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r opacity-[0.4]', bgVariants({ type }))}>
</div>
<div className="flex h-6 w-6 items-center justify-center">
<RiInformation2Fill className="text-text-accent" />
</div>
<div className="p-1">
<div className="system-xs-regular text-text-secondary">
{message}
</div>
</div>
<div
className="pointer-events-auto flex h-6 w-6 cursor-pointer items-center justify-center"
onClick={onHide}
>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
</div>
</div>
)
}
export default memo(Alert)

View File

@ -1,5 +1,5 @@
import Toast from '@/app/components/base/toast'
import { textToAudioStream } from '@/service/share'
import { AppSourceType, textToAudioStream } from '@/service/share'
declare global {
// eslint-disable-next-line ts/consistent-type-definitions
@ -100,7 +100,7 @@ export default class AudioPlayer {
private async loadAudio() {
try {
const audioResponse: any = await textToAudioStream(this.url, this.isPublic, { content_type: 'audio/mpeg' }, {
const audioResponse: any = await textToAudioStream(this.url, this.isPublic ? AppSourceType.webApp : AppSourceType.installedApp, { content_type: 'audio/mpeg' }, {
message_id: this.msgId,
streaming: true,
voice: this.voice,

View File

@ -0,0 +1,226 @@
import type { UseEmblaCarouselType } from 'embla-carousel-react'
import Autoplay from 'embla-carousel-autoplay'
import useEmblaCarousel from 'embla-carousel-react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: 'horizontal' | 'vertical'
}
type CarouselContextValue = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
selectedIndex: number
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextValue | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context)
throw new Error('useCarousel must be used within a <Carousel />')
return context
}
type TCarousel = {
Content: typeof CarouselContent
Item: typeof CarouselItem
Previous: typeof CarouselPrevious
Next: typeof CarouselNext
Dot: typeof CarouselDot
Plugin: typeof CarouselPlugins
} & React.ForwardRefExoticComponent<
React.HTMLAttributes<HTMLDivElement> & CarouselProps & React.RefAttributes<CarouselContextValue>
>
const Carousel: TCarousel = React.forwardRef(
({ orientation = 'horizontal', opts, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{ ...opts, axis: orientation === 'horizontal' ? 'x' : 'y' },
plugins,
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const [selectedIndex, setSelectedIndex] = React.useState(0)
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
React.useEffect(() => {
if (!api)
return
const onSelect = (api: CarouselApi) => {
if (!api)
return
setSelectedIndex(api.selectedScrollSnap())
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}
onSelect(api)
api.on('reInit', onSelect)
api.on('select', onSelect)
return () => {
api?.off('select', onSelect)
}
}, [api])
React.useImperativeHandle(ref, () => ({
carouselRef,
api,
opts,
orientation,
scrollPrev,
scrollNext,
selectedIndex,
canScrollPrev,
canScrollNext,
}))
return (
<CarouselContext.Provider
value={{
carouselRef,
api,
opts,
orientation,
scrollPrev,
scrollNext,
selectedIndex,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={carouselRef}
// onKeyDownCapture={handleKeyDown}
className={cn('relative overflow-hidden', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
},
) as TCarousel
Carousel.displayName = 'Carousel'
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
className={cn('flex', orientation === 'vertical' && 'flex-col', className)}
{...props}
/>
)
},
)
CarouselContent.displayName = 'CarouselContent'
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn('min-w-0 shrink-0 grow-0 basis-full', className)}
{...props}
/>
)
},
)
CarouselItem.displayName = 'CarouselItem'
type CarouselActionProps = {
children?: React.ReactNode
} & Omit<React.HTMLAttributes<HTMLButtonElement>, 'disabled' | 'onClick'>
const CarouselPrevious = React.forwardRef<HTMLButtonElement, CarouselActionProps>(
({ children, ...props }, ref) => {
const { scrollPrev, canScrollPrev } = useCarousel()
return (
<button ref={ref} {...props} disabled={!canScrollPrev} onClick={scrollPrev}>
{children}
</button>
)
},
)
CarouselPrevious.displayName = 'CarouselPrevious'
const CarouselNext = React.forwardRef<HTMLButtonElement, CarouselActionProps>(
({ children, ...props }, ref) => {
const { scrollNext, canScrollNext } = useCarousel()
return (
<button ref={ref} {...props} disabled={!canScrollNext} onClick={scrollNext}>
{children}
</button>
)
},
)
CarouselNext.displayName = 'CarouselNext'
const CarouselDot = React.forwardRef<HTMLButtonElement, CarouselActionProps>(
({ children, ...props }, ref) => {
const { api, selectedIndex } = useCarousel()
return api?.slideNodes().map((_, index) => {
return (
<button
key={index}
ref={ref}
{...props}
data-state={index === selectedIndex ? 'active' : 'inactive'}
onClick={() => {
api.scrollTo(index)
}}
>
{children}
</button>
)
})
},
)
CarouselDot.displayName = 'CarouselDot'
const CarouselPlugins = {
Autoplay,
}
Carousel.Content = CarouselContent
Carousel.Item = CarouselItem
Carousel.Previous = CarouselPrevious
Carousel.Next = CarouselNext
Carousel.Dot = CarouselDot
Carousel.Plugin = CarouselPlugins
export { Carousel, useCarousel }

View File

@ -12,6 +12,7 @@ import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested
import { Markdown } from '@/app/components/base/markdown'
import { InputVarType } from '@/app/components/workflow/types'
import {
AppSourceType,
fetchSuggestedQuestions,
getUrl,
stopChatMessageResponding,
@ -52,6 +53,11 @@ const ChatWrapper = () => {
initUserVariables,
} = useChatWithHistoryContext()
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
// Semantic variable for better code readability
const isHistoryConversation = !!currentConversationId
const appConfig = useMemo(() => {
const config = appParams || {}
@ -79,7 +85,7 @@ const ChatWrapper = () => {
inputsForm: inputsForms,
},
appPrevChatTree,
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
clearChatList,
setClearChatList,
)
@ -138,11 +144,11 @@ const ChatWrapper = () => {
}
handleSend(
getUrl('chat-messages', isInstalledApp, appId || ''),
getUrl('chat-messages', appSourceType, appId || ''),
data,
{
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp,
},
)

View File

@ -27,6 +27,7 @@ import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import { changeLanguage } from '@/i18n-config/client'
import {
AppSourceType,
delConversation,
pinConversation,
renameConversation,
@ -72,6 +73,7 @@ function getFormattedChatList(messages: any[]) {
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const appInfo = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams)
const appMeta = useWebAppStore(s => s.appMeta)
@ -177,7 +179,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [currentConversationId, newConversationId])
const { data: appPinnedConversationData } = useShareConversations({
isInstalledApp,
appSourceType,
appId,
pinned: true,
limit: 100,
@ -190,7 +192,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
data: appConversationData,
isLoading: appConversationDataLoading,
} = useShareConversations({
isInstalledApp,
appSourceType,
appId,
pinned: false,
limit: 100,
@ -204,7 +206,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
isLoading: appChatListDataLoading,
} = useShareChatList({
conversationId: chatShouldReloadKey,
isInstalledApp,
appSourceType,
appId,
}, {
enabled: !!chatShouldReloadKey,
@ -334,10 +336,11 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const { data: newConversation } = useShareConversationName({
conversationId: newConversationId,
isInstalledApp,
appSourceType,
appId,
}, {
refetchOnWindowFocus: false,
enabled: !!newConversationId,
})
const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([])
useEffect(() => {
@ -462,16 +465,16 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [invalidateShareConversations])
const handlePinConversation = useCallback(async (conversationId: string) => {
await pinConversation(isInstalledApp, appId, conversationId)
await pinConversation(appSourceType, appId, conversationId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList])
}, [appSourceType, appId, notify, t, handleUpdateConversationList])
const handleUnpinConversation = useCallback(async (conversationId: string) => {
await unpinConversation(isInstalledApp, appId, conversationId)
await unpinConversation(appSourceType, appId, conversationId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList])
}, [appSourceType, appId, notify, t, handleUpdateConversationList])
const [conversationDeleting, setConversationDeleting] = useState(false)
const handleDeleteConversation = useCallback(async (
@ -485,7 +488,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
try {
setConversationDeleting(true)
await delConversation(isInstalledApp, appId, conversationId)
await delConversation(appSourceType, appId, conversationId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
onSuccess()
}
@ -520,7 +523,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
setConversationRenaming(true)
try {
await renameConversation(isInstalledApp, appId, conversationId, newName)
await renameConversation(appSourceType, appId, conversationId, newName)
notify({
type: 'success',
@ -550,9 +553,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [handleConversationIdInfoChange, invalidateShareConversations])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId)
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
}, [isInstalledApp, appId, t, notify])
}, [appSourceType, appId, t, notify])
return {
isInstalledApp,

View File

@ -152,7 +152,7 @@ const Answer: FC<AnswerProps> = ({
data={workflowProcess}
item={item}
hideProcessDetail={hideProcessDetail}
readonly={hideProcessDetail && appData ? !appData.site.show_workflow_steps : undefined}
readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined}
/>
)
}

View File

@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { ChatItem } from '../../types'
import { memo } from 'react'
import { cn } from '@/utils/classnames'
import { useChatContext } from '../context'
type SuggestedQuestionsProps = {
@ -9,7 +10,7 @@ type SuggestedQuestionsProps = {
const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
item,
}) => {
const { onSend } = useChatContext()
const { onSend, readonly } = useChatContext()
const {
isOpeningStatement,
@ -24,8 +25,11 @@ const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
{suggestedQuestions.filter(q => !!q && q.trim()).map((question, index) => (
<div
key={index}
className="system-sm-medium mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover"
onClick={() => onSend?.(question)}
className={cn(
'system-sm-medium mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
readonly && 'pointer-events-none opacity-50',
)}
onClick={() => !readonly && onSend?.(question)}
>
{question}
</div>

View File

@ -5,6 +5,7 @@ import type {
} from '../../types'
import type { InputForm } from '../type'
import type { FileUpload } from '@/app/components/base/features/types'
import { noop } from 'es-toolkit/function'
import { decode } from 'html-entities'
import Recorder from 'js-audio-recorder'
import {
@ -30,6 +31,7 @@ import { useTextAreaHeight } from './hooks'
import Operation from './operation'
type ChatInputAreaProps = {
readonly?: boolean
botName?: string
showFeatureBar?: boolean
showFileUpload?: boolean
@ -45,6 +47,7 @@ type ChatInputAreaProps = {
disabled?: boolean
}
const ChatInputArea = ({
readonly,
botName,
showFeatureBar,
showFileUpload,
@ -170,6 +173,7 @@ const ChatInputArea = ({
const operation = (
<Operation
ref={holdSpaceRef}
readonly={readonly}
fileConfig={visionConfig}
speechToTextConfig={speechToTextConfig}
onShowVoiceInput={handleShowVoiceInput}
@ -205,7 +209,7 @@ const ChatInputArea = ({
className={cn(
'body-lg-regular w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none',
)}
placeholder={decode(t('chat.inputPlaceholder', { ns: 'common', botName }) || '')}
placeholder={decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')}
autoFocus
minRows={1}
value={query}
@ -218,6 +222,7 @@ const ChatInputArea = ({
onDragLeave={handleDragFileLeave}
onDragOver={handleDragFileOver}
onDrop={handleDropFile}
readOnly={readonly}
/>
</div>
{
@ -239,7 +244,14 @@ const ChatInputArea = ({
)
}
</div>
{showFeatureBar && <FeatureBar showFileUpload={showFileUpload} disabled={featureBarDisabled} onFeatureBarClick={onFeatureBarClick} />}
{showFeatureBar && (
<FeatureBar
showFileUpload={showFileUpload}
disabled={featureBarDisabled}
onFeatureBarClick={readonly ? noop : onFeatureBarClick}
hideEditEntrance={readonly}
/>
)}
</>
)
}

View File

@ -8,6 +8,7 @@ import {
RiMicLine,
RiSendPlane2Fill,
} from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { memo } from 'react'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
@ -15,6 +16,7 @@ import { FileUploaderInChatInput } from '@/app/components/base/file-uploader'
import { cn } from '@/utils/classnames'
type OperationProps = {
readonly?: boolean
fileConfig?: FileUpload
speechToTextConfig?: EnableType
onShowVoiceInput?: () => void
@ -23,6 +25,7 @@ type OperationProps = {
ref?: Ref<HTMLDivElement>
}
const Operation: FC<OperationProps> = ({
readonly,
ref,
fileConfig,
speechToTextConfig,
@ -41,11 +44,12 @@ const Operation: FC<OperationProps> = ({
ref={ref}
>
<div className="flex items-center space-x-1">
{fileConfig?.enabled && <FileUploaderInChatInput fileConfig={fileConfig} />}
{fileConfig?.enabled && <FileUploaderInChatInput readonly={readonly} fileConfig={fileConfig} />}
{
speechToTextConfig?.enabled && (
<ActionButton
size="l"
disabled={readonly}
onClick={onShowVoiceInput}
>
<RiMicLine className="h-5 w-5" />
@ -56,7 +60,7 @@ const Operation: FC<OperationProps> = ({
<Button
className="ml-3 w-8 px-0"
variant="primary"
onClick={onSend}
onClick={readonly ? noop : onSend}
style={
theme
? {

View File

@ -15,10 +15,14 @@ export type ChatContextValue = Pick<ChatProps, 'config'
| 'onAnnotationEdited'
| 'onAnnotationAdded'
| 'onAnnotationRemoved'
| 'onFeedback'>
| 'disableFeedback'
| 'onFeedback'> & {
readonly?: boolean
}
const ChatContext = createContext<ChatContextValue>({
chatList: [],
readonly: false,
})
type ChatContextProviderProps = {
@ -27,6 +31,7 @@ type ChatContextProviderProps = {
export const ChatContextProvider = ({
children,
readonly = false,
config,
isResponding,
chatList,
@ -38,11 +43,13 @@ export const ChatContextProvider = ({
onAnnotationEdited,
onAnnotationAdded,
onAnnotationRemoved,
disableFeedback,
onFeedback,
}: ChatContextProviderProps) => {
return (
<ChatContext.Provider value={{
config,
readonly,
isResponding,
chatList: chatList || [],
showPromptLog,
@ -53,6 +60,7 @@ export const ChatContextProvider = ({
onAnnotationEdited,
onAnnotationAdded,
onAnnotationRemoved,
disableFeedback,
onFeedback,
}}
>

View File

@ -36,6 +36,8 @@ import Question from './question'
import TryToAsk from './try-to-ask'
export type ChatProps = {
isTryApp?: boolean
readonly?: boolean
appData?: AppData
chatList: ChatItem[]
config?: ChatConfig
@ -60,6 +62,7 @@ export type ChatProps = {
onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
onAnnotationRemoved?: (index: number) => void
chatNode?: ReactNode
disableFeedback?: boolean
onFeedback?: (messageId: string, feedback: Feedback) => void
chatAnswerContainerInner?: string
hideProcessDetail?: boolean
@ -75,6 +78,8 @@ export type ChatProps = {
}
const Chat: FC<ChatProps> = ({
isTryApp,
readonly = false,
appData,
config,
onSend,
@ -98,6 +103,7 @@ const Chat: FC<ChatProps> = ({
onAnnotationEdited,
onAnnotationRemoved,
chatNode,
disableFeedback,
onFeedback,
chatAnswerContainerInner,
hideProcessDetail,
@ -245,6 +251,7 @@ const Chat: FC<ChatProps> = ({
return (
<ChatContextProvider
readonly={readonly}
config={config}
chatList={chatList}
isResponding={isResponding}
@ -256,17 +263,18 @@ const Chat: FC<ChatProps> = ({
onAnnotationAdded={onAnnotationAdded}
onAnnotationEdited={onAnnotationEdited}
onAnnotationRemoved={onAnnotationRemoved}
disableFeedback={disableFeedback}
onFeedback={onFeedback}
>
<div className="relative h-full">
<div className={cn('relative h-full', isTryApp && 'flex flex-col')}>
<div
ref={chatContainerRef}
className={cn('relative h-full overflow-y-auto overflow-x-hidden', chatContainerClassName)}
className={cn('relative h-full overflow-y-auto overflow-x-hidden', isTryApp && 'h-0 grow', chatContainerClassName)}
>
{chatNode}
<div
ref={chatContainerInnerRef}
className={cn('w-full', !noSpacing && 'px-8', chatContainerInnerClassName)}
className={cn('w-full', !noSpacing && 'px-8', chatContainerInnerClassName, isTryApp && 'px-0')}
>
{
chatList.map((item, index) => {
@ -310,7 +318,7 @@ const Chat: FC<ChatProps> = ({
>
<div
ref={chatFooterInnerRef}
className={cn('relative', chatFooterInnerClassName)}
className={cn('relative', chatFooterInnerClassName, isTryApp && 'px-0')}
>
{
!noStopResponding && isResponding && (
@ -333,7 +341,7 @@ const Chat: FC<ChatProps> = ({
{
!noChatInput && (
<ChatInputArea
botName={appData?.site.title || 'Bot'}
botName={appData?.site?.title || 'Bot'}
disabled={inputDisabled}
showFeatureBar={showFeatureBar}
showFileUpload={showFileUpload}
@ -346,6 +354,7 @@ const Chat: FC<ChatProps> = ({
inputsForm={inputsForm}
theme={themeBuilder?.theme}
isResponding={isResponding}
readonly={readonly}
/>
)
}

View File

@ -13,6 +13,7 @@ import LogoAvatar from '@/app/components/base/logo/logo-embedded-chat-avatar'
import { Markdown } from '@/app/components/base/markdown'
import { InputVarType } from '@/app/components/workflow/types'
import {
AppSourceType,
fetchSuggestedQuestions,
getUrl,
stopChatMessageResponding,
@ -42,6 +43,7 @@ const ChatWrapper = () => {
isInstalledApp,
appId,
appMeta,
disableFeedback,
handleFeedback,
currentChatInstanceRef,
themeBuilder,
@ -50,7 +52,9 @@ const ChatWrapper = () => {
setIsResponding,
allInputsHidden,
initUserVariables,
appSourceType,
} = useEmbeddedChatbotContext()
const appConfig = useMemo(() => {
const config = appParams || {}
@ -78,7 +82,7 @@ const ChatWrapper = () => {
inputsForm: inputsForms,
},
appPrevChatList,
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
clearChatList,
setClearChatList,
)
@ -134,14 +138,13 @@ const ChatWrapper = () => {
conversation_id: currentConversationId,
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
}
handleSend(
getUrl('chat-messages', isInstalledApp, appId || ''),
getUrl('chat-messages', appSourceType, appId || ''),
data,
{
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp,
isPublicAPI: appSourceType === AppSourceType.webApp,
},
)
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted])
@ -159,7 +162,8 @@ const ChatWrapper = () => {
return chatList.filter(item => !item.isOpeningStatement)
}, [chatList, currentConversationId])
const [collapsed, setCollapsed] = useState(!!currentConversationId)
const isTryApp = appSourceType === AppSourceType.tryApp
const [collapsed, setCollapsed] = useState(!!currentConversationId && !isTryApp) // try app always use the new chat
const chatNode = useMemo(() => {
if (allInputsHidden || !inputsForms.length)
@ -184,6 +188,8 @@ const ChatWrapper = () => {
return null
if (!collapsed && inputsForms.length > 0 && !allInputsHidden)
return null
if (!appData?.site)
return null
if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) {
return (
<div className={cn('flex items-center justify-center px-4 py-12', isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]')}>
@ -217,7 +223,7 @@ const ChatWrapper = () => {
</div>
</div>
)
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
}, [appData?.site, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
const answerIcon = isDify()
? <LogoAvatar className="relative shrink-0" />
@ -234,6 +240,7 @@ const ChatWrapper = () => {
return (
<Chat
isTryApp={isTryApp}
appData={appData || undefined}
config={appConfig}
chatList={messageList}
@ -253,6 +260,7 @@ const ChatWrapper = () => {
</>
)}
allToolIcons={appMeta?.tool_icons || {}}
disableFeedback={disableFeedback}
onFeedback={handleFeedback}
suggestedQuestions={suggestedQuestions}
answerIcon={answerIcon}

View File

@ -15,6 +15,7 @@ import type {
} from '@/models/share'
import { noop } from 'es-toolkit/function'
import { createContext, useContext } from 'use-context-selector'
import { AppSourceType } from '@/service/share'
export type EmbeddedChatbotContextValue = {
appMeta: AppMeta | null
@ -37,8 +38,10 @@ export type EmbeddedChatbotContextValue = {
chatShouldReloadKey: string
isMobile: boolean
isInstalledApp: boolean
appSourceType: AppSourceType
allowResetChat: boolean
appId?: string
disableFeedback?: boolean
handleFeedback: (messageId: string, feedback: Feedback) => void
currentChatInstanceRef: RefObject<{ handleStop: () => void }>
themeBuilder?: ThemeBuilder
@ -74,6 +77,7 @@ export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>
handleNewConversationCompleted: noop,
chatShouldReloadKey: '',
isMobile: false,
appSourceType: AppSourceType.webApp,
isInstalledApp: false,
allowResetChat: true,
handleFeedback: noop,

View File

@ -11,6 +11,7 @@ import DifyLogo from '@/app/components/base/logo/dify-logo'
import Tooltip from '@/app/components/base/tooltip'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { cn } from '@/utils/classnames'
import { isClient } from '@/utils/client'
import {
useEmbeddedChatbotContext,
} from '../context'
@ -40,7 +41,6 @@ const Header: FC<IHeaderProps> = ({
allInputsHidden,
} = useEmbeddedChatbotContext()
const isClient = typeof window !== 'undefined'
const isIframe = isClient ? window.self !== window.top : false
const [parentOrigin, setParentOrigin] = useState('')
const [showToggleExpandButton, setShowToggleExpandButton] = useState(false)

View File

@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { ToastProvider } from '@/app/components/base/toast'
import {
AppSourceType,
fetchChatList,
fetchConversations,
generationConversationName,
@ -145,7 +146,7 @@ describe('useEmbeddedChatbot', () => {
mockFetchChatList.mockResolvedValue({ data: [] })
// Act
const { result } = renderWithClient(() => useEmbeddedChatbot())
const { result } = renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
// Assert
await waitFor(() => {
@ -177,7 +178,7 @@ describe('useEmbeddedChatbot', () => {
mockFetchChatList.mockResolvedValue({ data: [] })
mockGenerationConversationName.mockResolvedValue(generatedConversation)
const { result, queryClient } = renderWithClient(() => useEmbeddedChatbot())
const { result, queryClient } = renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
// Act
@ -207,7 +208,7 @@ describe('useEmbeddedChatbot', () => {
mockFetchChatList.mockResolvedValue({ data: [] })
mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-1' }))
const { result } = renderWithClient(() => useEmbeddedChatbot())
const { result } = renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
await waitFor(() => {
expect(mockFetchChatList).toHaveBeenCalledTimes(1)
@ -237,7 +238,7 @@ describe('useEmbeddedChatbot', () => {
mockFetchChatList.mockResolvedValue({ data: [] })
mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new' }))
const { result } = renderWithClient(() => useEmbeddedChatbot())
const { result } = renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
// Act
act(() => {

View File

@ -5,7 +5,7 @@ import type {
} from '../types'
import type { Locale } from '@/i18n-config'
import type {
// AppData,
AppData,
ConversationItem,
} from '@/models/share'
import { useLocalStorageState } from 'ahooks'
@ -24,13 +24,14 @@ import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { InputVarType } from '@/app/components/workflow/types'
import { useWebAppStore } from '@/context/web-app-context'
import { changeLanguage } from '@/i18n-config/client'
import { updateFeedback } from '@/service/share'
import { AppSourceType, updateFeedback } from '@/service/share'
import {
useInvalidateShareConversations,
useShareChatList,
useShareConversationName,
useShareConversations,
} from '@/service/use-share'
import { useGetTryAppInfo, useGetTryAppParams } from '@/service/use-try-app'
import { TransferMethod } from '@/types/app'
import { getProcessedFilesFromResponse } from '../../file-uploader/utils'
import { CONVERSATION_ID_INFO } from '../constants'
@ -62,18 +63,36 @@ function getFormattedChatList(messages: any[]) {
return newChatList
}
export const useEmbeddedChatbot = () => {
const isInstalledApp = false
const appInfo = useWebAppStore(s => s.appInfo)
export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: string) => {
const isInstalledApp = false // just can be webapp and try app
const isTryApp = appSourceType === AppSourceType.tryApp
const { data: tryAppInfo } = useGetTryAppInfo(isTryApp ? tryAppId! : '')
const webAppInfo = useWebAppStore(s => s.appInfo)
const appInfo = isTryApp ? tryAppInfo : webAppInfo
const appMeta = useWebAppStore(s => s.appMeta)
const appParams = useWebAppStore(s => s.appParams)
const { data: tryAppParams } = useGetTryAppParams(isTryApp ? tryAppId! : '')
const webAppParams = useWebAppStore(s => s.appParams)
const appParams = isTryApp ? tryAppParams : webAppParams
const appId = useMemo(() => {
return isTryApp ? tryAppId : (appInfo as any)?.app_id
}, [appInfo])
const embeddedConversationId = useWebAppStore(s => s.embeddedConversationId)
const embeddedUserId = useWebAppStore(s => s.embeddedUserId)
const appId = useMemo(() => appInfo?.app_id, [appInfo])
const [userId, setUserId] = useState<string>()
const [conversationId, setConversationId] = useState<string>()
useEffect(() => {
if (isTryApp)
return
getProcessedSystemVariablesFromUrlParams().then(({ user_id, conversation_id }) => {
setUserId(user_id)
setConversationId(conversation_id)
})
}, [])
useEffect(() => {
setUserId(embeddedUserId || undefined)
}, [embeddedUserId])
@ -83,6 +102,8 @@ export const useEmbeddedChatbot = () => {
}, [embeddedConversationId])
useEffect(() => {
if (isTryApp)
return
const setLanguageFromParams = async () => {
// Check URL parameters for language override
const urlParams = new URLSearchParams(window.location.search)
@ -100,9 +121,9 @@ export const useEmbeddedChatbot = () => {
// If locale is set as a system variable, use that
await changeLanguage(localeFromSysVar)
}
else if (appInfo?.site.default_language) {
else if ((appInfo as unknown as AppData)?.site?.default_language) {
// Otherwise use the default from app config
await changeLanguage(appInfo.site.default_language)
await changeLanguage((appInfo as unknown as AppData).site?.default_language)
}
}
@ -112,6 +133,13 @@ export const useEmbeddedChatbot = () => {
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
defaultValue: {},
})
const removeConversationIdInfo = useCallback((appId: string) => {
setConversationIdInfo((prev) => {
const newInfo = { ...prev }
delete newInfo[appId]
return newInfo
})
}, [setConversationIdInfo])
const allowResetChat = !conversationId
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || conversationId || '', [appId, conversationIdInfo, userId, conversationId])
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
@ -138,7 +166,7 @@ export const useEmbeddedChatbot = () => {
}, [currentConversationId, newConversationId])
const { data: appPinnedConversationData } = useShareConversations({
isInstalledApp,
appSourceType,
appId,
pinned: true,
limit: 100,
@ -147,7 +175,7 @@ export const useEmbeddedChatbot = () => {
data: appConversationData,
isLoading: appConversationDataLoading,
} = useShareConversations({
isInstalledApp,
appSourceType,
appId,
pinned: false,
limit: 100,
@ -157,7 +185,7 @@ export const useEmbeddedChatbot = () => {
isLoading: appChatListDataLoading,
} = useShareChatList({
conversationId: chatShouldReloadKey,
isInstalledApp,
appSourceType,
appId,
})
const invalidateShareConversations = useInvalidateShareConversations()
@ -265,6 +293,8 @@ export const useEmbeddedChatbot = () => {
useEffect(() => {
// init inputs from url params
(async () => {
if (isTryApp)
return
const inputs = await getProcessedInputsFromUrlParams()
const userVariables = await getProcessedUserVariablesFromUrlParams()
setInitInputs(inputs)
@ -282,10 +312,11 @@ export const useEmbeddedChatbot = () => {
const { data: newConversation } = useShareConversationName({
conversationId: newConversationId,
isInstalledApp,
appSourceType,
appId,
}, {
refetchOnWindowFocus: false,
enabled: !isTryApp,
})
const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([])
useEffect(() => {
@ -335,7 +366,7 @@ export const useEmbeddedChatbot = () => {
}, [appChatListData, currentConversationId])
const [currentConversationInputs, setCurrentConversationInputs] = useState<Record<string, any>>(currentConversationLatestInputs || {})
useEffect(() => {
if (currentConversationItem)
if (currentConversationItem && !isTryApp)
setCurrentConversationInputs(currentConversationLatestInputs || {})
}, [currentConversationItem, currentConversationLatestInputs])
@ -395,12 +426,17 @@ export const useEmbeddedChatbot = () => {
setClearChatList(false)
}, [handleConversationIdInfoChange, setClearChatList])
const handleNewConversation = useCallback(async () => {
if (isTryApp) {
setClearChatList(true)
return
}
currentChatInstanceRef.current.handleStop()
setShowNewConversationItemInList(true)
handleChangeConversation('')
handleNewConversationInputsChange(await getProcessedInputsFromUrlParams())
setClearChatList(true)
}, [handleChangeConversation, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList])
}, [isTryApp, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList])
const handleNewConversationCompleted = useCallback((newConversationId: string) => {
setNewConversationId(newConversationId)
@ -410,16 +446,18 @@ export const useEmbeddedChatbot = () => {
}, [handleConversationIdInfoChange, invalidateShareConversations])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId)
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
}, [isInstalledApp, appId, t, notify])
}, [appSourceType, appId, t, notify])
return {
appSourceType,
isInstalledApp,
allowResetChat,
appId,
currentConversationId,
currentConversationItem,
removeConversationIdInfo,
handleConversationIdInfoChange,
appData: appInfo,
appParams: appParams || {} as ChatConfig,

View File

@ -1,4 +1,5 @@
'use client'
import type { AppData } from '@/models/share'
import {
useEffect,
} from 'react'
@ -11,6 +12,7 @@ import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { AppSourceType } from '@/service/share'
import { cn } from '@/utils/classnames'
import {
EmbeddedChatbotContext,
@ -132,11 +134,12 @@ const EmbeddedChatbotWrapper = () => {
setCurrentConversationInputs,
allInputsHidden,
initUserVariables,
} = useEmbeddedChatbot()
} = useEmbeddedChatbot(AppSourceType.webApp)
return (
<EmbeddedChatbotContext.Provider value={{
appData,
appSourceType: AppSourceType.webApp,
appData: (appData as AppData) || null,
appParams,
appMeta,
appChatListDataLoading,

View File

@ -4,6 +4,7 @@ import Button from '@/app/components/base/button'
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
import Divider from '@/app/components/base/divider'
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import { AppSourceType } from '@/service/share'
import { cn } from '@/utils/classnames'
import { useEmbeddedChatbotContext } from '../context'
@ -18,6 +19,7 @@ const InputsFormNode = ({
}: Props) => {
const { t } = useTranslation()
const {
appSourceType,
isMobile,
currentConversationId,
themeBuilder,
@ -25,15 +27,17 @@ const InputsFormNode = ({
allInputsHidden,
inputsForms,
} = useEmbeddedChatbotContext()
const isTryApp = appSourceType === AppSourceType.tryApp
if (allInputsHidden || inputsForms.length === 0)
return null
return (
<div className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4')}>
<div className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4', isTryApp && 'mb-0 px-0')}>
<div className={cn(
'w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md',
collapsed && 'border border-components-card-border bg-components-card-bg shadow-none',
isTryApp && 'max-w-[auto]',
)}
>
<div className={cn(

View File

@ -33,7 +33,7 @@ const ViewFormDropdown = ({ iconColor }: Props) => {
<RiChatSettingsLine className={cn('h-[18px] w-[18px]', iconColor)} />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<PortalToFollowElemContent className="z-[99]">
<div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm">
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4">
<Message3Fill className="h-6 w-6 shrink-0" />

View File

@ -14,6 +14,7 @@ type Props = {
showFileUpload?: boolean
disabled?: boolean
onFeatureBarClick?: (state: boolean) => void
hideEditEntrance?: boolean
}
const FeatureBar = ({
@ -21,6 +22,7 @@ const FeatureBar = ({
showFileUpload = true,
disabled,
onFeatureBarClick,
hideEditEntrance = false,
}: Props) => {
const { t } = useTranslation()
const features = useFeatures(s => s.features)
@ -133,10 +135,14 @@ const FeatureBar = ({
)}
</div>
<div className="body-xs-regular grow text-text-tertiary">{t('feature.bar.enableText', { ns: 'appDebug' })}</div>
<Button className="shrink-0" variant="ghost-accent" size="small" onClick={() => onFeatureBarClick?.(true)}>
<div className="mx-1">{t('feature.bar.manage', { ns: 'appDebug' })}</div>
<RiArrowRightLine className="h-3.5 w-3.5 text-text-accent" />
</Button>
{
!hideEditEntrance && (
<Button className="shrink-0" variant="ghost-accent" size="small" onClick={() => onFeatureBarClick?.(true)}>
<div className="mx-1">{t('feature.bar.manage', { ns: 'appDebug' })}</div>
<RiArrowRightLine className="h-3.5 w-3.5 text-text-accent" />
</Button>
)
}
</div>
)}
</div>

View File

@ -13,21 +13,27 @@ import FileFromLinkOrLocal from '../file-from-link-or-local'
type FileUploaderInChatInputProps = {
fileConfig: FileUpload
readonly?: boolean
}
const FileUploaderInChatInput = ({
fileConfig,
readonly,
}: FileUploaderInChatInputProps) => {
const renderTrigger = useCallback((open: boolean) => {
return (
<ActionButton
size="l"
className={cn(open && 'bg-state-base-hover')}
disabled={readonly}
>
<RiAttachmentLine className="h-5 w-5" />
</ActionButton>
)
}, [])
if (readonly)
return renderTrigger(false)
return (
<FileFromLinkOrLocal
trigger={renderTrigger}

View File

@ -70,10 +70,12 @@ const PasteImageLinkButton: FC<PasteImageLinkButtonProps> = ({
type TextGenerationImageUploaderProps = {
settings: VisionSettings
onFilesChange: (files: ImageFile[]) => void
disabled?: boolean
}
const TextGenerationImageUploader: FC<TextGenerationImageUploaderProps> = ({
settings,
onFilesChange,
disabled,
}) => {
const { t } = useTranslation()
@ -93,7 +95,7 @@ const TextGenerationImageUploader: FC<TextGenerationImageUploaderProps> = ({
const localUpload = (
<Uploader
onUpload={onUpload}
disabled={files.length >= settings.number_limits}
disabled={files.length >= settings.number_limits || disabled}
limit={+settings.image_file_size_limit!}
>
{
@ -115,7 +117,7 @@ const TextGenerationImageUploader: FC<TextGenerationImageUploaderProps> = ({
const urlUpload = (
<PasteImageLinkButton
onUpload={onUpload}
disabled={files.length >= settings.number_limits}
disabled={files.length >= settings.number_limits || disabled}
/>
)

View File

@ -16,6 +16,8 @@ export type ITabHeaderProps = {
items: Item[]
value: string
itemClassName?: string
itemWrapClassName?: string
activeItemClassName?: string
onChange: (value: string) => void
}
@ -23,6 +25,8 @@ const TabHeader: FC<ITabHeaderProps> = ({
items,
value,
itemClassName,
itemWrapClassName,
activeItemClassName,
onChange,
}) => {
const renderItem = ({ id, name, icon, extra, disabled }: Item) => (
@ -30,8 +34,9 @@ const TabHeader: FC<ITabHeaderProps> = ({
key={id}
className={cn(
'system-md-semibold relative flex cursor-pointer items-center border-b-2 border-transparent pb-2 pt-2.5',
id === value ? 'border-components-tab-active text-text-primary' : 'text-text-tertiary',
id === value ? cn('border-components-tab-active text-text-primary', activeItemClassName) : 'text-text-tertiary',
disabled && 'cursor-not-allowed opacity-30',
itemWrapClassName,
)}
onClick={() => !disabled && onChange(id)}
>

View File

@ -8,7 +8,7 @@ import { useParams, usePathname } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import { audioToText } from '@/service/share'
import { AppSourceType, audioToText } from '@/service/share'
import { cn } from '@/utils/classnames'
import s from './index.module.css'
import { convertToMp3 } from './utils'
@ -108,7 +108,7 @@ const VoiceInput = ({
}
try {
const audioResponse = await audioToText(url, isPublic, formData)
const audioResponse = await audioToText(url, isPublic ? AppSourceType.webApp : AppSourceType.installedApp, formData)
onConverted(audioResponse.text)
onCancel()
}

View File

@ -10,6 +10,7 @@ vi.mock('../../app/type-selector', () => ({
}))
const createApp = (overrides?: Partial<App>): App => ({
can_trial: true,
app_id: 'app-id',
description: 'App description',
copyright: '2024',

View File

@ -1,8 +1,13 @@
'use client'
import type { App } from '@/models/explore'
import { PlusIcon } from '@heroicons/react/20/solid'
import { RiInformation2Line } from '@remixicon/react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import AppIcon from '@/app/components/base/app-icon'
import ExploreContext from '@/context/explore-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { AppTypeIcon } from '../../app/type-selector'
@ -23,8 +28,17 @@ const AppCard = ({
}: AppCardProps) => {
const { t } = useTranslation()
const { app: appBasicInfo } = app
const { systemFeatures } = useGlobalPublicStore()
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
const setShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.setShowTryAppPanel)
const showTryAPPPanel = useCallback((appId: string) => {
return () => {
setShowTryAppPanel?.(true, { appId, app })
}
}, [setShowTryAppPanel, app])
return (
<div className={cn('group relative col-span-1 flex cursor-pointer flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-2 shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg')}>
<div className={cn('group relative col-span-1 flex cursor-pointer flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-2 shadow-sm transition-all duration-200 ease-in-out hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-lg')}>
<div className="flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pb-3 pt-[14px]">
<div className="relative shrink-0">
<AppIcon
@ -58,13 +72,19 @@ const AppCard = ({
{app.description}
</div>
</div>
{isExplore && canCreate && (
{isExplore && (canCreate || isTrialApp) && (
<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('flex h-8 w-full items-center space-x-2')}>
<Button variant="primary" className="h-7 grow" onClick={() => onCreate()}>
<div className={cn('grid h-8 w-full grid-cols-1 space-x-2', isTrialApp && 'grid-cols-2')}>
<Button variant="primary" className="h-7" onClick={() => onCreate()}>
<PlusIcon className="mr-1 h-4 w-4" />
<span className="text-xs">{t('appCard.addToWorkspace', { ns: 'explore' })}</span>
</Button>
{isTrialApp && (
<Button className="h-7" onClick={showTryAPPPanel(app.app_id)}>
<RiInformation2Line className="mr-1 size-4" />
<span>{t('appCard.try', { ns: 'explore' })}</span>
</Button>
)}
</div>
</div>
)}

View File

@ -102,6 +102,7 @@ const createApp = (overrides: Partial<App> = {}): App => ({
description: overrides.app?.description ?? 'Alpha description',
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
},
can_trial: true,
app_id: overrides.app_id ?? 'app-1',
description: overrides.description ?? 'Alpha description',
copyright: overrides.copyright ?? '',
@ -127,6 +128,8 @@ const renderWithContext = (hasEditPermission = false, onSuccess?: () => void) =>
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
}}
>
<AppList onSuccess={onSuccess} />

View File

@ -7,14 +7,17 @@ import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useContext, useContextSelector } from 'use-context-selector'
import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading'
import AppCard from '@/app/components/explore/app-card'
import Banner from '@/app/components/explore/banner/banner'
import Category from '@/app/components/explore/category'
import CreateAppModal from '@/app/components/explore/create-app-modal'
import ExploreContext from '@/context/explore-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useImportDSL } from '@/hooks/use-import-dsl'
import {
DSLImportMode,
@ -22,6 +25,7 @@ import {
import { fetchAppDetail } from '@/service/explore'
import { useExploreAppList } from '@/service/use-explore'
import { cn } from '@/utils/classnames'
import TryApp from '../try-app'
import s from './style.module.css'
type AppsProps = {
@ -32,12 +36,19 @@ const Apps = ({
onSuccess,
}: AppsProps) => {
const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
const { hasEditPermission } = useContext(ExploreContext)
const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' })
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
const hasFilterCondition = !!keywords
const handleResetFilter = useCallback(() => {
setKeywords('')
setSearchKeywords('')
}, [])
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
@ -84,6 +95,18 @@ const Apps = ({
isFetching,
} = useImportDSL()
const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false)
const isShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.isShowTryAppPanel)
const setShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.setShowTryAppPanel)
const hideTryAppPanel = useCallback(() => {
setShowTryAppPanel(false)
}, [setShowTryAppPanel])
const appParams = useContextSelector(ExploreContext, ctx => ctx.currentApp)
const handleShowFromTryApp = useCallback(() => {
setCurrApp(appParams?.app || null)
setIsShowCreateModal(true)
}, [appParams?.app])
const onCreate: CreateAppModalProps['onConfirm'] = async ({
name,
icon_type,
@ -91,6 +114,8 @@ const Apps = ({
icon_background,
description,
}) => {
hideTryAppPanel()
const { export_data } = await fetchAppDetail(
currApp?.app.id as string,
)
@ -137,22 +162,24 @@ const Apps = ({
'flex h-full flex-col border-l-[0.5px] border-divider-regular',
)}
>
<div className="shrink-0 px-12 pt-6">
<div className={`mb-1 ${s.textGradient} text-xl font-semibold`}>{t('apps.title', { ns: 'explore' })}</div>
<div className="text-sm text-text-tertiary">{t('apps.description', { ns: 'explore' })}</div>
</div>
{systemFeatures.enable_explore_banner && (
<div className="mt-4 px-12">
<Banner />
</div>
)}
<div className={cn(
'mt-6 flex items-center justify-between px-12',
)}
>
<Category
list={categories}
value={currCategory}
onChange={setCurrCategory}
allCategoriesEn={allCategoriesEn}
/>
<div className="flex items-center">
<div className="system-xl-semibold grow truncate text-text-primary">{!hasFilterCondition ? t('apps.title', { ns: 'explore' }) : t('apps.resultNum', { num: searchFilteredList.length, ns: 'explore' })}</div>
{hasFilterCondition && (
<>
<div className="mx-3 h-4 w-px bg-divider-regular"></div>
<Button size="medium" onClick={handleResetFilter}>{t('apps.resetFilter', { ns: 'explore' })}</Button>
</>
)}
</div>
<Input
showLeftIcon
showClearIcon
@ -163,6 +190,15 @@ const Apps = ({
/>
</div>
<div className="mt-2 px-12">
<Category
list={categories}
value={currCategory}
onChange={setCurrCategory}
allCategoriesEn={allCategoriesEn}
/>
</div>
<div className={cn(
'relative mt-4 flex flex-1 shrink-0 grow flex-col overflow-auto pb-6',
)}
@ -211,6 +247,15 @@ const Apps = ({
/>
)
}
{isShowTryAppPanel && (
<TryApp
appId={appParams?.appId || ''}
category={appParams?.app?.category}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}
/>
)}
</div>
)
}

View File

@ -0,0 +1,198 @@
import type { FC } from 'react'
import { RiArrowRightLine } from '@remixicon/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useCarousel } from '@/app/components/base/carousel'
import { cn } from '@/utils/classnames'
import { IndicatorButton } from './indicator-button'
export type BannerData = {
id: string
content: {
'category': string
'title': string
'description': string
'img-src': string
}
status: 'enabled' | 'disabled'
link: string
created_at: number
}
type BannerItemProps = {
banner: BannerData
autoplayDelay: number
isPaused?: boolean
}
const RESPONSIVE_BREAKPOINT = 1200
const MAX_RESPONSIVE_WIDTH = 600
const INDICATOR_WIDTH = 20
const INDICATOR_GAP = 8
const MIN_VIEW_MORE_WIDTH = 480
export const BannerItem: FC<BannerItemProps> = ({ banner, autoplayDelay, isPaused = false }) => {
const { t } = useTranslation()
const { api, selectedIndex } = useCarousel()
const { category, title, description, 'img-src': imgSrc } = banner.content
const [resetKey, setResetKey] = useState(0)
const textAreaRef = useRef<HTMLDivElement>(null)
const [maxWidth, setMaxWidth] = useState<number | undefined>(undefined)
const slideInfo = useMemo(() => {
const slides = api?.slideNodes() ?? []
const totalSlides = slides.length
const nextIndex = totalSlides > 0 ? (selectedIndex + 1) % totalSlides : 0
return { slides, totalSlides, nextIndex }
}, [api, selectedIndex])
const indicatorsWidth = useMemo(() => {
const count = slideInfo.totalSlides
if (count === 0)
return 0
// Calculate: indicator buttons + gaps + extra spacing (3 * 20px for divider and padding)
return (count + 2) * INDICATOR_WIDTH + (count - 1) * INDICATOR_GAP
}, [slideInfo.totalSlides])
const viewMoreStyle = useMemo(() => {
if (!maxWidth)
return undefined
return {
maxWidth: `${maxWidth}px`,
minWidth: indicatorsWidth ? `${Math.min(maxWidth - indicatorsWidth, MIN_VIEW_MORE_WIDTH)}px` : undefined,
}
}, [maxWidth, indicatorsWidth])
const responsiveStyle = useMemo(
() => (maxWidth !== undefined ? { maxWidth: `${maxWidth}px` } : undefined),
[maxWidth],
)
const incrementResetKey = useCallback(() => setResetKey(prev => prev + 1), [])
useEffect(() => {
const updateMaxWidth = () => {
if (window.innerWidth < RESPONSIVE_BREAKPOINT && textAreaRef.current) {
const textAreaWidth = textAreaRef.current.offsetWidth
setMaxWidth(Math.min(textAreaWidth, MAX_RESPONSIVE_WIDTH))
}
else {
setMaxWidth(undefined)
}
}
updateMaxWidth()
const resizeObserver = new ResizeObserver(updateMaxWidth)
if (textAreaRef.current)
resizeObserver.observe(textAreaRef.current)
window.addEventListener('resize', updateMaxWidth)
return () => {
resizeObserver.disconnect()
window.removeEventListener('resize', updateMaxWidth)
}
}, [])
useEffect(() => {
incrementResetKey()
}, [selectedIndex, incrementResetKey])
const handleBannerClick = useCallback(() => {
incrementResetKey()
if (banner.link)
window.open(banner.link, '_blank', 'noopener,noreferrer')
}, [banner.link, incrementResetKey])
const handleIndicatorClick = useCallback((index: number) => {
incrementResetKey()
api?.scrollTo(index)
}, [api, incrementResetKey])
return (
<div
className="relative flex w-full min-w-[784px] cursor-pointer overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg pr-[288px] transition-shadow hover:shadow-md"
onClick={handleBannerClick}
>
{/* Left content area */}
<div className="min-w-0 flex-1">
<div className="flex h-full flex-col gap-3 py-6 pl-8 pr-0">
{/* Text section */}
<div className="flex min-h-24 flex-wrap items-end gap-1 py-1">
{/* Title area */}
<div
ref={textAreaRef}
className="flex min-w-[480px] max-w-[680px] flex-[1_0_0] flex-col pr-4"
style={responsiveStyle}
>
<p className="title-4xl-semi-bold line-clamp-1 text-dify-logo-dify-logo-blue">
{category}
</p>
<p className="title-4xl-semi-bold line-clamp-2 text-dify-logo-dify-logo-black">
{title}
</p>
</div>
{/* Description area */}
<div
className="min-w-60 max-w-[600px] flex-[1_0_0] self-end overflow-hidden py-1 pr-4"
style={responsiveStyle}
>
<p className="body-sm-regular line-clamp-4 overflow-hidden text-text-tertiary">
{description}
</p>
</div>
</div>
{/* Actions section */}
<div className="flex items-center gap-1">
{/* View more button */}
<div
className="flex min-w-[480px] max-w-[680px] flex-[1_0_0] items-center gap-[6px] py-1 pr-8"
style={viewMoreStyle}
>
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-text-accent p-[2px]">
<RiArrowRightLine className="h-3 w-3 text-text-primary-on-surface" />
</div>
<span className="system-sm-semibold-uppercase text-text-accent">
{t('banner.viewMore', { ns: 'explore' })}
</span>
</div>
<div
className={cn('flex max-w-[600px] flex-[1_0_0] items-center gap-2 py-1 pr-10', maxWidth ? '' : 'min-w-60')}
style={responsiveStyle}
>
{/* Slide navigation indicators */}
<div className="flex items-center gap-2">
{slideInfo.slides.map((_: unknown, index: number) => (
<IndicatorButton
key={index}
index={index}
selectedIndex={selectedIndex}
isNextSlide={index === slideInfo.nextIndex}
autoplayDelay={autoplayDelay}
resetKey={resetKey}
isPaused={isPaused}
onClick={() => handleIndicatorClick(index)}
/>
))}
</div>
<div className="hidden h-[1px] flex-1 bg-divider-regular min-[1380px]:block" />
</div>
</div>
</div>
</div>
{/* Right image area */}
<div className="absolute right-0 top-0 flex h-full items-center p-2">
<img
src={imgSrc}
alt={title}
className="aspect-[4/3] h-full max-w-[296px] rounded-xl"
/>
</div>
</div>
)
}

View File

@ -0,0 +1,95 @@
import type { FC } from 'react'
import type { BannerData } from './banner-item'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Carousel } from '@/app/components/base/carousel'
import { useLocale } from '@/context/i18n'
import { useGetBanners } from '@/service/use-explore'
import Loading from '../../base/loading'
import { BannerItem } from './banner-item'
const AUTOPLAY_DELAY = 5000
const MIN_LOADING_HEIGHT = 168
const RESIZE_DEBOUNCE_DELAY = 50
const LoadingState: FC = () => (
<div
className="flex items-center justify-center rounded-2xl bg-components-panel-on-panel-item-bg shadow-md"
style={{ minHeight: MIN_LOADING_HEIGHT }}
>
<Loading />
</div>
)
const Banner: FC = () => {
const locale = useLocale()
const { data: banners, isLoading, isError } = useGetBanners(locale)
const [isHovered, setIsHovered] = useState(false)
const [isResizing, setIsResizing] = useState(false)
const resizeTimerRef = useRef<NodeJS.Timeout | null>(null)
const enabledBanners = useMemo(
() => banners?.filter((banner: BannerData) => banner.status === 'enabled') ?? [],
[banners],
)
const isPaused = isHovered || isResizing
// Handle window resize to pause animation
useEffect(() => {
const handleResize = () => {
setIsResizing(true)
if (resizeTimerRef.current)
clearTimeout(resizeTimerRef.current)
resizeTimerRef.current = setTimeout(() => {
setIsResizing(false)
}, RESIZE_DEBOUNCE_DELAY)
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
if (resizeTimerRef.current)
clearTimeout(resizeTimerRef.current)
}
}, [])
if (isLoading)
return <LoadingState />
if (isError || enabledBanners.length === 0)
return null
return (
<Carousel
opts={{ loop: true }}
plugins={[
Carousel.Plugin.Autoplay({
delay: AUTOPLAY_DELAY,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
]}
className="rounded-2xl"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Carousel.Content>
{enabledBanners.map((banner: BannerData) => (
<Carousel.Item key={banner.id}>
<BannerItem
banner={banner}
autoplayDelay={AUTOPLAY_DELAY}
isPaused={isPaused}
/>
</Carousel.Item>
))}
</Carousel.Content>
</Carousel>
)
}
export default React.memo(Banner)

View File

@ -0,0 +1,111 @@
import type { FC } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@/utils/classnames'
type IndicatorButtonProps = {
index: number
selectedIndex: number
isNextSlide: boolean
autoplayDelay: number
resetKey: number
isPaused?: boolean
onClick: () => void
}
const PROGRESS_MAX = 100
const DEGREES_PER_PERCENT = 3.6
export const IndicatorButton: FC<IndicatorButtonProps> = ({
index,
selectedIndex,
isNextSlide,
autoplayDelay,
resetKey,
isPaused = false,
onClick,
}) => {
const [progress, setProgress] = useState(0)
const frameIdRef = useRef<number | undefined>(undefined)
const startTimeRef = useRef(0)
const isActive = index === selectedIndex
const shouldAnimate = !document.hidden && !isPaused
useEffect(() => {
if (!isNextSlide) {
setProgress(0)
if (frameIdRef.current)
cancelAnimationFrame(frameIdRef.current)
return
}
setProgress(0)
startTimeRef.current = Date.now()
const animate = () => {
if (!document.hidden && !isPaused) {
const elapsed = Date.now() - startTimeRef.current
const newProgress = Math.min((elapsed / autoplayDelay) * PROGRESS_MAX, PROGRESS_MAX)
setProgress(newProgress)
if (newProgress < PROGRESS_MAX)
frameIdRef.current = requestAnimationFrame(animate)
}
else {
frameIdRef.current = requestAnimationFrame(animate)
}
}
if (shouldAnimate)
frameIdRef.current = requestAnimationFrame(animate)
return () => {
if (frameIdRef.current)
cancelAnimationFrame(frameIdRef.current)
}
}, [isNextSlide, autoplayDelay, resetKey, isPaused])
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
onClick()
}, [onClick])
const progressDegrees = progress * DEGREES_PER_PERCENT
return (
<button
onClick={handleClick}
className={cn(
'system-2xs-semibold-uppercase relative flex h-[18px] w-[20px] items-center justify-center rounded-[7px] border border-divider-subtle p-[2px] text-center transition-colors',
isActive
? 'bg-text-primary text-components-panel-on-panel-item-bg'
: 'bg-components-panel-on-panel-item-bg text-text-tertiary hover:text-text-secondary',
)}
>
{/* progress border for next slide */}
{isNextSlide && !isActive && (
<span
key={resetKey}
className="absolute inset-[-1px] rounded-[7px]"
style={{
background: `conic-gradient(
from 0deg,
var(--color-text-primary) ${progressDegrees}deg,
transparent ${progressDegrees}deg
)`,
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
mask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
maskComposite: 'exclude',
padding: '1px',
}}
/>
)}
{/* number content */}
<span className="relative z-10">
{String(index + 1).padStart(2, '0')}
</span>
</button>
)
}

View File

@ -29,7 +29,7 @@ const Category: FC<ICategoryProps> = ({
const isAllCategories = !list.includes(value as AppCategory) || value === allCategoriesEn
const itemClassName = (isSelected: boolean) => cn(
'flex h-[32px] cursor-pointer items-center rounded-lg border-[0.5px] border-transparent px-3 py-[7px] font-medium leading-[18px] text-text-tertiary hover:bg-components-main-nav-nav-button-bg-active',
'system-sm-medium flex h-7 cursor-pointer items-center rounded-lg border border-transparent px-3 text-text-tertiary hover:bg-components-main-nav-nav-button-bg-active',
isSelected && 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active text-components-main-nav-nav-button-text-active shadow-xs',
)

View File

@ -1,5 +1,6 @@
'use client'
import type { FC } from 'react'
import type { CurrentTryAppParams } from '@/context/explore-context'
import type { InstalledApp } from '@/models/explore'
import { useRouter } from 'next/navigation'
import * as React from 'react'
@ -41,6 +42,16 @@ const Explore: FC<IExploreProps> = ({
return router.replace('/datasets')
}, [isCurrentWorkspaceDatasetOperator])
const [currentTryAppParams, setCurrentTryAppParams] = useState<CurrentTryAppParams | undefined>(undefined)
const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false)
const setShowTryAppPanel = (showTryAppPanel: boolean, params?: CurrentTryAppParams) => {
if (showTryAppPanel)
setCurrentTryAppParams(params)
else
setCurrentTryAppParams(undefined)
setIsShowTryAppPanel(showTryAppPanel)
}
return (
<div className="flex h-full overflow-hidden border-t border-divider-regular bg-background-body">
<ExploreContext.Provider
@ -53,6 +64,9 @@ const Explore: FC<IExploreProps> = ({
setInstalledApps,
isFetchingInstalledApps,
setIsFetchingInstalledApps,
currentApp: currentTryAppParams,
isShowTryAppPanel,
setShowTryAppPanel,
}
}
>

View File

@ -1,5 +1,6 @@
'use client'
import type { FC } from 'react'
import type { AccessMode } from '@/models/access-control'
import type { AppData } from '@/models/share'
import * as React from 'react'
import { useEffect } from 'react'
@ -62,8 +63,8 @@ const InstalledApp: FC<IInstalledAppProps> = ({
if (appMeta)
updateWebAppMeta(appMeta)
if (webAppAccessMode)
updateWebAppAccessMode(webAppAccessMode.accessMode)
updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result))
updateWebAppAccessMode((webAppAccessMode as { accessMode: AccessMode }).accessMode)
updateUserCanAccessApp(Boolean(userCanAccessApp && (userCanAccessApp as { result: boolean })?.result))
}, [installedApp, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp, webAppAccessMode, updateWebAppAccessMode])
if (appParamsError) {

View File

@ -56,7 +56,7 @@ export default function AppNavItem({
<>
<div className="flex w-0 grow items-center space-x-2">
<AppIcon size="tiny" iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />
<div className="overflow-hidden text-ellipsis whitespace-nowrap" title={name}>{name}</div>
<div className="system-sm-regular truncate text-components-menu-item-text" title={name}>{name}</div>
</div>
<div className="h-6 shrink-0" onClick={e => e.stopPropagation()}>
<ItemOperation

View File

@ -72,7 +72,7 @@ const renderWithContext = (installedApps: InstalledApp[] = []) => {
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
}}
} as any}
>
<SideBar controlUpdateInstalledApps={0} />
</ExploreContext.Provider>,

View File

@ -1,5 +1,7 @@
'use client'
import type { FC } from 'react'
import { RiAppsFill, RiExpandRightLine, RiLayoutLeft2Line } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import Link from 'next/link'
import { useSelectedLayoutSegments } from 'next/navigation'
import * as React from 'react'
@ -14,6 +16,7 @@ import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/s
import { cn } from '@/utils/classnames'
import Toast from '../../base/toast'
import Item from './app-nav-item'
import NoApps from './no-apps'
const SelectedDiscoveryIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="current" xmlns="http://www.w3.org/2000/svg">
@ -45,6 +48,9 @@ const SideBar: FC<IExploreSideBarProps> = ({
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const [isFold, {
toggle: toggleIsFold,
}] = useBoolean(false)
const [showConfirm, setShowConfirm] = useState(false)
const [currId, setCurrId] = useState('')
@ -84,22 +90,31 @@ const SideBar: FC<IExploreSideBarProps> = ({
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
return (
<div className="w-fit shrink-0 cursor-pointer border-r border-divider-burn px-4 pt-6 sm:w-[216px]">
<div className={cn('relative w-fit shrink-0 cursor-pointer px-3 pt-6 sm:w-[240px]', isFold && 'sm:w-[56px]')}>
<div className={cn(isDiscoverySelected ? 'text-text-accent' : 'text-text-tertiary')}>
<Link
href="/explore/apps"
className={cn(isDiscoverySelected ? ' bg-components-main-nav-nav-button-bg-active' : 'font-medium hover:bg-state-base-hover', 'flex h-9 items-center gap-2 rounded-lg px-3 mobile:w-fit mobile:justify-center mobile:px-2 pc:w-full pc:justify-start')}
style={isDiscoverySelected ? { boxShadow: '0px 1px 2px rgba(16, 24, 40, 0.05)' } : {}}
className={cn(isDiscoverySelected ? 'bg-state-base-active' : 'hover:bg-state-base-hover', 'flex h-8 items-center gap-2 rounded-lg px-1 mobile:w-fit mobile:justify-center pc:w-full pc:justify-start')}
>
{isDiscoverySelected ? <SelectedDiscoveryIcon /> : <DiscoveryIcon />}
{!isMobile && <div className="text-sm">{t('sidebar.discovery', { ns: 'explore' })}</div>}
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid">
<RiAppsFill className="size-3.5 text-components-avatar-shape-fill-stop-100" />
</div>
{!isMobile && !isFold && <div className={cn('truncate', isDiscoverySelected ? 'system-sm-semibold text-components-menu-item-text-active' : 'system-sm-regular text-components-menu-item-text')}>{t('sidebar.title', { ns: 'explore' })}</div>}
</Link>
</div>
{installedApps.length === 0 && !isMobile && !isFold
&& (
<div className="mt-5">
<NoApps />
</div>
)}
{installedApps.length > 0 && (
<div className="mt-10">
<p className="break-all pl-2 text-xs font-medium uppercase text-text-tertiary mobile:px-0">{t('sidebar.workspace', { ns: 'explore' })}</p>
<div className="mt-5">
{!isMobile && !isFold && <p className="system-xs-medium-uppercase mb-1.5 break-all pl-2 uppercase text-text-tertiary mobile:px-0">{t('sidebar.webApps', { ns: 'explore' })}</p>}
<div
className="mt-3 space-y-1 overflow-y-auto overflow-x-hidden"
className="space-y-0.5 overflow-y-auto overflow-x-hidden"
style={{
height: 'calc(100vh - 250px)',
}}
@ -107,7 +122,7 @@ const SideBar: FC<IExploreSideBarProps> = ({
{installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => (
<React.Fragment key={id}>
<Item
isMobile={isMobile}
isMobile={isMobile || isFold}
name={name}
icon_type={icon_type}
icon={icon}
@ -129,6 +144,17 @@ const SideBar: FC<IExploreSideBarProps> = ({
</div>
</div>
)}
{!isMobile && (
<div className="absolute bottom-3 left-3 flex size-8 cursor-pointer items-center justify-center text-text-tertiary" onClick={toggleIsFold}>
{isFold
? <RiExpandRightLine className="size-4.5" />
: (
<RiLayoutLeft2Line className="size-4.5" />
)}
</div>
)}
{showConfirm && (
<Confirm
title={t('sidebar.delete.title', { ns: 'explore' })}

View File

@ -0,0 +1,24 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { cn } from '@/utils/classnames'
import s from './style.module.css'
const i18nPrefix = 'sidebar.noApps'
const NoApps: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
return (
<div className="rounded-xl bg-background-default-subtle p-4">
<div className={cn('h-[35px] w-[86px] bg-contain bg-center bg-no-repeat', theme === Theme.dark ? s.dark : s.light)}></div>
<div className="system-sm-semibold mt-2 text-text-secondary">{t(`${i18nPrefix}.title`, { ns: 'explore' })}</div>
<div className="system-xs-regular my-1 text-text-tertiary">{t(`${i18nPrefix}.description`, { ns: 'explore' })}</div>
<a className="system-xs-regular text-text-accent" target="_blank" rel="noopener noreferrer" href="https://docs.dify.ai/en/guides/application-publishing/launch-your-webapp-quickly/README">{t(`${i18nPrefix}.learnMore`, { ns: 'explore' })}</a>
</div>
)
}
export default React.memo(NoApps)

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,7 @@
.light {
background-image: url('./no-web-apps-light.png');
}
.dark {
background-image: url('./no-web-apps-dark.png');
}

View File

@ -0,0 +1,95 @@
'use client'
import type { FC } from 'react'
import type { TryAppInfo } from '@/service/try-app'
import { RiAddLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import { cn } from '@/utils/classnames'
import useGetRequirements from './use-get-requirements'
type Props = {
appId: string
appDetail: TryAppInfo
category?: string
className?: string
onCreate: () => void
}
const headerClassName = 'system-sm-semibold-uppercase text-text-secondary mb-3'
const AppInfo: FC<Props> = ({
appId,
className,
category,
appDetail,
onCreate,
}) => {
const { t } = useTranslation()
const mode = appDetail?.mode
const { requirements } = useGetRequirements({ appDetail, appId })
return (
<div className={cn('flex h-full flex-col px-4 pt-2', className)}>
{/* name and icon */}
<div className="flex shrink-0 grow-0 items-center gap-3">
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={appDetail.site.icon_type}
icon={appDetail.site.icon}
background={appDetail.site.icon_background}
imageUrl={appDetail.site.icon_url}
/>
<AppTypeIcon
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm"
className="h-3 w-3"
type={mode}
/>
</div>
<div className="w-0 grow py-[1px]">
<div className="flex items-center text-sm font-semibold leading-5 text-text-secondary">
<div className="truncate" title={appDetail.name}>{appDetail.name}</div>
</div>
<div className="flex items-center text-[10px] font-medium leading-[18px] text-text-tertiary">
{mode === 'advanced-chat' && <div className="truncate">{t('types.advanced', { ns: 'app' }).toUpperCase()}</div>}
{mode === 'chat' && <div className="truncate">{t('types.chatbot', { ns: 'app' }).toUpperCase()}</div>}
{mode === 'agent-chat' && <div className="truncate">{t('types.agent', { ns: 'app' }).toUpperCase()}</div>}
{mode === 'workflow' && <div className="truncate">{t('types.workflow', { ns: 'app' }).toUpperCase()}</div>}
{mode === 'completion' && <div className="truncate">{t('types.completion', { ns: 'app' }).toUpperCase()}</div>}
</div>
</div>
</div>
{appDetail.description && (
<div className="system-sm-regular mt-[14px] shrink-0 text-text-secondary">{appDetail.description}</div>
)}
<Button variant="primary" className="mt-3 flex w-full max-w-full" onClick={onCreate}>
<RiAddLine className="mr-1 size-4 shrink-0" />
<span className="truncate">{t('tryApp.createFromSampleApp', { ns: 'explore' })}</span>
</Button>
{category && (
<div className="mt-6 shrink-0">
<div className={headerClassName}>{t('tryApp.category', { ns: 'explore' })}</div>
<div className="system-md-regular text-text-secondary">{category}</div>
</div>
)}
{requirements.length > 0 && (
<div className="mt-5 grow overflow-y-auto">
<div className={headerClassName}>{t('tryApp.requirements', { ns: 'explore' })}</div>
<div className="space-y-0.5">
{requirements.map(item => (
<div className="flex items-center space-x-2 py-1" key={item.name}>
<div className="size-5 rounded-md bg-cover shadow-xs" style={{ backgroundImage: `url(${item.iconUrl})` }} />
<div className="system-md-regular w-0 grow truncate text-text-secondary">{item.name}</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
export default React.memo(AppInfo)

View File

@ -0,0 +1,78 @@
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types'
import type { TryAppInfo } from '@/service/try-app'
import type { AgentTool } from '@/types/app'
import { uniqBy } from 'es-toolkit/compat'
import { BlockEnum } from '@/app/components/workflow/types'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { useGetTryAppFlowPreview } from '@/service/use-try-app'
type Params = {
appDetail: TryAppInfo
appId: string
}
type RequirementItem = {
name: string
iconUrl: string
}
const getIconUrl = (provider: string, tool: string) => {
return `${MARKETPLACE_API_PREFIX}/plugins/${provider}/${tool}/icon`
}
const useGetRequirements = ({ appDetail, appId }: Params) => {
const isBasic = ['chat', 'completion', 'agent-chat'].includes(appDetail.mode)
const isAgent = appDetail.mode === 'agent-chat'
const isAdvanced = !isBasic
const { data: flowData } = useGetTryAppFlowPreview(appId, isBasic)
const requirements: RequirementItem[] = []
if (isBasic) {
const modelProviderAndName = appDetail.model_config.model.provider.split('/')
const name = appDetail.model_config.model.provider.split('/').pop() || ''
requirements.push({
name,
iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]),
})
}
if (isAgent) {
requirements.push(...appDetail.model_config.agent_mode.tools.filter(data => (data as AgentTool).enabled).map((data) => {
const tool = data as AgentTool
const modelProviderAndName = tool.provider_id.split('/')
return {
name: tool.tool_label,
iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]),
}
}))
}
if (isAdvanced && flowData && flowData?.graph?.nodes?.length > 0) {
const nodes = flowData.graph.nodes
const llmNodes = nodes.filter(node => node.data.type === BlockEnum.LLM)
requirements.push(...llmNodes.map((node) => {
const data = node.data as LLMNodeType
const modelProviderAndName = data.model.provider.split('/')
return {
name: data.model.name,
iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]),
}
}))
const toolNodes = nodes.filter(node => node.data.type === BlockEnum.Tool)
requirements.push(...toolNodes.map((node) => {
const data = node.data as ToolNodeType
const toolProviderAndName = data.provider_id.split('/')
return {
name: data.tool_label,
iconUrl: getIconUrl(toolProviderAndName[0], toolProviderAndName[1]),
}
}))
}
const uniqueRequirements = uniqBy(requirements, 'name')
return {
requirements: uniqueRequirements,
}
}
export default useGetRequirements

View File

@ -0,0 +1,101 @@
'use client'
import type { FC } from 'react'
import type { TryAppInfo } from '@/service/try-app'
import { RiResetLeftLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Alert from '@/app/components/base/alert'
import AppIcon from '@/app/components/base/app-icon'
import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper'
import {
EmbeddedChatbotContext,
} from '@/app/components/base/chat/embedded-chatbot/context'
import {
useEmbeddedChatbot,
} from '@/app/components/base/chat/embedded-chatbot/hooks'
import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown'
import Tooltip from '@/app/components/base/tooltip'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { AppSourceType } from '@/service/share'
import { cn } from '@/utils/classnames'
import { useThemeContext } from '../../../base/chat/embedded-chatbot/theme/theme-context'
type Props = {
appId: string
appDetail: TryAppInfo
className: string
}
const TryApp: FC<Props> = ({
appId,
appDetail,
className,
}) => {
const { t } = useTranslation()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const themeBuilder = useThemeContext()
const { removeConversationIdInfo, ...chatData } = useEmbeddedChatbot(AppSourceType.tryApp, appId)
const currentConversationId = chatData.currentConversationId
const inputsForms = chatData.inputsForms
useEffect(() => {
if (appId)
removeConversationIdInfo(appId)
}, [appId])
const [isHideTryNotice, {
setTrue: hideTryNotice,
}] = useBoolean(false)
const handleNewConversation = () => {
removeConversationIdInfo(appId)
chatData.handleNewConversation()
}
return (
<EmbeddedChatbotContext.Provider value={{
...chatData,
disableFeedback: true,
isMobile,
themeBuilder,
} as any}
>
<div className={cn('flex h-full flex-col rounded-2xl bg-background-section-burn', className)}>
<div className="flex shrink-0 justify-between p-3">
<div className="flex grow items-center space-x-2">
<AppIcon
size="large"
iconType={appDetail.site.icon_type}
icon={appDetail.site.icon}
background={appDetail.site.icon_background}
imageUrl={appDetail.site.icon_url}
/>
<div className="system-md-semibold grow truncate text-text-primary" title={appDetail.name}>{appDetail.name}</div>
</div>
<div className="flex items-center gap-1">
{currentConversationId && (
<Tooltip
popupContent={t('chat.resetChat', { ns: 'share' })}
>
<ActionButton size="l" onClick={handleNewConversation}>
<RiResetLeftLine className="h-[18px] w-[18px]" />
</ActionButton>
</Tooltip>
)}
{currentConversationId && inputsForms.length > 0 && (
<ViewFormDropdown />
)}
</div>
</div>
<div className="mx-auto mt-4 flex h-[0] w-[769px] grow flex-col">
{!isHideTryNotice && (
<Alert className="mb-4 shrink-0" message={t('tryApp.tryInfo', { ns: 'explore' })} onHide={hideTryNotice} />
)}
<ChatWrapper />
</div>
</div>
</EmbeddedChatbotContext.Provider>
)
}
export default React.memo(TryApp)

View File

@ -0,0 +1,44 @@
'use client'
import type { FC } from 'react'
import type { AppData } from '@/models/share'
import type { TryAppInfo } from '@/service/try-app'
import * as React from 'react'
import useDocumentTitle from '@/hooks/use-document-title'
import Chat from './chat'
import TextGeneration from './text-generation'
type Props = {
appId: string
appDetail: TryAppInfo
}
const TryApp: FC<Props> = ({
appId,
appDetail,
}) => {
const mode = appDetail?.mode
const isChat = ['chat', 'advanced-chat', 'agent-chat'].includes(mode!)
const isCompletion = !isChat
useDocumentTitle(appDetail?.site?.title || '')
return (
<div className="flex h-full w-full">
{isChat && (
<Chat appId={appId} appDetail={appDetail} className="h-full grow" />
)}
{isCompletion && (
<TextGeneration
appId={appId}
className="h-full grow"
isWorkflow={mode === 'workflow'}
appData={{
app_id: appId,
custom_config: {},
...appDetail,
} as AppData}
/>
)}
</div>
)
}
export default React.memo(TryApp)

View File

@ -0,0 +1,261 @@
'use client'
import type { FC } from 'react'
import type { Task } from '../../../share/text-generation/types'
import type { MoreLikeThisConfig, PromptConfig, TextToSpeechConfig } from '@/models/debug'
import type { AppData, SiteInfo } from '@/models/share'
import type { VisionFile, VisionSettings } from '@/types/app'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Alert from '@/app/components/base/alert'
import AppIcon from '@/app/components/base/app-icon'
import Loading from '@/app/components/base/loading'
import Res from '@/app/components/share/text-generation/result'
import { TaskStatus } from '@/app/components/share/text-generation/types'
import { appDefaultIconBackground } from '@/config'
import { useWebAppStore } from '@/context/web-app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { AppSourceType } from '@/service/share'
import { useGetTryAppParams } from '@/service/use-try-app'
import { Resolution, TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import { userInputsFormToPromptVariables } from '@/utils/model-config'
import RunOnce from '../../../share/text-generation/run-once'
type Props = {
appId: string
className?: string
isWorkflow?: boolean
appData: AppData | null
}
const TextGeneration: FC<Props> = ({
appId,
className,
isWorkflow,
appData,
}) => {
const { t } = useTranslation()
const media = useBreakpoints()
const isPC = media === MediaType.pc
const [inputs, doSetInputs] = useState<Record<string, any>>({})
const inputsRef = useRef<Record<string, any>>(inputs)
const setInputs = useCallback((newInputs: Record<string, any>) => {
doSetInputs(newInputs)
inputsRef.current = newInputs
}, [])
const updateAppInfo = useWebAppStore(s => s.updateAppInfo)
const { data: tryAppParams } = useGetTryAppParams(appId)
const updateAppParams = useWebAppStore(s => s.updateAppParams)
const appParams = useWebAppStore(s => s.appParams)
const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null)
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
const [customConfig, setCustomConfig] = useState<Record<string, any> | null>(null)
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null)
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null)
const [controlSend, setControlSend] = useState(0)
const [visionConfig, setVisionConfig] = useState<VisionSettings>({
enabled: false,
number_limits: 2,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
})
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
const [isShowResultPanel, { setTrue: doShowResultPanel, setFalse: hideResultPanel }] = useBoolean(false)
const showResultPanel = () => {
// fix: useClickAway hideResSidebar will close sidebar
setTimeout(() => {
doShowResultPanel()
}, 0)
}
const handleSend = () => {
setControlSend(Date.now())
showResultPanel()
}
const [resultExisted, setResultExisted] = useState(false)
useEffect(() => {
if (!appData)
return
updateAppInfo(appData)
}, [appData, updateAppInfo])
useEffect(() => {
if (!tryAppParams)
return
updateAppParams(tryAppParams)
}, [tryAppParams, updateAppParams])
useEffect(() => {
(async () => {
if (!appData || !appParams)
return
const { site: siteInfo, custom_config } = appData
setSiteInfo(siteInfo as SiteInfo)
setCustomConfig(custom_config)
const { user_input_form, more_like_this, file_upload, text_to_speech }: any = appParams
setVisionConfig({
// legacy of image upload compatible
...file_upload,
transfer_methods: file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods,
// legacy of image upload compatible
image_file_size_limit: appParams?.system_parameters.image_file_size_limit,
fileUploadConfig: appParams?.system_parameters,
} as any)
const prompt_variables = userInputsFormToPromptVariables(user_input_form)
setPromptConfig({
prompt_template: '', // placeholder for future
prompt_variables,
} as PromptConfig)
setMoreLikeThisConfig(more_like_this)
setTextToSpeechConfig(text_to_speech)
})()
}, [appData, appParams])
const [isCompleted, setIsCompleted] = useState(false)
const handleCompleted = useCallback(() => {
setIsCompleted(true)
}, [])
const [isHideTryNotice, {
setTrue: hideTryNotice,
}] = useBoolean(false)
const renderRes = (task?: Task) => (
<Res
key={task?.id}
isWorkflow={!!isWorkflow}
isCallBatchAPI={false}
isPC={isPC}
isMobile={!isPC}
appSourceType={AppSourceType.tryApp}
appId={appId}
isError={task?.status === TaskStatus.failed}
promptConfig={promptConfig}
moreLikeThisEnabled={!!moreLikeThisConfig?.enabled}
inputs={inputs}
controlSend={controlSend}
onShowRes={showResultPanel}
handleSaveMessage={noop}
taskId={task?.id}
onCompleted={handleCompleted}
visionConfig={visionConfig}
completionFiles={completionFiles}
isShowTextToSpeech={!!textToSpeechConfig?.enabled}
siteInfo={siteInfo}
onRunStart={() => setResultExisted(true)}
/>
)
const renderResWrap = (
<div
className={cn(
'relative flex h-full flex-col',
'rounded-r-2xl bg-chatbot-bg',
)}
>
<div className={cn(
'flex h-0 grow flex-col overflow-y-auto p-6',
)}
>
{isCompleted && !isHideTryNotice && (
<Alert className="mb-3 shrink-0" message={t('tryApp.tryInfo', { ns: 'explore' })} onHide={hideTryNotice} />
)}
{renderRes()}
</div>
</div>
)
if (!siteInfo || !promptConfig) {
return (
<div className={cn('flex h-screen items-center', className)}>
<Loading type="app" />
</div>
)
}
return (
<div className={cn(
'rounded-2xl border border-components-panel-border bg-background-section-burn',
isPC && 'flex',
!isPC && 'flex-col',
'h-full rounded-2xl shadow-md',
className,
)}
>
{/* Left */}
<div className={cn(
'relative flex h-full shrink-0 flex-col',
isPC && 'w-[600px] max-w-[50%]',
'rounded-l-2xl bg-components-panel-bg',
)}
>
{/* Header */}
<div className={cn('shrink-0 space-y-4 pb-2', isPC ? ' p-8 pb-0' : 'p-4 pb-0')}>
<div className="flex items-center gap-3">
<AppIcon
size={isPC ? 'large' : 'small'}
iconType={siteInfo.icon_type}
icon={siteInfo.icon}
background={siteInfo.icon_background || appDefaultIconBackground}
imageUrl={siteInfo.icon_url}
/>
<div className="system-md-semibold grow truncate text-text-secondary">{siteInfo.title}</div>
</div>
{siteInfo.description && (
<div className="system-xs-regular text-text-tertiary">{siteInfo.description}</div>
)}
</div>
{/* form */}
<div className={cn(
'h-0 grow overflow-y-auto',
isPC ? 'px-8' : 'px-4',
!isPC && resultExisted && customConfig?.remove_webapp_brand && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}
>
<RunOnce
siteInfo={siteInfo}
inputs={inputs}
inputsRef={inputsRef}
onInputsChange={setInputs}
promptConfig={promptConfig}
onSend={handleSend}
visionConfig={visionConfig}
onVisionFilesChange={setCompletionFiles}
/>
</div>
</div>
{/* Result */}
<div className={cn('h-full w-0 grow')}>
{!isPC && (
<div
className={cn(
isShowResultPanel
? 'flex items-center justify-center p-2 pt-6'
: 'absolute left-0 top-0 z-10 flex w-full items-center justify-center px-2 pb-[57px] pt-[3px]',
)}
onClick={() => {
if (isShowResultPanel)
hideResultPanel()
else
showResultPanel()
}}
>
<div className="h-1 w-8 cursor-grab rounded bg-divider-solid" />
</div>
)}
{renderResWrap}
</div>
</div>
)
}
export default React.memo(TextGeneration)

View File

@ -0,0 +1,73 @@
'use client'
import type { FC } from 'react'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useState } from 'react'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal/index'
import { useGetTryAppInfo } from '@/service/use-try-app'
import Button from '../../base/button'
import App from './app'
import AppInfo from './app-info'
import Preview from './preview'
import Tab, { TypeEnum } from './tab'
type Props = {
appId: string
category?: string
onClose: () => void
onCreate: () => void
}
const TryApp: FC<Props> = ({
appId,
category,
onClose,
onCreate,
}) => {
const [type, setType] = useState<TypeEnum>(TypeEnum.TRY)
const { data: appDetail, isLoading } = useGetTryAppInfo(appId)
return (
<Modal
isShow
onClose={onClose}
className="h-[calc(100vh-32px)] min-w-[1280px] max-w-[calc(100vw-32px)] overflow-x-auto p-2"
>
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Loading type="area" />
</div>
) : (
<div className="flex h-full flex-col">
<div className="flex shrink-0 justify-between pl-4">
<Tab
value={type}
onChange={setType}
/>
<Button
size="large"
variant="tertiary"
className="flex size-7 items-center justify-center rounded-[10px] p-0 text-components-button-tertiary-text"
onClick={onClose}
>
<RiCloseLine className="size-5" onClick={onClose} />
</Button>
</div>
{/* Main content */}
<div className="mt-2 flex h-0 grow justify-between space-x-2">
{type === TypeEnum.TRY ? <App appId={appId} appDetail={appDetail!} /> : <Preview appId={appId} appDetail={appDetail!} />}
<AppInfo
className="w-[360px] shrink-0"
appDetail={appDetail!}
appId={appId}
category={category}
onCreate={onCreate}
/>
</div>
</div>
)}
</Modal>
)
}
export default React.memo(TryApp)

View File

@ -0,0 +1,364 @@
'use client'
import type { FC } from 'react'
import type { Features as FeaturesData, FileUpload } from '@/app/components/base/features/types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ModelConfig } from '@/models/debug'
import type { ModelConfig as BackendModelConfig, PromptVariable } from '@/types/app'
import { noop } from 'es-toolkit/function'
import { clone } from 'es-toolkit/object'
import * as React from 'react'
import { useMemo, useState } from 'react'
import Config from '@/app/components/app/configuration/config'
import Debug from '@/app/components/app/configuration/debug'
import { FeaturesProvider } from '@/app/components/base/features'
import Loading from '@/app/components/base/loading'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { ANNOTATION_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import ConfigContext from '@/context/debug-configuration'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { PromptMode } from '@/models/debug'
import { useAllToolProviders } from '@/service/use-tools'
import { useGetTryAppDataSets, useGetTryAppInfo } from '@/service/use-try-app'
import { ModelModeType, Resolution, TransferMethod } from '@/types/app'
import { correctModelProvider, correctToolProvider } from '@/utils'
import { userInputsFormToPromptVariables } from '@/utils/model-config'
import { basePath } from '@/utils/var'
import { useTextGenerationCurrentProviderAndModelAndModelList } from '../../../header/account-setting/model-provider-page/hooks'
type Props = {
appId: string
}
const defaultModelConfig = {
provider: 'langgenius/openai/openai',
model_id: 'gpt-3.5-turbo',
mode: ModelModeType.unset,
configs: {
prompt_template: '',
prompt_variables: [] as PromptVariable[],
},
more_like_this: null,
opening_statement: '',
suggested_questions: [],
sensitive_word_avoidance: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null,
suggested_questions_after_answer: null,
retriever_resource: null,
annotation_reply: null,
dataSets: [],
agentConfig: DEFAULT_AGENT_SETTING,
}
const BasicAppPreview: FC<Props> = ({
appId,
}) => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { data: appDetail, isLoading: isLoadingAppDetail } = useGetTryAppInfo(appId)
const { data: collectionListFromServer, isLoading: isLoadingToolProviders } = useAllToolProviders()
const collectionList = collectionListFromServer?.map((item) => {
return {
...item,
icon: basePath && typeof item.icon == 'string' && !item.icon.includes(basePath) ? `${basePath}${item.icon}` : item.icon,
}
})
const datasetIds = (() => {
if (isLoadingAppDetail)
return []
const modelConfig = appDetail?.model_config
if (!modelConfig)
return []
let datasets: any = null
if (modelConfig.agent_mode?.tools?.find(({ dataset }: any) => dataset?.enabled))
datasets = modelConfig.agent_mode?.tools.filter(({ dataset }: any) => dataset?.enabled)
// new dataset struct
else if (modelConfig.dataset_configs.datasets?.datasets?.length > 0)
datasets = modelConfig.dataset_configs?.datasets?.datasets
if (datasets?.length && datasets?.length > 0)
return datasets.map(({ dataset }: any) => dataset.id)
return []
})()
const { data: dataSetData, isLoading: isLoadingDatasets } = useGetTryAppDataSets(appId, datasetIds)
const dataSets = dataSetData?.data || []
const isLoading = isLoadingAppDetail || isLoadingDatasets || isLoadingToolProviders
const modelConfig: ModelConfig = ((modelConfig?: BackendModelConfig) => {
if (isLoading || !modelConfig)
return defaultModelConfig
const model = modelConfig.model
const newModelConfig = {
provider: correctModelProvider(model.provider),
model_id: model.name,
mode: model.mode,
configs: {
prompt_template: modelConfig.pre_prompt || '',
prompt_variables: userInputsFormToPromptVariables(
[
...(modelConfig.user_input_form as any),
...(
modelConfig.external_data_tools?.length
? modelConfig.external_data_tools.map((item: any) => {
return {
external_data_tool: {
variable: item.variable as string,
label: item.label as string,
enabled: item.enabled,
type: item.type as string,
config: item.config,
required: true,
icon: item.icon,
icon_background: item.icon_background,
},
}
})
: []
),
],
modelConfig.dataset_query_variable,
),
},
more_like_this: modelConfig.more_like_this,
opening_statement: modelConfig.opening_statement,
suggested_questions: modelConfig.suggested_questions,
sensitive_word_avoidance: modelConfig.sensitive_word_avoidance,
speech_to_text: modelConfig.speech_to_text,
text_to_speech: modelConfig.text_to_speech,
file_upload: modelConfig.file_upload,
suggested_questions_after_answer: modelConfig.suggested_questions_after_answer,
retriever_resource: modelConfig.retriever_resource,
annotation_reply: modelConfig.annotation_reply,
external_data_tools: modelConfig.external_data_tools,
dataSets,
agentConfig: appDetail?.mode === 'agent-chat' ? {
max_iteration: DEFAULT_AGENT_SETTING.max_iteration,
...modelConfig.agent_mode,
// remove dataset
enabled: true, // modelConfig.agent_mode?.enabled is not correct. old app: the value of app with dataset's is always true
tools: modelConfig.agent_mode?.tools.filter((tool: any) => {
return !tool.dataset
}).map((tool: any) => {
const toolInCollectionList = collectionList?.find(c => tool.provider_id === c.id)
return {
...tool,
isDeleted: appDetail?.deleted_tools?.some((deletedTool: any) => deletedTool.id === tool.id && deletedTool.tool_name === tool.tool_name),
notAuthor: toolInCollectionList?.is_team_authorization === false,
...(tool.provider_type === 'builtin'
? {
provider_id: correctToolProvider(tool.provider_name, !!toolInCollectionList),
provider_name: correctToolProvider(tool.provider_name, !!toolInCollectionList),
}
: {}),
}
}),
} : DEFAULT_AGENT_SETTING,
}
return (newModelConfig as any)
})(appDetail?.model_config)
const mode = appDetail?.mode
// const isChatApp = ['chat', 'advanced-chat', 'agent-chat'].includes(mode!)
// chat configuration
const promptMode = modelConfig?.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple
const isAdvancedMode = promptMode === PromptMode.advanced
const isAgent = mode === 'agent-chat'
const chatPromptConfig = isAdvancedMode ? (modelConfig?.chat_prompt_config || clone(DEFAULT_CHAT_PROMPT_CONFIG)) : undefined
const suggestedQuestions = modelConfig?.suggested_questions || []
const moreLikeThisConfig = modelConfig?.more_like_this || { enabled: false }
const suggestedQuestionsAfterAnswerConfig = modelConfig?.suggested_questions_after_answer || { enabled: false }
const speechToTextConfig = modelConfig?.speech_to_text || { enabled: false }
const textToSpeechConfig = modelConfig?.text_to_speech || { enabled: false, voice: '', language: '' }
const citationConfig = modelConfig?.retriever_resource || { enabled: false }
const annotationConfig = modelConfig?.annotation_reply || {
id: '',
enabled: false,
score_threshold: ANNOTATION_DEFAULT.score_threshold,
embedding_model: {
embedding_provider_name: '',
embedding_model_name: '',
},
}
const moderationConfig = modelConfig?.sensitive_word_avoidance || { enabled: false }
// completion configuration
const completionPromptConfig = modelConfig?.completion_prompt_config || clone(DEFAULT_COMPLETION_PROMPT_CONFIG) as any
// prompt & model config
const inputs = {}
const query = ''
const completionParams = useState<FormValue>({})
const {
currentModel: currModel,
} = useTextGenerationCurrentProviderAndModelAndModelList(
{
provider: modelConfig.provider,
model: modelConfig.model_id,
},
)
const isShowVisionConfig = !!currModel?.features?.includes(ModelFeatureEnum.vision)
const isShowDocumentConfig = !!currModel?.features?.includes(ModelFeatureEnum.document)
const isShowAudioConfig = !!currModel?.features?.includes(ModelFeatureEnum.audio)
const isAllowVideoUpload = !!currModel?.features?.includes(ModelFeatureEnum.video)
const visionConfig = {
enabled: false,
number_limits: 2,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
}
const featuresData: FeaturesData = useMemo(() => {
return {
moreLikeThis: modelConfig.more_like_this || { enabled: false },
opening: {
enabled: !!modelConfig.opening_statement,
opening_statement: modelConfig.opening_statement || '',
suggested_questions: modelConfig.suggested_questions || [],
},
moderation: modelConfig.sensitive_word_avoidance || { enabled: false },
speech2text: modelConfig.speech_to_text || { enabled: false },
text2speech: modelConfig.text_to_speech || { enabled: false },
file: {
image: {
detail: modelConfig.file_upload?.image?.detail || Resolution.high,
enabled: !!modelConfig.file_upload?.image?.enabled,
number_limits: modelConfig.file_upload?.image?.number_limits || 3,
transfer_methods: modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
},
enabled: !!(modelConfig.file_upload?.enabled || modelConfig.file_upload?.image?.enabled),
allowed_file_types: modelConfig.file_upload?.allowed_file_types || [],
allowed_file_extensions: modelConfig.file_upload?.allowed_file_extensions || [...FILE_EXTS[SupportUploadFileTypes.image], ...FILE_EXTS[SupportUploadFileTypes.video]].map(ext => `.${ext}`),
allowed_file_upload_methods: modelConfig.file_upload?.allowed_file_upload_methods || modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
number_limits: modelConfig.file_upload?.number_limits || modelConfig.file_upload?.image?.number_limits || 3,
fileUploadConfig: {},
} as FileUpload,
suggested: modelConfig.suggested_questions_after_answer || { enabled: false },
citation: modelConfig.retriever_resource || { enabled: false },
annotationReply: modelConfig.annotation_reply || { enabled: false },
}
}, [modelConfig])
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<Loading type="area" />
</div>
)
}
const value = {
readonly: true,
appId,
isAPIKeySet: true,
isTrailFinished: false,
mode,
modelModeType: '',
promptMode,
isAdvancedMode,
isAgent,
isOpenAI: false,
isFunctionCall: false,
collectionList: [],
setPromptMode: noop,
canReturnToSimpleMode: false,
setCanReturnToSimpleMode: noop,
chatPromptConfig,
completionPromptConfig,
currentAdvancedPrompt: '',
setCurrentAdvancedPrompt: noop,
conversationHistoriesRole: completionPromptConfig.conversation_histories_role,
showHistoryModal: false,
setConversationHistoriesRole: noop,
hasSetBlockStatus: true,
conversationId: '',
introduction: '',
setIntroduction: noop,
suggestedQuestions,
setSuggestedQuestions: noop,
setConversationId: noop,
controlClearChatMessage: false,
setControlClearChatMessage: noop,
prevPromptConfig: {},
setPrevPromptConfig: noop,
moreLikeThisConfig,
setMoreLikeThisConfig: noop,
suggestedQuestionsAfterAnswerConfig,
setSuggestedQuestionsAfterAnswerConfig: noop,
speechToTextConfig,
setSpeechToTextConfig: noop,
textToSpeechConfig,
setTextToSpeechConfig: noop,
citationConfig,
setCitationConfig: noop,
annotationConfig,
setAnnotationConfig: noop,
moderationConfig,
setModerationConfig: noop,
externalDataToolsConfig: {},
setExternalDataToolsConfig: noop,
formattingChanged: false,
setFormattingChanged: noop,
inputs,
setInputs: noop,
query,
setQuery: noop,
completionParams,
setCompletionParams: noop,
modelConfig,
setModelConfig: noop,
showSelectDataSet: noop,
dataSets,
setDataSets: noop,
datasetConfigs: [],
datasetConfigsRef: {},
setDatasetConfigs: noop,
hasSetContextVar: true,
isShowVisionConfig,
visionConfig,
setVisionConfig: noop,
isAllowVideoUpload,
isShowDocumentConfig,
isShowAudioConfig,
rerankSettingModalOpen: false,
setRerankSettingModalOpen: noop,
}
return (
<ConfigContext.Provider value={value as any}>
<FeaturesProvider features={featuresData}>
<div className="flex h-full w-full flex-col bg-components-panel-on-panel-item-bg">
<div className="relative flex h-[200px] grow">
<div className="flex h-full w-full shrink-0 flex-col sm:w-1/2">
<Config />
</div>
{!isMobile && (
<div className="relative flex h-full w-1/2 grow flex-col overflow-y-auto " style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }}>
<div className="flex grow flex-col rounded-tl-2xl border-l-[0.5px] border-t-[0.5px] border-components-panel-border bg-chatbot-bg ">
<Debug
isAPIKeySet
onSetting={noop}
inputs={inputs}
modelParameterParams={{
setModel: noop,
onCompletionParamsChange: noop,
}}
debugWithMultipleModel={false}
multipleModelConfigs={[]}
onMultipleModelConfigsChange={noop}
/>
</div>
</div>
)}
</div>
</div>
</FeaturesProvider>
</ConfigContext.Provider>
)
}
export default React.memo(BasicAppPreview)

View File

@ -0,0 +1,39 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import Loading from '@/app/components/base/loading'
import WorkflowPreview from '@/app/components/workflow/workflow-preview'
import { useGetTryAppFlowPreview } from '@/service/use-try-app'
import { cn } from '@/utils/classnames'
type Props = {
appId: string
className?: string
}
const FlowAppPreview: FC<Props> = ({
appId,
className,
}) => {
const { data, isLoading } = useGetTryAppFlowPreview(appId)
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<Loading type="area" />
</div>
)
}
if (!data)
return null
return (
<div className="h-full w-full">
<WorkflowPreview
{...data.graph}
className={cn(className)}
miniMapToRight
/>
</div>
)
}
export default React.memo(FlowAppPreview)

View File

@ -0,0 +1,25 @@
'use client'
import type { FC } from 'react'
import type { TryAppInfo } from '@/service/try-app'
import * as React from 'react'
import BasicAppPreview from './basic-app-preview'
import FlowAppPreview from './flow-app-preview'
type Props = {
appId: string
appDetail: TryAppInfo
}
const Preview: FC<Props> = ({
appId,
appDetail,
}) => {
const isBasicApp = ['agent-chat', 'chat', 'completion'].includes(appDetail.mode)
return (
<div className="h-full w-full">
{isBasicApp ? <BasicAppPreview appId={appId} /> : <FlowAppPreview appId={appId} className="h-full" />}
</div>
)
}
export default React.memo(Preview)

View File

@ -0,0 +1,37 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import TabHeader from '../../base/tab-header'
export enum TypeEnum {
TRY = 'try',
DETAIL = 'detail',
}
type Props = {
value: TypeEnum
onChange: (value: TypeEnum) => void
}
const Tab: FC<Props> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
const tabs = [
{ id: TypeEnum.TRY, name: t('tryApp.tabHeader.try', { ns: 'explore' }) },
{ id: TypeEnum.DETAIL, name: t('tryApp.tabHeader.detail', { ns: 'explore' }) },
]
return (
<TabHeader
items={tabs}
value={value}
onChange={onChange as (value: string) => void}
itemClassName="ml-0 system-md-semibold-uppercase"
itemWrapClassName="pt-2"
activeItemClassName="border-util-colors-blue-brand-blue-brand-500"
/>
)
}
export default React.memo(Tab)

View File

@ -34,7 +34,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { changeLanguage } from '@/i18n-config/client'
import { AccessMode } from '@/models/access-control'
import { fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share'
import { AppSourceType, fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share'
import { Resolution, TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import { userInputsFormToPromptVariables } from '@/utils/model-config'
@ -73,6 +73,7 @@ const TextGeneration: FC<IMainProps> = ({
isWorkflow = false,
}) => {
const { notify } = Toast
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const { t } = useTranslation()
const media = useBreakpoints()
@ -102,16 +103,18 @@ const TextGeneration: FC<IMainProps> = ({
// save message
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
const fetchSavedMessage = useCallback(async () => {
const res: any = await doFetchSavedMessage(isInstalledApp, appId)
if (!appId)
return
const res: any = await doFetchSavedMessage(appSourceType, appId)
setSavedMessages(res.data)
}, [isInstalledApp, appId])
}, [appSourceType, appId])
const handleSaveMessage = async (messageId: string) => {
await saveMessage(messageId, isInstalledApp, appId)
await saveMessage(messageId, appSourceType, appId)
notify({ type: 'success', message: t('api.saved', { ns: 'common' }) })
fetchSavedMessage()
}
const handleRemoveSavedMessage = async (messageId: string) => {
await removeMessage(messageId, isInstalledApp, appId)
await removeMessage(messageId, appSourceType, appId)
notify({ type: 'success', message: t('api.remove', { ns: 'common' }) })
fetchSavedMessage()
}
@ -424,9 +427,8 @@ const TextGeneration: FC<IMainProps> = ({
isCallBatchAPI={isCallBatchAPI}
isPC={isPC}
isMobile={!isPC}
isInstalledApp={isInstalledApp}
appSourceType={isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp}
appId={appId}
installedAppInfo={installedAppInfo}
isError={task?.status === TaskStatus.failed}
promptConfig={promptConfig}
moreLikeThisEnabled={!!moreLikeThisConfig?.enabled}

View File

@ -4,8 +4,8 @@ import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { PromptConfig } from '@/models/debug'
import type { InstalledApp } from '@/models/explore'
import type { SiteInfo } from '@/models/share'
import type { AppSourceType } from '@/service/share'
import type { VisionFile, VisionSettings } from '@/types/app'
import { RiLoader2Line } from '@remixicon/react'
import { useBoolean } from 'ahooks'
@ -35,9 +35,8 @@ export type IResultProps = {
isCallBatchAPI: boolean
isPC: boolean
isMobile: boolean
isInstalledApp: boolean
appId: string
installedAppInfo?: InstalledApp
appSourceType: AppSourceType
appId?: string
isError: boolean
isShowTextToSpeech: boolean
promptConfig: PromptConfig | null
@ -63,9 +62,8 @@ const Result: FC<IResultProps> = ({
isCallBatchAPI,
isPC,
isMobile,
isInstalledApp,
appSourceType,
appId,
installedAppInfo,
isError,
isShowTextToSpeech,
promptConfig,
@ -133,7 +131,7 @@ const Result: FC<IResultProps> = ({
})
const handleFeedback = async (feedback: FeedbackType) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, installedAppInfo?.id)
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId)
setFeedback(feedback)
}
@ -147,9 +145,9 @@ const Result: FC<IResultProps> = ({
setIsStopping(true)
try {
if (isWorkflow)
await stopWorkflowMessage(appId, currentTaskId, isInstalledApp, installedAppInfo?.id || '')
await stopWorkflowMessage(appId!, currentTaskId, appSourceType, appId || '')
else
await stopChatMessageResponding(appId, currentTaskId, isInstalledApp, installedAppInfo?.id || '')
await stopChatMessageResponding(appId!, currentTaskId, appSourceType, appId || '')
abortControllerRef.current?.abort()
}
catch (error) {
@ -159,7 +157,7 @@ const Result: FC<IResultProps> = ({
finally {
setIsStopping(false)
}
}, [appId, currentTaskId, installedAppInfo?.id, isInstalledApp, isStopping, isWorkflow, notify])
}, [appId, currentTaskId, appSourceType, appId, isStopping, isWorkflow, notify])
useEffect(() => {
if (!onRunControlChange)
@ -468,8 +466,8 @@ const Result: FC<IResultProps> = ({
}))
},
},
isInstalledApp,
installedAppInfo?.id,
appSourceType,
appId,
).catch((error) => {
setRespondingFalse()
resetRunState()
@ -514,7 +512,7 @@ const Result: FC<IResultProps> = ({
getAbortController: (abortController) => {
abortControllerRef.current = abortController
},
}, isInstalledApp, installedAppInfo?.id)
}, appSourceType, appId)
}
}
@ -562,8 +560,8 @@ const Result: FC<IResultProps> = ({
feedback={feedback}
onSave={handleSaveMessage}
isMobile={isMobile}
isInstalledApp={isInstalledApp}
installedAppId={installedAppInfo?.id}
appSourceType={appSourceType}
installedAppId={appId}
isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false}
taskId={isCallBatchAPI ? ((taskId as number) < 10 ? `0${taskId}` : `${taskId}`) : undefined}
controlClearMoreLikeThis={controlClearMoreLikeThis}

View File

@ -0,0 +1,16 @@
type TaskParam = {
inputs: Record<string, any>
}
export type Task = {
id: number
status: TaskStatus
params: TaskParam
}
export enum TaskStatus {
pending = 'pending',
running = 'running',
completed = 'completed',
failed = 'failed',
}

View File

@ -13,6 +13,7 @@ import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import { useGetLanguage } from '@/context/i18n'
import { isServer } from '@/utils/client'
import { formatNumber } from '@/utils/format'
import { getMarketplaceUrl } from '@/utils/var'
import BlockIcon from '../block-icon'
@ -49,14 +50,14 @@ const FeaturedTools = ({
const language = useGetLanguage()
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined')
if (isServer)
return false
const stored = window.localStorage.getItem(STORAGE_KEY)
return stored === 'true'
})
useEffect(() => {
if (typeof window === 'undefined')
if (isServer)
return
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored !== null)
@ -64,7 +65,7 @@ const FeaturedTools = ({
}, [])
useEffect(() => {
if (typeof window === 'undefined')
if (isServer)
return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
}, [isCollapsed])

View File

@ -12,6 +12,7 @@ import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import { useGetLanguage } from '@/context/i18n'
import { isServer } from '@/utils/client'
import { formatNumber } from '@/utils/format'
import { getMarketplaceUrl } from '@/utils/var'
import BlockIcon from '../block-icon'
@ -42,14 +43,14 @@ const FeaturedTriggers = ({
const language = useGetLanguage()
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined')
if (isServer)
return false
const stored = window.localStorage.getItem(STORAGE_KEY)
return stored === 'true'
})
useEffect(() => {
if (typeof window === 'undefined')
if (isServer)
return
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored !== null)
@ -57,7 +58,7 @@ const FeaturedTriggers = ({
}, [])
useEffect(() => {
if (typeof window === 'undefined')
if (isServer)
return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
}, [isCollapsed])

View File

@ -11,6 +11,7 @@ import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid
import Loading from '@/app/components/base/loading'
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
import { useRAGRecommendedPlugins } from '@/service/use-tools'
import { isServer } from '@/utils/client'
import { getMarketplaceUrl } from '@/utils/var'
import List from './list'
@ -29,14 +30,14 @@ const RAGToolRecommendations = ({
}: RAGToolRecommendationsProps) => {
const { t } = useTranslation()
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined')
if (isServer)
return false
const stored = window.localStorage.getItem(STORAGE_KEY)
return stored === 'true'
})
useEffect(() => {
if (typeof window === 'undefined')
if (isServer)
return
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored !== null)
@ -44,7 +45,7 @@ const RAGToolRecommendations = ({
}, [])
useEffect(() => {
if (typeof window === 'undefined')
if (isServer)
return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
}, [isCollapsed])

View File

@ -10,6 +10,7 @@ type Props = {
value: boolean
required?: boolean
onChange: (value: boolean) => void
readonly?: boolean
}
const BoolInput: FC<Props> = ({
@ -17,6 +18,7 @@ const BoolInput: FC<Props> = ({
onChange,
name,
required,
readonly,
}) => {
const { t } = useTranslation()
const handleChange = useCallback(() => {
@ -28,6 +30,7 @@ const BoolInput: FC<Props> = ({
className="!h-4 !w-4"
checked={!!value}
onCheck={handleChange}
disabled={readonly}
/>
<div className="system-sm-medium flex items-center gap-1 text-text-secondary">
{name}

View File

@ -61,12 +61,14 @@ type WorkflowPreviewProps = {
edges: Edge[]
viewport: Viewport
className?: string
miniMapToRight?: boolean
}
const WorkflowPreview = ({
nodes,
edges,
viewport,
className,
miniMapToRight,
}: WorkflowPreviewProps) => {
const [nodesData, setNodesData] = useState(() => initialNodes(nodes, edges))
const [edgesData, setEdgesData] = useState(() => initialEdges(edges, nodes))
@ -97,8 +99,7 @@ const WorkflowPreview = ({
height: 72,
}}
maskColor="var(--color-workflow-minimap-bg)"
className="!absolute !bottom-14 !left-4 z-[9] !m-0 !h-[72px] !w-[102px] !rounded-lg !border-[0.5px]
!border-divider-subtle !bg-background-default-subtle !shadow-md !shadow-shadow-shadow-5"
className={cn('!absolute !bottom-14 z-[9] !m-0 !h-[72px] !w-[102px] !rounded-lg !border-[0.5px] !border-divider-subtle !bg-background-default-subtle !shadow-md !shadow-shadow-shadow-5', miniMapToRight ? '!right-4' : '!left-4')}
/>
<div className="absolute bottom-4 left-4 z-[9] mt-1 flex items-center gap-2">
<ZoomInOut />

View File

@ -0,0 +1,19 @@
import type { CurrentTryAppParams } from './explore-context'
import { noop } from 'es-toolkit/function'
import { createContext } from 'use-context-selector'
type Props = {
currentApp?: CurrentTryAppParams
isShowTryAppPanel: boolean
setShowTryAppPanel: (showTryAppPanel: boolean, params?: CurrentTryAppParams) => void
controlHideCreateFromTemplatePanel: number
}
const AppListContext = createContext<Props>({
isShowTryAppPanel: false,
setShowTryAppPanel: noop,
currentApp: undefined,
controlHideCreateFromTemplatePanel: 0,
})
export default AppListContext

View File

@ -29,6 +29,7 @@ import { PromptMode } from '@/models/debug'
import { AppModeEnum, ModelModeType, Resolution, RETRIEVE_TYPE, TransferMethod } from '@/types/app'
type IDebugConfiguration = {
readonly?: boolean
appId: string
isAPIKeySet: boolean
isTrailFinished: boolean
@ -108,6 +109,7 @@ type IDebugConfiguration = {
}
const DebugConfigurationContext = createContext<IDebugConfiguration>({
readonly: false,
appId: '',
isAPIKeySet: false,
isTrailFinished: false,

View File

@ -1,7 +1,12 @@
import type { InstalledApp } from '@/models/explore'
import type { App, InstalledApp } from '@/models/explore'
import { noop } from 'es-toolkit/function'
import { createContext } from 'use-context-selector'
export type CurrentTryAppParams = {
appId: string
app: App
}
type IExplore = {
controlUpdateInstalledApps: number
setControlUpdateInstalledApps: (controlUpdateInstalledApps: number) => void
@ -10,6 +15,9 @@ type IExplore = {
setInstalledApps: (installedApps: InstalledApp[]) => void
isFetchingInstalledApps: boolean
setIsFetchingInstalledApps: (isFetchingInstalledApps: boolean) => void
currentApp?: CurrentTryAppParams
isShowTryAppPanel: boolean
setShowTryAppPanel: (showTryAppPanel: boolean, params?: CurrentTryAppParams) => void
}
const ExploreContext = createContext<IExplore>({
@ -20,6 +28,9 @@ const ExploreContext = createContext<IExplore>({
setInstalledApps: noop,
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: noop,
isShowTryAppPanel: false,
setShowTryAppPanel: noop,
currentApp: undefined,
})
export default ExploreContext

View File

@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { IS_CLOUD_EDITION } from '@/config'
import { isServer } from '@/utils/client'
export type TriggerEventsLimitModalPayload = {
usage: number
@ -46,7 +47,7 @@ export const useTriggerEventsLimitModal = ({
useEffect(() => {
if (!IS_CLOUD_EDITION)
return
if (typeof window === 'undefined')
if (isServer)
return
if (!currentWorkspaceId)
return

View File

@ -5,12 +5,13 @@ import type { FC, PropsWithChildren } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
import { TanStackDevtoolsLoader } from '@/app/components/devtools/tanstack/loader'
import { isServer } from '@/utils/client'
import { makeQueryClient } from './query-client-server'
let browserQueryClient: QueryClient | undefined
function getQueryClient() {
if (typeof window === 'undefined') {
if (isServer) {
return makeQueryClient()
}
if (!browserQueryClient)

View File

@ -177,7 +177,7 @@ export default antfu(
},
rules: {
// 'dify-i18n/no-as-any-in-t': ['error', { mode: 'all' }],
'dify-i18n/no-as-any-in-t': 'error',
// 'dify-i18n/no-as-any-in-t': 'error',
// 'dify-i18n/no-legacy-namespace-prefix': 'error',
// 'dify-i18n/require-ns-option': 'error',
},

View File

@ -12,6 +12,13 @@ import {
usePricingModal,
} from './use-query-params'
// Mock isServer to allow runtime control in tests
const mockIsServer = vi.hoisted(() => ({ value: false }))
vi.mock('@/utils/client', () => ({
get isServer() { return mockIsServer.value },
get isClient() { return !mockIsServer.value },
}))
const renderWithAdapter = <T,>(hook: () => T, searchParams = '') => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
const wrapper = ({ children }: { children: ReactNode }) => (
@ -428,6 +435,7 @@ describe('clearQueryParams', () => {
afterEach(() => {
vi.unstubAllGlobals()
mockIsServer.value = false
})
it('should remove a single key when provided one key', () => {
@ -463,13 +471,13 @@ describe('clearQueryParams', () => {
replaceSpy.mockRestore()
})
it('should no-op when window is undefined', () => {
it('should no-op when running on server', () => {
// Arrange
const replaceSpy = vi.spyOn(window.history, 'replaceState')
vi.stubGlobal('window', undefined)
mockIsServer.value = true
// Act
expect(() => clearQueryParams('foo')).not.toThrow()
clearQueryParams('foo')
// Assert
expect(replaceSpy).not.toHaveBeenCalled()

View File

@ -21,6 +21,7 @@ import {
} from 'nuqs'
import { useCallback } from 'react'
import { ACCOUNT_SETTING_MODAL_ACTION } from '@/app/components/header/account-setting/constants'
import { isServer } from '@/utils/client'
/**
* Modal State Query Parameters
@ -176,7 +177,7 @@ export function usePluginInstallation() {
* clearQueryParams(['param1', 'param2'])
*/
export function clearQueryParams(keys: string | string[]) {
if (typeof window === 'undefined')
if (isServer)
return
const url = new URL(window.location.href)

View File

@ -1,11 +1,9 @@
{
"appCard.addToWorkspace": "إضافة إلى مساحة العمل",
"appCard.customize": "تخصيص",
"appCustomize.nameRequired": "اسم التطبيق مطلوب",
"appCustomize.subTitle": "أيقونة التطبيق واسمه",
"appCustomize.title": "إنشاء تطبيق من {{name}}",
"apps.allCategories": "موصى به",
"apps.description": "استخدم تطبيقات القوالب هذه فورًا أو خصص تطبيقاتك الخاصة بناءً على القوالب.",
"apps.title": "استكشاف التطبيقات",
"category.Agent": "وكيل",
"category.Assistant": "مساعد",
@ -23,7 +21,5 @@
"sidebar.chat": "دردشة",
"sidebar.delete.content": "هل أنت متأكد أنك تريد حذف هذا التطبيق؟",
"sidebar.delete.title": "حذف التطبيق",
"sidebar.discovery": "اكتشاف",
"sidebar.workspace": "مساحة العمل",
"title": "استكشاف"
}

View File

@ -1,12 +1,7 @@
{
"appCard.addToWorkspace": "Zum Arbeitsbereich hinzufügen",
"appCard.customize": "Anpassen",
"appCustomize.nameRequired": "App-Name ist erforderlich",
"appCustomize.subTitle": "App-Symbol & Name",
"appCustomize.title": "App aus {{name}} erstellen",
"apps.allCategories": "Alle Kategorien",
"apps.description": "Nutzen Sie diese Vorlagen-Apps sofort oder passen Sie Ihre eigenen Apps basierend auf den Vorlagen an.",
"apps.title": "Apps von Dify erkunden",
"category.Agent": "Agent",
"category.Assistant": "Assistent",
"category.Entertainment": "Unterhaltung",
@ -23,7 +18,5 @@
"sidebar.chat": "Chat",
"sidebar.delete.content": "Sind Sie sicher, dass Sie diese App löschen möchten?",
"sidebar.delete.title": "App löschen",
"sidebar.discovery": "Entdeckung",
"sidebar.workspace": "Arbeitsbereich",
"title": "Entdecken"
}

38
web/i18n/de-DE/explore.ts Normal file
View File

@ -0,0 +1,38 @@
const translation = {
title: 'Entdecken',
sidebar: {
chat: 'Chat',
action: {
pin: 'Anheften',
unpin: 'Lösen',
rename: 'Umbenennen',
delete: 'Löschen',
},
delete: {
title: 'App löschen',
content: 'Sind Sie sicher, dass Sie diese App löschen möchten?',
},
},
apps: {
},
appCard: {
customize: 'Anpassen',
},
appCustomize: {
title: 'App aus {{name}} erstellen',
subTitle: 'App-Symbol & Name',
nameRequired: 'App-Name ist erforderlich',
},
category: {
Assistant: 'Assistent',
Writing: 'Schreiben',
Translate: 'Übersetzen',
Programming: 'Programmieren',
HR: 'Personalwesen',
Agent: 'Agent',
Workflow: 'Arbeitsablauf',
Entertainment: 'Unterhaltung',
},
}
export default translation

View File

@ -108,6 +108,7 @@
"chat.conversationName": "Conversation name",
"chat.conversationNameCanNotEmpty": "Conversation name required",
"chat.conversationNamePlaceholder": "Please input conversation name",
"chat.inputDisabledPlaceholder": "Preview Only",
"chat.inputPlaceholder": "Talk to {{botName}}",
"chat.renameConversation": "Rename Conversation",
"chat.resend": "Resend",

789
web/i18n/en-US/common.ts Normal file
View File

@ -0,0 +1,789 @@
const translation = {
theme: {
theme: 'Theme',
light: 'light',
dark: 'dark',
auto: 'system',
},
api: {
success: 'Success',
actionSuccess: 'Action succeeded',
saved: 'Saved',
create: 'Created',
remove: 'Removed',
},
operation: {
create: 'Create',
confirm: 'Confirm',
cancel: 'Cancel',
clear: 'Clear',
save: 'Save',
yes: 'Yes',
no: 'No',
deleteConfirmTitle: 'Delete?',
confirmAction: 'Please confirm your action.',
saveAndEnable: 'Save & Enable',
edit: 'Edit',
add: 'Add',
added: 'Added',
refresh: 'Restart',
reset: 'Reset',
search: 'Search',
noSearchResults: 'No {{content}} were found',
resetKeywords: 'Reset keywords',
selectCount: '{{count}} Selected',
searchCount: 'Find {{count}} {{content}}',
noSearchCount: '0 {{content}}',
change: 'Change',
remove: 'Remove',
send: 'Send',
copy: 'Copy',
copied: 'Copied',
lineBreak: 'Line break',
sure: 'I\'m sure',
download: 'Download',
downloadSuccess: 'Download Completed.',
downloadFailed: 'Download failed. Please try again later.',
viewDetails: 'View Details',
delete: 'Delete',
now: 'Now',
deleteApp: 'Delete App',
settings: 'Settings',
setup: 'Setup',
config: 'Config',
getForFree: 'Get for free',
reload: 'Reload',
ok: 'OK',
log: 'Log',
learnMore: 'Learn More',
params: 'Params',
duplicate: 'Duplicate',
rename: 'Rename',
audioSourceUnavailable: 'AudioSource is unavailable',
close: 'Close',
copyImage: 'Copy Image',
imageCopied: 'Image copied',
zoomOut: 'Zoom Out',
zoomIn: 'Zoom In',
openInNewTab: 'Open in new tab',
in: 'in',
saveAndRegenerate: 'Save & Regenerate Child Chunks',
view: 'View',
viewMore: 'VIEW MORE',
regenerate: 'Regenerate',
submit: 'Submit',
skip: 'Skip',
format: 'Format',
more: 'More',
selectAll: 'Select All',
deSelectAll: 'Deselect All',
},
errorMsg: {
fieldRequired: '{{field}} is required',
urlError: 'url should start with http:// or https://',
},
placeholder: {
input: 'Please enter',
select: 'Please select',
search: 'Search...',
},
noData: 'No data',
label: {
optional: '(optional)',
},
voice: {
language: {
zhHans: 'Chinese',
zhHant: 'Traditional Chinese',
enUS: 'English',
deDE: 'German',
frFR: 'French',
esES: 'Spanish',
itIT: 'Italian',
thTH: 'Thai',
idID: 'Indonesian',
jaJP: 'Japanese',
koKR: 'Korean',
ptBR: 'Portuguese',
ruRU: 'Russian',
ukUA: 'Ukrainian',
viVN: 'Vietnamese',
plPL: 'Polish',
roRO: 'Romanian',
hiIN: 'Hindi',
trTR: 'Türkçe',
faIR: 'Farsi',
},
},
unit: {
char: 'chars',
},
actionMsg: {
noModification: 'No modifications at the moment.',
modifiedSuccessfully: 'Modified successfully',
modifiedUnsuccessfully: 'Modified unsuccessfully',
copySuccessfully: 'Copied successfully',
paySucceeded: 'Payment succeeded',
payCancelled: 'Payment cancelled',
generatedSuccessfully: 'Generated successfully',
generatedUnsuccessfully: 'Generated unsuccessfully',
},
model: {
params: {
temperature: 'Temperature',
temperatureTip:
'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.',
top_p: 'Top P',
top_pTip:
'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered.',
presence_penalty: 'Presence penalty',
presence_penaltyTip:
'How much to penalize new tokens based on whether they appear in the text so far.\nIncreases the model\'s likelihood to talk about new topics.',
frequency_penalty: 'Frequency penalty',
frequency_penaltyTip:
'How much to penalize new tokens based on their existing frequency in the text so far.\nDecreases the model\'s likelihood to repeat the same line verbatim.',
max_tokens: 'Max token',
max_tokensTip:
'Used to limit the maximum length of the reply, in tokens. \nLarger values may limit the space left for prompt words, chat logs, and Knowledge. \nIt is recommended to set it below two-thirds\ngpt-4-1106-preview, gpt-4-vision-preview max token (input 128k output 4k)',
maxTokenSettingTip: 'Your max token setting is high, potentially limiting space for prompts, queries, and data. Consider setting it below 2/3.',
setToCurrentModelMaxTokenTip: 'Max token is updated to the 80% maximum token of the current model {{maxToken}}.',
stop_sequences: 'Stop sequences',
stop_sequencesTip: 'Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.',
stop_sequencesPlaceholder: 'Enter sequence and press Tab',
},
tone: {
Creative: 'Creative',
Balanced: 'Balanced',
Precise: 'Precise',
Custom: 'Custom',
},
addMoreModel: 'Go to settings to add more models',
settingsLink: 'Model Provider Settings',
capabilities: 'MultiModal Capabilities',
},
menus: {
status: 'beta',
explore: 'Explore',
apps: 'Studio',
appDetail: 'App Detail',
account: 'Account',
plugins: 'Plugins',
exploreMarketplace: 'Explore Marketplace',
pluginsTips: 'Integrate third-party plugins or create ChatGPT-compatible AI-Plugins.',
datasets: 'Knowledge',
datasetsTips: 'COMING SOON: Import your own text data or write data in real-time via Webhook for LLM context enhancement.',
newApp: 'New App',
newDataset: 'Create Knowledge',
tools: 'Tools',
},
userProfile: {
settings: 'Settings',
contactUs: 'Contact Us',
emailSupport: 'Email Support',
workspace: 'Workspace',
createWorkspace: 'Create Workspace',
helpCenter: 'View Docs',
support: 'Support',
compliance: 'Compliance',
forum: 'Forum',
roadmap: 'Roadmap',
github: 'GitHub',
community: 'Community',
about: 'About',
logout: 'Log out',
},
compliance: {
soc2Type1: 'SOC 2 Type I Report',
soc2Type2: 'SOC 2 Type II Report',
iso27001: 'ISO 27001:2022 Certification',
gdpr: 'GDPR DPA',
sandboxUpgradeTooltip: 'Only available with a Professional or Team plan.',
professionalUpgradeTooltip: 'Only available with a Team plan or above.',
},
settings: {
accountGroup: 'GENERAL',
workplaceGroup: 'WORKSPACE',
generalGroup: 'GENERAL',
account: 'My account',
members: 'Members',
billing: 'Billing',
integrations: 'Integrations',
language: 'Language',
provider: 'Model Provider',
dataSource: 'Data Source',
plugin: 'Plugins',
apiBasedExtension: 'API Extension',
},
account: {
account: 'Account',
myAccount: 'My Account',
studio: 'Studio',
avatar: 'Avatar',
name: 'Name',
email: 'Email',
password: 'Password',
passwordTip: 'You can set a permanent password if you dont want to use temporary login codes',
setPassword: 'Set a password',
resetPassword: 'Reset password',
currentPassword: 'Current password',
newPassword: 'New password',
confirmPassword: 'Confirm password',
notEqual: 'Two passwords are different.',
langGeniusAccount: 'Account\'s data',
langGeniusAccountTip: 'The user data of your account.',
editName: 'Edit Name',
showAppLength: 'Show {{length}} apps',
delete: 'Delete Account',
deleteTip: 'Please note, once confirmed, as the Owner of any Workspaces, your workspaces will be scheduled in a queue for permanent deletion, and all your user data will be queued for permanent deletion.',
deletePrivacyLinkTip: 'For more information about how we handle your data, please see our ',
deletePrivacyLink: 'Privacy Policy.',
deleteSuccessTip: 'Your account needs time to finish deleting. We\'ll email you when it\'s all done.',
deleteLabel: 'To confirm, please type in your email below',
deletePlaceholder: 'Please enter your email',
sendVerificationButton: 'Send Verification Code',
verificationLabel: 'Verification Code',
verificationPlaceholder: 'Paste the 6-digit code',
permanentlyDeleteButton: 'Permanently Delete Account',
feedbackTitle: 'Feedback',
feedbackLabel: 'Tell us why you deleted your account?',
feedbackPlaceholder: 'Optional',
editWorkspaceInfo: 'Edit Workspace Info',
workspaceName: 'Workspace Name',
workspaceIcon: 'Workspace Icon',
changeEmail: {
title: 'Change Email',
verifyEmail: 'Verify your current email',
newEmail: 'Set up a new email address',
verifyNew: 'Verify your new email',
authTip: 'Once your email is changed, Google or GitHub accounts linked to your old email will no longer be able to log in to this account.',
content1: 'If you continue, we\'ll send a verification code to <email>{{email}}</email> for re-authentication.',
content2: 'Your current email is <email>{{email}}</email>. Verification code has been sent to this email address.',
content3: 'Enter a new email and we will send you a verification code.',
content4: 'We just sent you a temporary verification code to <email>{{email}}</email>.',
codeLabel: 'Verification code',
codePlaceholder: 'Paste the 6-digit code',
emailLabel: 'New email',
emailPlaceholder: 'Enter a new email',
existingEmail: 'A user with this email already exists.',
unAvailableEmail: 'This email is temporarily unavailable.',
sendVerifyCode: 'Send verification code',
continue: 'Continue',
changeTo: 'Change to {{email}}',
resendTip: 'Didn\'t receive a code?',
resendCount: 'Resend in {{count}}s',
resend: 'Resend',
},
},
members: {
team: 'Team',
invite: 'Add',
name: 'NAME',
lastActive: 'LAST ACTIVE',
role: 'ROLES',
pending: 'Pending...',
owner: 'Owner',
admin: 'Admin',
adminTip: 'Can build apps & manage team settings',
normal: 'Normal',
normalTip: 'Only can use apps, can not build apps',
builder: 'Builder',
builderTip: 'Can build & edit own apps',
editor: 'Editor',
editorTip: 'Can build & edit apps',
datasetOperator: 'Knowledge Admin',
datasetOperatorTip: 'Only can manage the knowledge base',
inviteTeamMember: 'Add team member',
inviteTeamMemberTip: 'They can access your team data directly after signing in.',
emailNotSetup: 'Email server is not set up, so invitation emails cannot be sent. Please notify users of the invitation link that will be issued after invitation instead.',
email: 'Email',
emailInvalid: 'Invalid Email Format',
emailPlaceholder: 'Please input emails',
sendInvite: 'Send Invite',
invitedAsRole: 'Invited as {{role}} user',
invitationSent: 'Invitation sent',
invitationSentTip: 'Invitation sent, and they can sign in to Dify to access your team data.',
invitationLink: 'Invitation Link',
failedInvitationEmails: 'Below users were not invited successfully',
ok: 'OK',
removeFromTeam: 'Remove from team',
removeFromTeamTip: 'Will remove team access',
setAdmin: 'Set as administrator',
setMember: 'Set to ordinary member',
setBuilder: 'Set as builder',
setEditor: 'Set as editor',
disInvite: 'Cancel the invitation',
deleteMember: 'Delete Member',
you: '(You)',
transferOwnership: 'Transfer Ownership',
transferModal: {
title: 'Transfer workspace ownership',
warning: 'You\'re about to transfer ownership of “{{workspace}}”. This takes effect immediately and can\'t be undone.',
warningTip: 'You\'ll become an admin member, and the new owner will have full control.',
sendTip: 'If you continue, we\'ll send a verification code to <email>{{email}}</email> for re-authentication.',
verifyEmail: 'Verify your current email',
verifyContent: 'Your current email is <email>{{email}}</email>.',
verifyContent2: 'We\'ll send a temporary verification code to this email for re-authentication.',
codeLabel: 'Verification code',
codePlaceholder: 'Paste the 6-digit code',
resendTip: 'Didn\'t receive a code?',
resendCount: 'Resend in {{count}}s',
resend: 'Resend',
transferLabel: 'Transfer workspace ownership to',
transferPlaceholder: 'Select a workspace member…',
sendVerifyCode: 'Send verification code',
continue: 'Continue',
transfer: 'Transfer workspace ownership',
},
},
feedback: {
title: 'Provide Feedback',
subtitle: 'Please tell us what went wrong with this response',
content: 'Feedback Content',
placeholder: 'Please describe what went wrong or how we can improve...',
},
integrations: {
connected: 'Connected',
google: 'Google',
googleAccount: 'Login with Google account',
github: 'GitHub',
githubAccount: 'Login with GitHub account',
connect: 'Connect',
},
language: {
displayLanguage: 'Display Language',
timezone: 'Time Zone',
},
provider: {
apiKey: 'API Key',
enterYourKey: 'Enter your API key here',
invalidKey: 'Invalid OpenAI API key',
validatedError: 'Validation failed: ',
validating: 'Validating key...',
saveFailed: 'Save api key failed',
apiKeyExceedBill: 'This API KEY has no quota available, please read',
addKey: 'Add Key',
comingSoon: 'Coming Soon',
editKey: 'Edit',
invalidApiKey: 'Invalid API key',
azure: {
apiBase: 'API Base',
apiBasePlaceholder: 'The API Base URL of your Azure OpenAI Endpoint.',
apiKey: 'API Key',
apiKeyPlaceholder: 'Enter your API key here',
helpTip: 'Learn Azure OpenAI Service',
},
openaiHosted: {
openaiHosted: 'Hosted OpenAI',
onTrial: 'ON TRIAL',
exhausted: 'QUOTA EXHAUSTED',
desc: 'The OpenAI hosting service provided by Dify allows you to use models such as GPT-3.5. Before your trial quota is used up, you need to set up other model providers.',
callTimes: 'Call times',
usedUp: 'Trial quota used up. Add own Model Provider.',
useYourModel: 'Currently using own Model Provider.',
close: 'Close',
},
anthropicHosted: {
anthropicHosted: 'Anthropic Claude',
onTrial: 'ON TRIAL',
exhausted: 'QUOTA EXHAUSTED',
desc: 'Powerful model, which excels at a wide range of tasks from sophisticated dialogue and creative content generation to detailed instruction.',
callTimes: 'Call times',
usedUp: 'Trial quota used up. Add own Model Provider.',
useYourModel: 'Currently using own Model Provider.',
close: 'Close',
trialQuotaTip: 'Your Anthropic trial quota will expire on 2025/03/17 and will no longer be available thereafter. Please make use of it in time.',
},
anthropic: {
using: 'The embedding capability is using',
enableTip: 'To enable the Anthropic model, you need to bind to OpenAI or Azure OpenAI Service first.',
notEnabled: 'Not enabled',
keyFrom: 'Get your API key from Anthropic',
},
encrypted: {
front: 'Your API KEY will be encrypted and stored using',
back: ' technology.',
},
},
modelProvider: {
notConfigured: 'The system model has not yet been fully configured',
systemModelSettings: 'System Model Settings',
systemModelSettingsLink: 'Why is it necessary to set up a system model?',
selectModel: 'Select your model',
setupModelFirst: 'Please set up your model first',
systemReasoningModel: {
key: 'System Reasoning Model',
tip: 'Set the default inference model to be used for creating applications, as well as features such as dialogue name generation and next question suggestion will also use the default inference model.',
},
embeddingModel: {
key: 'Embedding Model',
tip: 'Set the default model for document embedding processing of the Knowledge, both retrieval and import of the Knowledge use this Embedding model for vectorization processing. Switching will cause the vector dimension between the imported Knowledge and the question to be inconsistent, resulting in retrieval failure. To avoid retrieval failure, please do not switch this model at will.',
required: 'Embedding Model is required',
},
speechToTextModel: {
key: 'Speech-to-Text Model',
tip: 'Set the default model for speech-to-text input in conversation.',
},
ttsModel: {
key: 'Text-to-Speech Model',
tip: 'Set the default model for text-to-speech input in conversation.',
},
rerankModel: {
key: 'Rerank Model',
tip: 'Rerank model will reorder the candidate document list based on the semantic match with user query, improving the results of semantic ranking',
},
apiKey: 'API-KEY',
quota: 'Quota',
searchModel: 'Search model',
noModelFound: 'No model found for {{model}}',
models: 'Models',
showMoreModelProvider: 'Show more model provider',
selector: {
tip: 'This model has been removed. Please add a model or select another model.',
emptyTip: 'No available models',
emptySetting: 'Please go to settings to configure',
rerankTip: 'Please set up the Rerank model',
},
card: {
quota: 'QUOTA',
onTrial: 'On Trial',
paid: 'Paid',
quotaExhausted: 'Quota exhausted',
callTimes: 'Call times',
tokens: 'Tokens',
buyQuota: 'Buy Quota',
priorityUse: 'Priority use',
removeKey: 'Remove API Key',
tip: 'Priority will be given to the paid quota. The Trial quota will be used after the paid quota is exhausted.',
},
item: {
deleteDesc: '{{modelName}} are being used as system reasoning models. Some functions will not be available after removal. Please confirm.',
freeQuota: 'FREE QUOTA',
},
addApiKey: 'Add your API key',
invalidApiKey: 'Invalid API key',
encrypted: {
front: 'Your API KEY will be encrypted and stored using',
back: ' technology.',
},
freeQuota: {
howToEarn: 'How to earn',
},
addMoreModelProvider: 'ADD MORE MODEL PROVIDER',
addModel: 'Add Model',
modelsNum: '{{num}} Models',
showModels: 'Show Models',
showModelsNum: 'Show {{num}} Models',
collapse: 'Collapse',
config: 'Config',
modelAndParameters: 'Model and Parameters',
model: 'Model',
featureSupported: '{{feature}} supported',
callTimes: 'Call times',
credits: 'Message Credits',
buyQuota: 'Buy Quota',
getFreeTokens: 'Get free Tokens',
priorityUsing: 'Prioritize using',
deprecated: 'Deprecated',
confirmDelete: 'Confirm deletion?',
quotaTip: 'Remaining available free tokens',
loadPresets: 'Load Presets',
parameters: 'PARAMETERS',
loadBalancing: 'Load balancing',
loadBalancingDescription: 'Configure multiple credentials for the model and invoke them automatically. ',
loadBalancingHeadline: 'Load Balancing',
configLoadBalancing: 'Config Load Balancing',
modelHasBeenDeprecated: 'This model has been deprecated',
providerManaged: 'Provider managed',
providerManagedDescription: 'Use the single set of credentials provided by the model provider.',
defaultConfig: 'Default Config',
apiKeyStatusNormal: 'APIKey status is normal',
apiKeyRateLimit: 'Rate limit was reached, available after {{seconds}}s',
addConfig: 'Add Config',
editConfig: 'Edit Config',
loadBalancingLeastKeyWarning: 'To enable load balancing at least 2 keys must be enabled.',
loadBalancingInfo: 'By default, load balancing uses the Round-robin strategy. If rate limiting is triggered, a 1-minute cooldown period will be applied.',
upgradeForLoadBalancing: 'Upgrade your plan to enable Load Balancing.',
toBeConfigured: 'To be configured',
configureTip: 'Set up api-key or add model to use',
installProvider: 'Install model providers',
installDataSourceProvider: 'Install data source providers',
discoverMore: 'Discover more in ',
emptyProviderTitle: 'Model provider not set up',
emptyProviderTip: 'Please install a model provider first.',
auth: {
unAuthorized: 'Unauthorized',
authRemoved: 'Auth removed',
apiKeys: 'API Keys',
addApiKey: 'Add API Key',
addModel: 'Add model',
addNewModel: 'Add new model',
addCredential: 'Add credential',
addModelCredential: 'Add model credential',
editModelCredential: 'Edit model credential',
modelCredentials: 'Model credentials',
modelCredential: 'Model credential',
configModel: 'Config model',
configLoadBalancing: 'Config Load Balancing',
authorizationError: 'Authorization error',
specifyModelCredential: 'Specify model credential',
specifyModelCredentialTip: 'Use a configured model credential.',
providerManaged: 'Provider managed',
providerManagedTip: 'The current configuration is hosted by the provider.',
apiKeyModal: {
title: 'API Key Authorization Configuration',
desc: 'After configuring credentials, all members within the workspace can use this model when orchestrating applications.',
addModel: 'Add model',
},
manageCredentials: 'Manage Credentials',
customModelCredentials: 'Custom Model Credentials',
addNewModelCredential: 'Add new model credential',
removeModel: 'Remove Model',
selectModelCredential: 'Select a model credential',
customModelCredentialsDeleteTip: 'Credential is in use and cannot be deleted',
},
parametersInvalidRemoved: 'Some parameters are invalid and have been removed',
},
dataSource: {
add: 'Add a data source',
connect: 'Connect',
configure: 'Configure',
notion: {
title: 'Notion',
description: 'Using Notion as a data source for the Knowledge.',
connectedWorkspace: 'Connected workspace',
addWorkspace: 'Add workspace',
connected: 'Connected',
disconnected: 'Disconnected',
changeAuthorizedPages: 'Change authorized pages',
integratedAlert: 'Notion is integrated via internal credential, no need to re-authorize.',
pagesAuthorized: 'Pages authorized',
sync: 'Sync',
remove: 'Remove',
selector: {
pageSelected: 'Pages Selected',
searchPages: 'Search pages...',
noSearchResult: 'No search results',
addPages: 'Add pages',
preview: 'PREVIEW',
},
},
website: {
title: 'Website',
description: 'Import content from websites using web crawler.',
with: 'With',
configuredCrawlers: 'Configured crawlers',
active: 'Active',
inactive: 'Inactive',
},
},
plugin: {
serpapi: {
apiKey: 'API Key',
apiKeyPlaceholder: 'Enter your API key',
keyFrom: 'Get your SerpAPI key from SerpAPI Account Page',
},
},
apiBasedExtension: {
title: 'API extensions provide centralized API management, simplifying configuration for easy use across Dify\'s applications.',
link: 'Learn how to develop your own API Extension.',
add: 'Add API Extension',
selector: {
title: 'API Extension',
placeholder: 'Please select API extension',
manage: 'Manage API Extension',
},
modal: {
title: 'Add API Extension',
editTitle: 'Edit API Extension',
name: {
title: 'Name',
placeholder: 'Please enter the name',
},
apiEndpoint: {
title: 'API Endpoint',
placeholder: 'Please enter the API endpoint',
},
apiKey: {
title: 'API-key',
placeholder: 'Please enter the API-key',
lengthError: 'API-key length cannot be less than 5 characters',
},
},
type: 'Type',
},
about: {
changeLog: 'Changelog',
updateNow: 'Update now',
nowAvailable: 'Dify {{version}} is now available.',
latestAvailable: 'Dify {{version}} is the latest version available.',
},
appMenus: {
overview: 'Monitoring',
promptEng: 'Orchestrate',
apiAccess: 'API Access',
logAndAnn: 'Logs & Annotations',
logs: 'Logs',
},
environment: {
testing: 'TESTING',
development: 'DEVELOPMENT',
},
appModes: {
completionApp: 'Text Generator',
chatApp: 'Chat App',
},
datasetMenus: {
documents: 'Documents',
hitTesting: 'Retrieval Testing',
settings: 'Settings',
emptyTip: 'This Knowledge has not been integrated within any application. Please refer to the document for guidance.',
viewDoc: 'View documentation',
relatedApp: 'linked apps',
noRelatedApp: 'No linked apps',
pipeline: 'Pipeline',
},
voiceInput: {
speaking: 'Speak now...',
converting: 'Converting to text...',
notAllow: 'microphone not authorized',
},
modelName: {
'gpt-3.5-turbo': 'GPT-3.5-Turbo',
'gpt-3.5-turbo-16k': 'GPT-3.5-Turbo-16K',
'gpt-4': 'GPT-4',
'gpt-4-32k': 'GPT-4-32K',
'text-davinci-003': 'Text-Davinci-003',
'text-embedding-ada-002': 'Text-Embedding-Ada-002',
'whisper-1': 'Whisper-1',
'claude-instant-1': 'Claude-Instant',
'claude-2': 'Claude-2',
},
chat: {
renameConversation: 'Rename Conversation',
conversationName: 'Conversation name',
conversationNamePlaceholder: 'Please input conversation name',
conversationNameCanNotEmpty: 'Conversation name required',
citation: {
title: 'CITATIONS',
linkToDataset: 'Link to Knowledge',
characters: 'Characters:',
hitCount: 'Retrieval count:',
vectorHash: 'Vector hash:',
hitScore: 'Retrieval Score:',
},
inputPlaceholder: 'Talk to {{botName}}',
inputDisabledPlaceholder: 'Preview Only',
thinking: 'Thinking...',
thought: 'Thought',
resend: 'Resend',
},
promptEditor: {
placeholder: 'Write your prompt word here, enter \'{\' to insert a variable, enter \'/\' to insert a prompt content block',
context: {
item: {
title: 'Context',
desc: 'Insert context template',
},
modal: {
title: '{{num}} Knowledge in Context',
add: 'Add Context ',
footer: 'You can manage contexts in the Context section below.',
},
},
history: {
item: {
title: 'Conversation History',
desc: 'Insert historical message template',
},
modal: {
title: 'EXAMPLE',
user: 'Hello',
assistant: 'Hello! How can I assist you today?',
edit: 'Edit Conversation Role Names',
},
},
variable: {
item: {
title: 'Variables & External Tools',
desc: 'Insert Variables & External Tools',
},
outputToolDisabledItem: {
title: 'Variables',
desc: 'Insert Variables',
},
modal: {
add: 'New variable',
addTool: 'New tool',
},
},
query: {
item: {
title: 'Query',
desc: 'Insert user query template',
},
},
existed: 'Already exists in the prompt',
},
imageUploader: {
uploadFromComputer: 'Upload from Computer',
uploadFromComputerReadError: 'Image reading failed, please try again.',
uploadFromComputerUploadError: 'Image upload failed, please upload again.',
uploadFromComputerLimit: 'Upload images cannot exceed {{size}} MB',
pasteImageLink: 'Paste image link',
pasteImageLinkInputPlaceholder: 'Paste image link here',
pasteImageLinkInvalid: 'Invalid image link',
imageUpload: 'Image Upload',
},
fileUploader: {
uploadFromComputer: 'Local upload',
pasteFileLink: 'Paste file link',
pasteFileLinkInputPlaceholder: 'Enter URL...',
uploadFromComputerReadError: 'File reading failed, please try again.',
uploadFromComputerUploadError: 'File upload failed, please upload again.',
uploadFromComputerLimit: 'Upload {{type}} cannot exceed {{size}}',
pasteFileLinkInvalid: 'Invalid file link',
fileExtensionNotSupport: 'File extension not supported',
fileExtensionBlocked: 'This file type is blocked for security reasons',
},
tag: {
placeholder: 'All Tags',
addNew: 'Add new tag',
noTag: 'No tags',
noTagYet: 'No tags yet',
addTag: 'Add tags',
editTag: 'Edit tags',
manageTags: 'Manage Tags',
selectorPlaceholder: 'Type to search or create',
create: 'Create',
delete: 'Delete tag',
deleteTip: 'The tag is being used, delete it?',
created: 'Tag created successfully',
failed: 'Tag creation failed',
},
license: {
expiring: 'Expiring in one day',
expiring_plural: 'Expiring in {{count}} days',
unlimited: 'Unlimited',
},
pagination: {
perPage: 'Items per page',
},
avatar: {
deleteTitle: 'Remove Avatar',
deleteDescription: 'Are you sure you want to remove your profile picture? Your account will use the default initial avatar.',
},
imageInput: {
dropImageHere: 'Drop your image here, or',
browse: 'browse',
supportedFormats: 'Supports PNG, JPG, JPEG, WEBP and GIF',
},
you: 'You',
dynamicSelect: {
error: 'Loading options failed',
noData: 'No options available',
loading: 'Loading options...',
selected: '{{count}} selected',
},
}
export default translation

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