This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,122 @@
[
{
"id": "question-ebb73fe2-15de-46dd-aab5-75416d8448eb",
"content": "123",
"isAnswer": false,
"parentMessageId": "57c989f9-3fa4-4dec-9ee5-c3568dd27418"
},
{
"id": "ebb73fe2-15de-46dd-aab5-75416d8448eb",
"content": "237.",
"isAnswer": true,
"parentMessageId": "question-ebb73fe2-15de-46dd-aab5-75416d8448eb"
},
{
"id": "question-3553d508-3850-462e-8594-078539f940f9",
"content": "123",
"isAnswer": false,
"parentMessageId": "57c989f9-3fa4-4dec-9ee5-c3568dd27418"
},
{
"id": "3553d508-3850-462e-8594-078539f940f9",
"content": "My number is 256.",
"isAnswer": true,
"parentMessageId": "question-3553d508-3850-462e-8594-078539f940f9"
},
{
"id": "question-507f9df9-1f06-4a57-bb38-f00228c42c22",
"content": "123",
"isAnswer": false,
"parentMessageId": "57c989f9-3fa4-4dec-9ee5-c3568dd27418"
},
{
"id": "507f9df9-1f06-4a57-bb38-f00228c42c22",
"content": "My number is 259.",
"isAnswer": true,
"parentMessageId": "question-507f9df9-1f06-4a57-bb38-f00228c42c22"
},
{
"id": "question-9e51a13b-7780-4565-98dc-f2d8c3b1758f",
"content": "1024",
"isAnswer": false,
"parentMessageId": "507f9df9-1f06-4a57-bb38-f00228c42c22"
},
{
"id": "9e51a13b-7780-4565-98dc-f2d8c3b1758f",
"content": "My number is 2048.",
"isAnswer": true,
"parentMessageId": "question-9e51a13b-7780-4565-98dc-f2d8c3b1758f"
},
{
"id": "question-93bac05d-1470-4ac9-b090-fe21cd7c3d55",
"content": "3306",
"isAnswer": false,
"parentMessageId": "9e51a13b-7780-4565-98dc-f2d8c3b1758f"
},
{
"id": "93bac05d-1470-4ac9-b090-fe21cd7c3d55",
"content": "My number is 4782.",
"isAnswer": true,
"parentMessageId": "question-93bac05d-1470-4ac9-b090-fe21cd7c3d55"
},
{
"id": "question-a956de3d-ef95-4d90-84fe-f7a26ef28cd7",
"content": "-5",
"isAnswer": false,
"parentMessageId": "93bac05d-1470-4ac9-b090-fe21cd7c3d55"
},
{
"id": "a956de3d-ef95-4d90-84fe-f7a26ef28cd7",
"content": "My number is 22.",
"isAnswer": true,
"parentMessageId": "question-a956de3d-ef95-4d90-84fe-f7a26ef28cd7"
},
{
"id": "question-3cded945-855a-4a24-aab7-43c7dd54664c",
"content": "3.11",
"isAnswer": false,
"parentMessageId": "a956de3d-ef95-4d90-84fe-f7a26ef28cd7"
},
{
"id": "3cded945-855a-4a24-aab7-43c7dd54664c",
"content": "My number is 7.89.",
"isAnswer": true,
"parentMessageId": "question-3cded945-855a-4a24-aab7-43c7dd54664c"
},
{
"id": "question-46a49bb9-0881-459e-8c6a-24d20ae48d2f",
"content": "78",
"isAnswer": false,
"parentMessageId": "3cded945-855a-4a24-aab7-43c7dd54664c"
},
{
"id": "46a49bb9-0881-459e-8c6a-24d20ae48d2f",
"content": "My number is 145.",
"isAnswer": true,
"parentMessageId": "question-46a49bb9-0881-459e-8c6a-24d20ae48d2f"
},
{
"id": "question-5c56a2b3-f057-42a0-9b2c-52a35713cd8c",
"content": "π",
"isAnswer": false,
"parentMessageId": "46a49bb9-0881-459e-8c6a-24d20ae48d2f"
},
{
"id": "5c56a2b3-f057-42a0-9b2c-52a35713cd8c",
"content": "My number is 2π (approximately 6.28).",
"isAnswer": true,
"parentMessageId": "question-5c56a2b3-f057-42a0-9b2c-52a35713cd8c"
},
{
"id": "question-9eac3bcc-8d3b-4e56-a12b-44c34cebc719",
"content": "e",
"isAnswer": false,
"parentMessageId": "5c56a2b3-f057-42a0-9b2c-52a35713cd8c"
},
{
"id": "9eac3bcc-8d3b-4e56-a12b-44c34cebc719",
"content": "My number is 3e (approximately 8.15).",
"isAnswer": true,
"parentMessageId": "question-9eac3bcc-8d3b-4e56-a12b-44c34cebc719"
}
]

View 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"
}
]

View File

@@ -0,0 +1,271 @@
import { get } from 'lodash-es'
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'
import partialMessages from './partialMessages.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()
})
const partialMessages1 = (realWorldMessages as ChatItemInTree[]).slice(-10)
const tree7 = buildChatItemTree(partialMessages1)
it('should work with partial messages 1', () => {
expect(tree7).toMatchSnapshot()
})
const partialMessages2 = partialMessages as ChatItemInTree[]
const tree8 = buildChatItemTree(partialMessages2)
it('should work with partial messages 2', () => {
expect(tree8).toMatchSnapshot()
})
})

View File

@@ -0,0 +1,302 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import Chat from '../chat'
import type {
ChatConfig,
ChatItem,
OnSend,
} from '../types'
import { useChat } from '../chat/hooks'
import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
import { useChatWithHistoryContext } from './context'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import InputsForm from '@/app/components/base/chat/chat-with-history/inputs-form'
import {
fetchSuggestedQuestions,
getUrl,
stopChatMessageResponding,
} from '@/service/share'
import AppIcon from '@/app/components/base/app-icon'
import AnswerIcon from '@/app/components/base/answer-icon'
import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions'
import { Markdown } from '@/app/components/base/markdown'
import cn from '@/utils/classnames'
import type { FileEntity } from '../../file-uploader/types'
import { formatBooleanInputs } from '@/utils/model-config'
import Avatar from '../../avatar'
const ChatWrapper = () => {
const {
appParams,
appPrevChatTree,
currentConversationId,
currentConversationItem,
currentConversationInputs,
inputsForms,
newConversationInputs,
newConversationInputsRef,
handleNewConversationCompleted,
isMobile,
isInstalledApp,
appId,
appMeta,
handleFeedback,
currentChatInstanceRef,
appData,
themeBuilder,
sidebarCollapseState,
clearChatList,
setClearChatList,
setIsResponding,
allInputsHidden,
initUserVariables,
} = useChatWithHistoryContext()
const appConfig = useMemo(() => {
const config = appParams || {}
return {
...config,
file_upload: {
...(config as any).file_upload,
fileUploadConfig: (config as any).system_parameters,
},
supportFeedback: true,
opening_statement: currentConversationItem?.introduction || (config as any).opening_statement,
} as ChatConfig
}, [appParams, currentConversationItem?.introduction])
const {
chatList,
setTargetMessageId,
handleSend,
handleStop,
isResponding: respondingState,
suggestedQuestions,
} = useChat(
appConfig,
{
inputs: (currentConversationId ? currentConversationInputs : newConversationInputs) as any,
inputsForm: inputsForms,
},
appPrevChatTree,
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
clearChatList,
setClearChatList,
)
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
const inputDisabled = useMemo(() => {
if (allInputsHidden)
return false
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox)
if (requiredVars.length) {
requiredVars.forEach(({ variable, label, type }) => {
if (hasEmptyInput)
return
if (fileIsUploading)
return
if (!inputsFormValue?.[variable])
hasEmptyInput = label as string
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputsFormValue?.[variable]) {
const files = inputsFormValue[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)
return true
if (fileIsUploading)
return true
return false
}, [inputsFormValue, inputsForms, allInputsHidden])
useEffect(() => {
if (currentChatInstanceRef.current)
currentChatInstanceRef.current.handleStop = handleStop
}, [])
useEffect(() => {
setIsResponding(respondingState)
}, [respondingState, setIsResponding])
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
const data: any = {
query: message,
files,
inputs: formatBooleanInputs(inputsForms, currentConversationId ? currentConversationInputs : newConversationInputs),
conversation_id: currentConversationId,
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
}
handleSend(
getUrl('chat-messages', isInstalledApp, appId || ''),
data,
{
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp,
},
)
}, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId])
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
const parentAnswer = chatList.find(item => item.id === question.parentMessageId)
doSend(editedQuestion ? editedQuestion.message : question.content,
editedQuestion ? editedQuestion.files : question.message_files,
true,
isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null,
)
}, [chatList, doSend])
const messageList = useMemo(() => {
if (currentConversationId || chatList.length > 1)
return chatList
// Without messages we are in the welcome screen, so hide the opening statement from chatlist
return chatList.filter(item => !item.isOpeningStatement)
}, [chatList])
const [collapsed, setCollapsed] = useState(!!currentConversationId)
const chatNode = useMemo(() => {
if (allInputsHidden || !inputsForms.length)
return null
if (isMobile) {
if (!currentConversationId)
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
return null
}
else {
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
}
},
[
inputsForms.length,
isMobile,
currentConversationId,
collapsed, allInputsHidden,
])
const welcome = useMemo(() => {
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
if (respondingState)
return null
if (currentConversationId)
return null
if (!welcomeMessage)
return null
if (!collapsed && inputsForms.length > 0 && !allInputsHidden)
return null
if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) {
return (
<div className='flex min-h-[50vh] items-center justify-center px-4 py-12'>
<div className='flex max-w-[720px] grow gap-4'>
<AppIcon
size='xl'
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background={appData?.site.icon_background}
imageUrl={appData?.site.icon_url}
/>
<div className='w-0 grow'>
<div className='body-lg-regular grow rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary'>
<Markdown content={welcomeMessage.content} />
<SuggestedQuestions item={welcomeMessage} />
</div>
</div>
</div>
</div>
)
}
return (
<div className={cn('flex h-[50vh] flex-col items-center justify-center gap-3 py-12')}>
<AppIcon
size='xl'
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background={appData?.site.icon_background}
imageUrl={appData?.site.icon_url}
/>
<div className='max-w-[768px] px-4'>
<Markdown className='!body-2xl-regular !text-text-tertiary' content={welcomeMessage.content} />
</div>
</div>
)
},
[
appData?.site.icon,
appData?.site.icon_background,
appData?.site.icon_type,
appData?.site.icon_url,
chatList, collapsed,
currentConversationId,
inputsForms.length,
respondingState,
allInputsHidden,
])
const answerIcon = (appData?.site && appData.site.use_icon_as_answer_icon)
? <AnswerIcon
iconType={appData.site.icon_type}
icon={appData.site.icon}
background={appData.site.icon_background}
imageUrl={appData.site.icon_url}
/>
: null
return (
<div
className='h-full overflow-hidden bg-chatbot-bg'
>
<Chat
appData={appData ?? undefined}
config={appConfig}
chatList={messageList}
isResponding={respondingState}
chatContainerInnerClassName={`mx-auto pt-6 w-full max-w-[768px] ${isMobile && 'px-4'}`}
chatFooterClassName='pb-4'
chatFooterInnerClassName={`mx-auto w-full max-w-[768px] ${isMobile ? 'px-2' : 'px-4'}`}
onSend={doSend}
inputs={currentConversationId ? currentConversationInputs as any : newConversationInputs}
inputsForm={inputsForms}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
chatNode={
<>
{chatNode}
{welcome}
</>
}
allToolIcons={appMeta?.tool_icons || {}}
onFeedback={handleFeedback}
suggestedQuestions={suggestedQuestions}
answerIcon={answerIcon}
hideProcessDetail
themeBuilder={themeBuilder}
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
inputDisabled={inputDisabled}
isMobile={isMobile}
sidebarCollapseState={sidebarCollapseState}
questionIcon={
initUserVariables?.avatar_url
? <Avatar
avatar={initUserVariables.avatar_url}
name={initUserVariables.name || 'user'}
size={40}
/> : undefined
}
/>
</div>
)
}
export default ChatWrapper

View File

@@ -0,0 +1,99 @@
'use client'
import type { RefObject } from 'react'
import { createContext, useContext } from 'use-context-selector'
import type {
Callback,
ChatConfig,
ChatItemInTree,
Feedback,
} from '../types'
import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
import type {
AppConversationData,
AppData,
AppMeta,
ConversationItem,
} from '@/models/share'
import { noop } from 'lodash-es'
export type ChatWithHistoryContextValue = {
appMeta?: AppMeta | null
appData?: AppData | null
appParams?: ChatConfig
appChatListDataLoading?: boolean
currentConversationId: string
currentConversationItem?: ConversationItem
appPrevChatTree: ChatItemInTree[]
pinnedConversationList: AppConversationData['data']
conversationList: AppConversationData['data']
newConversationInputs: Record<string, any>
newConversationInputsRef: RefObject<Record<string, any>>
handleNewConversationInputsChange: (v: Record<string, any>) => void
inputsForms: any[]
handleNewConversation: () => void
handleStartChat: (callback?: any) => void
handleChangeConversation: (conversationId: string) => void
handlePinConversation: (conversationId: string) => void
handleUnpinConversation: (conversationId: string) => void
handleDeleteConversation: (conversationId: string, callback: Callback) => void
conversationRenaming: boolean
handleRenameConversation: (conversationId: string, newName: string, callback: Callback) => void
handleNewConversationCompleted: (newConversationId: string) => void
chatShouldReloadKey: string
isMobile: boolean
isInstalledApp: boolean
appId?: string
handleFeedback: (messageId: string, feedback: Feedback) => void
currentChatInstanceRef: RefObject<{ handleStop: () => void }>
themeBuilder?: ThemeBuilder
sidebarCollapseState?: boolean
handleSidebarCollapse: (state: boolean) => void
clearChatList?: boolean
setClearChatList: (state: boolean) => void
isResponding?: boolean
setIsResponding: (state: boolean) => void,
currentConversationInputs: Record<string, any> | null,
setCurrentConversationInputs: (v: Record<string, any>) => void,
allInputsHidden: boolean,
initUserVariables?: {
name?: string
avatar_url?: string
}
}
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
currentConversationId: '',
appPrevChatTree: [],
pinnedConversationList: [],
conversationList: [],
newConversationInputs: {},
newConversationInputsRef: { current: {} },
handleNewConversationInputsChange: noop,
inputsForms: [],
handleNewConversation: noop,
handleStartChat: noop,
handleChangeConversation: noop,
handlePinConversation: noop,
handleUnpinConversation: noop,
handleDeleteConversation: noop,
conversationRenaming: false,
handleRenameConversation: noop,
handleNewConversationCompleted: noop,
chatShouldReloadKey: '',
isMobile: false,
isInstalledApp: false,
handleFeedback: noop,
currentChatInstanceRef: { current: { handleStop: noop } },
sidebarCollapseState: false,
handleSidebarCollapse: noop,
clearChatList: false,
setClearChatList: noop,
isResponding: false,
setIsResponding: noop,
currentConversationInputs: {},
setCurrentConversationInputs: noop,
allInputsHidden: false,
initUserVariables: {},
})
export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext)

View File

@@ -0,0 +1,152 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiMenuLine,
} from '@remixicon/react'
import { useChatWithHistoryContext } from './context'
import Operation from './header/operation'
import Sidebar from './sidebar'
import MobileOperationDropdown from './header/mobile-operation-dropdown'
import AppIcon from '@/app/components/base/app-icon'
import ActionButton from '@/app/components/base/action-button'
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
import Confirm from '@/app/components/base/confirm'
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
import type { ConversationItem } from '@/models/share'
const HeaderInMobile = () => {
const {
appData,
currentConversationId,
currentConversationItem,
pinnedConversationList,
handleNewConversation,
handlePinConversation,
handleUnpinConversation,
handleDeleteConversation,
handleRenameConversation,
conversationRenaming,
inputsForms,
} = useChatWithHistoryContext()
const { t } = useTranslation()
const isPin = pinnedConversationList.some(item => item.id === currentConversationId)
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
const handleOperate = useCallback((type: string) => {
if (type === 'pin')
handlePinConversation(currentConversationId)
if (type === 'unpin')
handleUnpinConversation(currentConversationId)
if (type === 'delete')
setShowConfirm(currentConversationItem as any)
if (type === 'rename')
setShowRename(currentConversationItem as any)
}, [currentConversationId, currentConversationItem, handlePinConversation, handleUnpinConversation])
const handleCancelConfirm = useCallback(() => {
setShowConfirm(null)
}, [])
const handleDelete = useCallback(() => {
if (showConfirm)
handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm })
}, [showConfirm, handleDeleteConversation, handleCancelConfirm])
const handleCancelRename = useCallback(() => {
setShowRename(null)
}, [])
const handleRename = useCallback((newName: string) => {
if (showRename)
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
}, [showRename, handleRenameConversation, handleCancelRename])
const [showSidebar, setShowSidebar] = useState(false)
const [showChatSettings, setShowChatSettings] = useState(false)
return (
<>
<div className='flex shrink-0 items-center gap-1 bg-mask-top2bottom-gray-50-to-transparent px-2 py-3'>
<ActionButton size='l' className='shrink-0' onClick={() => setShowSidebar(true)}>
<RiMenuLine className='h-[18px] w-[18px]' />
</ActionButton>
<div className='flex grow items-center justify-center'>
{!currentConversationId && (
<>
<AppIcon
className='mr-2'
size='tiny'
icon={appData?.site.icon}
iconType={appData?.site.icon_type}
imageUrl={appData?.site.icon_url}
background={appData?.site.icon_background}
/>
<div className='system-md-semibold truncate text-text-secondary'>
{appData?.site.title}
</div>
</>
)}
{currentConversationId && (
<Operation
title={currentConversationItem?.name || ''}
isPinned={!!isPin}
togglePin={() => handleOperate(isPin ? 'unpin' : 'pin')}
isShowDelete
isShowRenameConversation
onRenameConversation={() => handleOperate('rename')}
onDelete={() => handleOperate('delete')}
/>
)}
</div>
<MobileOperationDropdown
handleResetChat={handleNewConversation}
handleViewChatSettings={() => setShowChatSettings(true)}
hideViewChatSettings={inputsForms.length < 1}
/>
</div>
{showSidebar && (
<div className='fixed inset-0 z-50 flex bg-background-overlay p-1'
onClick={() => setShowSidebar(false)}
>
<div className='flex h-full w-[calc(100vw_-_40px)] rounded-xl bg-components-panel-bg shadow-lg backdrop-blur-sm' onClick={e => e.stopPropagation()}>
<Sidebar />
</div>
</div>
)}
{showChatSettings && (
<div className='fixed inset-0 z-50 flex justify-end bg-background-overlay p-1'
onClick={() => setShowChatSettings(false)}
>
<div className='flex h-full w-[calc(100vw_-_40px)] flex-col rounded-xl bg-components-panel-bg shadow-lg backdrop-blur-sm' onClick={e => e.stopPropagation()}>
<div className='flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-4 py-3'>
<Message3Fill className='h-6 w-6 shrink-0' />
<div className='system-xl-semibold grow text-text-secondary'>{t('share.chat.chatSettingsTitle')}</div>
</div>
<div className='p-4'>
<InputsFormContent />
</div>
</div>
</div>
)}
{!!showConfirm && (
<Confirm
title={t('share.chat.deleteConversation.title')}
content={t('share.chat.deleteConversation.content') || ''}
isShow
onCancel={handleCancelConfirm}
onConfirm={handleDelete}
/>
)}
{showRename && (
<RenameModal
isShow
onClose={handleCancelRename}
saveLoading={conversationRenaming}
name={showRename?.name || ''}
onSave={handleRename}
/>
)}
</>
)
}
export default HeaderInMobile

View File

@@ -0,0 +1,164 @@
import { useCallback, useState } from 'react'
import {
RiEditBoxLine,
RiLayoutRight2Line,
RiResetLeftLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import {
useChatWithHistoryContext,
} from '../context'
import Operation from './operation'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import AppIcon from '@/app/components/base/app-icon'
import Tooltip from '@/app/components/base/tooltip'
import ViewFormDropdown from '@/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown'
import Confirm from '@/app/components/base/confirm'
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
import type { ConversationItem } from '@/models/share'
import cn from '@/utils/classnames'
const Header = () => {
const {
appData,
currentConversationId,
currentConversationItem,
inputsForms,
pinnedConversationList,
handlePinConversation,
handleUnpinConversation,
conversationRenaming,
handleRenameConversation,
handleDeleteConversation,
handleNewConversation,
sidebarCollapseState,
handleSidebarCollapse,
isResponding,
} = useChatWithHistoryContext()
const { t } = useTranslation()
const isSidebarCollapsed = sidebarCollapseState
const isPin = pinnedConversationList.some(item => item.id === currentConversationId)
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
const handleOperate = useCallback((type: string) => {
if (type === 'pin')
handlePinConversation(currentConversationId)
if (type === 'unpin')
handleUnpinConversation(currentConversationId)
if (type === 'delete')
setShowConfirm(currentConversationItem as any)
if (type === 'rename')
setShowRename(currentConversationItem as any)
}, [currentConversationId, currentConversationItem, handlePinConversation, handleUnpinConversation])
const handleCancelConfirm = useCallback(() => {
setShowConfirm(null)
}, [])
const handleDelete = useCallback(() => {
if (showConfirm)
handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm })
}, [showConfirm, handleDeleteConversation, handleCancelConfirm])
const handleCancelRename = useCallback(() => {
setShowRename(null)
}, [])
const handleRename = useCallback((newName: string) => {
if (showRename)
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
}, [showRename, handleRenameConversation, handleCancelRename])
return (
<>
<div className='flex h-14 shrink-0 items-center justify-between p-3'>
<div className={cn('flex items-center gap-1 transition-all duration-200 ease-in-out', !isSidebarCollapsed && 'user-select-none opacity-0')}>
<ActionButton className={cn(!isSidebarCollapsed && 'cursor-default')} size='l' onClick={() => handleSidebarCollapse(false)}>
<RiLayoutRight2Line className='h-[18px] w-[18px]' />
</ActionButton>
<div className='mr-1 shrink-0'>
<AppIcon
size='large'
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background={appData?.site.icon_background}
imageUrl={appData?.site.icon_url}
/>
</div>
{!currentConversationId && (
<div className={cn('system-md-semibold grow truncate text-text-secondary')}>{appData?.site.title}</div>
)}
{currentConversationId && currentConversationItem && isSidebarCollapsed && (
<>
<div className='p-1 text-divider-deep'>/</div>
<Operation
title={currentConversationItem?.name || ''}
isPinned={!!isPin}
togglePin={() => handleOperate(isPin ? 'unpin' : 'pin')}
isShowDelete
isShowRenameConversation
onRenameConversation={() => handleOperate('rename')}
onDelete={() => handleOperate('delete')}
/>
</>
)}
<div className='flex items-center px-1'>
<div className='h-[14px] w-px bg-divider-regular'></div>
</div>
{isSidebarCollapsed && (
<Tooltip
disabled={!!currentConversationId}
popupContent={t('share.chat.newChatTip')}
>
<div>
<ActionButton
size='l'
state={(!currentConversationId || isResponding) ? ActionButtonState.Disabled : ActionButtonState.Default}
disabled={!currentConversationId || isResponding}
onClick={handleNewConversation}
>
<RiEditBoxLine className='h-[18px] w-[18px]' />
</ActionButton>
</div>
</Tooltip>
)}
</div>
<div className='flex items-center gap-1'>
{currentConversationId && (
<Tooltip
popupContent={t('share.chat.resetChat')}
>
<ActionButton size='l' onClick={handleNewConversation}>
<RiResetLeftLine className='h-[18px] w-[18px]' />
</ActionButton>
</Tooltip>
)}
{currentConversationId && inputsForms.length > 0 && (
<ViewFormDropdown />
)}
</div>
</div>
{!!showConfirm && (
<Confirm
title={t('share.chat.deleteConversation.title')}
content={t('share.chat.deleteConversation.content') || ''}
isShow
onCancel={handleCancelConfirm}
onConfirm={handleDelete}
/>
)}
{showRename && (
<RenameModal
isShow
onClose={handleCancelRename}
saveLoading={conversationRenaming}
name={showRename?.name || ''}
onSave={handleRename}
/>
)}
</>
)
}
export default Header

