mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
merge main
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"id": "question-1",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": null
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-1"
|
||||
},
|
||||
{
|
||||
"id": "question-2",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-2"
|
||||
},
|
||||
{
|
||||
"id": "question-3",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "2"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-3"
|
||||
},
|
||||
{
|
||||
"id": "question-4",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "1"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-4"
|
||||
}
|
||||
]
|
||||
@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"id": "question-1",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-1"
|
||||
},
|
||||
{
|
||||
"id": "question-2",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-2"
|
||||
},
|
||||
{
|
||||
"id": "question-3",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-3"
|
||||
},
|
||||
{
|
||||
"id": "question-4",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-4"
|
||||
}
|
||||
]
|
||||
@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"id": "question-1",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-1"
|
||||
},
|
||||
{
|
||||
"id": "question-2",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-2"
|
||||
},
|
||||
{
|
||||
"id": "question-3",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "2"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-3"
|
||||
},
|
||||
{
|
||||
"id": "question-4",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "1"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-4"
|
||||
}
|
||||
]
|
||||
@ -0,0 +1,52 @@
|
||||
[
|
||||
{
|
||||
"id": "question-1",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": null
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-1"
|
||||
},
|
||||
{
|
||||
"id": "question-2",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-2"
|
||||
},
|
||||
{
|
||||
"id": "question-3",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "2"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-3"
|
||||
},
|
||||
{
|
||||
"id": "question-4",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "1"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-4"
|
||||
},
|
||||
{
|
||||
"id": "question-5",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": null
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-5"
|
||||
}
|
||||
]
|
||||
@ -0,0 +1,52 @@
|
||||
[
|
||||
{
|
||||
"id": "question-1",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-1"
|
||||
},
|
||||
{
|
||||
"id": "question-2",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-2"
|
||||
},
|
||||
{
|
||||
"id": "question-3",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-3"
|
||||
},
|
||||
{
|
||||
"id": "question-4",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "1"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-4"
|
||||
},
|
||||
{
|
||||
"id": "question-5",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": null
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-5"
|
||||
}
|
||||
]
|
||||
441
web/app/components/base/chat/__tests__/realWorldMessages.json
Normal file
441
web/app/components/base/chat/__tests__/realWorldMessages.json
Normal file
@ -0,0 +1,441 @@
|
||||
[
|
||||
{
|
||||
"id": "question-ff4c2b43-48a5-47ad-9dc5-08b34ddba61b",
|
||||
"content": "Let's play a game, I say a number , and you response me with another bigger, yet random-looking number. I'll start first, 38",
|
||||
"isAnswer": false,
|
||||
"message_files": []
|
||||
},
|
||||
{
|
||||
"id": "ff4c2b43-48a5-47ad-9dc5-08b34ddba61b",
|
||||
"content": "Sure, I'll play! My number is 57. Your turn!",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "f9d7ff7c-3a3b-4d9a-a289-657817f4caff",
|
||||
"chain_id": null,
|
||||
"message_id": "ff4c2b43-48a5-47ad-9dc5-08b34ddba61b",
|
||||
"position": 1,
|
||||
"thought": "Sure, I'll play! My number is 57. Your turn!",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726105791,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure, I'll play! My number is 57. Your turn!",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 09:49 PM",
|
||||
"tokens": 49,
|
||||
"latency": "1.56"
|
||||
},
|
||||
"parentMessageId": "question-ff4c2b43-48a5-47ad-9dc5-08b34ddba61b"
|
||||
},
|
||||
{
|
||||
"id": "question-73bbad14-d915-499d-87bf-0df14d40779d",
|
||||
"content": "58",
|
||||
"isAnswer": false,
|
||||
"message_files": [],
|
||||
"parentMessageId": "ff4c2b43-48a5-47ad-9dc5-08b34ddba61b"
|
||||
},
|
||||
{
|
||||
"id": "73bbad14-d915-499d-87bf-0df14d40779d",
|
||||
"content": "I choose 83. What's your next number?",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "f61a3fce-37ac-4f9d-9935-95f97e598dfe",
|
||||
"chain_id": null,
|
||||
"message_id": "73bbad14-d915-499d-87bf-0df14d40779d",
|
||||
"position": 1,
|
||||
"thought": "I choose 83. What's your next number?",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726105795,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure, I'll play! My number is 57. Your turn!",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "58",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "I choose 83. What's your next number?",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "58"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 09:49 PM",
|
||||
"tokens": 68,
|
||||
"latency": "1.33"
|
||||
},
|
||||
"parentMessageId": "question-73bbad14-d915-499d-87bf-0df14d40779d"
|
||||
},
|
||||
{
|
||||
"id": "question-4c5d0841-1206-463e-95d8-71f812877658",
|
||||
"content": "99",
|
||||
"isAnswer": false,
|
||||
"message_files": [],
|
||||
"parentMessageId": "73bbad14-d915-499d-87bf-0df14d40779d"
|
||||
},
|
||||
{
|
||||
"id": "4c5d0841-1206-463e-95d8-71f812877658",
|
||||
"content": "I'll go with 112. Your turn!",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "9730d587-9268-4683-9dd9-91a1cab9510b",
|
||||
"chain_id": null,
|
||||
"message_id": "4c5d0841-1206-463e-95d8-71f812877658",
|
||||
"position": 1,
|
||||
"thought": "I'll go with 112. Your turn!",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726105799,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure, I'll play! My number is 57. Your turn!",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "58",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "I choose 83. What's your next number?",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "99",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "I'll go with 112. Your turn!",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "99"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 09:50 PM",
|
||||
"tokens": 86,
|
||||
"latency": "1.49"
|
||||
},
|
||||
"parentMessageId": "question-4c5d0841-1206-463e-95d8-71f812877658"
|
||||
},
|
||||
{
|
||||
"id": "question-cd5affb0-7bc2-4a6f-be7e-25e74595c9dd",
|
||||
"content": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"isAnswer": false,
|
||||
"message_files": []
|
||||
},
|
||||
{
|
||||
"id": "cd5affb0-7bc2-4a6f-be7e-25e74595c9dd",
|
||||
"content": "Sure! My number is 54. Your turn!",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "1019cd79-d141-4f9f-880a-fc1441cfd802",
|
||||
"chain_id": null,
|
||||
"message_id": "cd5affb0-7bc2-4a6f-be7e-25e74595c9dd",
|
||||
"position": 1,
|
||||
"thought": "Sure! My number is 54. Your turn!",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726105809,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure! My number is 54. Your turn!",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 09:50 PM",
|
||||
"tokens": 46,
|
||||
"latency": "1.52"
|
||||
},
|
||||
"parentMessageId": "question-cd5affb0-7bc2-4a6f-be7e-25e74595c9dd"
|
||||
},
|
||||
{
|
||||
"id": "question-324bce32-c98c-435d-a66b-bac974ebb5ed",
|
||||
"content": "3306",
|
||||
"isAnswer": false,
|
||||
"message_files": [],
|
||||
"parentMessageId": "cd5affb0-7bc2-4a6f-be7e-25e74595c9dd"
|
||||
},
|
||||
{
|
||||
"id": "324bce32-c98c-435d-a66b-bac974ebb5ed",
|
||||
"content": "My number is 4729. Your turn!",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "0773bec7-b992-4a53-92b2-20ebaeae8798",
|
||||
"chain_id": null,
|
||||
"message_id": "324bce32-c98c-435d-a66b-bac974ebb5ed",
|
||||
"position": 1,
|
||||
"thought": "My number is 4729. Your turn!",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726105822,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure! My number is 54. Your turn!",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "3306",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "My number is 4729. Your turn!",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "3306"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 09:50 PM",
|
||||
"tokens": 66,
|
||||
"latency": "1.30"
|
||||
},
|
||||
"parentMessageId": "question-324bce32-c98c-435d-a66b-bac974ebb5ed"
|
||||
},
|
||||
{
|
||||
"id": "question-684b5396-4e91-4043-88e9-aabe48b21acc",
|
||||
"content": "3306",
|
||||
"isAnswer": false,
|
||||
"message_files": [],
|
||||
"parentMessageId": "cd5affb0-7bc2-4a6f-be7e-25e74595c9dd"
|
||||
},
|
||||
{
|
||||
"id": "684b5396-4e91-4043-88e9-aabe48b21acc",
|
||||
"content": "My number is 4821. Your turn!",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "5ca650f3-982c-4399-8b95-9ea241c76707",
|
||||
"chain_id": null,
|
||||
"message_id": "684b5396-4e91-4043-88e9-aabe48b21acc",
|
||||
"position": 1,
|
||||
"thought": "My number is 4821. Your turn!",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726107812,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure! My number is 54. Your turn!",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "3306",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "My number is 4821. Your turn!",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "3306"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 10:23 PM",
|
||||
"tokens": 66,
|
||||
"latency": "1.48"
|
||||
},
|
||||
"parentMessageId": "question-684b5396-4e91-4043-88e9-aabe48b21acc"
|
||||
},
|
||||
{
|
||||
"id": "question-19904a7b-7494-4ed8-b72c-1d18668cea8c",
|
||||
"content": "1003",
|
||||
"isAnswer": false,
|
||||
"message_files": [],
|
||||
"parentMessageId": "684b5396-4e91-4043-88e9-aabe48b21acc"
|
||||
},
|
||||
{
|
||||
"id": "19904a7b-7494-4ed8-b72c-1d18668cea8c",
|
||||
"content": "My number is 1456. Your turn!",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "095cacab-afad-4387-a41d-1662578b8b13",
|
||||
"chain_id": null,
|
||||
"message_id": "19904a7b-7494-4ed8-b72c-1d18668cea8c",
|
||||
"position": 1,
|
||||
"thought": "My number is 1456. Your turn!",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726111024,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure! My number is 54. Your turn!",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "3306",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "My number is 4821. Your turn!",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "1003",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "My number is 1456. Your turn!",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "1003"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 11:17 PM",
|
||||
"tokens": 86,
|
||||
"latency": "1.38"
|
||||
},
|
||||
"parentMessageId": "question-19904a7b-7494-4ed8-b72c-1d18668cea8c"
|
||||
}
|
||||
]
|
||||
258
web/app/components/base/chat/__tests__/utils.spec.ts
Normal file
258
web/app/components/base/chat/__tests__/utils.spec.ts
Normal file
@ -0,0 +1,258 @@
|
||||
import { get } from 'lodash'
|
||||
import { buildChatItemTree, getThreadMessages } from '../utils'
|
||||
import type { ChatItemInTree } from '../types'
|
||||
import branchedTestMessages from './branchedTestMessages.json'
|
||||
import legacyTestMessages from './legacyTestMessages.json'
|
||||
import mixedTestMessages from './mixedTestMessages.json'
|
||||
import multiRootNodesMessages from './multiRootNodesMessages.json'
|
||||
import multiRootNodesWithLegacyTestMessages from './multiRootNodesWithLegacyTestMessages.json'
|
||||
import realWorldMessages from './realWorldMessages.json'
|
||||
|
||||
function visitNode(tree: ChatItemInTree | ChatItemInTree[], path: string): ChatItemInTree {
|
||||
return get(tree, path)
|
||||
}
|
||||
|
||||
describe('build chat item tree and get thread messages', () => {
|
||||
const tree1 = buildChatItemTree(branchedTestMessages as ChatItemInTree[])
|
||||
|
||||
it('should build chat item tree1', () => {
|
||||
const a1 = visitNode(tree1, '0.children.0')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(a1.children).toHaveLength(2)
|
||||
|
||||
const a2 = visitNode(a1, 'children.0.children.0')
|
||||
expect(a2.id).toBe('2')
|
||||
expect(a2.siblingIndex).toBe(0)
|
||||
|
||||
const a3 = visitNode(a2, 'children.0.children.0')
|
||||
expect(a3.id).toBe('3')
|
||||
|
||||
const a4 = visitNode(a1, 'children.1.children.0')
|
||||
expect(a4.id).toBe('4')
|
||||
expect(a4.siblingIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('should get thread messages from tree1, using the last message as the target', () => {
|
||||
const threadChatItems1_1 = getThreadMessages(tree1)
|
||||
expect(threadChatItems1_1).toHaveLength(4)
|
||||
|
||||
const q1 = visitNode(threadChatItems1_1, '0')
|
||||
const a1 = visitNode(threadChatItems1_1, '1')
|
||||
const q4 = visitNode(threadChatItems1_1, '2')
|
||||
const a4 = visitNode(threadChatItems1_1, '3')
|
||||
|
||||
expect(q1.id).toBe('question-1')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(q4.id).toBe('question-4')
|
||||
expect(a4.id).toBe('4')
|
||||
|
||||
expect(a4.siblingCount).toBe(2)
|
||||
expect(a4.siblingIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('should get thread messages from tree1, using the message with id 3 as the target', () => {
|
||||
const threadChatItems1_2 = getThreadMessages(tree1, '3')
|
||||
expect(threadChatItems1_2).toHaveLength(6)
|
||||
|
||||
const q1 = visitNode(threadChatItems1_2, '0')
|
||||
const a1 = visitNode(threadChatItems1_2, '1')
|
||||
const q2 = visitNode(threadChatItems1_2, '2')
|
||||
const a2 = visitNode(threadChatItems1_2, '3')
|
||||
const q3 = visitNode(threadChatItems1_2, '4')
|
||||
const a3 = visitNode(threadChatItems1_2, '5')
|
||||
|
||||
expect(q1.id).toBe('question-1')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(q2.id).toBe('question-2')
|
||||
expect(a2.id).toBe('2')
|
||||
expect(q3.id).toBe('question-3')
|
||||
expect(a3.id).toBe('3')
|
||||
|
||||
expect(a2.siblingCount).toBe(2)
|
||||
expect(a2.siblingIndex).toBe(0)
|
||||
})
|
||||
|
||||
const tree2 = buildChatItemTree(legacyTestMessages as ChatItemInTree[])
|
||||
it('should work with legacy chat items', () => {
|
||||
expect(tree2).toHaveLength(1)
|
||||
const q1 = visitNode(tree2, '0')
|
||||
const a1 = visitNode(q1, 'children.0')
|
||||
const q2 = visitNode(a1, 'children.0')
|
||||
const a2 = visitNode(q2, 'children.0')
|
||||
const q3 = visitNode(a2, 'children.0')
|
||||
const a3 = visitNode(q3, 'children.0')
|
||||
const q4 = visitNode(a3, 'children.0')
|
||||
const a4 = visitNode(q4, 'children.0')
|
||||
|
||||
expect(q1.id).toBe('question-1')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(q2.id).toBe('question-2')
|
||||
expect(a2.id).toBe('2')
|
||||
expect(q3.id).toBe('question-3')
|
||||
expect(a3.id).toBe('3')
|
||||
expect(q4.id).toBe('question-4')
|
||||
expect(a4.id).toBe('4')
|
||||
})
|
||||
|
||||
it('should get thread messages from tree2, using the last message as the target', () => {
|
||||
const threadMessages2 = getThreadMessages(tree2)
|
||||
expect(threadMessages2).toHaveLength(8)
|
||||
|
||||
const q1 = visitNode(threadMessages2, '0')
|
||||
const a1 = visitNode(threadMessages2, '1')
|
||||
const q2 = visitNode(threadMessages2, '2')
|
||||
const a2 = visitNode(threadMessages2, '3')
|
||||
const q3 = visitNode(threadMessages2, '4')
|
||||
const a3 = visitNode(threadMessages2, '5')
|
||||
const q4 = visitNode(threadMessages2, '6')
|
||||
const a4 = visitNode(threadMessages2, '7')
|
||||
|
||||
expect(q1.id).toBe('question-1')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(q2.id).toBe('question-2')
|
||||
expect(a2.id).toBe('2')
|
||||
expect(q3.id).toBe('question-3')
|
||||
expect(a3.id).toBe('3')
|
||||
expect(q4.id).toBe('question-4')
|
||||
expect(a4.id).toBe('4')
|
||||
|
||||
expect(a1.siblingCount).toBe(1)
|
||||
expect(a1.siblingIndex).toBe(0)
|
||||
expect(a2.siblingCount).toBe(1)
|
||||
expect(a2.siblingIndex).toBe(0)
|
||||
expect(a3.siblingCount).toBe(1)
|
||||
expect(a3.siblingIndex).toBe(0)
|
||||
expect(a4.siblingCount).toBe(1)
|
||||
expect(a4.siblingIndex).toBe(0)
|
||||
})
|
||||
|
||||
const tree3 = buildChatItemTree(mixedTestMessages as ChatItemInTree[])
|
||||
it('should build mixed chat items tree', () => {
|
||||
expect(tree3).toHaveLength(1)
|
||||
|
||||
const a1 = visitNode(tree3, '0.children.0')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(a1.children).toHaveLength(2)
|
||||
|
||||
const a2 = visitNode(a1, 'children.0.children.0')
|
||||
expect(a2.id).toBe('2')
|
||||
expect(a2.siblingIndex).toBe(0)
|
||||
|
||||
const a3 = visitNode(a2, 'children.0.children.0')
|
||||
expect(a3.id).toBe('3')
|
||||
|
||||
const a4 = visitNode(a1, 'children.1.children.0')
|
||||
expect(a4.id).toBe('4')
|
||||
expect(a4.siblingIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('should get thread messages from tree3, using the last message as the target', () => {
|
||||
const threadMessages3_1 = getThreadMessages(tree3)
|
||||
expect(threadMessages3_1).toHaveLength(4)
|
||||
|
||||
const q1 = visitNode(threadMessages3_1, '0')
|
||||
const a1 = visitNode(threadMessages3_1, '1')
|
||||
const q4 = visitNode(threadMessages3_1, '2')
|
||||
const a4 = visitNode(threadMessages3_1, '3')
|
||||
|
||||
expect(q1.id).toBe('question-1')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(q4.id).toBe('question-4')
|
||||
expect(a4.id).toBe('4')
|
||||
|
||||
expect(a4.siblingCount).toBe(2)
|
||||
expect(a4.siblingIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('should get thread messages from tree3, using the message with id 3 as the target', () => {
|
||||
const threadMessages3_2 = getThreadMessages(tree3, '3')
|
||||
expect(threadMessages3_2).toHaveLength(6)
|
||||
|
||||
const q1 = visitNode(threadMessages3_2, '0')
|
||||
const a1 = visitNode(threadMessages3_2, '1')
|
||||
const q2 = visitNode(threadMessages3_2, '2')
|
||||
const a2 = visitNode(threadMessages3_2, '3')
|
||||
const q3 = visitNode(threadMessages3_2, '4')
|
||||
const a3 = visitNode(threadMessages3_2, '5')
|
||||
|
||||
expect(q1.id).toBe('question-1')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(q2.id).toBe('question-2')
|
||||
expect(a2.id).toBe('2')
|
||||
expect(q3.id).toBe('question-3')
|
||||
expect(a3.id).toBe('3')
|
||||
|
||||
expect(a2.siblingCount).toBe(2)
|
||||
expect(a2.siblingIndex).toBe(0)
|
||||
})
|
||||
|
||||
const tree4 = buildChatItemTree(multiRootNodesMessages as ChatItemInTree[])
|
||||
it('should build multi root nodes chat items tree', () => {
|
||||
expect(tree4).toHaveLength(2)
|
||||
|
||||
const a5 = visitNode(tree4, '1.children.0')
|
||||
expect(a5.id).toBe('5')
|
||||
expect(a5.siblingIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('should get thread messages from tree4, using the last message as the target', () => {
|
||||
const threadMessages4 = getThreadMessages(tree4)
|
||||
expect(threadMessages4).toHaveLength(2)
|
||||
|
||||
const a1 = visitNode(threadMessages4, '0.children.0')
|
||||
expect(a1.id).toBe('5')
|
||||
})
|
||||
|
||||
it('should get thread messages from tree4, using the message with id 2 as the target', () => {
|
||||
const threadMessages4_1 = getThreadMessages(tree4, '2')
|
||||
expect(threadMessages4_1).toHaveLength(6)
|
||||
const a1 = visitNode(threadMessages4_1, '1')
|
||||
expect(a1.id).toBe('1')
|
||||
const a2 = visitNode(threadMessages4_1, '3')
|
||||
expect(a2.id).toBe('2')
|
||||
const a3 = visitNode(threadMessages4_1, '5')
|
||||
expect(a3.id).toBe('3')
|
||||
})
|
||||
|
||||
const tree5 = buildChatItemTree(multiRootNodesWithLegacyTestMessages as ChatItemInTree[])
|
||||
it('should work with multi root nodes chat items with legacy chat items', () => {
|
||||
expect(tree5).toHaveLength(2)
|
||||
|
||||
const q5 = visitNode(tree5, '1')
|
||||
expect(q5.id).toBe('question-5')
|
||||
expect(q5.parentMessageId).toBe(null)
|
||||
|
||||
const a5 = visitNode(q5, 'children.0')
|
||||
expect(a5.id).toBe('5')
|
||||
expect(a5.children).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should get thread messages from tree5, using the last message as the target', () => {
|
||||
const threadMessages5 = getThreadMessages(tree5)
|
||||
expect(threadMessages5).toHaveLength(2)
|
||||
|
||||
const q5 = visitNode(threadMessages5, '0')
|
||||
const a5 = visitNode(threadMessages5, '1')
|
||||
|
||||
expect(q5.id).toBe('question-5')
|
||||
expect(a5.id).toBe('5')
|
||||
|
||||
expect(a5.siblingCount).toBe(2)
|
||||
expect(a5.siblingIndex).toBe(1)
|
||||
})
|
||||
|
||||
const tree6 = buildChatItemTree(realWorldMessages as ChatItemInTree[])
|
||||
it('should work with real world messages', () => {
|
||||
expect(tree6).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it ('should get thread messages from tree6, using the last message as target', () => {
|
||||
const threadMessages6_1 = getThreadMessages(tree6)
|
||||
expect(threadMessages6_1).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it ('should get thread messages from tree6, using specified message as target', () => {
|
||||
const threadMessages6_2 = getThreadMessages(tree6, 'ff4c2b43-48a5-47ad-9dc5-08b34ddba61b')
|
||||
expect(threadMessages6_2).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
71
web/app/components/base/chat/chat/answer/tool-detail.tsx
Normal file
71
web/app/components/base/chat/chat/answer/tool-detail.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiHammerFill,
|
||||
RiLoader2Line,
|
||||
} from '@remixicon/react'
|
||||
import type { ToolInfoInThought } from '../type'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ToolDetailProps = {
|
||||
payload: ToolInfoInThought
|
||||
}
|
||||
const ToolDetail = ({
|
||||
payload,
|
||||
}: ToolDetailProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { name, label, input, isFinished, output } = payload
|
||||
const toolLabel = name.startsWith('dataset_') ? t('dataset.knowledge') : label
|
||||
const [expand, setExpand] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl',
|
||||
!expand && 'border-l-[0.25px] border-components-panel-border bg-workflow-process-bg',
|
||||
expand && 'border-[0.5px] border-components-panel-border-subtle bg-background-section-burn',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center system-xs-medium text-text-tertiary px-2.5 py-2 cursor-pointer',
|
||||
expand && 'pb-1.5',
|
||||
)}
|
||||
onClick={() => setExpand(!expand)}
|
||||
>
|
||||
{isFinished && <RiHammerFill className='mr-1 w-3.5 h-3.5' />}
|
||||
{!isFinished && <RiLoader2Line className='mr-1 w-3.5 h-3.5 animate-spin' />}
|
||||
{t(`tools.thought.${isFinished ? 'used' : 'using'}`)}
|
||||
<div className='mx-1 text-text-secondary'>{toolLabel}</div>
|
||||
{!expand && <RiArrowRightSLine className='w-4 h-4' />}
|
||||
{expand && <RiArrowDownSLine className='ml-auto w-4 h-4' />}
|
||||
</div>
|
||||
{
|
||||
expand && (
|
||||
<>
|
||||
<div className='mb-0.5 mx-1 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary'>
|
||||
<div className='flex items-center justify-between px-2 pt-1 h-7 system-xs-semibold-uppercase'>
|
||||
{t('tools.thought.requestTitle')}
|
||||
</div>
|
||||
<div className='pt-1 px-3 pb-2 code-xs-regular break-words'>
|
||||
{input}
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx-1 mb-1 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary'>
|
||||
<div className='flex items-center justify-between px-2 pt-1 h-7 system-xs-semibold-uppercase'>
|
||||
{t('tools.thought.responseTitle')}
|
||||
</div>
|
||||
<div className='pt-1 px-3 pb-2 code-xs-regular break-words'>
|
||||
{output}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToolDetail
|
||||
47
web/app/components/base/chat/chat/chat-input-area/hooks.ts
Normal file
47
web/app/components/base/chat/chat/chat-input-area/hooks.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { TextAreaRef } from 'rc-textarea'
|
||||
|
||||
export const useTextAreaHeight = () => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
const textValueRef = useRef<HTMLDivElement>(null)
|
||||
const holdSpaceRef = useRef<HTMLDivElement>(null)
|
||||
const [isMultipleLine, setIsMultipleLine] = useState(false)
|
||||
|
||||
const handleComputeHeight = useCallback(() => {
|
||||
const textareaElement = textareaRef.current?.resizableTextArea.textArea
|
||||
if (wrapperRef.current && textareaElement && textValueRef.current && holdSpaceRef.current) {
|
||||
const { width: wrapperWidth } = wrapperRef.current.getBoundingClientRect()
|
||||
const { height: textareaHeight } = textareaElement.getBoundingClientRect()
|
||||
const { width: textValueWidth } = textValueRef.current.getBoundingClientRect()
|
||||
const { width: holdSpaceWidth } = holdSpaceRef.current.getBoundingClientRect()
|
||||
|
||||
if (textareaHeight > 32) {
|
||||
setIsMultipleLine(true)
|
||||
}
|
||||
else {
|
||||
if (textValueWidth + holdSpaceWidth >= wrapperWidth)
|
||||
setIsMultipleLine(true)
|
||||
else
|
||||
setIsMultipleLine(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTextareaResize = useCallback(() => {
|
||||
handleComputeHeight()
|
||||
}, [handleComputeHeight])
|
||||
|
||||
return {
|
||||
wrapperRef,
|
||||
textareaRef,
|
||||
textValueRef,
|
||||
holdSpaceRef,
|
||||
handleTextareaResize,
|
||||
isMultipleLine,
|
||||
}
|
||||
}
|
||||
209
web/app/components/base/chat/chat/chat-input-area/index.tsx
Normal file
209
web/app/components/base/chat/chat/chat-input-area/index.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import Textarea from 'rc-textarea'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import type {
|
||||
EnableType,
|
||||
OnSend,
|
||||
} from '../../types'
|
||||
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
|
||||
import type { InputForm } from '../type'
|
||||
import { useCheckInputsForms } from '../check-input-forms-hooks'
|
||||
import { useTextAreaHeight } from './hooks'
|
||||
import Operation from './operation'
|
||||
import cn from '@/utils/classnames'
|
||||
import { FileListInChatInput } from '@/app/components/base/file-uploader'
|
||||
import { useFile } from '@/app/components/base/file-uploader/hooks'
|
||||
import {
|
||||
FileContextProvider,
|
||||
useFileStore,
|
||||
} from '@/app/components/base/file-uploader/store'
|
||||
import VoiceInput from '@/app/components/base/voice-input'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
showFeatureBar?: boolean
|
||||
showFileUpload?: boolean
|
||||
featureBarDisabled?: boolean
|
||||
onFeatureBarClick?: (state: boolean) => void
|
||||
visionConfig?: FileUpload
|
||||
speechToTextConfig?: EnableType
|
||||
onSend?: OnSend
|
||||
inputs?: Record<string, any>
|
||||
inputsForm?: InputForm[]
|
||||
theme?: Theme | null
|
||||
}
|
||||
const ChatInputArea = ({
|
||||
showFeatureBar,
|
||||
showFileUpload,
|
||||
featureBarDisabled,
|
||||
onFeatureBarClick,
|
||||
visionConfig,
|
||||
speechToTextConfig = { enabled: true },
|
||||
onSend,
|
||||
inputs = {},
|
||||
inputsForm = [],
|
||||
theme,
|
||||
}: ChatInputAreaProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const {
|
||||
wrapperRef,
|
||||
textareaRef,
|
||||
textValueRef,
|
||||
holdSpaceRef,
|
||||
handleTextareaResize,
|
||||
isMultipleLine,
|
||||
} = useTextAreaHeight()
|
||||
const [query, setQuery] = useState('')
|
||||
const isUseInputMethod = useRef(false)
|
||||
const [showVoiceInput, setShowVoiceInput] = useState(false)
|
||||
const filesStore = useFileStore()
|
||||
const {
|
||||
handleDragFileEnter,
|
||||
handleDragFileLeave,
|
||||
handleDragFileOver,
|
||||
handleDropFile,
|
||||
handleClipboardPasteFile,
|
||||
isDragActive,
|
||||
} = useFile(visionConfig!)
|
||||
const { checkInputsForm } = useCheckInputsForms()
|
||||
|
||||
const handleSend = () => {
|
||||
if (onSend) {
|
||||
const { files, setFiles } = filesStore.getState()
|
||||
if (files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
|
||||
return
|
||||
}
|
||||
if (!query || !query.trim()) {
|
||||
notify({ type: 'info', message: t('appAnnotation.errorMessage.queryRequired') })
|
||||
return
|
||||
}
|
||||
if (checkInputsForm(inputs, inputsForm)) {
|
||||
onSend(query, files)
|
||||
setQuery('')
|
||||
setFiles([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
// prevent send message when using input method enter
|
||||
if (!e.shiftKey && !isUseInputMethod.current)
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
isUseInputMethod.current = e.nativeEvent.isComposing
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
setQuery(query.replace(/\n$/, ''))
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowVoiceInput = useCallback(() => {
|
||||
(Recorder as any).getPermission().then(() => {
|
||||
setShowVoiceInput(true)
|
||||
}, () => {
|
||||
notify({ type: 'error', message: t('common.voiceInput.notAllow') })
|
||||
})
|
||||
}, [t, notify])
|
||||
|
||||
const operation = (
|
||||
<Operation
|
||||
ref={holdSpaceRef}
|
||||
fileConfig={visionConfig}
|
||||
speechToTextConfig={speechToTextConfig}
|
||||
onShowVoiceInput={handleShowVoiceInput}
|
||||
onSend={handleSend}
|
||||
theme={theme}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'relative pb-[9px] bg-components-panel-bg-blur border border-components-chat-input-border rounded-xl shadow-md z-10',
|
||||
isDragActive && 'border border-dashed border-components-option-card-option-selected-border',
|
||||
)}
|
||||
>
|
||||
<div className='relative px-[9px] pt-[9px] max-h-[158px] overflow-x-hidden overflow-y-auto'>
|
||||
<FileListInChatInput fileConfig={visionConfig!} />
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className='flex items-center justify-between'
|
||||
>
|
||||
<div className='flex items-center relative grow w-full'>
|
||||
<div
|
||||
ref={textValueRef}
|
||||
className='absolute w-auto h-auto p-1 leading-6 body-lg-regular pointer-events-none whitespace-pre invisible'
|
||||
>
|
||||
{query}
|
||||
</div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
'p-1 w-full leading-6 body-lg-regular text-text-tertiary outline-none',
|
||||
)}
|
||||
placeholder={t('common.chat.inputPlaceholder') || ''}
|
||||
autoSize={{ minRows: 1 }}
|
||||
onResize={handleTextareaResize}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
handleTextareaResize()
|
||||
}}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handleClipboardPasteFile}
|
||||
onDragEnter={handleDragFileEnter}
|
||||
onDragLeave={handleDragFileLeave}
|
||||
onDragOver={handleDragFileOver}
|
||||
onDrop={handleDropFile}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
!isMultipleLine && operation
|
||||
}
|
||||
</div>
|
||||
{
|
||||
showVoiceInput && (
|
||||
<VoiceInput
|
||||
onCancel={() => setShowVoiceInput(false)}
|
||||
onConverted={text => setQuery(text)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
isMultipleLine && (
|
||||
<div className='px-[9px]'>{operation}</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{showFeatureBar && <FeatureBar showFileUpload={showFileUpload} disabled={featureBarDisabled} onFeatureBarClick={onFeatureBarClick} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ChatInputAreaWrapper = (props: ChatInputAreaProps) => {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<ChatInputArea {...props} />
|
||||
</FileContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatInputAreaWrapper
|
||||
@ -0,0 +1,76 @@
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
} from 'react'
|
||||
import {
|
||||
RiMicLine,
|
||||
RiSendPlane2Fill,
|
||||
} from '@remixicon/react'
|
||||
import type {
|
||||
EnableType,
|
||||
} from '../../types'
|
||||
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { FileUploaderInChatInput } from '@/app/components/base/file-uploader'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type OperationProps = {
|
||||
fileConfig?: FileUpload
|
||||
speechToTextConfig?: EnableType
|
||||
onShowVoiceInput?: () => void
|
||||
onSend: () => void
|
||||
theme?: Theme | null
|
||||
}
|
||||
const Operation = forwardRef<HTMLDivElement, OperationProps>(({
|
||||
fileConfig,
|
||||
speechToTextConfig,
|
||||
onShowVoiceInput,
|
||||
onSend,
|
||||
theme,
|
||||
}, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 flex items-center justify-end',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='flex items-center pl-1'
|
||||
ref={ref}
|
||||
>
|
||||
<div className='flex items-center space-x-1'>
|
||||
{fileConfig?.enabled && <FileUploaderInChatInput fileConfig={fileConfig} />}
|
||||
{
|
||||
speechToTextConfig?.enabled && (
|
||||
<ActionButton
|
||||
size='l'
|
||||
onClick={onShowVoiceInput}
|
||||
>
|
||||
<RiMicLine className='w-5 h-5' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<Button
|
||||
className='ml-3 px-0 w-8'
|
||||
variant='primary'
|
||||
onClick={onSend}
|
||||
style={
|
||||
theme
|
||||
? {
|
||||
backgroundColor: theme.primaryColor,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<RiSendPlane2Fill className='w-4 h-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Operation.displayName = 'Operation'
|
||||
|
||||
export default memo(Operation)
|
||||
@ -1,258 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from 'rc-textarea'
|
||||
import type {
|
||||
EnableType,
|
||||
OnSend,
|
||||
VisionConfig,
|
||||
} from '../types'
|
||||
import { TransferMethod } from '../types'
|
||||
import { useChatWithHistoryContext } from '../chat-with-history/context'
|
||||
import type { Theme } from '../embedded-chatbot/theme/theme-context'
|
||||
import { CssTransform } from '../embedded-chatbot/theme/utils'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import VoiceInput from '@/app/components/base/voice-input'
|
||||
import { Microphone01 } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import { Microphone01 as Microphone01Solid } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
|
||||
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { Send03 } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader'
|
||||
import ImageList from '@/app/components/base/image-uploader/image-list'
|
||||
import {
|
||||
useClipboardUploader,
|
||||
useDraggableUploader,
|
||||
useImageFiles,
|
||||
} from '@/app/components/base/image-uploader/hooks'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ChatInputProps = {
|
||||
visionConfig?: VisionConfig
|
||||
speechToTextConfig?: EnableType
|
||||
onSend?: OnSend
|
||||
theme?: Theme | null
|
||||
noSpacing?: boolean
|
||||
}
|
||||
const ChatInput: FC<ChatInputProps> = ({
|
||||
visionConfig,
|
||||
speechToTextConfig,
|
||||
onSend,
|
||||
theme,
|
||||
noSpacing,
|
||||
}) => {
|
||||
const { appData } = useChatWithHistoryContext()
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [voiceInputShow, setVoiceInputShow] = useState(false)
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const {
|
||||
files,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onReUpload,
|
||||
onImageLinkLoadError,
|
||||
onImageLinkLoadSuccess,
|
||||
onClear,
|
||||
} = useImageFiles()
|
||||
const { onPaste } = useClipboardUploader({ onUpload, visionConfig, files })
|
||||
const { onDragEnter, onDragLeave, onDragOver, onDrop, isDragActive } = useDraggableUploader<HTMLTextAreaElement>({ onUpload, files, visionConfig })
|
||||
const isUseInputMethod = useRef(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value
|
||||
setQuery(value)
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
if (onSend) {
|
||||
if (files.find(item => item.type === TransferMethod.local_file && !item.fileId)) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForImgUpload') })
|
||||
return
|
||||
}
|
||||
if (!query || !query.trim()) {
|
||||
notify({ type: 'info', message: t('appAnnotation.errorMessage.queryRequired') })
|
||||
return
|
||||
}
|
||||
onSend(query, files.filter(file => file.progress !== -1).map(fileItem => ({
|
||||
type: 'image',
|
||||
transfer_method: fileItem.type,
|
||||
url: fileItem.url,
|
||||
upload_file_id: fileItem.fileId,
|
||||
})))
|
||||
setQuery('')
|
||||
onClear()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
// prevent send message when using input method enter
|
||||
if (!e.shiftKey && !isUseInputMethod.current)
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
isUseInputMethod.current = e.nativeEvent.isComposing
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
setQuery(query.replace(/\n$/, ''))
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const logError = (message: string) => {
|
||||
notify({ type: 'error', message })
|
||||
}
|
||||
const handleVoiceInputShow = () => {
|
||||
(Recorder as any).getPermission().then(() => {
|
||||
setVoiceInputShow(true)
|
||||
}, () => {
|
||||
logError(t('common.voiceInput.notAllow'))
|
||||
})
|
||||
}
|
||||
|
||||
const [isActiveIconFocused, setActiveIconFocused] = useState(false)
|
||||
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const sendIconThemeStyle = theme
|
||||
? {
|
||||
color: (isActiveIconFocused || query || (query.trim() !== '')) ? theme.primaryColor : '#d1d5db',
|
||||
}
|
||||
: {}
|
||||
const sendBtn = (
|
||||
<div
|
||||
className='group flex items-center justify-center w-8 h-8 rounded-lg hover:bg-[#EBF5FF] cursor-pointer'
|
||||
onMouseEnter={() => setActiveIconFocused(true)}
|
||||
onMouseLeave={() => setActiveIconFocused(false)}
|
||||
onClick={handleSend}
|
||||
style={isActiveIconFocused ? CssTransform(theme?.chatBubbleColorStyle ?? '') : {}}
|
||||
>
|
||||
<Send03
|
||||
style={sendIconThemeStyle}
|
||||
className={`
|
||||
w-5 h-5 text-gray-300 group-hover:text-primary-600
|
||||
${!!query.trim() && 'text-primary-600'}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('relative', !noSpacing && 'px-8')}>
|
||||
<div
|
||||
className={`
|
||||
p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto
|
||||
${isDragActive && 'border-primary-600'} mb-2
|
||||
`}
|
||||
>
|
||||
{
|
||||
visionConfig?.enabled && (
|
||||
<>
|
||||
<div className={cn('absolute bottom-2 flex items-center', noSpacing ? 'left-2' : 'left-10')}>
|
||||
<ChatImageUploader
|
||||
settings={visionConfig}
|
||||
onUpload={onUpload}
|
||||
disabled={files.length >= visionConfig.number_limits}
|
||||
/>
|
||||
<div className='mx-1 w-[1px] h-4 bg-black/5' />
|
||||
</div>
|
||||
<div className='pl-[52px]'>
|
||||
<ImageList
|
||||
list={files}
|
||||
onRemove={onRemove}
|
||||
onReUpload={onReUpload}
|
||||
onImageLinkLoadSuccess={onImageLinkLoadSuccess}
|
||||
onImageLinkLoadError={onImageLinkLoadError}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<Textarea
|
||||
ref={textAreaRef}
|
||||
className={`
|
||||
block w-full px-2 pr-[118px] py-[7px] leading-5 max-h-none text-sm text-gray-700 outline-none appearance-none resize-none
|
||||
${visionConfig?.enabled && 'pl-12'}
|
||||
`}
|
||||
value={query}
|
||||
onChange={handleContentChange}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={onPaste}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
autoSize
|
||||
/>
|
||||
<div className={cn('absolute bottom-[7px] flex items-center h-8', noSpacing ? 'right-2' : 'right-10')}>
|
||||
<div className='flex items-center px-1 h-5 rounded-md bg-gray-100 text-xs font-medium text-gray-500'>
|
||||
{query.trim().length}
|
||||
</div>
|
||||
{
|
||||
query
|
||||
? (
|
||||
<div className='flex justify-center items-center ml-2 w-8 h-8 cursor-pointer hover:bg-gray-100 rounded-lg' onClick={() => setQuery('')}>
|
||||
<XCircle className='w-4 h-4 text-[#98A2B3]' />
|
||||
</div>
|
||||
)
|
||||
: speechToTextConfig?.enabled
|
||||
? (
|
||||
<div
|
||||
className='group flex justify-center items-center ml-2 w-8 h-8 hover:bg-primary-50 rounded-lg cursor-pointer'
|
||||
onClick={handleVoiceInputShow}
|
||||
>
|
||||
<Microphone01 className='block w-4 h-4 text-gray-500 group-hover:hidden' />
|
||||
<Microphone01Solid className='hidden w-4 h-4 text-primary-600 group-hover:block' />
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
<div className='mx-2 w-[1px] h-4 bg-black opacity-5' />
|
||||
{isMobile
|
||||
? sendBtn
|
||||
: (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div>
|
||||
<div>{t('common.operation.send')} Enter</div>
|
||||
<div>{t('common.operation.lineBreak')} Shift Enter</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{sendBtn}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
voiceInputShow && (
|
||||
<VoiceInput
|
||||
onCancel={() => setVoiceInputShow(false)}
|
||||
onConverted={(text) => {
|
||||
setQuery(text)
|
||||
textAreaRef.current?.focus()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{appData?.site?.custom_disclaimer && <div className='text-xs text-gray-500 mt-1 text-center'>
|
||||
{appData.site.custom_disclaimer}
|
||||
</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ChatInput)
|
||||
54
web/app/components/base/chat/chat/check-input-forms-hooks.ts
Normal file
54
web/app/components/base/chat/chat/check-input-forms-hooks.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { InputForm } from './type'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
export const useCheckInputsForms = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
|
||||
const checkInputsForm = useCallback((inputs: Record<string, any>, inputsForm: InputForm[]) => {
|
||||
let hasEmptyInput = ''
|
||||
let fileIsUploading = false
|
||||
const requiredVars = inputsForm.filter(({ required }) => required)
|
||||
|
||||
if (requiredVars?.length) {
|
||||
requiredVars.forEach(({ variable, label, type }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (fileIsUploading)
|
||||
return
|
||||
|
||||
if (!inputs[variable])
|
||||
hasEmptyInput = label as string
|
||||
|
||||
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputs[variable]) {
|
||||
const files = inputs[variable]
|
||||
if (Array.isArray(files))
|
||||
fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
|
||||
else
|
||||
fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (hasEmptyInput) {
|
||||
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) })
|
||||
return false
|
||||
}
|
||||
|
||||
if (fileIsUploading) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
|
||||
return
|
||||
}
|
||||
|
||||
return true
|
||||
}, [notify, t])
|
||||
|
||||
return {
|
||||
checkInputsForm,
|
||||
}
|
||||
}
|
||||
32
web/app/components/base/chat/chat/utils.ts
Normal file
32
web/app/components/base/chat/chat/utils.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { InputForm } from './type'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
|
||||
|
||||
export const processOpeningStatement = (openingStatement: string, inputs: Record<string, any>, inputsForm: InputForm[]) => {
|
||||
if (!openingStatement)
|
||||
return openingStatement
|
||||
|
||||
return openingStatement.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
|
||||
const name = inputs[key]
|
||||
if (name) { // has set value
|
||||
return name
|
||||
}
|
||||
|
||||
const valueObj = inputsForm.find(v => v.variable === key)
|
||||
return valueObj ? `{{${valueObj.label}}}` : match
|
||||
})
|
||||
}
|
||||
|
||||
export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: InputForm[]) => {
|
||||
const processedInputs = { ...inputs }
|
||||
|
||||
inputsForm.forEach((item) => {
|
||||
if (item.type === InputVarType.multiFiles && inputs[item.variable])
|
||||
processedInputs[item.variable] = getProcessedFiles(inputs[item.variable])
|
||||
|
||||
if (item.type === InputVarType.singleFile && inputs[item.variable])
|
||||
processedInputs[item.variable] = getProcessedFiles([inputs[item.variable]])[0]
|
||||
})
|
||||
|
||||
return processedInputs
|
||||
}
|
||||
Reference in New Issue
Block a user