View File

@@ -0,0 +1,59 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiMoreFill,
} from '@remixicon/react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
type Props = {
handleResetChat: () => void
handleViewChatSettings: () => void
hideViewChatSettings?: boolean
}
const MobileOperationDropdown = ({
handleResetChat,
handleViewChatSettings,
hideViewChatSettings = false,
}: Props) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
>
<ActionButton size='l' state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
<RiMoreFill className='h-[18px] w-[18px]' />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-40">
<div
className={'min-w-[160px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm'}
>
<div className='system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={handleResetChat}>
<span className='grow'>{t('share.chat.resetChat')}</span>
</div>
{!hideViewChatSettings && (
<div className='system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={handleViewChatSettings}>
<span className='grow'>{t('share.chat.viewChatSettings')}</span>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default MobileOperationDropdown

View File

@@ -0,0 +1,73 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import type { Placement } from '@floating-ui/react'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import cn from '@/utils/classnames'
type Props = {
title: string
isPinned: boolean
isShowRenameConversation?: boolean
onRenameConversation?: () => void
isShowDelete: boolean
togglePin: () => void
onDelete: () => void
placement?: Placement
}
const Operation: FC<Props> = ({
title,
isPinned,
togglePin,
isShowRenameConversation,
onRenameConversation,
isShowDelete,
onDelete,
placement = 'bottom-start',
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement={placement}
offset={4}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
>
<div className={cn('flex cursor-pointer items-center rounded-lg p-1.5 pl-2 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
<div className='system-md-semibold'>{title}</div>
<RiArrowDownSLine className='h-4 w-4 ' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div
className={'min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm'}
>
<div className={cn('system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover')} onClick={togglePin}>
<span className='grow'>{isPinned ? t('explore.sidebar.action.unpin') : t('explore.sidebar.action.pin')}</span>
</div>
{isShowRenameConversation && (
<div className={cn('system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover')} onClick={onRenameConversation}>
<span className='grow'>{t('explore.sidebar.action.rename')}</span>
</div>
)}
{isShowDelete && (
<div className={cn('system-md-regular group flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive')} onClick={onDelete} >
<span className='grow'>{t('explore.sidebar.action.delete')}</span>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(Operation)

View File

@@ -0,0 +1,576 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useLocalStorageState } from 'ahooks'
import { produce } from 'immer'
import type {
Callback,
ChatConfig,
ChatItem,
Feedback,
} from '../types'
import { CONVERSATION_ID_INFO } from '../constants'
import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams, getRawUserVariablesFromUrlParams } from '../utils'
import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import {
delConversation,
fetchChatList,
fetchConversations,
generationConversationName,
pinConversation,
renameConversation,
unpinConversation,
updateFeedback,
} from '@/service/share'
import type { InstalledApp } from '@/models/explore'
import type {
AppData,
ConversationItem,
} from '@/models/share'
import { useToastContext } from '@/app/components/base/toast'
import { changeLanguage } from '@/i18n-config/i18next-config'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { noop } from 'lodash-es'
import { useWebAppStore } from '@/context/web-app-context'
function getFormattedChatList(messages: any[]) {
const newChatList: ChatItem[] = []
messages.forEach((item) => {
const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || []
newChatList.push({
id: `question-${item.id}`,
content: item.query,
isAnswer: false,
message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))),
parentMessageId: item.parent_message_id || undefined,
})
const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
newChatList.push({
id: item.id,
content: item.answer,
agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files),
feedback: item.feedback,
isAnswer: true,
citation: item.retriever_resources,
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))),
parentMessageId: `question-${item.id}`,
})
})
return newChatList
}
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const appInfo = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams)
const appMeta = useWebAppStore(s => s.appMeta)
useAppFavicon({
enable: !installedAppInfo,
icon_type: appInfo?.site.icon_type,
icon: appInfo?.site.icon,
icon_background: appInfo?.site.icon_background,
icon_url: appInfo?.site.icon_url,
})
const appData = useMemo(() => {
if (isInstalledApp) {
const { id, app } = installedAppInfo!
return {
app_id: id,
site: {
title: app.name,
icon_type: app.icon_type,
icon: app.icon,
icon_background: app.icon_background,
icon_url: app.icon_url,
prompt_public: false,
copyright: '',
show_workflow_steps: true,
use_icon_as_answer_icon: app.use_icon_as_answer_icon,
},
plan: 'basic',
custom_config: null,
} as AppData
}
return appInfo
}, [isInstalledApp, installedAppInfo, appInfo])
const appId = useMemo(() => appData?.app_id, [appData])
const [userId, setUserId] = useState<string>()
useEffect(() => {
getProcessedSystemVariablesFromUrlParams().then(({ user_id }) => {
setUserId(user_id)
})
}, [])
useEffect(() => {
const setLocaleFromProps = async () => {
if (appData?.site.default_language)
await changeLanguage(appData.site.default_language)
}
setLocaleFromProps()
}, [appData])
const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
try {
const localState = localStorage.getItem('webappSidebarCollapse')
return localState === 'collapsed'
}
catch {
// localStorage may be disabled in private browsing mode or by security settings
// fallback to default value
return false
}
}
return false
})
const handleSidebarCollapse = useCallback((state: boolean) => {
if (appId) {
setSidebarCollapseState(state)
try {
localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded')
}
catch {
// localStorage may be disabled, continue without persisting state
}
}
}, [appId, setSidebarCollapseState])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
defaultValue: {},
})
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || '', [appId, conversationIdInfo, userId])
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
if (appId) {
let prevValue = conversationIdInfo?.[appId || '']
if (typeof prevValue === 'string')
prevValue = {}
setConversationIdInfo({
...conversationIdInfo,
[appId || '']: {
...prevValue,
[userId || 'DEFAULT']: changeConversationId,
},
})
}
}, [appId, conversationIdInfo, setConversationIdInfo, userId])
const [newConversationId, setNewConversationId] = useState('')
const chatShouldReloadKey = useMemo(() => {
if (currentConversationId === newConversationId)
return ''
return currentConversationId
}, [currentConversationId, newConversationId])
const { data: appPinnedConversationData, mutate: mutateAppPinnedConversationData } = useSWR(
appId ? ['appConversationData', isInstalledApp, appId, true] : null,
() => fetchConversations(isInstalledApp, appId, undefined, true, 100),
{ revalidateOnFocus: false, revalidateOnReconnect: false },
)
const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(
appId ? ['appConversationData', isInstalledApp, appId, false] : null,
() => fetchConversations(isInstalledApp, appId, undefined, false, 100),
{ revalidateOnFocus: false, revalidateOnReconnect: false },
)
const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(
chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null,
() => fetchChatList(chatShouldReloadKey, isInstalledApp, appId),
{ revalidateOnFocus: false, revalidateOnReconnect: false },
)
const [clearChatList, setClearChatList] = useState(false)
const [isResponding, setIsResponding] = useState(false)
const appPrevChatTree = useMemo(
() => (currentConversationId && appChatListData?.data.length)
? buildChatItemTree(getFormattedChatList(appChatListData.data))
: [],
[appChatListData, currentConversationId],
)
const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false)
const pinnedConversationList = useMemo(() => {
return appPinnedConversationData?.data || []
}, [appPinnedConversationData])
const { t } = useTranslation()
const newConversationInputsRef = useRef<Record<string, any>>({})
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any>>({})
const [initInputs, setInitInputs] = useState<Record<string, any>>({})
const [initUserVariables, setInitUserVariables] = useState<Record<string, any>>({})
const handleNewConversationInputsChange = useCallback((newInputs: Record<string, any>) => {
newConversationInputsRef.current = newInputs
setNewConversationInputs(newInputs)
}, [])
const inputsForms = useMemo(() => {
return (appParams?.user_input_form || []).filter((item: any) => !item.external_data_tool).map((item: any) => {
if (item.paragraph) {
let value = initInputs[item.paragraph.variable]
if (value && item.paragraph.max_length && value.length > item.paragraph.max_length)
value = value.slice(0, item.paragraph.max_length)
return {
...item.paragraph,
default: value || item.default || item.paragraph.default,
type: 'paragraph',
}
}
if (item.number) {
const convertedNumber = Number(initInputs[item.number.variable])
return {
...item.number,
default: convertedNumber || item.default || item.number.default,
type: 'number',
}
}
if (item.checkbox) {
const preset = initInputs[item.checkbox.variable] === true
return {
...item.checkbox,
default: preset || item.default || item.checkbox.default,
type: 'checkbox',
}
}
if (item.select) {
const isInputInOptions = item.select.options.includes(initInputs[item.select.variable])
return {
...item.select,
default: (isInputInOptions ? initInputs[item.select.variable] : undefined) || item.select.default,
type: 'select',
}
}
if (item['file-list']) {
return {
...item['file-list'],
type: 'file-list',
}
}
if (item.file) {
return {
...item.file,
type: 'file',
}
}
if (item.json_object) {
return {
...item.json_object,
type: 'json_object',
}
}
let value = initInputs[item['text-input'].variable]
if (value && item['text-input'].max_length && value.length > item['text-input'].max_length)
value = value.slice(0, item['text-input'].max_length)
return {
...item['text-input'],
default: value || item.default || item['text-input'].default,
type: 'text-input',
}
})
}, [initInputs, appParams])
const allInputsHidden = useMemo(() => {
return inputsForms.length > 0 && inputsForms.every(item => item.hide === true)
}, [inputsForms])
useEffect(() => {
// init inputs from url params
(async () => {
const inputs = await getRawInputsFromUrlParams()
const userVariables = await getRawUserVariablesFromUrlParams()
setInitInputs(inputs)
setInitUserVariables(userVariables)
})()
}, [])
useEffect(() => {
const conversationInputs: Record<string, any> = {}
inputsForms.forEach((item: any) => {
conversationInputs[item.variable] = item.default || null
})
handleNewConversationInputsChange(conversationInputs)
}, [handleNewConversationInputsChange, inputsForms])
const { data: newConversation } = useSWR(newConversationId ? [isInstalledApp, appId, newConversationId] : null, () => generationConversationName(isInstalledApp, appId, newConversationId), { revalidateOnFocus: false })
const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([])
useEffect(() => {
if (appConversationData?.data && !appConversationDataLoading)
setOriginConversationList(appConversationData?.data)
}, [appConversationData, appConversationDataLoading])
const conversationList = useMemo(() => {
const data = originConversationList.slice()
if (showNewConversationItemInList && data[0]?.id !== '') {
data.unshift({
id: '',
name: t('share.chat.newChatDefaultName'),
inputs: {},
introduction: '',
})
}
return data
}, [originConversationList, showNewConversationItemInList, t])
useEffect(() => {
if (newConversation) {
setOriginConversationList(produce((draft) => {
const index = draft.findIndex(item => item.id === newConversation.id)
if (index > -1)
draft[index] = newConversation
else
draft.unshift(newConversation)
}))
}
}, [newConversation])
const currentConversationItem = useMemo(() => {
let conversationItem = conversationList.find(item => item.id === currentConversationId)
if (!conversationItem && pinnedConversationList.length)
conversationItem = pinnedConversationList.find(item => item.id === currentConversationId)
return conversationItem
}, [conversationList, currentConversationId, pinnedConversationList])
const currentConversationLatestInputs = useMemo(() => {
if (!currentConversationId || !appChatListData?.data.length)
return newConversationInputsRef.current || {}
return appChatListData.data.slice().pop().inputs || {}
}, [appChatListData, currentConversationId])
const [currentConversationInputs, setCurrentConversationInputs] = useState<Record<string, any>>(currentConversationLatestInputs || {})
useEffect(() => {
if (currentConversationItem)
setCurrentConversationInputs(currentConversationLatestInputs || {})
}, [currentConversationItem, currentConversationLatestInputs])
const { notify } = useToastContext()
const checkInputsRequired = useCallback((silent?: boolean) => {
if (allInputsHidden)
return true
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox)
if (requiredVars.length) {
requiredVars.forEach(({ variable, label, type }) => {
if (hasEmptyInput)
return
if (fileIsUploading)
return
if (!newConversationInputsRef.current[variable] && !silent)
hasEmptyInput = label as string
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) {
const files = newConversationInputsRef.current[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
}, [inputsForms, notify, t, allInputsHidden])
const handleStartChat = useCallback((callback: any) => {
if (checkInputsRequired()) {
setShowNewConversationItemInList(true)
callback?.()
}
}, [setShowNewConversationItemInList, checkInputsRequired])
const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: noop })
const handleChangeConversation = useCallback((conversationId: string) => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
handleConversationIdInfoChange(conversationId)
if (conversationId)
setClearChatList(false)
}, [handleConversationIdInfoChange, setClearChatList])
const handleNewConversation = useCallback(async () => {
currentChatInstanceRef.current.handleStop()
setShowNewConversationItemInList(true)
handleChangeConversation('')
const conversationInputs: Record<string, any> = {}
inputsForms.forEach((item: any) => {
conversationInputs[item.variable] = item.default || null
})
handleNewConversationInputsChange(conversationInputs)
setClearChatList(true)
}, [handleChangeConversation, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList, inputsForms])
const handleUpdateConversationList = useCallback(() => {
mutateAppConversationData()
mutateAppPinnedConversationData()
}, [mutateAppConversationData, mutateAppPinnedConversationData])
const handlePinConversation = useCallback(async (conversationId: string) => {
await pinConversation(isInstalledApp, appId, conversationId)
notify({ type: 'success', message: t('common.api.success') })
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList])
const handleUnpinConversation = useCallback(async (conversationId: string) => {
await unpinConversation(isInstalledApp, appId, conversationId)
notify({ type: 'success', message: t('common.api.success') })
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList])
const [conversationDeleting, setConversationDeleting] = useState(false)
const handleDeleteConversation = useCallback(async (
conversationId: string,
{
onSuccess,
}: Callback,
) => {
if (conversationDeleting)
return
try {
setConversationDeleting(true)
await delConversation(isInstalledApp, appId, conversationId)
notify({ type: 'success', message: t('common.api.success') })
onSuccess()
}
finally {
setConversationDeleting(false)
}
if (conversationId === currentConversationId)
handleNewConversation()
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList, handleNewConversation, currentConversationId, conversationDeleting])
const [conversationRenaming, setConversationRenaming] = useState(false)
const handleRenameConversation = useCallback(async (
conversationId: string,
newName: string,
{
onSuccess,
}: Callback,
) => {
if (conversationRenaming)
return
if (!newName.trim()) {
notify({
type: 'error',
message: t('common.chat.conversationNameCanNotEmpty'),
})
return
}
setConversationRenaming(true)
try {
await renameConversation(isInstalledApp, appId, conversationId, newName)
notify({
type: 'success',
message: t('common.actionMsg.modifiedSuccessfully'),
})
setOriginConversationList(produce((draft) => {
const index = originConversationList.findIndex(item => item.id === conversationId)
const item = draft[index]
draft[index] = {
...item,
name: newName,
}
}))
onSuccess()
}
finally {
setConversationRenaming(false)
}
}, [isInstalledApp, appId, notify, t, conversationRenaming, originConversationList])
const handleNewConversationCompleted = useCallback((newConversationId: string) => {
setNewConversationId(newConversationId)
handleConversationIdInfoChange(newConversationId)
setShowNewConversationItemInList(false)
mutateAppConversationData()
}, [mutateAppConversationData, handleConversationIdInfoChange])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.success') })
}, [isInstalledApp, appId, t, notify])
return {
isInstalledApp,
appId,
currentConversationId,
currentConversationItem,
handleConversationIdInfoChange,
appData,
appParams: appParams || {} as ChatConfig,
appMeta,
appPinnedConversationData,
appConversationData,
appConversationDataLoading,
appChatListData,
appChatListDataLoading,
appPrevChatTree,
pinnedConversationList,
conversationList,
setShowNewConversationItemInList,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
conversationDeleting,
handleDeleteConversation,
conversationRenaming,
handleRenameConversation,
handleNewConversationCompleted,
newConversationId,
chatShouldReloadKey,
handleFeedback,
currentChatInstanceRef,
sidebarCollapseState,
handleSidebarCollapse,
clearChatList,
setClearChatList,
isResponding,
setIsResponding,
currentConversationInputs,
setCurrentConversationInputs,
allInputsHidden,
initUserVariables,
}
}

View File

@@ -0,0 +1,209 @@
'use client'
import type { FC } from 'react'
import {
useEffect,
useState,
} from 'react'
import { useThemeContext } from '../embedded-chatbot/theme/theme-context'
import {
ChatWithHistoryContext,
useChatWithHistoryContext,
} from './context'
import { useChatWithHistory } from './hooks'
import Sidebar from './sidebar'
import Header from './header'
import HeaderInMobile from './header-in-mobile'
import ChatWrapper from './chat-wrapper'
import type { InstalledApp } from '@/models/explore'
import Loading from '@/app/components/base/loading'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import cn from '@/utils/classnames'
import useDocumentTitle from '@/hooks/use-document-title'
type ChatWithHistoryProps = {
className?: string
}
const ChatWithHistory: FC<ChatWithHistoryProps> = ({
className,
}) => {
const {
appData,
appChatListDataLoading,
chatShouldReloadKey,
isMobile,
themeBuilder,
sidebarCollapseState,
} = useChatWithHistoryContext()
const isSidebarCollapsed = sidebarCollapseState
const customConfig = appData?.custom_config
const site = appData?.site
const [showSidePanel, setShowSidePanel] = useState(false)
useEffect(() => {
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
}, [site, customConfig, themeBuilder])
useEffect(() => {
if (!isSidebarCollapsed)
setShowSidePanel(false)
}, [isSidebarCollapsed])
useDocumentTitle(site?.title || 'Chat')
return (
<div className={cn(
'flex h-full bg-background-default-burn',
isMobile && 'flex-col',
className,
)}>
{!isMobile && (
<div className={cn(
'flex w-[236px] flex-col p-1 pr-0 transition-all duration-200 ease-in-out',
isSidebarCollapsed && 'w-0 overflow-hidden !p-0',
)}>
<Sidebar />
</div>
)}
{isMobile && (
<HeaderInMobile />
)}
<div className={cn('relative grow p-2', isMobile && 'h-[calc(100%_-_56px)] p-0')}>
{isSidebarCollapsed && (
<div
className={cn(
'absolute top-0 z-20 flex h-full w-[256px] flex-col p-2 transition-all duration-500 ease-in-out',
showSidePanel ? 'left-0' : 'left-[-248px]',
)}
onMouseEnter={() => setShowSidePanel(true)}
onMouseLeave={() => setShowSidePanel(false)}
>
<Sidebar isPanel panelVisible={showSidePanel} />
</div>
)}
<div className={cn('flex h-full flex-col overflow-hidden border-[0,5px] border-components-panel-border-subtle bg-chatbot-bg', isMobile ? 'rounded-t-2xl' : 'rounded-2xl')}>
{!isMobile && <Header />}
{appChatListDataLoading && (
<Loading type='app' />
)}
{!appChatListDataLoading && (
<ChatWrapper key={chatShouldReloadKey} />
)}
</div>
</div>
</div>
)
}
export type ChatWithHistoryWrapProps = {
installedAppInfo?: InstalledApp
className?: string
}
const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
installedAppInfo,
className,
}) => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const themeBuilder = useThemeContext()
const {
appData,
appParams,
appMeta,
appChatListDataLoading,
currentConversationId,
currentConversationItem,
appPrevChatTree,
pinnedConversationList,
conversationList,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
handleDeleteConversation,
conversationRenaming,
handleRenameConversation,
handleNewConversationCompleted,
chatShouldReloadKey,
isInstalledApp,
appId,
handleFeedback,
currentChatInstanceRef,
sidebarCollapseState,
handleSidebarCollapse,
clearChatList,
setClearChatList,
isResponding,
setIsResponding,
currentConversationInputs,
setCurrentConversationInputs,
allInputsHidden,
initUserVariables,
} = useChatWithHistory(installedAppInfo)
return (
<ChatWithHistoryContext.Provider value={{
appData,
appParams,
appMeta,
appChatListDataLoading,
currentConversationId,
currentConversationItem,
appPrevChatTree,
pinnedConversationList,
conversationList,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
handleDeleteConversation,
conversationRenaming,
handleRenameConversation,
handleNewConversationCompleted,
chatShouldReloadKey,
isMobile,
isInstalledApp,
appId,
handleFeedback,
currentChatInstanceRef,
themeBuilder,
sidebarCollapseState,
handleSidebarCollapse,
clearChatList,
setClearChatList,
isResponding,
setIsResponding,
currentConversationInputs,
setCurrentConversationInputs,
allInputsHidden,
initUserVariables,
}}>
<ChatWithHistory className={className} />
</ChatWithHistoryContext.Provider>
)
}
const ChatWithHistoryWrapWithCheckToken: FC<ChatWithHistoryWrapProps> = ({
installedAppInfo,
className,
}) => {
return (
<ChatWithHistoryWrap
installedAppInfo={installedAppInfo}
className={className}
/>
)
}
export default ChatWithHistoryWrapWithCheckToken

View File

@@ -0,0 +1,142 @@
import React, { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useChatWithHistoryContext } from '../context'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { PortalSelect } from '@/app/components/base/select'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { InputVarType } from '@/app/components/workflow/types'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
type Props = {
showTip?: boolean
}
const InputsFormContent = ({ showTip }: Props) => {
const { t } = useTranslation()
const {
appParams,
inputsForms,
currentConversationId,
currentConversationInputs,
setCurrentConversationInputs,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
} = useChatWithHistoryContext()
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputs
const handleFormChange = useCallback((variable: string, value: any) => {
setCurrentConversationInputs({
...currentConversationInputs,
[variable]: value,
})
handleNewConversationInputsChange({
...newConversationInputsRef.current,
[variable]: value,
})
}, [newConversationInputsRef, handleNewConversationInputsChange, currentConversationInputs, setCurrentConversationInputs])
const visibleInputsForms = inputsForms.filter(form => form.hide !== true)
return (
<div className='space-y-4'>
{visibleInputsForms.map(form => (
<div key={form.variable} className='space-y-1'>
{form.type !== InputVarType.checkbox && (
<div className='flex h-6 items-center gap-1'>
<div className='system-md-semibold text-text-secondary'>{form.label}</div>
{!form.required && (
<div className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</div>
)}
</div>
)}
{form.type === InputVarType.textInput && (
<Input
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
/>
)}
{form.type === InputVarType.number && (
<Input
type='number'
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
/>
)}
{form.type === InputVarType.paragraph && (
<Textarea
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
/>
)}
{form.type === InputVarType.checkbox && (
<BoolInput
name={form.label}
value={!!inputsFormValue?.[form.variable]}
required={form.required}
onChange={value => handleFormChange(form.variable, value)}
/>
)}
{form.type === InputVarType.select && (
<PortalSelect
popupClassName='w-[200px]'
value={inputsFormValue?.[form.variable] ?? form.default ?? ''}
items={form.options.map((option: string) => ({ value: option, name: option }))}
onSelect={item => handleFormChange(form.variable, item.value as string)}
placeholder={form.label}
/>
)}
{form.type === InputVarType.singleFile && (
<FileUploaderInAttachmentWrapper
value={inputsFormValue?.[form.variable] ? [inputsFormValue?.[form.variable]] : []}
onChange={files => handleFormChange(form.variable, files[0])}
fileConfig={{
allowed_file_types: form.allowed_file_types,
allowed_file_extensions: form.allowed_file_extensions,
allowed_file_upload_methods: form.allowed_file_upload_methods,
number_limits: 1,
fileUploadConfig: (appParams as any).system_parameters,
}}
/>
)}
{form.type === InputVarType.multiFiles && (
<FileUploaderInAttachmentWrapper
value={inputsFormValue?.[form.variable] || []}
onChange={files => handleFormChange(form.variable, files)}
fileConfig={{
allowed_file_types: form.allowed_file_types,
allowed_file_extensions: form.allowed_file_extensions,
allowed_file_upload_methods: form.allowed_file_upload_methods,
number_limits: form.max_length,
fileUploadConfig: (appParams as any).system_parameters,
}}
/>
)}
{form.type === InputVarType.jsonObject && (
<CodeEditor
language={CodeLanguage.json}
value={inputsFormValue?.[form.variable] || ''}
onChange={v => handleFormChange(form.variable, v)}
noWrapper
className='bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1'
placeholder={
<div className='whitespace-pre'>{form.json_schema}</div>
}
/>
)}
</div>
))}
{showTip && (
<div className='system-xs-regular text-text-tertiary'>{t('share.chat.chatFormTip')}</div>
)}
</div>
)
}
export default memo(InputsFormContent)

View File

@@ -0,0 +1,84 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
import { useChatWithHistoryContext } from '../context'
import cn from '@/utils/classnames'
type Props = {
collapsed: boolean
setCollapsed: (collapsed: boolean) => void
}
const InputsFormNode = ({
collapsed,
setCollapsed,
}: Props) => {
const { t } = useTranslation()
const {
isMobile,
currentConversationId,
handleStartChat,
allInputsHidden,
themeBuilder,
inputsForms,
} = useChatWithHistoryContext()
if (allInputsHidden || inputsForms.length === 0)
return null
return (
<div className={cn('flex flex-col items-center px-4 pt-6', isMobile && 'pt-4')}>
<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',
)}>
<div className={cn(
'flex items-center gap-3 rounded-t-2xl px-6 py-4',
!collapsed && 'border-b border-divider-subtle',
isMobile && 'px-4 py-3',
)}>
<Message3Fill className='h-6 w-6 shrink-0' />
<div className='system-xl-semibold grow text-text-secondary'>{t('share.chat.chatSettingsTitle')}</div>
{collapsed && (
<Button className='uppercase text-text-tertiary' size='small' variant='ghost' onClick={() => setCollapsed(false)}>{t('common.operation.edit')}</Button>
)}
{!collapsed && currentConversationId && (
<Button className='uppercase text-text-tertiary' size='small' variant='ghost' onClick={() => setCollapsed(true)}>{t('common.operation.close')}</Button>
)}
</div>
{!collapsed && (
<div className={cn('p-6', isMobile && 'p-4')}>
<InputsFormContent />
</div>
)}
{!collapsed && !currentConversationId && (
<div className={cn('p-6', isMobile && 'p-4')}>
<Button
variant='primary'
className='w-full'
onClick={() => handleStartChat(() => setCollapsed(true))}
style={
themeBuilder?.theme
? {
backgroundColor: themeBuilder?.theme.primaryColor,
}
: {}
}
>{t('share.chat.startChat')}</Button>
</div>
)}
</div>
{collapsed && (
<div className='flex w-full max-w-[720px] items-center py-4'>
<Divider bgStyle='gradient' className='h-px basis-1/2 rotate-180' />
<Divider bgStyle='gradient' className='h-px basis-1/2' />
</div>
)}
</div>
)
}
export default InputsFormNode

View File

@@ -0,0 +1,48 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiChatSettingsLine,
} from '@remixicon/react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
const ViewFormDropdown = () => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: 4,
}}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
>
<ActionButton size='l' state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
<RiChatSettingsLine className='h-[18px] w-[18px]' />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<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' />
<div className='system-xl-semibold grow text-text-secondary'>{t('share.chat.chatSettingsTitle')}</div>
</div>
<div className='p-6'>
<InputsFormContent />
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ViewFormDropdown

View File

@@ -0,0 +1,188 @@
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiEditBoxLine,
RiExpandRightLine,
RiLayoutLeft2Line,
} from '@remixicon/react'
import { useChatWithHistoryContext } from '../context'
import AppIcon from '@/app/components/base/app-icon'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
import List from '@/app/components/base/chat/chat-with-history/sidebar/list'
import MenuDropdown from '@/app/components/share/text-generation/menu-dropdown'
import Confirm from '@/app/components/base/confirm'
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import type { ConversationItem } from '@/models/share'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
type Props = {
isPanel?: boolean
panelVisible?: boolean
}
const Sidebar = ({ isPanel, panelVisible }: Props) => {
const { t } = useTranslation()
const {
isInstalledApp,
appData,
handleNewConversation,
pinnedConversationList,
conversationList,
currentConversationId,
handleChangeConversation,
handlePinConversation,
handleUnpinConversation,
conversationRenaming,
handleRenameConversation,
handleDeleteConversation,
sidebarCollapseState,
handleSidebarCollapse,
isMobile,
isResponding,
} = useChatWithHistoryContext()
const isSidebarCollapsed = sidebarCollapseState
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
const handleOperate = useCallback((type: string, item: ConversationItem) => {
if (type === 'pin')
handlePinConversation(item.id)
if (type === 'unpin')
handleUnpinConversation(item.id)
if (type === 'delete')
setShowConfirm(item)
if (type === 'rename')
setShowRename(item)
}, [handlePinConversation, handleUnpinConversation])
const handleCancelConfirm = useCallback(() => {
setShowConfirm(null)
}, [])
const handleDelete = useCallback(() => {
if (showConfirm)
handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm })
}, [showConfirm, handleDeleteConversation, handleCancelConfirm])
const handleCancelRename = useCallback(() => {
setShowRename(null)
}, [])
const handleRename = useCallback((newName: string) => {
if (showRename)
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
}, [showRename, handleRenameConversation, handleCancelRename])
return (
<div className={cn(
'flex w-full grow flex-col',
isPanel && 'rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-bg shadow-lg',
)}>
<div className={cn(
'flex shrink-0 items-center gap-3 p-3 pr-2',
)}>
<div className='shrink-0'>
<AppIcon
size='large'
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background={appData?.site.icon_background}
imageUrl={appData?.site.icon_url}
/>
</div>
<div className={cn('system-md-semibold grow truncate text-text-secondary')}>{appData?.site.title}</div>
{!isMobile && isSidebarCollapsed && (
<ActionButton size='l' onClick={() => handleSidebarCollapse(false)}>
<RiExpandRightLine className='h-[18px] w-[18px]' />
</ActionButton>
)}
{!isMobile && !isSidebarCollapsed && (
<ActionButton size='l' onClick={() => handleSidebarCollapse(true)}>
<RiLayoutLeft2Line className='h-[18px] w-[18px]' />
</ActionButton>
)}
</div>
<div className='shrink-0 px-3 py-4'>
<Button variant='secondary-accent' disabled={isResponding} className='w-full justify-center' onClick={handleNewConversation}>
<RiEditBoxLine className='mr-1 h-4 w-4' />
{t('share.chat.newChat')}
</Button>
</div>
<div className='h-0 grow space-y-2 overflow-y-auto px-3 pt-4'>
{/* pinned list */}
{!!pinnedConversationList.length && (
<div className='mb-4'>
<List
isPin
title={t('share.chat.pinnedTitle') || ''}
list={pinnedConversationList}
onChangeConversation={handleChangeConversation}
onOperate={handleOperate}
currentConversationId={currentConversationId}
/>
</div>
)}
{!!conversationList.length && (
<List
title={(pinnedConversationList.length && t('share.chat.unpinnedTitle')) || ''}
list={conversationList}
onChangeConversation={handleChangeConversation}
onOperate={handleOperate}
currentConversationId={currentConversationId}
/>
)}
</div>
<div className='flex shrink-0 items-center justify-between p-3'>
<MenuDropdown
hideLogout={isInstalledApp}
placement='top-start'
data={appData?.site}
forceClose={isPanel && !panelVisible}
/>
{/* powered by */}
<div className='shrink-0'>
{!appData?.custom_config?.remove_webapp_brand && (
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
: appData?.custom_config?.replace_webapp_logo
? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
: <DifyLogo size='small' />
}
</div>
)}
</div>
{!!showConfirm && (
<Confirm
title={t('share.chat.deleteConversation.title')}
content={t('share.chat.deleteConversation.content') || ''}
isShow
onCancel={handleCancelConfirm}
onConfirm={handleDelete}
/>
)}
{showRename && (
<RenameModal
isShow
onClose={handleCancelRename}
saveLoading={conversationRenaming}
name={showRename?.name || ''}
onSave={handleRename}
/>
)}
</div>
</div>
)
}
export default Sidebar

View File

@@ -0,0 +1,58 @@
import type { FC } from 'react'
import {
memo,
useRef,
} from 'react'
import { useHover } from 'ahooks'
import type { ConversationItem } from '@/models/share'
import Operation from '@/app/components/base/chat/chat-with-history/sidebar/operation'
import cn from '@/utils/classnames'
type ItemProps = {
isPin?: boolean
item: ConversationItem
onOperate: (type: string, item: ConversationItem) => void
onChangeConversation: (conversationId: string) => void
currentConversationId: string
}
const Item: FC<ItemProps> = ({
isPin,
item,
onOperate,
onChangeConversation,
currentConversationId,
}) => {
const ref = useRef(null)
const isHovering = useHover(ref)
const isSelected = currentConversationId === item.id
return (
<div
ref={ref}
key={item.id}
className={cn(
'system-sm-medium group flex cursor-pointer rounded-lg p-1 pl-3 text-components-menu-item-text hover:bg-state-base-hover',
isSelected && 'bg-state-accent-active text-text-accent hover:bg-state-accent-active',
)}
onClick={() => onChangeConversation(item.id)}
>
<div className='grow truncate p-1 pl-0' title={item.name}>{item.name}</div>
{item.id !== '' && (
<div className='shrink-0' onClick={e => e.stopPropagation()}>
<Operation
isActive={isSelected}
isPinned={!!isPin}
isItemHovering={isHovering}
togglePin={() => onOperate(isPin ? 'unpin' : 'pin', item)}
isShowDelete
isShowRenameConversation
onRenameConversation={() => onOperate('rename', item)}
onDelete={() => onOperate('delete', item)}
/>
</div>
)}
</div>
)
}
export default memo(Item)

View File

@@ -0,0 +1,40 @@
import type { FC } from 'react'
import Item from './item'
import type { ConversationItem } from '@/models/share'
type ListProps = {
isPin?: boolean
title?: string
list: ConversationItem[]
onOperate: (type: string, item: ConversationItem) => void
onChangeConversation: (conversationId: string) => void
currentConversationId: string
}
const List: FC<ListProps> = ({
isPin,
title,
list,
onOperate,
onChangeConversation,
currentConversationId,
}) => {
return (
<div className='space-y-0.5'>
{title && (
<div className='system-xs-medium-uppercase px-3 pb-1 pt-2 text-text-tertiary'>{title}</div>
)}
{list.map(item => (
<Item
key={item.id}
isPin={isPin}
item={item}
onOperate={onOperate}
onChangeConversation={onChangeConversation}
currentConversationId={currentConversationId}
/>
))}
</div>
)
}
export default List

View File

@@ -0,0 +1,101 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import {
RiDeleteBinLine,
RiEditLine,
RiMoreFill,
RiPushpinLine,
RiUnpinLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import cn from '@/utils/classnames'
type Props = {
isActive?: boolean
isItemHovering?: boolean
isPinned: boolean
isShowRenameConversation?: boolean
onRenameConversation?: () => void
isShowDelete: boolean
togglePin: () => void
onDelete: () => void
}
const Operation: FC<Props> = ({
isActive,
isItemHovering,
isPinned,
togglePin,
isShowRenameConversation,
onRenameConversation,
isShowDelete,
onDelete,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef(null)
const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false)
useEffect(() => {
if (!isItemHovering && !isHovering)
setOpen(false)
}, [isItemHovering, isHovering])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={4}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
>
<ActionButton
className={cn((isItemHovering || open) ? 'opacity-100' : 'opacity-0')}
state={
isActive
? ActionButtonState.Active
: open
? ActionButtonState.Hover
: ActionButtonState.Default
}
>
<RiMoreFill className='h-4 w-4' />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div
ref={ref}
className={'min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm'}
onMouseEnter={setIsHovering}
onMouseLeave={setNotHovering}
onClick={(e) => {
e.stopPropagation()
}}
>
<div className={cn('system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover')} onClick={togglePin}>
{isPinned && <RiUnpinLine className='h-4 w-4 shrink-0 text-text-tertiary' />}
{!isPinned && <RiPushpinLine className='h-4 w-4 shrink-0 text-text-tertiary' />}
<span className='grow'>{isPinned ? t('explore.sidebar.action.unpin') : t('explore.sidebar.action.pin')}</span>
</div>
{isShowRenameConversation && (
<div className={cn('system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover')} onClick={onRenameConversation}>
<RiEditLine className='h-4 w-4 shrink-0 text-text-tertiary' />
<span className='grow'>{t('explore.sidebar.action.rename')}</span>
</div>
)}
{isShowDelete && (
<div className={cn('system-md-regular group flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive')} onClick={onDelete} >
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary group-hover:text-text-destructive')} />
<span className='grow'>{t('explore.sidebar.action.delete')}</span>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(Operation)

View File

@@ -0,0 +1,47 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
export type IRenameModalProps = {
isShow: boolean
saveLoading: boolean
name: string
onClose: () => void
onSave: (name: string) => void
}
const RenameModal: FC<IRenameModalProps> = ({
isShow,
saveLoading,
name,
onClose,
onSave,
}) => {
const { t } = useTranslation()
const [tempName, setTempName] = useState(name)
return (
<Modal
title={t('common.chat.renameConversation')}
isShow={isShow}
onClose={onClose}
>
<div className={'mt-6 text-sm font-medium leading-[21px] text-text-primary'}>{t('common.chat.conversationName')}</div>
<Input className='mt-2 h-10 w-full'
value={tempName}
onChange={e => setTempName(e.target.value)}
placeholder={t('common.chat.conversationNamePlaceholder') || ''}
/>
<div className='mt-10 flex justify-end'>
<Button className='mr-2 shrink-0' onClick={onClose}>{t('common.operation.cancel')}</Button>
<Button variant='primary' className='shrink-0' onClick={() => onSave(tempName)} loading={saveLoading}>{t('common.operation.save')}</Button>
</div>
</Modal>
)
}
export default React.memo(RenameModal)

View File

@@ -0,0 +1,61 @@
export const markdownContent = `
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
# Basic markdown content.
Should support **bold**, *italic*, and ~~strikethrough~~.
Should support [links](https://www.google.com).
Should support inline \`code\` blocks.
# Number list
1. First item
2. Second item
3. Third item
# Bullet list
- First item
- Second item
- Third item
# Link
[Google](https://www.google.com)
# Image
![Alt text](https://picsum.photos/200/300)
# Table
| Column 1 | Column 2 | Column 3 |
| -------- | -------- | -------- |
| Cell 1 | Cell 2 | Cell 3 |
| Cell 4 | Cell 5 | Cell 6 |
| Cell 7 | Cell 8 | Cell 9 |
# Code
\`\`\`JavaScript
const code = "code"
\`\`\`
# Blockquote
> This is a blockquote.
# Horizontal rule
---
`

View File

@@ -0,0 +1,27 @@
export const markdownContentSVG = `
\`\`\`svg
<svg width="400" height="600" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#F0F8FF"/>
<text x="50%" y="60" font-family="楷体" font-size="32" fill="#4682B4" text-anchor="middle">创意 Logo 设计</text>
<line x1="50" y1="80" x2="350" y2="80" stroke="#B0C4DE" stroke-width="2"/>
<text x="50%" y="120" font-family="Arial" font-size="24" fill="#708090" text-anchor="middle">科研</text>
<text x="50%" y="150" font-family="MS Mincho" font-size="20" fill="#778899" text-anchor="middle">科学研究</text>
<text x="50%" y="200" font-family="汇文明朝体" font-size="18" fill="#696969" text-anchor="middle">
<tspan x="50%" dy="25">探索未知的灯塔,</tspan>
<tspan x="50%" dy="25">照亮人类前进的道路。</tspan>
<tspan x="50%" dy="25">科研,是永不熄灭的好奇心,</tspan>
<tspan x="50%" dy="25">也是推动世界进步的引擎。</tspan>
</text>
<circle cx="200" cy="400" r="80" fill="none" stroke="#4169E1" stroke-width="3"/>
<line x1="200" y1="320" x2="200" y2="480" stroke="#4169E1" stroke-width="3"/>
<line x1="120" y1="400" x2="280" y2="400" stroke="#4169E1" stroke-width="3"/>
<text x="50%" y="550" font-family="微软雅黑" font-size="16" fill="#1E90FF" text-anchor="middle">探索 • 创新 • 进步</text>
</svg>
\`\`\`
`

View File

@@ -0,0 +1,138 @@
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
export const mockedWorkflowProcess = {
status: WorkflowRunningStatus.Succeeded,
resultText: 'Hello, how can I assist you today?',
tracing: [
{
extras: {},
id: 'f6337dc9-e280-4915-965f-10b0552dd917',
node_id: '1724232060789',
node_type: 'start',
title: 'Start',
index: 1,
predecessor_node_id: null,
inputs: {
'sys.query': 'hi',
'sys.files': [],
'sys.conversation_id': '92ce0a3e-8f15-43d1-b31d-32716c4b10a7',
'sys.user_id': 'fbff43f9-d5a4-4e85-b63b-d3a91d806c6f',
'sys.dialogue_count': 1,
'sys.app_id': 'b2e8906a-aad3-43a0-9ace-0e44cc7315e1',
'sys.workflow_id': '70004abe-561f-418b-b9e8-8c957ce55140',
'sys.workflow_run_id': '69db9267-aaee-42e1-9581-dbfb67e8eeb5',
},
process_data: null,
outputs: {
'sys.query': 'hi',
'sys.files': [],
'sys.conversation_id': '92ce0a3e-8f15-43d1-b31d-32716c4b10a7',
'sys.user_id': 'fbff43f9-d5a4-4e85-b63b-d3a91d806c6f',
'sys.dialogue_count': 1,
'sys.app_id': 'b2e8906a-aad3-43a0-9ace-0e44cc7315e1',
'sys.workflow_id': '70004abe-561f-418b-b9e8-8c957ce55140',
'sys.workflow_run_id': '69db9267-aaee-42e1-9581-dbfb67e8eeb5',
},
status: 'succeeded',
error: null,
elapsed_time: 0.035744,
execution_metadata: null,
created_at: 1728980002,
finished_at: 1728980002,
files: [],
parallel_id: null,
parallel_start_node_id: null,
parent_parallel_id: null,
parent_parallel_start_node_id: null,
iteration_id: null,
loop_id: null,
},
{
extras: {},
id: '92204d8d-4198-4c46-aa02-c2754b11dec9',
node_id: 'llm',
node_type: 'llm',
title: 'LLM',
index: 2,
predecessor_node_id: '1724232060789',
inputs: null,
process_data: {
model_mode: 'chat',
prompts: [
{
role: 'system',
text: 'hi',
files: [],
},
{
role: 'user',
text: 'hi',
files: [],
},
],
model_provider: 'openai',
model_name: 'gpt-4o-mini',
},
outputs: {
text: 'Hello! How can I assist you today?',
usage: {
prompt_tokens: 13,
prompt_unit_price: '0.15',
prompt_price_unit: '0.000001',
prompt_price: '0.0000020',
completion_tokens: 9,
completion_unit_price: '0.60',
completion_price_unit: '0.000001',
completion_price: '0.0000054',
total_tokens: 22,
total_price: '0.0000074',
currency: 'USD',
latency: 1.8902503330027685,
},
finish_reason: 'stop',
},
status: 'succeeded',
error: null,
elapsed_time: 5.089409,
execution_metadata: {
total_tokens: 22,
total_price: '0.0000074',
currency: 'USD',
},
created_at: 1728980002,
finished_at: 1728980007,
files: [],
parallel_id: null,
parallel_start_node_id: null,
parent_parallel_id: null,
parent_parallel_start_node_id: null,
iteration_id: null,
loop_id: null,
},
{
extras: {},
id: '7149bac6-60f9-4e06-a5ed-1d9d3764c06b',
node_id: 'answer',
node_type: 'answer',
title: 'Answer',
index: 3,
predecessor_node_id: 'llm',
inputs: null,
process_data: null,
outputs: {
answer: 'Hello! How can I assist you today?',
},
status: 'succeeded',
error: null,
elapsed_time: 0.015339,
execution_metadata: null,
created_at: 1728980007,
finished_at: 1728980007,
parallel_id: null,
parallel_start_node_id: null,
parent_parallel_id: null,
parent_parallel_start_node_id: null,
},
],
} as unknown as WorkflowProcess

View File

@@ -0,0 +1,61 @@
import type { FC } from 'react'
import { memo } from 'react'
import type {
ChatItem,
} from '../../types'
import { Markdown } from '@/app/components/base/markdown'
import Thought from '@/app/components/base/chat/chat/thought'
import { FileList } from '@/app/components/base/file-uploader'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
type AgentContentProps = {
item: ChatItem
responding?: boolean
content?: string
}
const AgentContent: FC<AgentContentProps> = ({
item,
responding,
content,
}) => {
const {
annotation,
agent_thoughts,
} = item
if (annotation?.logAnnotation)
return <Markdown content={annotation?.logAnnotation.content || ''} />
return (
<div>
{content ? <Markdown content={content} /> : agent_thoughts?.map((thought, index) => (
<div key={index} className='px-2 py-1'>
{thought.thought && (
<Markdown content={thought.thought} />
)}
{/* {item.tool} */}
{/* perhaps not use tool */}
{!!thought.tool && (
<Thought
thought={thought}
isFinished={!!thought.observation || !responding}
/>
)}
{
!!thought.message_files?.length && (
<FileList
files={getProcessedFilesFromResponse(thought.message_files.map((item: any) => ({ ...item, related_id: item.id })))}
showDeleteAction={false}
showDownloadAction={true}
canPreview={true}
/>
)
}
</div>
))}
</div>
)
}
export default memo(AgentContent)

View File

@@ -0,0 +1,31 @@
import type { FC } from 'react'
import { memo } from 'react'
import type { ChatItem } from '../../types'
import { Markdown } from '@/app/components/base/markdown'
import cn from '@/utils/classnames'
type BasicContentProps = {
item: ChatItem
}
const BasicContent: FC<BasicContentProps> = ({
item,
}) => {
const {
annotation,
content,
} = item
if (annotation?.logAnnotation)
return <Markdown content={annotation?.logAnnotation.content || ''} />
return (
<Markdown
className={cn(
item.isError && '!text-[#F04438]',
)}
content={content}
/>
)
}
export default memo(BasicContent)

View File

@@ -0,0 +1,100 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import type { ChatItem } from '../../types'
import { markdownContent } from './__mocks__/markdownContent'
import { markdownContentSVG } from './__mocks__/markdownContentSVG'
import Answer from '.'
const meta = {
title: 'Base/Other/Chat Answer',
component: Answer,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
argTypes: {
noChatInput: { control: 'boolean', description: 'If set to true, some buttons that are supposed to be shown on hover will not be displayed.' },
responding: { control: 'boolean', description: 'Indicates if the answer is being generated.' },
showPromptLog: { control: 'boolean', description: 'If set to true, the prompt log button will be shown on hover.' },
},
args: {
noChatInput: false,
responding: false,
showPromptLog: false,
},
} satisfies Meta<typeof Answer>
export default meta
type Story = StoryObj<typeof meta>
const mockedBaseChatItem = {
id: '1',
isAnswer: true,
content: 'Hello, how can I assist you today?',
} satisfies ChatItem
const mockedWorkflowProcess = {
status: WorkflowRunningStatus.Succeeded,
tracing: [],
}
export const Basic: Story = {
args: {
item: mockedBaseChatItem,
question: mockedBaseChatItem.content,
index: 0,
},
render: (args) => {
return <div className="w-full px-10 py-5">
<Answer {...args} />
</div>
},
}
export const WithWorkflowProcess: Story = {
args: {
item: {
...mockedBaseChatItem,
workflowProcess: mockedWorkflowProcess,
},
question: mockedBaseChatItem.content,
index: 0,
},
render: (args) => {
return <div className="w-full px-10 py-5">
<Answer {...args} />
</div>
},
}
export const WithMarkdownContent: Story = {
args: {
item: {
...mockedBaseChatItem,
content: markdownContent,
},
question: mockedBaseChatItem.content,
index: 0,
},
render: (args) => {
return <div className="w-full px-10 py-5">
<Answer {...args} />
</div>
},
}
export const WithMarkdownSVG: Story = {
args: {
item: {
...mockedBaseChatItem,
content: markdownContentSVG,
},
question: mockedBaseChatItem.content,
index: 0,
},
render: (args) => {
return <div className="w-full px-10 py-5">
<Answer {...args} />
</div>
},
}

View File

@@ -0,0 +1,233 @@
import type {
FC,
ReactNode,
} from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
ChatConfig,
ChatItem,
} from '../../types'
import Operation from './operation'
import AgentContent from './agent-content'
import BasicContent from './basic-content'
import SuggestedQuestions from './suggested-questions'
import More from './more'
import WorkflowProcessItem from './workflow-process'
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
import Citation from '@/app/components/base/chat/chat/citation'
import { EditTitle } from '@/app/components/app/annotation/edit-annotation-modal/edit-item'
import type { AppData } from '@/models/share'
import AnswerIcon from '@/app/components/base/answer-icon'
import cn from '@/utils/classnames'
import { FileList } from '@/app/components/base/file-uploader'
import ContentSwitch from '../content-switch'
type AnswerProps = {
item: ChatItem
question: string
index: number
config?: ChatConfig
answerIcon?: ReactNode
responding?: boolean
showPromptLog?: boolean
chatAnswerContainerInner?: string
hideProcessDetail?: boolean
appData?: AppData
noChatInput?: boolean
switchSibling?: (siblingMessageId: string) => void
}
const Answer: FC<AnswerProps> = ({
item,
question,
index,
config,
answerIcon,
responding,
showPromptLog,
chatAnswerContainerInner,
hideProcessDetail,
appData,
noChatInput,
switchSibling,
}) => {
const { t } = useTranslation()
const {
content,
citation,
agent_thoughts,
more,
annotation,
workflowProcess,
allFiles,
message_files,
} = item
const hasAgentThoughts = !!agent_thoughts?.length
const [containerWidth, setContainerWidth] = useState(0)
const [contentWidth, setContentWidth] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const getContainerWidth = () => {
if (containerRef.current)
setContainerWidth(containerRef.current?.clientWidth + 16)
}
useEffect(() => {
getContainerWidth()
}, [])
const getContentWidth = () => {
if (contentRef.current)
setContentWidth(contentRef.current?.clientWidth)
}
useEffect(() => {
if (!responding)
getContentWidth()
}, [responding])
// Recalculate contentWidth when content changes (e.g., SVG preview/source toggle)
useEffect(() => {
if (!containerRef.current)
return
const resizeObserver = new ResizeObserver(() => {
getContentWidth()
})
resizeObserver.observe(containerRef.current)
return () => {
resizeObserver.disconnect()
}
}, [])
const handleSwitchSibling = useCallback((direction: 'prev' | 'next') => {
if (direction === 'prev') {
if (item.prevSibling)
switchSibling?.(item.prevSibling)
}
else {
if (item.nextSibling)
switchSibling?.(item.nextSibling)
}
}, [switchSibling, item.prevSibling, item.nextSibling])
const contentIsEmpty = content.trim() === ''
return (
<div className='mb-2 flex last:mb-0'>
<div className='relative h-10 w-10 shrink-0'>
{answerIcon || <AnswerIcon />}
{responding && (
<div className='absolute left-[-3px] top-[-3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs'>
<LoadingAnim type='avatar' />
</div>
)}
</div>
<div className='chat-answer-container group ml-4 w-0 grow pb-4' ref={containerRef}>
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
<div
ref={contentRef}
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', workflowProcess && 'w-full')}
>
{
!responding && (
<Operation
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - contentWidth - 4}
contentWidth={contentWidth}
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)
}
{/** Render workflow process */}
{
workflowProcess && (
<WorkflowProcessItem
data={workflowProcess}
item={item}
hideProcessDetail={hideProcessDetail}
readonly={hideProcessDetail && appData ? !appData.site.show_workflow_steps : undefined}
/>
)
}
{
responding && contentIsEmpty && !hasAgentThoughts && (
<div className='flex h-5 w-6 items-center justify-center'>
<LoadingAnim type='text' />
</div>
)
}
{
!contentIsEmpty && !hasAgentThoughts && (
<BasicContent item={item} />
)
}
{
(hasAgentThoughts) && (
<AgentContent
item={item}
responding={responding}
content={content}
/>
)
}
{
!!allFiles?.length && (
<FileList
className='my-1'
files={allFiles}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
!!message_files?.length && (
<FileList
className='my-1'
files={message_files}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
annotation?.id && annotation.authorName && (
<EditTitle
className='mt-1'
title={t('appAnnotation.editBy', { author: annotation.authorName })}
/>
)
}
<SuggestedQuestions item={item} />
{
!!citation?.length && !responding && (
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
)
}
{
item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined && (
<ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}
prevDisabled={!item.prevSibling}
nextDisabled={!item.nextSibling}
switchSibling={handleSwitchSibling}
/>
)
}
</div>
</div>
<More more={more} />
</div>
</div>
)
}
export default memo(Answer)

View File

@@ -0,0 +1,46 @@
import type { FC } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import type { ChatItem } from '../../types'
import { formatNumber } from '@/utils/format'
type MoreProps = {
more: ChatItem['more']
}
const More: FC<MoreProps> = ({
more,
}) => {
const { t } = useTranslation()
return (
<div className='system-xs-regular mt-1 flex items-center text-text-quaternary opacity-0 group-hover:opacity-100'>
{
more && (
<>
<div
className='mr-2 max-w-[33.3%] shrink-0 truncate'
title={`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}
>
{`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}
</div>
<div
className='max-w-[33.3%] shrink-0 truncate'
title={`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}
>
{`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}
</div>
<div className='mx-2 shrink-0'>·</div>
<div
className='max-w-[33.3%] shrink-0 truncate'
title={more.time}
>
{more.time}
</div>
</>
)
}
</div>
)
}
export default memo(More)

View File

@@ -0,0 +1,284 @@
import type { FC } from 'react'
import {
memo,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiClipboardLine,
RiResetLeftLine,
RiThumbDownLine,
RiThumbUpLine,
} from '@remixicon/react'
import type { ChatItem } from '../../types'
import { useChatContext } from '../context'
import copy from 'copy-to-clipboard'
import Toast from '@/app/components/base/toast'
import AnnotationCtrlButton from '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button'
import EditReplyModal from '@/app/components/app/annotation/edit-annotation-modal'
import Log from '@/app/components/base/chat/chat/log'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import NewAudioButton from '@/app/components/base/new-audio-button'
import Modal from '@/app/components/base/modal/modal'
import Textarea from '@/app/components/base/textarea'
import cn from '@/utils/classnames'
type OperationProps = {
item: ChatItem
question: string
index: number
showPromptLog?: boolean
maxSize: number
contentWidth: number
hasWorkflowProcess: boolean
noChatInput?: boolean
}
const Operation: FC<OperationProps> = ({
item,
question,
index,
showPromptLog,
maxSize,
contentWidth,
hasWorkflowProcess,
noChatInput,
}) => {
const { t } = useTranslation()
const {
config,
onAnnotationAdded,
onAnnotationEdited,
onAnnotationRemoved,
onFeedback,
onRegenerate,
} = useChatContext()
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
const [isShowFeedbackModal, setIsShowFeedbackModal] = useState(false)
const [feedbackContent, setFeedbackContent] = useState('')
const {
id,
isOpeningStatement,
content: messageContent,
annotation,
feedback,
adminFeedback,
agent_thoughts,
} = item
const [localFeedback, setLocalFeedback] = useState(config?.supportAnnotation ? adminFeedback : feedback)
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
// Separate feedback types for display
const userFeedback = feedback
const content = useMemo(() => {
if (agent_thoughts?.length)
return agent_thoughts.reduce((acc, cur) => acc + cur.thought, '')
return messageContent
}, [agent_thoughts, messageContent])
const handleFeedback = async (rating: 'like' | 'dislike' | null, content?: string) => {
if (!config?.supportFeedback || !onFeedback)
return
await onFeedback?.(id, { rating, content })
setLocalFeedback({ rating })
// Update admin feedback state separately if annotation is supported
if (config?.supportAnnotation)
setAdminLocalFeedback(rating ? { rating } : undefined)
}
const handleThumbsDown = () => {
setIsShowFeedbackModal(true)
}
const handleFeedbackSubmit = async () => {
await handleFeedback('dislike', feedbackContent)
setFeedbackContent('')
setIsShowFeedbackModal(false)
}
const handleFeedbackCancel = () => {
setFeedbackContent('')
setIsShowFeedbackModal(false)
}
const operationWidth = useMemo(() => {
let width = 0
if (!isOpeningStatement)
width += 26
if (!isOpeningStatement && showPromptLog)
width += 28 + 8
if (!isOpeningStatement && config?.text_to_speech?.enabled)
width += 26
if (!isOpeningStatement && config?.supportAnnotation && config?.annotation_reply?.enabled)
width += 26
if (config?.supportFeedback && !localFeedback?.rating && onFeedback && !isOpeningStatement)
width += 60 + 8
if (config?.supportFeedback && localFeedback?.rating && onFeedback && !isOpeningStatement)
width += 28 + 8
return width
}, [isOpeningStatement, showPromptLog, config?.text_to_speech?.enabled, config?.supportAnnotation, config?.annotation_reply?.enabled, config?.supportFeedback, localFeedback?.rating, onFeedback])
const positionRight = useMemo(() => operationWidth < maxSize, [operationWidth, maxSize])
return (
<>
<div
className={cn(
'absolute flex justify-end gap-1',
hasWorkflowProcess && '-bottom-4 right-2',
!positionRight && '-bottom-4 right-2',
!hasWorkflowProcess && positionRight && '!top-[9px]',
)}
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
>
{showPromptLog && !isOpeningStatement && (
<div className='hidden group-hover:block'>
<Log logItem={item} />
</div>
)}
{!isOpeningStatement && (
<div className='ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex'>
{(config?.text_to_speech?.enabled) && (
<NewAudioButton
id={id}
value={content}
voice={config?.text_to_speech?.voice}
/>
)}
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') })
}}>
<RiClipboardLine className='h-4 w-4' />
</ActionButton>
{!noChatInput && (
<ActionButton onClick={() => onRegenerate?.(item)}>
<RiResetLeftLine className='h-4 w-4' />
</ActionButton>
)}
{(config?.supportAnnotation && config.annotation_reply?.enabled) && (
<AnnotationCtrlButton
appId={config?.appId || ''}
messageId={id}
cached={!!annotation?.id}
query={question}
answer={content}
onAdded={(id, authorName) => onAnnotationAdded?.(id, authorName, question, content, index)}
onEdit={() => setIsShowReplyModal(true)}
/>
)}
</div>
)}
{!isOpeningStatement && config?.supportFeedback && !localFeedback?.rating && onFeedback && (
<div className='ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex'>
{!localFeedback?.rating && (
<>
<ActionButton onClick={() => handleFeedback('like')}>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
<ActionButton onClick={handleThumbsDown}>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
</>
)}
</div>
)}
{!isOpeningStatement && config?.supportFeedback && onFeedback && (
<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'>
{/* User Feedback Display */}
{userFeedback?.rating && (
<div className='flex items-center'>
<span className='mr-1 text-xs text-text-tertiary'>User</span>
{userFeedback.rating === 'like' ? (
<ActionButton state={ActionButtonState.Active} title={userFeedback.content ? `User liked this response: ${userFeedback.content}` : 'User liked this response'}>
<RiThumbUpLine className='h-3 w-3' />
</ActionButton>
) : (
<ActionButton state={ActionButtonState.Destructive} title={userFeedback.content ? `User disliked this response: ${userFeedback.content}` : 'User disliked this response'}>
<RiThumbDownLine className='h-3 w-3' />
</ActionButton>
)}
</div>
)}
{/* Admin Feedback Controls */}
{config?.supportAnnotation && (
<div className='flex items-center'>
{userFeedback?.rating && <div className='mx-1 h-3 w-[0.5px] bg-components-actionbar-border' />}
{!adminLocalFeedback?.rating ? (
<>
<ActionButton onClick={() => handleFeedback('like')}>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
<ActionButton onClick={handleThumbsDown}>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
</>
) : (
<>
{adminLocalFeedback.rating === 'like' ? (
<ActionButton state={ActionButtonState.Active} onClick={() => handleFeedback(null)}>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
) : (
<ActionButton state={ActionButtonState.Destructive} onClick={() => handleFeedback(null)}>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
)}
</>
)}
</div>
)}
</div>
)}
</div>
<EditReplyModal
isShow={isShowReplyModal}
onHide={() => setIsShowReplyModal(false)}
query={question}
answer={content}
onEdited={(editedQuery, editedAnswer) => onAnnotationEdited?.(editedQuery, editedAnswer, index)}
onAdded={(annotationId, authorName, editedQuery, editedAnswer) => onAnnotationAdded?.(annotationId, authorName, editedQuery, editedAnswer, index)}
appId={config?.appId || ''}
messageId={id}
annotationId={annotation?.id || ''}
createdAt={annotation?.created_at}
onRemove={() => onAnnotationRemoved?.(index)}
/>
{isShowFeedbackModal && (
<Modal
title={t('common.feedback.title') || 'Provide Feedback'}
subTitle={t('common.feedback.subtitle') || 'Please tell us what went wrong with this response'}
onClose={handleFeedbackCancel}
onConfirm={handleFeedbackSubmit}
onCancel={handleFeedbackCancel}
confirmButtonText={t('common.operation.submit') || 'Submit'}
cancelButtonText={t('common.operation.cancel') || 'Cancel'}
>
<div className='space-y-3'>
<div>
<label className='system-sm-semibold mb-2 block text-text-secondary'>
{t('common.feedback.content') || 'Feedback Content'}
</label>
<Textarea
value={feedbackContent}
onChange={e => setFeedbackContent(e.target.value)}
placeholder={t('common.feedback.placeholder') || 'Please describe what went wrong or how we can improve...'}
rows={4}
className='w-full'
/>
</div>
</div>
</Modal>
)}
</>
)
}
export default memo(Operation)

View File

@@ -0,0 +1,37 @@
import type { FC } from 'react'
import { memo } from 'react'
import type { ChatItem } from '../../types'
import { useChatContext } from '../context'
type SuggestedQuestionsProps = {
item: ChatItem
}
const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
item,
}) => {
const { onSend } = useChatContext()
const {
isOpeningStatement,
suggestedQuestions,
} = item
if (!isOpeningStatement || !suggestedQuestions?.length)
return null
return (
<div className='flex flex-wrap'>
{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)}
>
{question}
</div>),
)}
</div>
)
}
export default memo(SuggestedQuestions)

View 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(
'system-xs-medium flex cursor-pointer items-center px-2.5 py-2 text-text-tertiary',
expand && 'pb-1.5',
)}
onClick={() => setExpand(!expand)}
>
{isFinished && <RiHammerFill className='mr-1 h-3.5 w-3.5' />}
{!isFinished && <RiLoader2Line className='mr-1 h-3.5 w-3.5 animate-spin' />}
{t(`tools.thought.${isFinished ? 'used' : 'using'}`)}
<div className='mx-1 text-text-secondary'>{toolLabel}</div>
{!expand && <RiArrowRightSLine className='h-4 w-4' />}
{expand && <RiArrowDownSLine className='ml-auto h-4 w-4' />}
</div>
{
expand && (
<>
<div className='mx-1 mb-0.5 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary'>
<div className='system-xs-semibold-uppercase flex h-7 items-center justify-between px-2 pt-1'>
{t('tools.thought.requestTitle')}
</div>
<div className='code-xs-regular break-words px-3 pb-2 pt-1'>
{input}
</div>
</div>
<div className='mx-1 mb-1 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary'>
<div className='system-xs-semibold-uppercase flex h-7 items-center justify-between px-2 pt-1'>
{t('tools.thought.responseTitle')}
</div>
<div className='code-xs-regular break-words px-3 pb-2 pt-1'>
{output}
</div>
</div>
</>
)
}
</div>
)
}
export default ToolDetail

View File

@@ -0,0 +1,96 @@
import {
useEffect,
useState,
} from 'react'
import {
RiArrowRightSLine,
RiErrorWarningFill,
RiLoader2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import type { ChatItem, WorkflowProcess } from '../../types'
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
import cn from '@/utils/classnames'
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
type WorkflowProcessProps = {
data: WorkflowProcess
item?: ChatItem
expand?: boolean
hideInfo?: boolean
hideProcessDetail?: boolean
readonly?: boolean
}
const WorkflowProcessItem = ({
data,
expand = false,
hideInfo = false,
hideProcessDetail = false,
readonly = false,
}: WorkflowProcessProps) => {
const { t } = useTranslation()
const [collapse, setCollapse] = useState(!expand)
const running = data.status === WorkflowRunningStatus.Running
const succeeded = data.status === WorkflowRunningStatus.Succeeded
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
useEffect(() => {
setCollapse(!expand)
}, [expand])
if (readonly) return null
return (
<div
className={cn(
'-mx-1 rounded-xl px-2.5',
collapse ? 'border-l-[0.25px] border-components-panel-border py-[7px]' : 'border-[0.5px] border-components-panel-border-subtle px-1 pb-1 pt-[7px]',
running && !collapse && 'bg-background-section-burn',
succeeded && !collapse && 'bg-state-success-hover',
failed && !collapse && 'bg-state-destructive-hover',
collapse && 'bg-workflow-process-bg',
)}
>
<div
className={cn('flex cursor-pointer items-center', !collapse && 'px-1.5')}
onClick={() => setCollapse(!collapse)}
>
{
running && (
<RiLoader2Line className='mr-1 h-3.5 w-3.5 shrink-0 animate-spin text-text-tertiary' />
)
}
{
succeeded && (
<CheckCircle className='mr-1 h-3.5 w-3.5 shrink-0 text-text-success' />
)
}
{
failed && (
<RiErrorWarningFill className='mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive' />
)
}
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
{t('workflow.common.workflowProcess')}
</div>
<RiArrowRightSLine className={cn('ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
</div>
{
!collapse && (
<div className='mt-1.5'>
{
<TracingPanel
list={data.tracing}
hideNodeInfo={hideInfo}
hideNodeProcessDetail={hideProcessDetail}
/>
}
</div>
)
}
</div>
)
}
export default WorkflowProcessItem

View File

@@ -0,0 +1,46 @@
import {
useCallback,
useRef,
useState,
} from 'react'
export const useTextAreaHeight = () => {
const wrapperRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement | undefined>(undefined)
const textValueRef = useRef<HTMLDivElement>(null)
const holdSpaceRef = useRef<HTMLDivElement>(null)
const [isMultipleLine, setIsMultipleLine] = useState(false)
const handleComputeHeight = useCallback(() => {
const textareaElement = textareaRef.current
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,
}
}

View File

@@ -0,0 +1,254 @@
import {
useCallback,
useRef,
useState,
} from 'react'
import Textarea from 'react-textarea-autosize'
import { useTranslation } from 'react-i18next'
import Recorder from 'js-audio-recorder'
import { decode } from 'html-entities'
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 = {
botName?: string
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
isResponding?: boolean
disabled?: boolean
}
const ChatInputArea = ({
botName,
showFeatureBar,
showFileUpload,
featureBarDisabled,
onFeatureBarClick,
visionConfig,
speechToTextConfig = { enabled: true },
onSend,
inputs = {},
inputsForm = [],
theme,
isResponding,
disabled,
}: ChatInputAreaProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const {
wrapperRef,
textareaRef,
textValueRef,
holdSpaceRef,
handleTextareaResize,
isMultipleLine,
} = useTextAreaHeight()
const [query, setQuery] = useState('')
const [showVoiceInput, setShowVoiceInput] = useState(false)
const filesStore = useFileStore()
const {
handleDragFileEnter,
handleDragFileLeave,
handleDragFileOver,
handleDropFile,
handleClipboardPasteFile,
isDragActive,
} = useFile(visionConfig!)
const { checkInputsForm } = useCheckInputsForms()
const historyRef = useRef([''])
const [currentIndex, setCurrentIndex] = useState(-1)
const isComposingRef = useRef(false)
const handleQueryChange = useCallback(
(value: string) => {
setQuery(value)
setTimeout(handleTextareaResize, 0)
},
[handleTextareaResize],
)
const handleSend = () => {
if (isResponding) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
return
}
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)
handleQueryChange('')
setFiles([])
}
}
}
const handleCompositionStart = () => {
// e: React.CompositionEvent<HTMLTextAreaElement>
isComposingRef.current = true
}
const handleCompositionEnd = () => {
// safari or some browsers will trigger compositionend before keydown.
// delay 50ms for safari.
setTimeout(() => {
isComposingRef.current = false
}, 50)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
// if isComposing, exit
if (isComposingRef.current) return
e.preventDefault()
setQuery(query.replace(/\n$/, ''))
historyRef.current.push(query)
setCurrentIndex(historyRef.current.length)
handleSend()
}
else if (e.key === 'ArrowUp' && !e.shiftKey && !e.nativeEvent.isComposing && e.metaKey) {
// When the cmd + up key is pressed, output the previous element
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
handleQueryChange(historyRef.current[currentIndex - 1])
}
}
else if (e.key === 'ArrowDown' && !e.shiftKey && !e.nativeEvent.isComposing && e.metaKey) {
// When the cmd + down key is pressed, output the next element
if (currentIndex < historyRef.current.length - 1) {
setCurrentIndex(currentIndex + 1)
handleQueryChange(historyRef.current[currentIndex + 1])
}
else if (currentIndex === historyRef.current.length - 1) {
// If it is the last element, clear the input box
setCurrentIndex(historyRef.current.length)
handleQueryChange('')
}
}
}
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 z-10 overflow-hidden rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[9px] shadow-md',
isDragActive && 'border border-dashed border-components-option-card-option-selected-border',
disabled && 'pointer-events-none border-components-panel-border opacity-50 shadow-none',
)}
>
<div className='relative max-h-[158px] overflow-y-auto overflow-x-hidden px-[9px] pt-[9px]'>
<FileListInChatInput fileConfig={visionConfig!} />
<div
ref={wrapperRef}
className='flex items-center justify-between'
>
<div className='relative flex w-full grow items-center'>
<div
ref={textValueRef}
className='body-lg-regular pointer-events-none invisible absolute h-auto w-auto whitespace-pre p-1 leading-6'
>
{query}
</div>
<Textarea
ref={ref => textareaRef.current = ref as any}
className={cn(
'body-lg-regular w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none',
)}
placeholder={decode(t('common.chat.inputPlaceholder', { botName }) || '')}
autoFocus
minRows={1}
value={query}
onChange={e => handleQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onPaste={handleClipboardPasteFile}
onDragEnter={handleDragFileEnter}
onDragLeave={handleDragFileLeave}
onDragOver={handleDragFileOver}
onDrop={handleDropFile}
/>
</div>
{
!isMultipleLine && operation
}
</div>
{
showVoiceInput && (
<VoiceInput
onCancel={() => setShowVoiceInput(false)}
onConverted={text => handleQueryChange(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

View File

@@ -0,0 +1,76 @@
import type { FC, Ref } from 'react'
import { 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,
ref?: Ref<HTMLDivElement>;
}
const Operation: FC<OperationProps> = ({
ref,
fileConfig,
speechToTextConfig,
onShowVoiceInput,
onSend,
theme,
}) => {
return (
<div
className={cn(
'flex shrink-0 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='h-5 w-5' />
</ActionButton>
)
}
</div>
<Button
className='ml-3 w-8 px-0'
variant='primary'
onClick={onSend}
style={
theme
? {
backgroundColor: theme.primaryColor,
}
: {}
}
>
<RiSendPlane2Fill className='h-4 w-4' />
</Button>
</div>
</div>
)
}
Operation.displayName = 'Operation'
export default memo(Operation)

View 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, type }) => required && type !== InputVarType.checkbox) // boolean can be not checked
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,
}
}

View File

@@ -0,0 +1,125 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine } from '@remixicon/react'
import type { CitationItem } from '../type'
import Popup from './popup'
export type Resources = {
documentId: string
documentName: string
dataSourceType: string
sources: CitationItem[]
}
type CitationProps = {
data: CitationItem[]
showHitInfo?: boolean
containerClassName?: string
}
const Citation: FC<CitationProps> = ({
data,
showHitInfo,
containerClassName = 'chat-answer-container',
}) => {
const { t } = useTranslation()
const elesRef = useRef<HTMLDivElement[]>([])
const [limitNumberInOneLine, setLimitNumberInOneLine] = useState(0)
const [showMore, setShowMore] = useState(false)
const resources = useMemo(() => data.reduce((prev: Resources[], next) => {
const documentId = next.document_id
const documentName = next.document_name
const dataSourceType = next.data_source_type
const documentIndex = prev.findIndex(i => i.documentId === documentId)
if (documentIndex > -1) {
prev[documentIndex].sources.push(next)
}
else {
prev.push({
documentId,
documentName,
dataSourceType,
sources: [next],
})
}
return prev
}, []), [data])
const handleAdjustResourcesLayout = () => {
const containerWidth = document.querySelector(`.${containerClassName}`)!.clientWidth - 40
let totalWidth = 0
for (let i = 0; i < resources.length; i++) {
totalWidth += elesRef.current[i].clientWidth
if (totalWidth + i * 4 > containerWidth!) {
totalWidth -= elesRef.current[i].clientWidth
if (totalWidth + 34 > containerWidth!)
setLimitNumberInOneLine(i - 1)
else
setLimitNumberInOneLine(i)
break
}
else {
setLimitNumberInOneLine(i + 1)
}
}
}
useEffect(() => {
handleAdjustResourcesLayout()
}, [])
const resourcesLength = resources.length
return (
<div className='-mb-1 mt-3'>
<div className='system-xs-medium mb-2 flex items-center text-text-tertiary'>
{t('common.chat.citation.title')}
<div className='ml-2 h-px grow bg-divider-regular' />
</div>
<div className='relative flex flex-wrap'>
{
resources.map((res, index) => (
<div
key={index}
className='absolute left-0 top-0 -z-10 mb-1 mr-1 h-7 w-auto max-w-[240px] whitespace-nowrap pl-7 pr-2 text-xs opacity-0'
ref={(ele: any) => (elesRef.current[index] = ele!)}
>
{res.documentName}
</div>
))
}
{
resources.slice(0, showMore ? resourcesLength : limitNumberInOneLine).map((res, index) => (
<div key={index} className='mb-1 mr-1 cursor-pointer'>
<Popup
data={res}
showHitInfo={showHitInfo}
/>
</div>
))
}
{
limitNumberInOneLine < resourcesLength && (
<div
className='system-xs-medium flex h-7 cursor-pointer items-center rounded-lg bg-components-panel-bg px-2 text-text-tertiary'
onClick={() => setShowMore(v => !v)}
>
{
!showMore
? `+ ${resourcesLength - limitNumberInOneLine}`
: <RiArrowDownSLine className='h-4 w-4 rotate-180 text-text-tertiary' />
}
</div>
)
}
</div>
</div>
)
}
export default Citation

View File

@@ -0,0 +1,131 @@
import { Fragment, useState } from 'react'
import type { FC } from 'react'
import Link from 'next/link'
import { useTranslation } from 'react-i18next'
import Tooltip from './tooltip'
import ProgressTooltip from './progress-tooltip'
import type { Resources } from './index'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import FileIcon from '@/app/components/base/file-icon'
import {
Hash02,
Target04,
} from '@/app/components/base/icons/src/vender/line/general'
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
import {
BezierCurve03,
TypeSquare,
} from '@/app/components/base/icons/src/vender/line/editor'
type PopupProps = {
data: Resources
showHitInfo?: boolean
}
const Popup: FC<PopupProps> = ({
data,
showHitInfo = false,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const fileType = data.dataSourceType !== 'notion'
? (/\.([^.]*)$/g.exec(data.documentName)?.[1] || '')
: 'notion'
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='top-start'
offset={{
mainAxis: 8,
crossAxis: -2,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className='flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2'>
<FileIcon type={fileType} className='mr-1 h-4 w-4 shrink-0' />
<div className='truncate text-xs text-text-tertiary'>{data.documentName}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
<div className='max-w-[360px] rounded-xl bg-background-section-burn shadow-lg'>
<div className='px-4 pb-2 pt-3'>
<div className='flex h-[18px] items-center'>
<FileIcon type={fileType} className='mr-1 h-4 w-4 shrink-0' />
<div className='system-xs-medium truncate text-text-tertiary'>{data.documentName}</div>
</div>
</div>
<div className='max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5'>
<div className='w-full'>
{
data.sources.map((source, index) => (
<Fragment key={index}>
<div className='group py-3'>
<div className='mb-2 flex items-center justify-between'>
<div className='flex h-5 items-center rounded-md border border-divider-subtle px-1.5'>
<Hash02 className='mr-0.5 h-3 w-3 text-text-quaternary' />
<div className='text-[11px] font-medium text-text-tertiary'>
{source.segment_position || index + 1}
</div>
</div>
{
showHitInfo && (
<Link
href={`/datasets/${source.dataset_id}/documents/${source.document_id}`}
className='hidden h-[18px] items-center text-xs text-text-accent group-hover:flex'>
{t('common.chat.citation.linkToDataset')}
<ArrowUpRight className='ml-1 h-3 w-3' />
</Link>
)
}
</div>
<div className='break-words text-[13px] text-text-secondary'>{source.content}</div>
{
showHitInfo && (
<div className='system-xs-medium mt-2 flex flex-wrap items-center text-text-quaternary'>
<Tooltip
text={t('common.chat.citation.characters')}
data={source.word_count}
icon={<TypeSquare className='mr-1 h-3 w-3' />}
/>
<Tooltip
text={t('common.chat.citation.hitCount')}
data={source.hit_count}
icon={<Target04 className='mr-1 h-3 w-3' />}
/>
<Tooltip
text={t('common.chat.citation.vectorHash')}
data={source.index_node_hash?.substring(0, 7)}
icon={<BezierCurve03 className='mr-1 h-3 w-3' />}
/>
{
source.score && (
<ProgressTooltip data={Number(source.score.toFixed(2))} />
)
}
</div>
)
}
</div>
{
index !== data.sources.length - 1 && (
<div className='my-1 h-px bg-divider-regular' />
)
}
</Fragment>
))
}
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default Popup

View File

@@ -0,0 +1,46 @@
import { useState } from 'react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type ProgressTooltipProps = {
data: number
}
const ProgressTooltip: FC<ProgressTooltipProps> = ({
data,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='top-start'
>
<PortalToFollowElemTrigger
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div className='flex grow items-center'>
<div className='mr-1 h-1.5 w-16 overflow-hidden rounded-[3px] border border-components-progress-gray-border'>
<div className='h-full bg-components-progress-gray-progress' style={{ width: `${data * 100}%` }}></div>
</div>
{data}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
<div className='system-xs-medium rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg'>
{t('common.chat.citation.hitScore')} {data}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ProgressTooltip

View File

@@ -0,0 +1,46 @@
import React, { useState } from 'react'
import type { FC } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type TooltipProps = {
data: number | string
text: string
icon: React.ReactNode
}
const Tooltip: FC<TooltipProps> = ({
data,
text,
icon,
}) => {
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='top-start'
>
<PortalToFollowElemTrigger
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div className='mr-6 flex items-center'>
{icon}
{data}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
<div className='system-xs-medium rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg'>
{text} {data}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default Tooltip

View File

@@ -0,0 +1,39 @@
import { ChevronRight } from '../../icons/src/vender/line/arrows'
export default function ContentSwitch({
count,
currentIndex,
prevDisabled,
nextDisabled,
switchSibling,
}: {
count?: number
currentIndex?: number
prevDisabled: boolean
nextDisabled: boolean
switchSibling: (direction: 'prev' | 'next') => void
}) {
return (
count && count > 1 && currentIndex !== undefined && (
<div className="flex items-center justify-center pt-3.5 text-sm">
<button type="button"
className={`${prevDisabled ? 'opacity-30' : 'opacity-100'}`}
disabled={prevDisabled}
onClick={() => !prevDisabled && switchSibling('prev')}
>
<ChevronRight className="h-[14px] w-[14px] rotate-180 text-text-primary" />
</button>
<span className="px-2 text-xs text-text-primary">
{currentIndex + 1} / {count}
</span>
<button type="button"
className={`${nextDisabled ? 'opacity-30' : 'opacity-100'}`}
disabled={nextDisabled}
onClick={() => !nextDisabled && switchSibling('next')}
>
<ChevronRight className="h-[14px] w-[14px] text-text-primary" />
</button>
</div>
)
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import type { ReactNode } from 'react'
import { createContext, useContext } from 'use-context-selector'
import type { ChatProps } from './index'
export type ChatContextValue = Pick<ChatProps, 'config'
| 'isResponding'
| 'chatList'
| 'showPromptLog'
| 'questionIcon'
| 'answerIcon'
| 'onSend'
| 'onRegenerate'
| 'onAnnotationEdited'
| 'onAnnotationAdded'
| 'onAnnotationRemoved'
| 'onFeedback'
>
const ChatContext = createContext<ChatContextValue>({
chatList: [],
})
type ChatContextProviderProps = {
children: ReactNode
} & ChatContextValue
export const ChatContextProvider = ({
children,
config,
isResponding,
chatList,
showPromptLog,
questionIcon,
answerIcon,
onSend,
onRegenerate,
onAnnotationEdited,
onAnnotationAdded,
onAnnotationRemoved,
onFeedback,
}: ChatContextProviderProps) => {
return (
<ChatContext.Provider value={{
config,
isResponding,
chatList: chatList || [],
showPromptLog,
questionIcon,
answerIcon,
onSend,
onRegenerate,
onAnnotationEdited,
onAnnotationAdded,
onAnnotationRemoved,
onFeedback,
}}>
{children}
</ChatContext.Provider>
)
}
export const useChatContext = () => useContext(ChatContext)
export default ChatContext

View File

@@ -0,0 +1,724 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { produce, setAutoFreeze } from 'immer'
import { uniqBy } from 'lodash-es'
import { useParams, usePathname } from 'next/navigation'
import { v4 as uuidV4 } from 'uuid'
import type {
ChatConfig,
ChatItem,
ChatItemInTree,
Inputs,
} from '../types'
import { getThreadMessages } from '../utils'
import type { InputForm } from './type'
import {
getProcessedInputs,
processOpeningStatement,
} from './utils'
import { TransferMethod } from '@/types/app'
import { useToastContext } from '@/app/components/base/toast'
import { ssePost } from '@/service/base'
import type { Annotation } from '@/models/log'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import useTimestamp from '@/hooks/use-timestamp'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import {
getProcessedFiles,
getProcessedFilesFromResponse,
} from '@/app/components/base/file-uploader/utils'
import { noop } from 'lodash-es'
type GetAbortController = (abortController: AbortController) => void
type SendCallback = {
onGetConversationMessages?: (conversationId: string, getAbortController: GetAbortController) => Promise<any>
onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any>
onConversationComplete?: (conversationId: string) => void
isPublicAPI?: boolean
}
export const useChat = (
config?: ChatConfig,
formSettings?: {
inputs: Inputs
inputsForm: InputForm[]
},
prevChatTree?: ChatItemInTree[],
stopChat?: (taskId: string) => void,
clearChatList?: boolean,
clearChatListCallback?: (state: boolean) => void,
) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const { notify } = useToastContext()
const conversationId = useRef('')
const hasStopResponded = useRef(false)
const [isResponding, setIsResponding] = useState(false)
const isRespondingRef = useRef(false)
const taskIdRef = useRef('')
const [suggestedQuestions, setSuggestQuestions] = useState<string[]>([])
const conversationMessagesAbortControllerRef = useRef<AbortController | null>(null)
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
const params = useParams()
const pathname = usePathname()
const [chatTree, setChatTree] = useState<ChatItemInTree[]>(prevChatTree || [])
const chatTreeRef = useRef<ChatItemInTree[]>(chatTree)
const [targetMessageId, setTargetMessageId] = useState<string>()
const threadMessages = useMemo(() => getThreadMessages(chatTree, targetMessageId), [chatTree, targetMessageId])
const getIntroduction = useCallback((str: string) => {
return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || [])
}, [formSettings?.inputs, formSettings?.inputsForm])
/** Final chat list that will be rendered */
const chatList = useMemo(() => {
const ret = [...threadMessages]
if (config?.opening_statement) {
const index = threadMessages.findIndex(item => item.isOpeningStatement)
if (index > -1) {
ret[index] = {
...ret[index],
content: getIntroduction(config.opening_statement),
suggestedQuestions: config.suggested_questions?.map(item => getIntroduction(item)),
}
}
else {
ret.unshift({
id: 'opening-statement',
content: getIntroduction(config.opening_statement),
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: config.suggested_questions?.map(item => getIntroduction(item)),
})
}
}
return ret
}, [threadMessages, config?.opening_statement, getIntroduction, config?.suggested_questions])
useEffect(() => {
setAutoFreeze(false)
return () => {
setAutoFreeze(true)
}
}, [])
/** Find the target node by bfs and then operate on it */
const produceChatTreeNode = useCallback((targetId: string, operation: (node: ChatItemInTree) => void) => {
return produce(chatTreeRef.current, (draft) => {
const queue: ChatItemInTree[] = [...draft]
while (queue.length > 0) {
const current = queue.shift()!
if (current.id === targetId) {
operation(current)
break
}
if (current.children)
queue.push(...current.children)
}
})
}, [])
type UpdateChatTreeNode = {
(id: string, fields: Partial<ChatItemInTree>): void
(id: string, update: (node: ChatItemInTree) => void): void
}
const updateChatTreeNode: UpdateChatTreeNode = useCallback((
id: string,
fieldsOrUpdate: Partial<ChatItemInTree> | ((node: ChatItemInTree) => void),
) => {
const nextState = produceChatTreeNode(id, (node) => {
if (typeof fieldsOrUpdate === 'function') {
fieldsOrUpdate(node)
}
else {
Object.keys(fieldsOrUpdate).forEach((key) => {
(node as any)[key] = (fieldsOrUpdate as any)[key]
})
}
})
setChatTree(nextState)
chatTreeRef.current = nextState
}, [produceChatTreeNode])
const handleResponding = useCallback((isResponding: boolean) => {
setIsResponding(isResponding)
isRespondingRef.current = isResponding
}, [])
const handleStop = useCallback(() => {
hasStopResponded.current = true
handleResponding(false)
if (stopChat && taskIdRef.current)
stopChat(taskIdRef.current)
if (conversationMessagesAbortControllerRef.current)
conversationMessagesAbortControllerRef.current.abort()
if (suggestedQuestionsAbortControllerRef.current)
suggestedQuestionsAbortControllerRef.current.abort()
}, [stopChat, handleResponding])
const handleRestart = useCallback((cb?: any) => {
conversationId.current = ''
taskIdRef.current = ''
handleStop()
setChatTree([])
setSuggestQuestions([])
cb?.()
}, [handleStop])
const updateCurrentQAOnTree = useCallback(({
parentId,
responseItem,
placeholderQuestionId,
questionItem,
}: {
parentId?: string
responseItem: ChatItem
placeholderQuestionId: string
questionItem: ChatItem
}) => {
let nextState: ChatItemInTree[]
const currentQA = { ...questionItem, children: [{ ...responseItem, children: [] }] }
if (!parentId && !chatTree.some(item => [placeholderQuestionId, questionItem.id].includes(item.id))) {
// QA whose parent is not provided is considered as a first message of the conversation,
// and it should be a root node of the chat tree
nextState = produce(chatTree, (draft) => {
draft.push(currentQA)
})
}
else {
// find the target QA in the tree and update it; if not found, insert it to its parent node
nextState = produceChatTreeNode(parentId!, (parentNode) => {
const questionNodeIndex = parentNode.children!.findIndex(item => [placeholderQuestionId, questionItem.id].includes(item.id))
if (questionNodeIndex === -1)
parentNode.children!.push(currentQA)
else
parentNode.children![questionNodeIndex] = currentQA
})
}
setChatTree(nextState)
chatTreeRef.current = nextState
}, [chatTree, produceChatTreeNode])
const handleSend = useCallback(async (
url: string,
data: {
query: string
files?: FileEntity[]
parent_message_id?: string
[key: string]: any
},
{
onGetConversationMessages,
onGetSuggestedQuestions,
onConversationComplete,
isPublicAPI,
}: SendCallback,
) => {
setSuggestQuestions([])
if (isRespondingRef.current) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
return false
}
const parentMessage = threadMessages.find(item => item.id === data.parent_message_id)
const placeholderQuestionId = `question-${Date.now()}`
const questionItem = {
id: placeholderQuestionId,
content: data.query,
isAnswer: false,
message_files: data.files,
parentMessageId: data.parent_message_id,
}
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
const placeholderAnswerItem = {
id: placeholderAnswerId,
content: '',
isAnswer: true,
parentMessageId: questionItem.id,
siblingIndex: parentMessage?.children?.length ?? chatTree.length,
}
setTargetMessageId(parentMessage?.id)
updateCurrentQAOnTree({
parentId: data.parent_message_id,
responseItem: placeholderAnswerItem,
placeholderQuestionId,
questionItem,
})
// answer
const responseItem: ChatItemInTree = {
id: placeholderAnswerId,
content: '',
agent_thoughts: [],
message_files: [],
isAnswer: true,
parentMessageId: questionItem.id,
siblingIndex: parentMessage?.children?.length ?? chatTree.length,
}
handleResponding(true)
hasStopResponded.current = false
const { query, files, inputs, ...restData } = data
const bodyParams = {
response_mode: 'streaming',
conversation_id: conversationId.current,
files: getProcessedFiles(files || []),
query,
inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []),
...restData,
}
if (bodyParams?.files?.length) {
bodyParams.files = bodyParams.files.map((item) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
let isAgentMode = false
let hasSetResponseId = false
let ttsUrl = ''
let ttsIsPublic = false
if (params.token) {
ttsUrl = '/text-to-audio'
ttsIsPublic = true
}
else if (params.appId) {
if (pathname.search('explore/installed') > -1)
ttsUrl = `/installed-apps/${params.appId}/text-to-audio`
else
ttsUrl = `/apps/${params.appId}/text-to-audio`
}
// Lazy initialization: Only create AudioPlayer when TTS is actually needed
// This prevents opening audio channel unnecessarily
let player: AudioPlayer | null = null
const getOrCreatePlayer = () => {
if (!player)
player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', noop)
return player
}
ssePost(
url,
{
body: bodyParams,
},
{
isPublicAPI,
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
if (!isAgentMode) {
responseItem.content = responseItem.content + message
}
else {
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
if (lastThought)
lastThought.thought = lastThought.thought + message // need immer setAutoFreeze
}
if (messageId && !hasSetResponseId) {
questionItem.id = `question-${messageId}`
responseItem.id = messageId
responseItem.parentMessageId = questionItem.id
hasSetResponseId = true
}
if (isFirstMessage && newConversationId)
conversationId.current = newConversationId
taskIdRef.current = taskId
if (messageId)
responseItem.id = messageId
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
async onCompleted(hasError?: boolean) {
handleResponding(false)
if (hasError)
return
if (onConversationComplete)
onConversationComplete(conversationId.current)
if (conversationId.current && !hasStopResponded.current && onGetConversationMessages) {
const { data }: any = await onGetConversationMessages(
conversationId.current,
newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
)
const newResponseItem = data.find((item: any) => item.id === responseItem.id)
if (!newResponseItem)
return
const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
updateChatTreeNode(responseItem.id, {
content: isUseAgentThought ? '' : newResponseItem.answer,
log: [
...newResponseItem.message,
...(newResponseItem.message[newResponseItem.message.length - 1].role !== 'assistant'
? [
{
role: 'assistant',
text: newResponseItem.answer,
files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
},
]
: []),
],
more: {
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
latency: newResponseItem.provider_response_latency.toFixed(2),
},
// for agent log
conversationId: conversationId.current,
input: {
inputs: newResponseItem.inputs,
query: newResponseItem.query,
},
})
}
if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
try {
const { data }: any = await onGetSuggestedQuestions(
responseItem.id,
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
)
setSuggestQuestions(data)
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
setSuggestQuestions([])
}
}
},
onFile(file) {
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
if (lastThought)
responseItem.agent_thoughts![responseItem.agent_thoughts!.length - 1].message_files = [...(lastThought as any).message_files, file]
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onThought(thought) {
isAgentMode = true
const response = responseItem as any
if (thought.message_id && !hasSetResponseId)
response.id = thought.message_id
if (thought.conversation_id)
response.conversationId = thought.conversation_id
if (response.agent_thoughts.length === 0) {
response.agent_thoughts.push(thought)
}
else {
const lastThought = response.agent_thoughts[response.agent_thoughts.length - 1]
// thought changed but still the same thought, so update.
if (lastThought.id === thought.id) {
thought.thought = lastThought.thought
thought.message_files = lastThought.message_files
responseItem.agent_thoughts![response.agent_thoughts.length - 1] = thought
}
else {
responseItem.agent_thoughts!.push(thought)
}
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onMessageEnd: (messageEnd) => {
if (messageEnd.metadata?.annotation_reply) {
responseItem.id = messageEnd.id
responseItem.annotation = ({
id: messageEnd.metadata.annotation_reply.id,
authorName: messageEnd.metadata.annotation_reply.account.name,
})
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
return
}
responseItem.citation = messageEnd.metadata?.retriever_resources || []
const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || [])
responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id')
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onMessageReplace: (messageReplace) => {
responseItem.content = messageReplace.answer
},
onError() {
handleResponding(false)
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
taskIdRef.current = task_id
responseItem.workflow_run_id = workflow_run_id
responseItem.workflowProcess = {
status: WorkflowRunningStatus.Running,
tracing: [],
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onWorkflowFinished: ({ data: workflowFinishedData }) => {
responseItem.workflowProcess!.status = workflowFinishedData.status as WorkflowRunningStatus
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onIterationStart: ({ data: iterationStartedData }) => {
responseItem.workflowProcess!.tracing!.push({
...iterationStartedData,
status: WorkflowRunningStatus.Running,
})
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onIterationFinish: ({ data: iterationFinishedData }) => {
const tracing = responseItem.workflowProcess!.tracing!
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
tracing[iterationIndex] = {
...tracing[iterationIndex],
...iterationFinishedData,
status: WorkflowRunningStatus.Succeeded,
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onNodeStarted: ({ data: nodeStartedData }) => {
if (nodeStartedData.iteration_id)
return
if (data.loop_id)
return
responseItem.workflowProcess!.tracing!.push({
...nodeStartedData,
status: WorkflowRunningStatus.Running,
})
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onNodeFinished: ({ data: nodeFinishedData }) => {
if (nodeFinishedData.iteration_id)
return
if (data.loop_id)
return
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
if (!item.execution_metadata?.parallel_id)
return item.node_id === nodeFinishedData.node_id
return item.node_id === nodeFinishedData.node_id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata?.parallel_id)
})
responseItem.workflowProcess!.tracing[currentIndex] = nodeFinishedData as any
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
const audioPlayer = getOrCreatePlayer()
if (audioPlayer) {
audioPlayer.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
}
},
onTTSEnd: (messageId: string, audio: string) => {
const audioPlayer = getOrCreatePlayer()
if (audioPlayer)
audioPlayer.playAudioWithAudio(audio, false)
},
onLoopStart: ({ data: loopStartedData }) => {
responseItem.workflowProcess!.tracing!.push({
...loopStartedData,
status: WorkflowRunningStatus.Running,
})
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onLoopFinish: ({ data: loopFinishedData }) => {
const tracing = responseItem.workflowProcess!.tracing!
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
tracing[loopIndex] = {
...tracing[loopIndex],
...loopFinishedData,
status: WorkflowRunningStatus.Succeeded,
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
})
return true
}, [
t,
chatTree.length,
threadMessages,
config?.suggested_questions_after_answer,
updateCurrentQAOnTree,
updateChatTreeNode,
notify,
handleResponding,
formatTime,
params.token,
params.appId,
pathname,
formSettings,
])
const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => {
const targetQuestionId = chatList[index - 1].id
const targetAnswerId = chatList[index].id
updateChatTreeNode(targetQuestionId, {
content: query,
})
updateChatTreeNode(targetAnswerId, {
content: answer,
annotation: {
...chatList[index].annotation,
logAnnotation: undefined,
} as any,
})
}, [chatList, updateChatTreeNode])
const handleAnnotationAdded = useCallback((annotationId: string, authorName: string, query: string, answer: string, index: number) => {
const targetQuestionId = chatList[index - 1].id
const targetAnswerId = chatList[index].id
updateChatTreeNode(targetQuestionId, {
content: query,
})
updateChatTreeNode(targetAnswerId, {
content: chatList[index].content,
annotation: {
id: annotationId,
authorName,
logAnnotation: {
content: answer,
account: {
id: '',
name: authorName,
email: '',
},
},
} as Annotation,
})
}, [chatList, updateChatTreeNode])
const handleAnnotationRemoved = useCallback((index: number) => {
const targetAnswerId = chatList[index].id
updateChatTreeNode(targetAnswerId, {
content: chatList[index].content,
annotation: {
...chatList[index].annotation,
id: '',
} as Annotation,
})
}, [chatList, updateChatTreeNode])
useEffect(() => {
if (clearChatList)
handleRestart(() => clearChatListCallback?.(false))
}, [clearChatList, clearChatListCallback, handleRestart])
return {
chatList,
setTargetMessageId,
conversationId: conversationId.current,
isResponding,
setIsResponding,
handleSend,
suggestedQuestions,
handleRestart,
handleStop,
handleAnnotationEdited,
handleAnnotationAdded,
handleAnnotationRemoved,
}
}

View File

@@ -0,0 +1,354 @@
import type {
FC,
ReactNode,
} from 'react'
import {
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { debounce } from 'lodash-es'
import { useShallow } from 'zustand/react/shallow'
import type {
ChatConfig,
ChatItem,
Feedback,
OnRegenerate,
OnSend,
} from '../types'
import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
import Question from './question'
import Answer from './answer'
import ChatInputArea from './chat-input-area'
import TryToAsk from './try-to-ask'
import { ChatContextProvider } from './context'
import type { InputForm } from './type'
import cn from '@/utils/classnames'
import type { Emoji } from '@/app/components/tools/types'
import Button from '@/app/components/base/button'
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import AgentLogModal from '@/app/components/base/agent-log-modal'
import PromptLogModal from '@/app/components/base/prompt-log-modal'
import { useStore as useAppStore } from '@/app/components/app/store'
import type { AppData } from '@/models/share'
export type ChatProps = {
appData?: AppData
chatList: ChatItem[]
config?: ChatConfig
isResponding?: boolean
noStopResponding?: boolean
onStopResponding?: () => void
noChatInput?: boolean
onSend?: OnSend
inputs?: Record<string, any>
inputsForm?: InputForm[]
onRegenerate?: OnRegenerate
chatContainerClassName?: string
chatContainerInnerClassName?: string
chatFooterClassName?: string
chatFooterInnerClassName?: string
suggestedQuestions?: string[]
showPromptLog?: boolean
questionIcon?: ReactNode
answerIcon?: ReactNode
allToolIcons?: Record<string, string | Emoji>
onAnnotationEdited?: (question: string, answer: string, index: number) => void
onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
onAnnotationRemoved?: (index: number) => void
chatNode?: ReactNode
onFeedback?: (messageId: string, feedback: Feedback) => void
chatAnswerContainerInner?: string
hideProcessDetail?: boolean
hideLogModal?: boolean
themeBuilder?: ThemeBuilder
switchSibling?: (siblingMessageId: string) => void
showFeatureBar?: boolean
showFileUpload?: boolean
onFeatureBarClick?: (state: boolean) => void
noSpacing?: boolean
inputDisabled?: boolean
isMobile?: boolean
sidebarCollapseState?: boolean
}
const Chat: FC<ChatProps> = ({
appData,
config,
onSend,
inputs,
inputsForm,
onRegenerate,
chatList,
isResponding,
noStopResponding,
onStopResponding,
noChatInput,
chatContainerClassName,
chatContainerInnerClassName,
chatFooterClassName,
chatFooterInnerClassName,
suggestedQuestions,
showPromptLog,
questionIcon,
answerIcon,
onAnnotationAdded,
onAnnotationEdited,
onAnnotationRemoved,
chatNode,
onFeedback,
chatAnswerContainerInner,
hideProcessDetail,
hideLogModal,
themeBuilder,
switchSibling,
showFeatureBar,
showFileUpload,
onFeatureBarClick,
noSpacing,
inputDisabled,
isMobile,
sidebarCollapseState,
}) => {
const { t } = useTranslation()
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem,
showPromptLogModal: state.showPromptLogModal,
setShowPromptLogModal: state.setShowPromptLogModal,
showAgentLogModal: state.showAgentLogModal,
setShowAgentLogModal: state.setShowAgentLogModal,
})))
const [width, setWidth] = useState(0)
const chatContainerRef = useRef<HTMLDivElement>(null)
const chatContainerInnerRef = useRef<HTMLDivElement>(null)
const chatFooterRef = useRef<HTMLDivElement>(null)
const chatFooterInnerRef = useRef<HTMLDivElement>(null)
const userScrolledRef = useRef(false)
const handleScrollToBottom = useCallback(() => {
if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current)
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
}, [chatList.length])
const handleWindowResize = useCallback(() => {
if (chatContainerRef.current)
setWidth(document.body.clientWidth - (chatContainerRef.current?.clientWidth + 16) - 8)
if (chatContainerRef.current && chatFooterRef.current)
chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px`
if (chatContainerInnerRef.current && chatFooterInnerRef.current)
chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px`
}, [])
useEffect(() => {
handleScrollToBottom()
handleWindowResize()
}, [handleScrollToBottom, handleWindowResize])
useEffect(() => {
if (chatContainerRef.current) {
requestAnimationFrame(() => {
handleScrollToBottom()
handleWindowResize()
})
}
})
useEffect(() => {
const debouncedHandler = debounce(handleWindowResize, 200)
window.addEventListener('resize', debouncedHandler)
return () => {
window.removeEventListener('resize', debouncedHandler)
debouncedHandler.cancel()
}
}, [handleWindowResize])
useEffect(() => {
if (chatFooterRef.current && chatContainerRef.current) {
// container padding bottom
const resizeContainerObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { blockSize } = entry.borderBoxSize[0]
chatContainerRef.current!.style.paddingBottom = `${blockSize}px`
handleScrollToBottom()
}
})
resizeContainerObserver.observe(chatFooterRef.current)
// footer width
const resizeFooterObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { inlineSize } = entry.borderBoxSize[0]
chatFooterRef.current!.style.width = `${inlineSize}px`
}
})
resizeFooterObserver.observe(chatContainerRef.current)
return () => {
resizeContainerObserver.disconnect()
resizeFooterObserver.disconnect()
}
}
}, [handleScrollToBottom])
useEffect(() => {
const chatContainer = chatContainerRef.current
if (chatContainer) {
const setUserScrolled = () => {
// eslint-disable-next-line sonarjs/no-gratuitous-expressions
if (chatContainer) // its in event callback, chatContainer may be null
userScrolledRef.current = chatContainer.scrollHeight - chatContainer.scrollTop > chatContainer.clientHeight
}
chatContainer.addEventListener('scroll', setUserScrolled)
return () => chatContainer.removeEventListener('scroll', setUserScrolled)
}
}, [])
useEffect(() => {
if (!sidebarCollapseState)
setTimeout(() => handleWindowResize(), 200)
}, [handleWindowResize, sidebarCollapseState])
const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend
return (
<ChatContextProvider
config={config}
chatList={chatList}
isResponding={isResponding}
showPromptLog={showPromptLog}
questionIcon={questionIcon}
answerIcon={answerIcon}
onSend={onSend}
onRegenerate={onRegenerate}
onAnnotationAdded={onAnnotationAdded}
onAnnotationEdited={onAnnotationEdited}
onAnnotationRemoved={onAnnotationRemoved}
onFeedback={onFeedback}
>
<div className='relative h-full'>
<div
ref={chatContainerRef}
className={cn('relative h-full overflow-y-auto overflow-x-hidden', chatContainerClassName)}
>
{chatNode}
<div
ref={chatContainerInnerRef}
className={cn('w-full', !noSpacing && 'px-8', chatContainerInnerClassName)}
>
{
chatList.map((item, index) => {
if (item.isAnswer) {
const isLast = item.id === chatList[chatList.length - 1]?.id
return (
<Answer
appData={appData}
key={item.id}
item={item}
question={chatList[index - 1]?.content}
index={index}
config={config}
answerIcon={answerIcon}
responding={isLast && isResponding}
showPromptLog={showPromptLog}
chatAnswerContainerInner={chatAnswerContainerInner}
hideProcessDetail={hideProcessDetail}
noChatInput={noChatInput}
switchSibling={switchSibling}
/>
)
}
return (
<Question
key={item.id}
item={item}
questionIcon={questionIcon}
theme={themeBuilder?.theme}
enableEdit={config?.questionEditEnable}
switchSibling={switchSibling}
/>
)
})
}
</div>
</div>
<div
className={`absolute bottom-0 z-10 flex justify-center bg-chat-input-mask ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
ref={chatFooterRef}
>
<div
ref={chatFooterInnerRef}
className={cn('relative', chatFooterInnerClassName)}
>
{
!noStopResponding && isResponding && (
<div className='mb-2 flex justify-center'>
<Button className='border-components-panel-border bg-components-panel-bg text-components-button-secondary-text' onClick={onStopResponding}>
<StopCircle className='mr-[5px] h-3.5 w-3.5' />
<span className='text-xs font-normal'>{t('appDebug.operation.stopResponding')}</span>
</Button>
</div>
)
}
{
hasTryToAsk && (
<TryToAsk
suggestedQuestions={suggestedQuestions}
onSend={onSend}
isMobile={isMobile}
/>
)
}
{
!noChatInput && (
<ChatInputArea
botName={appData?.site.title || 'Bot'}
disabled={inputDisabled}
showFeatureBar={showFeatureBar}
showFileUpload={showFileUpload}
featureBarDisabled={isResponding}
onFeatureBarClick={onFeatureBarClick}
visionConfig={config?.file_upload}
speechToTextConfig={config?.speech_to_text}
onSend={onSend}
inputs={inputs}
inputsForm={inputsForm}
theme={themeBuilder?.theme}
isResponding={isResponding}
/>
)
}
</div>
</div>
{showPromptLogModal && !hideLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
{showAgentLogModal && !hideLogModal && (
<AgentLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}}
/>
)}
</div>
</ChatContextProvider>
)
}
export default memo(Chat)

View File

@@ -0,0 +1,18 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import s from './style.module.css'
import cn from '@/utils/classnames'
export type ILoadingAnimProps = {
type: 'text' | 'avatar'
}
const LoadingAnim: FC<ILoadingAnimProps> = ({
type,
}) => {
return (
<div className={cn(s['dot-flashing'], s[type])} />
)
}
export default React.memo(LoadingAnim)

View File

@@ -0,0 +1,94 @@
.dot-flashing {
position: relative;
animation: dot-flashing 1s infinite linear alternate;
animation-delay: 0.5s;
}
.dot-flashing::before,
.dot-flashing::after {
content: "";
display: inline-block;
position: absolute;
top: 0;
animation: dot-flashing 1s infinite linear alternate;
}
.dot-flashing::before {
animation-delay: 0s;
}
.dot-flashing::after {
animation-delay: 1s;
}
@keyframes dot-flashing {
0% {
background-color: #667085;
}
50%,
100% {
background-color: rgba(102, 112, 133, 0.3);
}
}
@keyframes dot-flashing-avatar {
0% {
background-color: #155EEF;
}
50%,
100% {
background-color: rgba(21, 94, 239, 0.3);
}
}
.text,
.text::before,
.text::after {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: #667085;
color: #667085;
animation: dot-flashing 1s infinite linear alternate;
}
.text {
animation-delay: 0.5s;
}
.text::before {
left: -7px;
animation-delay: 0s;
}
.text::after {
left: 7px;
animation-delay: 1s;
}
.avatar,
.avatar::before,
.avatar::after {
width: 2px;
height: 2px;
border-radius: 50%;
background-color: #155EEF;
color: #155EEF;
animation: dot-flashing-avatar 1s infinite linear alternate;
}
.avatar {
animation-delay: 0.5s;
}
.avatar::before {
left: -5px;
animation-delay: 0s;
}
.avatar::after {
left: 5px;
animation-delay: 1s;
}

View File

@@ -0,0 +1,42 @@
import type { FC } from 'react'
import { RiFileList3Line } from '@remixicon/react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { useStore as useAppStore } from '@/app/components/app/store'
import ActionButton from '@/app/components/base/action-button'
type LogProps = {
logItem: IChatItem
}
const Log: FC<LogProps> = ({
logItem,
}) => {
const setCurrentLogItem = useAppStore(s => s.setCurrentLogItem)
const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal)
const setShowAgentLogModal = useAppStore(s => s.setShowAgentLogModal)
const setShowMessageLogModal = useAppStore(s => s.setShowMessageLogModal)
const { workflow_run_id: runID, agent_thoughts } = logItem
const isAgent = agent_thoughts && agent_thoughts.length > 0
return (
<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'
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setCurrentLogItem(logItem)
if (runID)
setShowMessageLogModal(true)
else if (isAgent)
setShowAgentLogModal(true)
else
setShowPromptLogModal(true)
}}
>
<ActionButton>
<RiFileList3Line className='h-4 w-4' />
</ActionButton>
</div>
)
}
export default Log

View File

@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import type { ChatItem } from '../types'
import Question from './question'
import { User } from '@/app/components/base/icons/src/public/avatar'
const meta = {
title: 'Base/Other/Chat Question',
component: Question,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {},
args: {},
} satisfies Meta<typeof Question>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
item: {
id: '1',
isAnswer: false,
content: 'You are a helpful assistant.',
} satisfies ChatItem,
theme: undefined,
questionIcon: <div className='h-full w-full rounded-full border-[0.5px] border-black/5'>
<User className='h-full w-full' />
</div>,
},
}

View File

@@ -0,0 +1,182 @@
import type {
FC,
ReactNode,
} from 'react'
import {
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import type { ChatItem } from '../types'
import type { Theme } from '../embedded-chatbot/theme/theme-context'
import { CssTransform } from '../embedded-chatbot/theme/utils'
import ContentSwitch from './content-switch'
import { User } from '@/app/components/base/icons/src/public/avatar'
import { Markdown } from '@/app/components/base/markdown'
import { FileList } from '@/app/components/base/file-uploader'
import ActionButton from '../../action-button'
import { RiClipboardLine, RiEditLine } from '@remixicon/react'
import Toast from '../../toast'
import copy from 'copy-to-clipboard'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import Textarea from 'react-textarea-autosize'
import Button from '../../button'
import { useChatContext } from './context'
type QuestionProps = {
item: ChatItem
questionIcon?: ReactNode
theme: Theme | null | undefined
enableEdit?: boolean
switchSibling?: (siblingMessageId: string) => void
}
const Question: FC<QuestionProps> = ({
item,
questionIcon,
theme,
enableEdit = true,
switchSibling,
}) => {
const { t } = useTranslation()
const {
content,
message_files,
} = item
const {
onRegenerate,
} = useChatContext()
const [isEditing, setIsEditing] = useState(false)
const [editedContent, setEditedContent] = useState(content)
const [contentWidth, setContentWidth] = useState(0)
const contentRef = useRef<HTMLDivElement>(null)
const handleEdit = useCallback(() => {
setIsEditing(true)
setEditedContent(content)
}, [content])
const handleResend = useCallback(() => {
setIsEditing(false)
onRegenerate?.(item, { message: editedContent, files: message_files })
}, [editedContent, message_files, item, onRegenerate])
const handleCancelEditing = useCallback(() => {
setIsEditing(false)
setEditedContent(content)
}, [content])
const handleSwitchSibling = useCallback((direction: 'prev' | 'next') => {
if (direction === 'prev') {
if (item.prevSibling)
switchSibling?.(item.prevSibling)
}
else {
if (item.nextSibling)
switchSibling?.(item.nextSibling)
}
}, [switchSibling, item.prevSibling, item.nextSibling])
const getContentWidth = () => {
if (contentRef.current)
setContentWidth(contentRef.current?.clientWidth)
}
useEffect(() => {
if (!contentRef.current)
return
const resizeObserver = new ResizeObserver(() => {
getContentWidth()
})
resizeObserver.observe(contentRef.current)
return () => {
resizeObserver.disconnect()
}
}, [])
return (
<div className='mb-2 flex justify-end last:mb-0'>
<div className={cn('group relative mr-4 flex max-w-full items-start overflow-x-hidden pl-14', isEditing && 'flex-1')}>
<div className={cn('mr-2 gap-1', isEditing ? 'hidden' : 'flex')}>
<div
className="absolute hidden gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex"
style={{ right: contentWidth + 8 }}
>
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') })
}}>
<RiClipboardLine className='h-4 w-4' />
</ActionButton>
{enableEdit && <ActionButton onClick={handleEdit}>
<RiEditLine className='h-4 w-4' />
</ActionButton>}
</div>
</div>
<div
ref={contentRef}
className='w-full rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 px-4 py-3 text-sm text-text-primary'
style={theme?.chatBubbleColorStyle ? CssTransform(theme.chatBubbleColorStyle) : {}}
>
{
!!message_files?.length && (
<FileList
className='mb-2'
files={message_files}
showDeleteAction={false}
showDownloadAction={true}
/>
)
}
{!isEditing
? <Markdown content={content} />
: <div className="
flex flex-col gap-2 rounded-xl
border border-components-chat-input-border bg-components-panel-bg-blur p-[9px] shadow-md
">
<div className="max-h-[158px] overflow-y-auto overflow-x-hidden">
<Textarea
className={cn(
'body-lg-regular w-full p-1 leading-6 text-text-tertiary outline-none',
)}
autoFocus
minRows={1}
value={editedContent}
onChange={e => setEditedContent(e.target.value)}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant='ghost' onClick={handleCancelEditing}>{t('common.operation.cancel')}</Button>
<Button variant='primary' onClick={handleResend}>{t('common.chat.resend')}</Button>
</div>
</div>}
{!isEditing && <ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}
prevDisabled={!item.prevSibling}
nextDisabled={!item.nextSibling}
switchSibling={handleSwitchSibling}
/>}
</div>
<div className='mt-1 h-[18px]' />
</div>
<div className='h-10 w-10 shrink-0'>
{
questionIcon || (
<div className='h-full w-full rounded-full border-[0.5px] border-black/5'>
<User className='h-full w-full' />
</div>
)
}
</div>
</div>
)
}
export default memo(Question)

View File

@@ -0,0 +1,58 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import type { ThoughtItem, ToolInfoInThought } from '../type'
import ToolDetail from '@/app/components/base/chat/chat/answer/tool-detail'
export type IThoughtProps = {
thought: ThoughtItem
isFinished: boolean
}
function getValue(value: string, isValueArray: boolean, index: number) {
if (isValueArray) {
try {
return JSON.parse(value)[index]
}
catch {
}
}
return value
}
const Thought: FC<IThoughtProps> = ({
thought,
isFinished,
}) => {
const [toolNames, isValueArray]: [string[], boolean] = (() => {
try {
if (Array.isArray(JSON.parse(thought.tool)))
return [JSON.parse(thought.tool), true]
}
catch {
}
return [[thought.tool], false]
})()
const toolThoughtList = toolNames.map((toolName, index) => {
return {
name: toolName,
label: thought.tool_labels?.toolName?.language ?? toolName,
input: getValue(thought.tool_input, isValueArray, index),
output: getValue(thought.observation, isValueArray, index),
isFinished,
}
})
return (
<div className='my-2 space-y-2'>
{toolThoughtList.map((item: ToolInfoInThought, index) => (
<ToolDetail
key={index}
payload={item}
/>
))}
</div>
)
}
export default React.memo(Thought)

View File

@@ -0,0 +1,47 @@
import type { FC } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import type { OnSend } from '../types'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import cn from '@/utils/classnames'
type TryToAskProps = {
suggestedQuestions: string[]
onSend: OnSend
isMobile?: boolean
}
const TryToAsk: FC<TryToAskProps> = ({
suggestedQuestions,
onSend,
isMobile,
}) => {
const { t } = useTranslation()
return (
<div className='mb-2 py-2'>
<div className={cn('mb-2.5 flex items-center justify-between gap-2', isMobile && 'justify-end')}>
<Divider bgStyle='gradient' className='h-px grow rotate-180' />
<div className='system-xs-medium-uppercase shrink-0 text-text-tertiary'>{t('appDebug.feature.suggestedQuestionsAfterAnswer.tryToAsk')}</div>
{!isMobile && <Divider bgStyle='gradient' className='h-px grow' />}
</div>
<div className={cn('flex flex-wrap justify-center', isMobile && 'justify-end')}>
{
suggestedQuestions.map((suggestQuestion, index) => (
<Button
size='small'
key={index}
variant='secondary-accent'
className='mb-1 mr-1 last:mr-0'
onClick={() => onSend(suggestQuestion)}
>
{suggestQuestion}
</Button>
))
}
</div>
</div>
)
}
export default memo(TryToAsk)

View File

@@ -0,0 +1,148 @@
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Annotation, MessageRating } from '@/models/log'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputVarType } from '@/app/components/workflow/types'
import type { FileResponse } from '@/types/workflow'
export type MessageMore = {
time: string
tokens: number
latency: number | string
}
export type FeedbackType = {
rating: MessageRating
content?: string | null
}
export type FeedbackFunc = (
messageId: string,
feedback: FeedbackType,
) => Promise<any>
export type SubmitAnnotationFunc = (
messageId: string,
content: string,
) => Promise<any>
export type DisplayScene = 'web' | 'console'
export type ToolInfoInThought = {
name: string
label: string
input: string
output: string
isFinished: boolean
}
export type ThoughtItem = {
id: string
tool: string // plugin or dataset. May has multi.
thought: string
tool_input: string
tool_labels?: { [key: string]: TypeWithI18N }
message_id: string
conversation_id: string
observation: string
position: number
files?: string[]
message_files?: FileEntity[]
}
export type CitationItem = {
content: string
data_source_type: string
dataset_name: string
dataset_id: string
document_id: string
document_name: string
hit_count: number
index_node_hash: string
segment_id: string
segment_position: number
score: number
word_count: number
}
export type IChatItem = {
id: string
content: string
citation?: CitationItem[]
/**
* Specific message type
*/
isAnswer: boolean
/**
* The user feedback result of this message
*/
feedback?: FeedbackType
/**
* The admin feedback result of this message
*/
adminFeedback?: FeedbackType
/**
* Whether to hide the feedback area
*/
feedbackDisabled?: boolean
/**
* More information about this message
*/
more?: MessageMore
annotation?: Annotation
useCurrentUserAvatar?: boolean
isOpeningStatement?: boolean
suggestedQuestions?: string[]
log?: { role: string; text: string; files?: FileEntity[] }[]
agent_thoughts?: ThoughtItem[]
message_files?: FileEntity[]
workflow_run_id?: string
// for agent log
conversationId?: string
input?: any
parentMessageId?: string | null
siblingCount?: number
siblingIndex?: number
prevSibling?: string
nextSibling?: string
}
export type Metadata = {
retriever_resources?: CitationItem[]
annotation_reply: {
id: string
account: {
id: string
name: string
}
}
}
export type MessageEnd = {
id: string
metadata: Metadata
files?: FileResponse[]
}
export type MessageReplace = {
id: string
task_id: string
answer: string
conversation_id: string
}
export type AnnotationReply = {
id: string
task_id: string
answer: string
conversation_id: string
annotation_id: string
annotation_author_name: string
}
export type InputForm = {
type: InputVarType
label: string
variable: any
required: boolean
hide: boolean
[key: string]: any
}

View File

@@ -0,0 +1,58 @@
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 processInputFileFromServer = (fileItem: Record<string, any>) => {
return {
type: fileItem.type,
transfer_method: fileItem.transfer_method,
url: fileItem.remote_url,
upload_file_id: fileItem.related_id,
}
}
export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: InputForm[]) => {
const processedInputs = { ...inputs }
inputsForm.forEach((item) => {
const inputValue = inputs[item.variable]
// set boolean type default value
if(item.type === InputVarType.checkbox) {
processedInputs[item.variable] = !!inputValue
return
}
if (!inputValue)
return
if (item.type === InputVarType.singleFile) {
if ('transfer_method' in inputValue)
processedInputs[item.variable] = processInputFileFromServer(inputValue)
else
processedInputs[item.variable] = getProcessedFiles([inputValue])[0]
}
else if (item.type === InputVarType.multiFiles) {
if ('transfer_method' in inputValue[0])
processedInputs[item.variable] = inputValue.map(processInputFileFromServer)
else
processedInputs[item.variable] = getProcessedFiles(inputValue)
}
})
return processedInputs
}

View File

@@ -0,0 +1,2 @@
export const CONVERSATION_ID_INFO = 'conversationIdInfo'
export const UUID_NIL = '00000000-0000-0000-0000-000000000000'

View File

@@ -0,0 +1,278 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import Chat from '../chat'
import type {
ChatConfig,
ChatItem,
OnSend,
} from '../types'
import { useChat } from '../chat/hooks'
import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
import { useEmbeddedChatbotContext } from './context'
import { isDify } from './utils'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import InputsForm from '@/app/components/base/chat/embedded-chatbot/inputs-form'
import {
fetchSuggestedQuestions,
getUrl,
stopChatMessageResponding,
} from '@/service/share'
import AppIcon from '@/app/components/base/app-icon'
import LogoAvatar from '@/app/components/base/logo/logo-embedded-chat-avatar'
import AnswerIcon from '@/app/components/base/answer-icon'
import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions'
import { Markdown } from '@/app/components/base/markdown'
import cn from '@/utils/classnames'
import type { FileEntity } from '../../file-uploader/types'
import Avatar from '../../avatar'
const ChatWrapper = () => {
const {
appData,
appParams,
appPrevChatList,
currentConversationId,
currentConversationItem,
currentConversationInputs,
inputsForms,
newConversationInputs,
newConversationInputsRef,
handleNewConversationCompleted,
isMobile,
isInstalledApp,
appId,
appMeta,
handleFeedback,
currentChatInstanceRef,
themeBuilder,
clearChatList,
setClearChatList,
setIsResponding,
allInputsHidden,
initUserVariables,
} = useEmbeddedChatbotContext()
const appConfig = useMemo(() => {
const config = appParams || {}
return {
...config,
file_upload: {
...(config as any).file_upload,
fileUploadConfig: (config as any).system_parameters,
},
supportFeedback: true,
opening_statement: currentConversationItem?.introduction || (config as any).opening_statement,
} as ChatConfig
}, [appParams, currentConversationItem?.introduction])
const {
chatList,
setTargetMessageId,
handleSend,
handleStop,
isResponding: respondingState,
suggestedQuestions,
} = useChat(
appConfig,
{
inputs: (currentConversationId ? currentConversationInputs : newConversationInputs) as any,
inputsForm: inputsForms,
},
appPrevChatList,
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
clearChatList,
setClearChatList,
)
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
const inputDisabled = useMemo(() => {
if (allInputsHidden)
return false
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox) // boolean can be not checked
if (requiredVars.length) {
requiredVars.forEach(({ variable, label, type }) => {
if (hasEmptyInput)
return
if (fileIsUploading)
return
if (!inputsFormValue?.[variable])
hasEmptyInput = label as string
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputsFormValue?.[variable]) {
const files = inputsFormValue[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)
return true
if (fileIsUploading)
return true
return false
}, [inputsFormValue, inputsForms, allInputsHidden])
useEffect(() => {
if (currentChatInstanceRef.current)
currentChatInstanceRef.current.handleStop = handleStop
}, [currentChatInstanceRef, handleStop])
useEffect(() => {
setIsResponding(respondingState)
}, [respondingState, setIsResponding])
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
const data: any = {
query: message,
files,
inputs: currentConversationId ? currentConversationInputs : newConversationInputs,
conversation_id: currentConversationId,
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
}
handleSend(
getUrl('chat-messages', isInstalledApp, appId || ''),
data,
{
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp,
},
)
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted])
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
const parentAnswer = chatList.find(item => item.id === question.parentMessageId)
doSend(editedQuestion ? editedQuestion.message : question.content,
editedQuestion ? editedQuestion.files : question.message_files,
true,
isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null,
)
}, [chatList, doSend])
const messageList = useMemo(() => {
if (currentConversationId || chatList.length > 1)
return chatList
// Without messages we are in the welcome screen, so hide the opening statement from chatlist
return chatList.filter(item => !item.isOpeningStatement)
}, [chatList, currentConversationId])
const [collapsed, setCollapsed] = useState(!!currentConversationId)
const chatNode = useMemo(() => {
if (allInputsHidden || !inputsForms.length)
return null
if (isMobile) {
if (!currentConversationId)
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
return <div className='mb-4'></div>
}
else {
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
}
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
const welcome = useMemo(() => {
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
if (respondingState)
return null
if (currentConversationId)
return null
if (!welcomeMessage)
return null
if (!collapsed && inputsForms.length > 0 && !allInputsHidden)
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]')}>
<div className='flex max-w-[720px] grow gap-4'>
<AppIcon
size='xl'
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background={appData?.site.icon_background}
imageUrl={appData?.site.icon_url}
/>
<div className='body-lg-regular grow rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary'>
<Markdown content={welcomeMessage.content} />
<SuggestedQuestions item={welcomeMessage} />
</div>
</div>
</div>
)
}
return (
<div className={cn('flex h-[50vh] flex-col items-center justify-center gap-3 py-12', isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]')}>
<AppIcon
size='xl'
iconType={appData?.site.icon_type}
icon={appData?.site.icon}
background={appData?.site.icon_background}
imageUrl={appData?.site.icon_url}
/>
<div className='max-w-[768px] px-4'>
<Markdown className='!body-2xl-regular !text-text-tertiary' content={welcomeMessage.content} />
</div>
</div>
)
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
const answerIcon = isDify()
? <LogoAvatar className='relative shrink-0' />
: (appData?.site && appData.site.use_icon_as_answer_icon)
? <AnswerIcon
iconType={appData.site.icon_type}
icon={appData.site.icon}
background={appData.site.icon_background}
imageUrl={appData.site.icon_url}
/>
: null
return (
<Chat
appData={appData || undefined}
config={appConfig}
chatList={messageList}
isResponding={respondingState}
chatContainerInnerClassName={cn('mx-auto w-full max-w-full px-4', messageList.length && 'pt-4')}
chatFooterClassName={cn('pb-4', !isMobile && 'rounded-b-2xl')}
chatFooterInnerClassName={cn('mx-auto w-full max-w-full px-4', isMobile && 'px-2')}
onSend={doSend}
inputs={currentConversationId ? currentConversationInputs as any : newConversationInputs}
inputsForm={inputsForms}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
chatNode={
<>
{chatNode}
{welcome}
</>
}
allToolIcons={appMeta?.tool_icons || {}}
onFeedback={handleFeedback}
suggestedQuestions={suggestedQuestions}
answerIcon={answerIcon}
hideProcessDetail
themeBuilder={themeBuilder}
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
inputDisabled={inputDisabled}
isMobile={isMobile}
questionIcon={
initUserVariables?.avatar_url
? <Avatar
avatar={initUserVariables.avatar_url}
name={initUserVariables.name || 'user'}
size={40}
/> : undefined
}
/>
)
}
export default ChatWrapper

View File

@@ -0,0 +1,90 @@
'use client'
import type { RefObject } from 'react'
import { createContext, useContext } from 'use-context-selector'
import type {
ChatConfig,
ChatItem,
Feedback,
} from '../types'
import type { ThemeBuilder } from './theme/theme-context'
import type {
AppConversationData,
AppData,
AppMeta,
ConversationItem,
} from '@/models/share'
import { noop } from 'lodash-es'
export type EmbeddedChatbotContextValue = {
appMeta: AppMeta | null
appData: AppData | null
appParams: ChatConfig | null
appChatListDataLoading?: boolean
currentConversationId: string
currentConversationItem?: ConversationItem
appPrevChatList: ChatItem[]
pinnedConversationList: AppConversationData['data']
conversationList: AppConversationData['data']
newConversationInputs: Record<string, any>
newConversationInputsRef: RefObject<Record<string, any>>
handleNewConversationInputsChange: (v: Record<string, any>) => void
inputsForms: any[]
handleNewConversation: () => void
handleStartChat: (callback?: any) => void
handleChangeConversation: (conversationId: string) => void
handleNewConversationCompleted: (newConversationId: string) => void
chatShouldReloadKey: string
isMobile: boolean
isInstalledApp: boolean
allowResetChat: boolean
appId?: string
handleFeedback: (messageId: string, feedback: Feedback) => void
currentChatInstanceRef: RefObject<{ handleStop: () => void }>
themeBuilder?: ThemeBuilder
clearChatList?: boolean
setClearChatList: (state: boolean) => void
isResponding?: boolean
setIsResponding: (state: boolean) => void,
currentConversationInputs: Record<string, any> | null,
setCurrentConversationInputs: (v: Record<string, any>) => void,
allInputsHidden: boolean
initUserVariables?: {
name?: string
avatar_url?: string
}
}
export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({
appData: null,
appMeta: null,
appParams: null,
appChatListDataLoading: false,
currentConversationId: '',
appPrevChatList: [],
pinnedConversationList: [],
conversationList: [],
newConversationInputs: {},
newConversationInputsRef: { current: {} },
handleNewConversationInputsChange: noop,
inputsForms: [],
handleNewConversation: noop,
handleStartChat: noop,
handleChangeConversation: noop,
handleNewConversationCompleted: noop,
chatShouldReloadKey: '',
isMobile: false,
isInstalledApp: false,
allowResetChat: true,
handleFeedback: noop,
currentChatInstanceRef: { current: { handleStop: noop } },
clearChatList: false,
setClearChatList: noop,
isResponding: false,
setIsResponding: noop,
currentConversationInputs: {},
setCurrentConversationInputs: noop,
allInputsHidden: false,
initUserVariables: {},
})
export const useEmbeddedChatbotContext = () => useContext(EmbeddedChatbotContext)

View File

@@ -0,0 +1,183 @@
import type { FC } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { RiCollapseDiagonal2Line, RiExpandDiagonal2Line, RiResetLeftLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import type { Theme } from '../theme/theme-context'
import { CssTransform } from '../theme/utils'
import {
useEmbeddedChatbotContext,
} from '../context'
import Tooltip from '@/app/components/base/tooltip'
import ActionButton from '@/app/components/base/action-button'
import Divider from '@/app/components/base/divider'
import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
export type IHeaderProps = {
isMobile?: boolean
allowResetChat?: boolean
customerIcon?: React.ReactNode
title: string
theme?: Theme
onCreateNewChat?: () => void
}
const Header: FC<IHeaderProps> = ({
isMobile,
allowResetChat,
customerIcon,
title,
theme,
onCreateNewChat,
}) => {
const { t } = useTranslation()
const {
appData,
currentConversationId,
inputsForms,
allInputsHidden,
} = useEmbeddedChatbotContext()
const isClient = typeof window !== 'undefined'
const isIframe = isClient ? window.self !== window.top : false
const [parentOrigin, setParentOrigin] = useState('')
const [showToggleExpandButton, setShowToggleExpandButton] = useState(false)
const [expanded, setExpanded] = useState(false)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const handleMessageReceived = useCallback((event: MessageEvent) => {
let currentParentOrigin = parentOrigin
if (!currentParentOrigin && event.data.type === 'dify-chatbot-config') {
currentParentOrigin = event.origin
setParentOrigin(event.origin)
}
if (event.origin !== currentParentOrigin)
return
if (event.data.type === 'dify-chatbot-config')
setShowToggleExpandButton(event.data.payload.isToggledByButton && !event.data.payload.isDraggable)
}, [parentOrigin])
useEffect(() => {
if (!isIframe) return
const listener = (event: MessageEvent) => handleMessageReceived(event)
window.addEventListener('message', listener)
window.parent.postMessage({ type: 'dify-chatbot-iframe-ready' }, '*')
return () => window.removeEventListener('message', listener)
}, [isIframe, handleMessageReceived])
const handleToggleExpand = useCallback(() => {
if (!isIframe || !showToggleExpandButton) return
setExpanded(!expanded)
window.parent.postMessage({
type: 'dify-chatbot-expand-change',
}, parentOrigin)
}, [isIframe, parentOrigin, showToggleExpandButton, expanded])
if (!isMobile) {
return (
<div className='flex h-14 shrink-0 items-center justify-end p-3'>
<div className='flex items-center gap-1'>
{/* powered by */}
<div className='shrink-0'>
{!appData?.custom_config?.remove_webapp_brand && (
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-2',
)}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
: appData?.custom_config?.replace_webapp_logo
? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
: <DifyLogo size='small' />
}
</div>
)}
</div>
{currentConversationId && (
<Divider type='vertical' className='h-3.5' />
)}
{
showToggleExpandButton && (
<Tooltip
popupContent={expanded ? t('share.chat.collapse') : t('share.chat.expand')}
>
<ActionButton size='l' onClick={handleToggleExpand}>
{
expanded
? <RiCollapseDiagonal2Line className='h-[18px] w-[18px]' />
: <RiExpandDiagonal2Line className='h-[18px] w-[18px]' />
}
</ActionButton>
</Tooltip>
)
}
{currentConversationId && allowResetChat && (
<Tooltip
popupContent={t('share.chat.resetChat')}
>
<ActionButton size='l' onClick={onCreateNewChat}>
<RiResetLeftLine className='h-[18px] w-[18px]' />
</ActionButton>
</Tooltip>
)}
{currentConversationId && inputsForms.length > 0 && !allInputsHidden && (
<ViewFormDropdown />
)}
</div>
</div>
)
}
return (
<div
className={cn('flex h-14 shrink-0 items-center justify-between rounded-t-2xl px-3')}
style={CssTransform(theme?.headerBorderBottomStyle ?? '')}
>
<div className="flex grow items-center space-x-3">
{customerIcon}
<div
className='system-md-semibold truncate'
style={CssTransform(theme?.colorFontOnHeaderStyle ?? '')}
>
{title}
</div>
</div>
<div className='flex items-center gap-1'>
{
showToggleExpandButton && (
<Tooltip
popupContent={expanded ? t('share.chat.collapse') : t('share.chat.expand')}
>
<ActionButton size='l' onClick={handleToggleExpand}>
{
expanded
? <RiCollapseDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
: <RiExpandDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
}
</ActionButton>
</Tooltip>
)
}
{currentConversationId && allowResetChat && (
<Tooltip
popupContent={t('share.chat.resetChat')}
>
<ActionButton size='l' onClick={onCreateNewChat}>
<RiResetLeftLine className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
</ActionButton>
</Tooltip>
)}
{currentConversationId && inputsForms.length > 0 && !allInputsHidden && (
<ViewFormDropdown iconColor={theme?.colorPathOnHeader} />
)}
</div>
</div>
)
}
export default React.memo(Header)

View File

@@ -0,0 +1,430 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useLocalStorageState } from 'ahooks'
import { produce } from 'immer'
import type {
ChatConfig,
ChatItem,
Feedback,
} from '../types'
import { CONVERSATION_ID_INFO } from '../constants'
import { buildChatItemTree, getProcessedInputsFromUrlParams, getProcessedSystemVariablesFromUrlParams, getProcessedUserVariablesFromUrlParams } from '../utils'
import { getProcessedFilesFromResponse } from '../../file-uploader/utils'
import {
fetchChatList,
fetchConversations,
generationConversationName,
updateFeedback,
} from '@/service/share'
import type {
// AppData,
ConversationItem,
} from '@/models/share'
import { useToastContext } from '@/app/components/base/toast'
import { changeLanguage } from '@/i18n-config/i18next-config'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { noop } from 'lodash-es'
import { useWebAppStore } from '@/context/web-app-context'
function getFormattedChatList(messages: any[]) {
const newChatList: ChatItem[] = []
messages.forEach((item) => {
const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || []
newChatList.push({
id: `question-${item.id}`,
content: item.query,
isAnswer: false,
message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id }))),
parentMessageId: item.parent_message_id || undefined,
})
const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
newChatList.push({
id: item.id,
content: item.answer,
agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files),
feedback: item.feedback,
isAnswer: true,
citation: item.retriever_resources,
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))),
parentMessageId: `question-${item.id}`,
})
})
return newChatList
}
export const useEmbeddedChatbot = () => {
const isInstalledApp = false
const appInfo = useWebAppStore(s => s.appInfo)
const appMeta = useWebAppStore(s => s.appMeta)
const appParams = useWebAppStore(s => s.appParams)
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(() => {
setUserId(embeddedUserId || undefined)
}, [embeddedUserId])
useEffect(() => {
setConversationId(embeddedConversationId || undefined)
}, [embeddedConversationId])
useEffect(() => {
const setLanguageFromParams = async () => {
// Check URL parameters for language override
const urlParams = new URLSearchParams(window.location.search)
const localeParam = urlParams.get('locale')
// Check for encoded system variables
const systemVariables = await getProcessedSystemVariablesFromUrlParams()
const localeFromSysVar = systemVariables.locale
if (localeParam) {
// If locale parameter exists in URL, use it instead of default
await changeLanguage(localeParam)
}
else if (localeFromSysVar) {
// If locale is set as a system variable, use that
await changeLanguage(localeFromSysVar)
}
else if (appInfo?.site.default_language) {
// Otherwise use the default from app config
await changeLanguage(appInfo.site.default_language)
}
}
setLanguageFromParams()
}, [appInfo])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
defaultValue: {},
})
const allowResetChat = !conversationId
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || conversationId || '',
[appId, conversationIdInfo, userId, conversationId])
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
if (appId) {
let prevValue = conversationIdInfo?.[appId || '']
if (typeof prevValue === 'string')
prevValue = {}
setConversationIdInfo({
...conversationIdInfo,
[appId || '']: {
...prevValue,
[userId || 'DEFAULT']: changeConversationId,
},
})
}
}, [appId, conversationIdInfo, setConversationIdInfo, userId])
const [newConversationId, setNewConversationId] = useState('')
const chatShouldReloadKey = useMemo(() => {
if (currentConversationId === newConversationId)
return ''
return currentConversationId
}, [currentConversationId, newConversationId])
const { data: appPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations(isInstalledApp, appId, undefined, true, 100))
const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100))
const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId))
const [clearChatList, setClearChatList] = useState(false)
const [isResponding, setIsResponding] = useState(false)
const appPrevChatList = useMemo(
() => (currentConversationId && appChatListData?.data.length)
? buildChatItemTree(getFormattedChatList(appChatListData.data))
: [],
[appChatListData, currentConversationId],
)
const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false)
const pinnedConversationList = useMemo(() => {
return appPinnedConversationData?.data || []
}, [appPinnedConversationData])
const { t } = useTranslation()
const newConversationInputsRef = useRef<Record<string, any>>({})
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any>>({})
const [initInputs, setInitInputs] = useState<Record<string, any>>({})
const [initUserVariables, setInitUserVariables] = useState<Record<string, any>>({})
const handleNewConversationInputsChange = useCallback((newInputs: Record<string, any>) => {
newConversationInputsRef.current = newInputs
setNewConversationInputs(newInputs)
}, [])
const inputsForms = useMemo(() => {
return (appParams?.user_input_form || []).filter((item: any) => !item.external_data_tool).map((item: any) => {
if (item.paragraph) {
let value = initInputs[item.paragraph.variable]
if (value && item.paragraph.max_length && value.length > item.paragraph.max_length)
value = value.slice(0, item.paragraph.max_length)
return {
...item.paragraph,
default: value || item.default || item.paragraph.default,
type: 'paragraph',
}
}
if (item.number) {
const convertedNumber = Number(initInputs[item.number.variable])
return {
...item.number,
default: convertedNumber || item.default || item.number.default,
type: 'number',
}
}
if (item.checkbox) {
const preset = initInputs[item.checkbox.variable] === true
return {
...item.checkbox,
default: preset || item.default || item.checkbox.default,
type: 'checkbox',
}
}
if (item.select) {
const isInputInOptions = item.select.options.includes(initInputs[item.select.variable])
return {
...item.select,
default: (isInputInOptions ? initInputs[item.select.variable] : undefined) || item.select.default,
type: 'select',
}
}
if (item['file-list']) {
return {
...item['file-list'],
type: 'file-list',
}
}
if (item.file) {
return {
...item.file,
type: 'file',
}
}
if (item.json_object) {
return {
...item.json_object,
type: 'json_object',
}
}
let value = initInputs[item['text-input'].variable]
if (value && item['text-input'].max_length && value.length > item['text-input'].max_length)
value = value.slice(0, item['text-input'].max_length)
return {
...item['text-input'],
default: value || item.default || item['text-input'].default,
type: 'text-input',
}
})
}, [initInputs, appParams])
const allInputsHidden = useMemo(() => {
return inputsForms.length > 0 && inputsForms.every(item => item.hide === true)
}, [inputsForms])
useEffect(() => {
// init inputs from url params
(async () => {
const inputs = await getProcessedInputsFromUrlParams()
const userVariables = await getProcessedUserVariablesFromUrlParams()
setInitInputs(inputs)
setInitUserVariables(userVariables)
})()
}, [])
useEffect(() => {
const conversationInputs: Record<string, any> = {}
inputsForms.forEach((item: any) => {
conversationInputs[item.variable] = item.default || null
})
handleNewConversationInputsChange(conversationInputs)
}, [handleNewConversationInputsChange, inputsForms])
const { data: newConversation } = useSWR(newConversationId ? [isInstalledApp, appId, newConversationId] : null, () => generationConversationName(isInstalledApp, appId, newConversationId), { revalidateOnFocus: false })
const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([])
useEffect(() => {
if (appConversationData?.data && !appConversationDataLoading)
setOriginConversationList(appConversationData?.data)
}, [appConversationData, appConversationDataLoading])
const conversationList = useMemo(() => {
const data = originConversationList.slice()
if (showNewConversationItemInList && data[0]?.id !== '') {
data.unshift({
id: '',
name: t('share.chat.newChatDefaultName'),
inputs: {},
introduction: '',
})
}
return data
}, [originConversationList, showNewConversationItemInList, t])
useEffect(() => {
if (newConversation) {
setOriginConversationList(produce((draft) => {
const index = draft.findIndex(item => item.id === newConversation.id)
if (index > -1)
draft[index] = newConversation
else
draft.unshift(newConversation)
}))
}
}, [newConversation])
const currentConversationItem = useMemo(() => {
let conversationItem = conversationList.find(item => item.id === currentConversationId)
if (!conversationItem && pinnedConversationList.length)
conversationItem = pinnedConversationList.find(item => item.id === currentConversationId)
return conversationItem
}, [conversationList, currentConversationId, pinnedConversationList])
const currentConversationLatestInputs = useMemo(() => {
if (!currentConversationId || !appChatListData?.data.length)
return newConversationInputsRef.current || {}
return appChatListData.data.slice().pop().inputs || {}
}, [appChatListData, currentConversationId])
const [currentConversationInputs, setCurrentConversationInputs] = useState<Record<string, any>>(currentConversationLatestInputs || {})
useEffect(() => {
if (currentConversationItem)
setCurrentConversationInputs(currentConversationLatestInputs || {})
}, [currentConversationItem, currentConversationLatestInputs])
const { notify } = useToastContext()
const checkInputsRequired = useCallback((silent?: boolean) => {
if (allInputsHidden)
return true
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox)
if (requiredVars.length) {
requiredVars.forEach(({ variable, label, type }) => {
if (hasEmptyInput)
return
if (fileIsUploading)
return
if (!newConversationInputsRef.current[variable] && !silent)
hasEmptyInput = label as string
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) {
const files = newConversationInputsRef.current[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
}, [inputsForms, notify, t, allInputsHidden])
const handleStartChat = useCallback((callback?: any) => {
if (checkInputsRequired()) {
setShowNewConversationItemInList(true)
callback?.()
}
}, [setShowNewConversationItemInList, checkInputsRequired])
const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: noop })
const handleChangeConversation = useCallback((conversationId: string) => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
handleConversationIdInfoChange(conversationId)
if (conversationId)
setClearChatList(false)
}, [handleConversationIdInfoChange, setClearChatList])
const handleNewConversation = useCallback(async () => {
currentChatInstanceRef.current.handleStop()
setShowNewConversationItemInList(true)
handleChangeConversation('')
handleNewConversationInputsChange(await getProcessedInputsFromUrlParams())
setClearChatList(true)
}, [handleChangeConversation, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList])
const handleNewConversationCompleted = useCallback((newConversationId: string) => {
setNewConversationId(newConversationId)
handleConversationIdInfoChange(newConversationId)
setShowNewConversationItemInList(false)
mutateAppConversationData()
}, [mutateAppConversationData, handleConversationIdInfoChange])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.success') })
}, [isInstalledApp, appId, t, notify])
return {
isInstalledApp,
allowResetChat,
appId,
currentConversationId,
currentConversationItem,
handleConversationIdInfoChange,
appData: appInfo,
appParams: appParams || {} as ChatConfig,
appMeta,
appPinnedConversationData,
appConversationData,
appConversationDataLoading,
appChatListData,
appChatListDataLoading,
appPrevChatList,
pinnedConversationList,
conversationList,
setShowNewConversationItemInList,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handleNewConversationCompleted,
newConversationId,
chatShouldReloadKey,
handleFeedback,
currentChatInstanceRef,
clearChatList,
setClearChatList,
isResponding,
setIsResponding,
currentConversationInputs,
setCurrentConversationInputs,
allInputsHidden,
initUserVariables,
}
}

View File

@@ -0,0 +1,179 @@
'use client'
import {
useEffect,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
EmbeddedChatbotContext,
useEmbeddedChatbotContext,
} from './context'
import { useEmbeddedChatbot } from './hooks'
import { isDify } from './utils'
import { useThemeContext } from './theme/theme-context'
import { CssTransform } from './theme/utils'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Loading from '@/app/components/base/loading'
import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header'
import Header from '@/app/components/base/chat/embedded-chatbot/header'
import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import cn from '@/utils/classnames'
import useDocumentTitle from '@/hooks/use-document-title'
import { useGlobalPublicStore } from '@/context/global-public-context'
const Chatbot = () => {
const {
isMobile,
allowResetChat,
appData,
appChatListDataLoading,
chatShouldReloadKey,
handleNewConversation,
themeBuilder,
} = useEmbeddedChatbotContext()
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const customConfig = appData?.custom_config
const site = appData?.site
const difyIcon = <LogoHeader />
useEffect(() => {
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
}, [site, customConfig, themeBuilder])
useDocumentTitle(site?.title || 'Chat')
return (
<div className='relative'>
<div
className={cn(
'flex flex-col rounded-2xl',
isMobile ? 'h-[calc(100vh_-_60px)] shadow-xs' : 'h-[100vh] bg-chatbot-bg',
)}
style={isMobile ? Object.assign({}, CssTransform(themeBuilder?.theme?.backgroundHeaderColorStyle ?? '')) : {}}
>
<Header
isMobile={isMobile}
allowResetChat={allowResetChat}
title={site?.title || ''}
customerIcon={isDify() ? difyIcon : ''}
theme={themeBuilder?.theme}
onCreateNewChat={handleNewConversation}
/>
<div className={cn('flex grow flex-col overflow-y-auto', isMobile && 'm-[0.5px] !h-[calc(100vh_-_3rem)] rounded-2xl bg-chatbot-bg')}>
{appChatListDataLoading && (
<Loading type='app' />
)}
{!appChatListDataLoading && (
<ChatWrapper key={chatShouldReloadKey} />
)}
</div>
</div>
{/* powered by */}
{isMobile && (
<div className='flex h-[60px] shrink-0 items-center pl-2'>
{!appData?.custom_config?.remove_webapp_brand && (
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-2',
)}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
: appData?.custom_config?.replace_webapp_logo
? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
: <DifyLogo size='small' />
}
</div>
)}
</div>
)}
</div>
)
}
const EmbeddedChatbotWrapper = () => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const themeBuilder = useThemeContext()
const {
appData,
appParams,
appMeta,
appChatListDataLoading,
currentConversationId,
currentConversationItem,
appPrevChatList,
pinnedConversationList,
conversationList,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handleNewConversationCompleted,
chatShouldReloadKey,
isInstalledApp,
allowResetChat,
appId,
handleFeedback,
currentChatInstanceRef,
clearChatList,
setClearChatList,
isResponding,
setIsResponding,
currentConversationInputs,
setCurrentConversationInputs,
allInputsHidden,
initUserVariables,
} = useEmbeddedChatbot()
return <EmbeddedChatbotContext.Provider value={{
appData,
appParams,
appMeta,
appChatListDataLoading,
currentConversationId,
currentConversationItem,
appPrevChatList,
pinnedConversationList,
conversationList,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
inputsForms,
handleNewConversation,
handleStartChat,
handleChangeConversation,
handleNewConversationCompleted,
chatShouldReloadKey,
isMobile,
isInstalledApp,
allowResetChat,
appId,
handleFeedback,
currentChatInstanceRef,
themeBuilder,
clearChatList,
setClearChatList,
isResponding,
setIsResponding,
currentConversationInputs,
setCurrentConversationInputs,
allInputsHidden,
initUserVariables,
}}>
<Chatbot />
</EmbeddedChatbotContext.Provider>
}
const EmbeddedChatbot = () => {
return <EmbeddedChatbotWrapper />
}
export default EmbeddedChatbot

View File

@@ -0,0 +1,142 @@
import React, { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useEmbeddedChatbotContext } from '../context'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { PortalSelect } from '@/app/components/base/select'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { InputVarType } from '@/app/components/workflow/types'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
type Props = {
showTip?: boolean
}
const InputsFormContent = ({ showTip }: Props) => {
const { t } = useTranslation()
const {
appParams,
inputsForms,
currentConversationId,
currentConversationInputs,
setCurrentConversationInputs,
newConversationInputs,
newConversationInputsRef,
handleNewConversationInputsChange,
} = useEmbeddedChatbotContext()
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputs
const handleFormChange = useCallback((variable: string, value: any) => {
setCurrentConversationInputs({
...currentConversationInputs,
[variable]: value,
})
handleNewConversationInputsChange({
...newConversationInputsRef.current,
[variable]: value,
})
}, [newConversationInputsRef, handleNewConversationInputsChange, currentConversationInputs, setCurrentConversationInputs])
const visibleInputsForms = inputsForms.filter(form => form.hide !== true)
return (
<div className='space-y-4'>
{visibleInputsForms.map(form => (
<div key={form.variable} className='space-y-1'>
{form.type !== InputVarType.checkbox && (
<div className='flex h-6 items-center gap-1'>
<div className='system-md-semibold text-text-secondary'>{form.label}</div>
{!form.required && (
<div className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</div>
)}
</div>
)}
{form.type === InputVarType.textInput && (
<Input
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
/>
)}
{form.type === InputVarType.number && (
<Input
type='number'
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
/>
)}
{form.type === InputVarType.paragraph && (
<Textarea
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
/>
)}
{form.type === InputVarType.checkbox && (
<BoolInput
name={form.label}
value={inputsFormValue?.[form.variable]}
required={form.required}
onChange={value => handleFormChange(form.variable, value)}
/>
)}
{form.type === InputVarType.select && (
<PortalSelect
popupClassName='w-[200px]'
value={inputsFormValue?.[form.variable] ?? form.default ?? ''}
items={form.options.map((option: string) => ({ value: option, name: option }))}
onSelect={item => handleFormChange(form.variable, item.value as string)}
placeholder={form.label}
/>
)}
{form.type === InputVarType.singleFile && (
<FileUploaderInAttachmentWrapper
value={inputsFormValue?.[form.variable] ? [inputsFormValue?.[form.variable]] : []}
onChange={files => handleFormChange(form.variable, files[0])}
fileConfig={{
allowed_file_types: form.allowed_file_types,
allowed_file_extensions: form.allowed_file_extensions,
allowed_file_upload_methods: form.allowed_file_upload_methods,
number_limits: 1,
fileUploadConfig: (appParams as any).system_parameters,
}}
/>
)}
{form.type === InputVarType.multiFiles && (
<FileUploaderInAttachmentWrapper
value={inputsFormValue?.[form.variable] || []}
onChange={files => handleFormChange(form.variable, files)}
fileConfig={{
allowed_file_types: form.allowed_file_types,
allowed_file_extensions: form.allowed_file_extensions,
allowed_file_upload_methods: form.allowed_file_upload_methods,
number_limits: form.max_length,
fileUploadConfig: (appParams as any).system_parameters,
}}
/>
)}
{form.type === InputVarType.jsonObject && (
<CodeEditor
language={CodeLanguage.json}
value={inputsFormValue?.[form.variable] || ''}
onChange={v => handleFormChange(form.variable, v)}
noWrapper
className='bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1'
placeholder={
<div className='whitespace-pre'>{form.json_schema}</div>
}
/>
)}
</div>
))}
{showTip && (
<div className='system-xs-regular text-text-tertiary'>{t('share.chat.chatFormTip')}</div>
)}
</div>
)
}
export default memo(InputsFormContent)

View File

@@ -0,0 +1,84 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
import { useEmbeddedChatbotContext } from '../context'
import cn from '@/utils/classnames'
type Props = {
collapsed: boolean
setCollapsed: (collapsed: boolean) => void
}
const InputsFormNode = ({
collapsed,
setCollapsed,
}: Props) => {
const { t } = useTranslation()
const {
isMobile,
currentConversationId,
themeBuilder,
handleStartChat,
allInputsHidden,
inputsForms,
} = useEmbeddedChatbotContext()
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(
'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',
)}>
<div className={cn(
'flex items-center gap-3 rounded-t-2xl px-6 py-4',
!collapsed && 'border-b border-divider-subtle',
isMobile && 'px-4 py-3',
)}>
<Message3Fill className='h-6 w-6 shrink-0' />
<div className='system-xl-semibold grow text-text-secondary'>{t('share.chat.chatSettingsTitle')}</div>
{collapsed && (
<Button className='uppercase text-text-tertiary' size='small' variant='ghost' onClick={() => setCollapsed(false)}>{t('common.operation.edit')}</Button>
)}
{!collapsed && currentConversationId && (
<Button className='uppercase text-text-tertiary' size='small' variant='ghost' onClick={() => setCollapsed(true)}>{t('common.operation.close')}</Button>
)}
</div>
{!collapsed && (
<div className={cn('p-6', isMobile && 'p-4')}>
<InputsFormContent />
</div>
)}
{!collapsed && !currentConversationId && (
<div className={cn('p-6', isMobile && 'p-4')}>
<Button
variant='primary'
className='w-full'
onClick={() => handleStartChat(() => setCollapsed(true))}
style={
themeBuilder?.theme
? {
backgroundColor: themeBuilder?.theme.primaryColor,
}
: {}
}
>{t('share.chat.startChat')}</Button>
</div>
)}
</div>
{collapsed && (
<div className='flex w-full max-w-[720px] items-center py-4'>
<Divider bgStyle='gradient' className='h-px basis-1/2 rotate-180' />
<Divider bgStyle='gradient' className='h-px basis-1/2' />
</div>
)}
</div>
)
}
export default InputsFormNode

View File

@@ -0,0 +1,52 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiChatSettingsLine,
} from '@remixicon/react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
import cn from '@/utils/classnames'
type Props = {
iconColor?: string
}
const ViewFormDropdown = ({ iconColor }: Props) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: 4,
}}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
>
<ActionButton size='l' state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
<RiChatSettingsLine className={cn('h-[18px] w-[18px]', iconColor)} />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<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' />
<div className='system-xl-semibold grow text-text-secondary'>{t('share.chat.chatSettingsTitle')}</div>
</div>
<div className='p-6'>
<InputsFormContent />
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ViewFormDropdown

View File

@@ -0,0 +1,73 @@
import { createContext, useContext } from 'use-context-selector'
import { hexToRGBA } from './utils'
export class Theme {
public chatColorTheme: string | null
public chatColorThemeInverted: boolean
public primaryColor = '#1C64F2'
public backgroundHeaderColorStyle = 'backgroundImage: linear-gradient(to right, #2563eb, #0ea5e9)'
public headerBorderBottomStyle = ''
public colorFontOnHeaderStyle = 'color: white'
public colorPathOnHeader = 'text-text-primary-on-surface'
public backgroundButtonDefaultColorStyle = 'backgroundColor: #1C64F2'
public roundedBackgroundColorStyle = 'backgroundColor: rgb(245 248 255)'
public chatBubbleColorStyle = ''
constructor(chatColorTheme: string | null = null, chatColorThemeInverted = false) {
this.chatColorTheme = chatColorTheme
this.chatColorThemeInverted = chatColorThemeInverted
this.configCustomColor()
this.configInvertedColor()
}
private configCustomColor() {
if (this.chatColorTheme !== null && this.chatColorTheme !== '') {
this.primaryColor = this.chatColorTheme ?? '#1C64F2'
this.backgroundHeaderColorStyle = `backgroundColor: ${this.primaryColor}`
this.backgroundButtonDefaultColorStyle = `backgroundColor: ${this.primaryColor}; color: ${this.colorFontOnHeaderStyle};`
this.roundedBackgroundColorStyle = `backgroundColor: ${hexToRGBA(this.primaryColor, 0.05)}`
this.chatBubbleColorStyle = `backgroundColor: ${hexToRGBA(this.primaryColor, 0.15)}`
}
}
private configInvertedColor() {
if (this.chatColorThemeInverted) {
this.backgroundHeaderColorStyle = 'backgroundColor: #ffffff'
this.colorFontOnHeaderStyle = `color: ${this.primaryColor}`
this.headerBorderBottomStyle = 'borderBottom: 1px solid #ccc'
this.colorPathOnHeader = this.primaryColor
}
}
}
export class ThemeBuilder {
private _theme?: Theme
private buildChecker = false
public get theme() {
if (this._theme === undefined) {
this._theme = new Theme()
return this._theme
}
else {
return this._theme
}
}
public buildTheme(chatColorTheme: string | null = null, chatColorThemeInverted = false) {
if (!this.buildChecker) {
this._theme = new Theme(chatColorTheme, chatColorThemeInverted)
this.buildChecker = true
}
else {
if (this.theme?.chatColorTheme !== chatColorTheme || this.theme?.chatColorThemeInverted !== chatColorThemeInverted) {
this._theme = new Theme(chatColorTheme, chatColorThemeInverted)
this.buildChecker = true
}
}
}
}
const ThemeContext = createContext<ThemeBuilder>(new ThemeBuilder())
export const useThemeContext = () => useContext(ThemeContext)

View File

@@ -0,0 +1,29 @@
export function hexToRGBA(hex: string, opacity: number): string {
hex = hex.replace('#', '')
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
// Returning an RGB color object
return `rgba(${r},${g},${b},${opacity.toString()})`
}
/**
* Since strings cannot be directly assigned to the 'style' attribute in JSX,
* this method transforms the string into an object representation of the styles.
*/
export function CssTransform(cssString: string): object {
if (cssString.length === 0)
return {}
const style: object = {}
const propertyValuePairs = cssString.split(';')
for (const pair of propertyValuePairs) {
if (pair.trim().length > 0) {
const [property, value] = pair.split(':')
Object.assign(style, { [property.trim()]: value.trim() })
}
}
return style
}

View File

@@ -0,0 +1,3 @@
export const isDify = () => {
return document.referrer.includes('dify.ai')
}

View File

@@ -0,0 +1,97 @@
import type {
ModelConfig,
VisionSettings,
} from '@/types/app'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { NodeTracing } from '@/types/workflow'
import type { WorkflowRunningStatus } from '@/app/components/workflow/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
export type { VisionFile } from '@/types/app'
export { TransferMethod } from '@/types/app'
export type {
Inputs,
PromptVariable,
} from '@/models/debug'
export type UserInputForm = {
default: string
label: string
required: boolean
variable: string
}
export type UserInputFormTextInput = {
'text-input': UserInputForm & {
max_length: number
}
}
export type UserInputFormSelect = {
select: UserInputForm & {
options: string[]
}
}
export type UserInputFormParagraph = {
paragraph: UserInputForm
}
export type VisionConfig = VisionSettings
export type EnableType = {
enabled: boolean
}
export type ChatConfig = Omit<ModelConfig, 'model'> & {
supportAnnotation?: boolean
appId?: string
questionEditEnable?: boolean
supportFeedback?: boolean
supportCitationHitInfo?: boolean
system_parameters: {
audio_file_size_limit: number
file_size_limit: number
image_file_size_limit: number
video_file_size_limit: number
workflow_file_upload_limit: number
}
more_like_this: {
enabled: boolean
}
}
export type WorkflowProcess = {
status: WorkflowRunningStatus
tracing: NodeTracing[]
expand?: boolean // for UI
resultText?: string
files?: FileEntity[]
}
export type ChatItem = IChatItem & {
isError?: boolean
workflowProcess?: WorkflowProcess
conversationId?: string
allFiles?: FileEntity[]
}
export type ChatItemInTree = {
children?: ChatItemInTree[]
} & ChatItem
export type OnSend = {
(message: string, files?: FileEntity[]): void
(message: string, files: FileEntity[] | undefined, isRegenerate: boolean, lastAnswer?: ChatItem | null): void
}
export type OnRegenerate = (chatItem: ChatItem, editedQuestion?: { message: string; files?: FileEntity[] }) => void
export type Callback = {
onSuccess: () => void
}
export type Feedback = {
rating: 'like' | 'dislike' | null
content?: string | null
}

View File

@@ -0,0 +1,243 @@
import { UUID_NIL } from './constants'
import type { IChatItem } from './chat/type'
import type { ChatItem, ChatItemInTree } from './types'
async function decodeBase64AndDecompress(base64String: string) {
try {
const binaryString = atob(base64String)
const compressedUint8Array = Uint8Array.from(binaryString, char => char.charCodeAt(0))
const decompressedStream = new Response(compressedUint8Array).body?.pipeThrough(new DecompressionStream('gzip'))
const decompressedArrayBuffer = await new Response(decompressedStream).arrayBuffer()
return new TextDecoder().decode(decompressedArrayBuffer)
}
catch {
return undefined
}
}
async function getRawInputsFromUrlParams(): Promise<Record<string, any>> {
const urlParams = new URLSearchParams(window.location.search)
const inputs: Record<string, any> = {}
const entriesArray = Array.from(urlParams.entries())
entriesArray.forEach(([key, value]) => {
const prefixArray = ['sys.', 'user.']
if (!prefixArray.some(prefix => key.startsWith(prefix)))
inputs[key] = decodeURIComponent(value)
})
return inputs
}
async function getProcessedInputsFromUrlParams(): Promise<Record<string, any>> {
const urlParams = new URLSearchParams(window.location.search)
const inputs: Record<string, any> = {}
const entriesArray = Array.from(urlParams.entries())
await Promise.all(
entriesArray.map(async ([key, value]) => {
const prefixArray = ['sys.', 'user.']
if (!prefixArray.some(prefix => key.startsWith(prefix)))
inputs[key] = await decodeBase64AndDecompress(decodeURIComponent(value))
}),
)
return inputs
}
async function getProcessedSystemVariablesFromUrlParams(): Promise<Record<string, any>> {
const urlParams = new URLSearchParams(window.location.search)
const redirectUrl = urlParams.get('redirect_url')
if (redirectUrl) {
const decodedRedirectUrl = decodeURIComponent(redirectUrl)
const queryString = decodedRedirectUrl.split('?')[1]
if (queryString) {
const redirectParams = new URLSearchParams(queryString)
for (const [key, value] of redirectParams.entries())
urlParams.set(key, value)
}
}
const systemVariables: Record<string, any> = {}
const entriesArray = Array.from(urlParams.entries())
await Promise.all(
entriesArray.map(async ([key, value]) => {
if (key.startsWith('sys.'))
systemVariables[key.slice(4)] = await decodeBase64AndDecompress(decodeURIComponent(value))
}),
)
return systemVariables
}
async function getProcessedUserVariablesFromUrlParams(): Promise<Record<string, any>> {
const urlParams = new URLSearchParams(window.location.search)
const userVariables: Record<string, any> = {}
const entriesArray = Array.from(urlParams.entries())
await Promise.all(
entriesArray.map(async ([key, value]) => {
if (key.startsWith('user.'))
userVariables[key.slice(5)] = await decodeBase64AndDecompress(decodeURIComponent(value))
}),
)
return userVariables
}
async function getRawUserVariablesFromUrlParams(): Promise<Record<string, any>> {
const urlParams = new URLSearchParams(window.location.search)
const userVariables: Record<string, any> = {}
const entriesArray = Array.from(urlParams.entries())
entriesArray.forEach(([key, value]) => {
if (key.startsWith('user.'))
userVariables[key.slice(5)] = decodeURIComponent(value)
})
return userVariables
}
function isValidGeneratedAnswer(item?: ChatItem | ChatItemInTree): boolean {
return !!item && item.isAnswer && !item.id.startsWith('answer-placeholder-') && !item.isOpeningStatement
}
function getLastAnswer<T extends ChatItem | ChatItemInTree>(chatList: T[]): T | null {
for (let i = chatList.length - 1; i >= 0; i--) {
const item = chatList[i]
if (isValidGeneratedAnswer(item))
return item
}
return null
}
/**
* Build a chat item tree from a chat list
* @param allMessages - The chat list, sorted from oldest to newest
* @returns The chat item tree
*/
function buildChatItemTree(allMessages: IChatItem[]): ChatItemInTree[] {
const map: Record<string, ChatItemInTree> = {}
const rootNodes: ChatItemInTree[] = []
const childrenCount: Record<string, number> = {}
let lastAppendedLegacyAnswer: ChatItemInTree | null = null
for (let i = 0; i < allMessages.length; i += 2) {
const question = allMessages[i]!
const answer = allMessages[i + 1]!
const isLegacy = question.parentMessageId === UUID_NIL
const parentMessageId = isLegacy
? (lastAppendedLegacyAnswer?.id || '')
: (question.parentMessageId || '')
// Process question
childrenCount[parentMessageId] = (childrenCount[parentMessageId] || 0) + 1
const questionNode: ChatItemInTree = {
...question,
children: [],
}
map[question.id] = questionNode
// Process answer
childrenCount[question.id] = 1
const answerNode: ChatItemInTree = {
...answer,
children: [],
siblingIndex: isLegacy ? 0 : childrenCount[parentMessageId] - 1,
}
map[answer.id] = answerNode
// Connect question and answer
questionNode.children!.push(answerNode)
// Append to parent or add to root
if (isLegacy) {
if (!lastAppendedLegacyAnswer)
rootNodes.push(questionNode)
else
lastAppendedLegacyAnswer.children!.push(questionNode)
lastAppendedLegacyAnswer = answerNode
}
else {
if (
!parentMessageId
|| !allMessages.some(item => item.id === parentMessageId) // parent message might not be fetched yet, in this case we will append the question to the root nodes
)
rootNodes.push(questionNode)
else
map[parentMessageId]?.children!.push(questionNode)
}
}
return rootNodes
}
function getThreadMessages(tree: ChatItemInTree[], targetMessageId?: string): ChatItemInTree[] {
let ret: ChatItemInTree[] = []
let targetNode: ChatItemInTree | undefined
// find path to the target message
const stack = tree.slice().reverse().map(rootNode => ({
node: rootNode,
path: [rootNode],
}))
while (stack.length > 0) {
const { node, path } = stack.pop()!
if (
node.id === targetMessageId
|| (!targetMessageId && !node.children?.length && !stack.length) // if targetMessageId is not provided, we use the last message in the tree as the target
) {
targetNode = node
ret = path.map((item, index) => {
if (!item.isAnswer)
return item
const parentAnswer = path[index - 2]
const siblingCount = !parentAnswer ? tree.length : parentAnswer.children!.length
const prevSibling = !parentAnswer ? tree[item.siblingIndex! - 1]?.children?.[0]?.id : parentAnswer.children![item.siblingIndex! - 1]?.children?.[0].id
const nextSibling = !parentAnswer ? tree[item.siblingIndex! + 1]?.children?.[0]?.id : parentAnswer.children![item.siblingIndex! + 1]?.children?.[0].id
return { ...item, siblingCount, prevSibling, nextSibling }
})
break
}
if (node.children) {
for (let i = node.children.length - 1; i >= 0; i--) {
stack.push({
node: node.children[i],
path: [...path, node.children[i]],
})
}
}
}
// append all descendant messages to the path
if (targetNode) {
const stack = [targetNode]
while (stack.length > 0) {
const node = stack.pop()!
if (node !== targetNode)
ret.push(node)
if (node.children?.length) {
const lastChild = node.children.at(-1)!
if (!lastChild.isAnswer) {
stack.push(lastChild)
continue
}
const parentAnswer = ret.at(-2)
const siblingCount = parentAnswer?.children?.length
const prevSibling = parentAnswer?.children?.at(-2)?.children?.[0]?.id
stack.push({ ...lastChild, siblingCount, prevSibling })
}
}
}
return ret
}
export {
getRawInputsFromUrlParams,
getProcessedInputsFromUrlParams,
getProcessedSystemVariablesFromUrlParams,
getProcessedUserVariablesFromUrlParams,
getRawUserVariablesFromUrlParams,
isValidGeneratedAnswer,
getLastAnswer,
buildChatItemTree,
getThreadMessages,
}