merge main

This commit is contained in:
Joel
2024-10-25 11:25:04 +08:00
parent ae00211691
commit bdb990eb90
375 changed files with 18637 additions and 7426 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,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,258 @@
import { get } from 'lodash'
import { buildChatItemTree, getThreadMessages } from '../utils'
import type { ChatItemInTree } from '../types'
import branchedTestMessages from './branchedTestMessages.json'
import legacyTestMessages from './legacyTestMessages.json'
import mixedTestMessages from './mixedTestMessages.json'
import multiRootNodesMessages from './multiRootNodesMessages.json'
import multiRootNodesWithLegacyTestMessages from './multiRootNodesWithLegacyTestMessages.json'
import realWorldMessages from './realWorldMessages.json'
function visitNode(tree: ChatItemInTree | ChatItemInTree[], path: string): ChatItemInTree {
return get(tree, path)
}
describe('build chat item tree and get thread messages', () => {
const tree1 = buildChatItemTree(branchedTestMessages as ChatItemInTree[])
it('should build chat item tree1', () => {
const a1 = visitNode(tree1, '0.children.0')
expect(a1.id).toBe('1')
expect(a1.children).toHaveLength(2)
const a2 = visitNode(a1, 'children.0.children.0')
expect(a2.id).toBe('2')
expect(a2.siblingIndex).toBe(0)
const a3 = visitNode(a2, 'children.0.children.0')
expect(a3.id).toBe('3')
const a4 = visitNode(a1, 'children.1.children.0')
expect(a4.id).toBe('4')
expect(a4.siblingIndex).toBe(1)
})
it('should get thread messages from tree1, using the last message as the target', () => {
const threadChatItems1_1 = getThreadMessages(tree1)
expect(threadChatItems1_1).toHaveLength(4)
const q1 = visitNode(threadChatItems1_1, '0')
const a1 = visitNode(threadChatItems1_1, '1')
const q4 = visitNode(threadChatItems1_1, '2')
const a4 = visitNode(threadChatItems1_1, '3')
expect(q1.id).toBe('question-1')
expect(a1.id).toBe('1')
expect(q4.id).toBe('question-4')
expect(a4.id).toBe('4')
expect(a4.siblingCount).toBe(2)
expect(a4.siblingIndex).toBe(1)
})
it('should get thread messages from tree1, using the message with id 3 as the target', () => {
const threadChatItems1_2 = getThreadMessages(tree1, '3')
expect(threadChatItems1_2).toHaveLength(6)
const q1 = visitNode(threadChatItems1_2, '0')
const a1 = visitNode(threadChatItems1_2, '1')
const q2 = visitNode(threadChatItems1_2, '2')
const a2 = visitNode(threadChatItems1_2, '3')
const q3 = visitNode(threadChatItems1_2, '4')
const a3 = visitNode(threadChatItems1_2, '5')
expect(q1.id).toBe('question-1')
expect(a1.id).toBe('1')
expect(q2.id).toBe('question-2')
expect(a2.id).toBe('2')
expect(q3.id).toBe('question-3')
expect(a3.id).toBe('3')
expect(a2.siblingCount).toBe(2)
expect(a2.siblingIndex).toBe(0)
})
const tree2 = buildChatItemTree(legacyTestMessages as ChatItemInTree[])
it('should work with legacy chat items', () => {
expect(tree2).toHaveLength(1)
const q1 = visitNode(tree2, '0')
const a1 = visitNode(q1, 'children.0')
const q2 = visitNode(a1, 'children.0')
const a2 = visitNode(q2, 'children.0')
const q3 = visitNode(a2, 'children.0')
const a3 = visitNode(q3, 'children.0')
const q4 = visitNode(a3, 'children.0')
const a4 = visitNode(q4, 'children.0')
expect(q1.id).toBe('question-1')
expect(a1.id).toBe('1')
expect(q2.id).toBe('question-2')
expect(a2.id).toBe('2')
expect(q3.id).toBe('question-3')
expect(a3.id).toBe('3')
expect(q4.id).toBe('question-4')
expect(a4.id).toBe('4')
})
it('should get thread messages from tree2, using the last message as the target', () => {
const threadMessages2 = getThreadMessages(tree2)
expect(threadMessages2).toHaveLength(8)
const q1 = visitNode(threadMessages2, '0')
const a1 = visitNode(threadMessages2, '1')
const q2 = visitNode(threadMessages2, '2')
const a2 = visitNode(threadMessages2, '3')
const q3 = visitNode(threadMessages2, '4')
const a3 = visitNode(threadMessages2, '5')
const q4 = visitNode(threadMessages2, '6')
const a4 = visitNode(threadMessages2, '7')
expect(q1.id).toBe('question-1')
expect(a1.id).toBe('1')
expect(q2.id).toBe('question-2')
expect(a2.id).toBe('2')
expect(q3.id).toBe('question-3')
expect(a3.id).toBe('3')
expect(q4.id).toBe('question-4')
expect(a4.id).toBe('4')
expect(a1.siblingCount).toBe(1)
expect(a1.siblingIndex).toBe(0)
expect(a2.siblingCount).toBe(1)
expect(a2.siblingIndex).toBe(0)
expect(a3.siblingCount).toBe(1)
expect(a3.siblingIndex).toBe(0)
expect(a4.siblingCount).toBe(1)
expect(a4.siblingIndex).toBe(0)
})
const tree3 = buildChatItemTree(mixedTestMessages as ChatItemInTree[])
it('should build mixed chat items tree', () => {
expect(tree3).toHaveLength(1)
const a1 = visitNode(tree3, '0.children.0')
expect(a1.id).toBe('1')
expect(a1.children).toHaveLength(2)
const a2 = visitNode(a1, 'children.0.children.0')
expect(a2.id).toBe('2')
expect(a2.siblingIndex).toBe(0)
const a3 = visitNode(a2, 'children.0.children.0')
expect(a3.id).toBe('3')
const a4 = visitNode(a1, 'children.1.children.0')
expect(a4.id).toBe('4')
expect(a4.siblingIndex).toBe(1)
})
it('should get thread messages from tree3, using the last message as the target', () => {
const threadMessages3_1 = getThreadMessages(tree3)
expect(threadMessages3_1).toHaveLength(4)
const q1 = visitNode(threadMessages3_1, '0')
const a1 = visitNode(threadMessages3_1, '1')
const q4 = visitNode(threadMessages3_1, '2')
const a4 = visitNode(threadMessages3_1, '3')
expect(q1.id).toBe('question-1')
expect(a1.id).toBe('1')
expect(q4.id).toBe('question-4')
expect(a4.id).toBe('4')
expect(a4.siblingCount).toBe(2)
expect(a4.siblingIndex).toBe(1)
})
it('should get thread messages from tree3, using the message with id 3 as the target', () => {
const threadMessages3_2 = getThreadMessages(tree3, '3')
expect(threadMessages3_2).toHaveLength(6)
const q1 = visitNode(threadMessages3_2, '0')
const a1 = visitNode(threadMessages3_2, '1')
const q2 = visitNode(threadMessages3_2, '2')
const a2 = visitNode(threadMessages3_2, '3')
const q3 = visitNode(threadMessages3_2, '4')
const a3 = visitNode(threadMessages3_2, '5')
expect(q1.id).toBe('question-1')
expect(a1.id).toBe('1')
expect(q2.id).toBe('question-2')
expect(a2.id).toBe('2')
expect(q3.id).toBe('question-3')
expect(a3.id).toBe('3')
expect(a2.siblingCount).toBe(2)
expect(a2.siblingIndex).toBe(0)
})
const tree4 = buildChatItemTree(multiRootNodesMessages as ChatItemInTree[])
it('should build multi root nodes chat items tree', () => {
expect(tree4).toHaveLength(2)
const a5 = visitNode(tree4, '1.children.0')
expect(a5.id).toBe('5')
expect(a5.siblingIndex).toBe(1)
})
it('should get thread messages from tree4, using the last message as the target', () => {
const threadMessages4 = getThreadMessages(tree4)
expect(threadMessages4).toHaveLength(2)
const a1 = visitNode(threadMessages4, '0.children.0')
expect(a1.id).toBe('5')
})
it('should get thread messages from tree4, using the message with id 2 as the target', () => {
const threadMessages4_1 = getThreadMessages(tree4, '2')
expect(threadMessages4_1).toHaveLength(6)
const a1 = visitNode(threadMessages4_1, '1')
expect(a1.id).toBe('1')
const a2 = visitNode(threadMessages4_1, '3')
expect(a2.id).toBe('2')
const a3 = visitNode(threadMessages4_1, '5')
expect(a3.id).toBe('3')
})
const tree5 = buildChatItemTree(multiRootNodesWithLegacyTestMessages as ChatItemInTree[])
it('should work with multi root nodes chat items with legacy chat items', () => {
expect(tree5).toHaveLength(2)
const q5 = visitNode(tree5, '1')
expect(q5.id).toBe('question-5')
expect(q5.parentMessageId).toBe(null)
const a5 = visitNode(q5, 'children.0')
expect(a5.id).toBe('5')
expect(a5.children).toHaveLength(0)
})
it('should get thread messages from tree5, using the last message as the target', () => {
const threadMessages5 = getThreadMessages(tree5)
expect(threadMessages5).toHaveLength(2)
const q5 = visitNode(threadMessages5, '0')
const a5 = visitNode(threadMessages5, '1')
expect(q5.id).toBe('question-5')
expect(a5.id).toBe('5')
expect(a5.siblingCount).toBe(2)
expect(a5.siblingIndex).toBe(1)
})
const tree6 = buildChatItemTree(realWorldMessages as ChatItemInTree[])
it('should work with real world messages', () => {
expect(tree6).toMatchSnapshot()
})
it ('should get thread messages from tree6, using the last message as target', () => {
const threadMessages6_1 = getThreadMessages(tree6)
expect(threadMessages6_1).toMatchSnapshot()
})
it ('should get thread messages from tree6, using specified message as target', () => {
const threadMessages6_2 = getThreadMessages(tree6, 'ff4c2b43-48a5-47ad-9dc5-08b34ddba61b')
expect(threadMessages6_2).toMatchSnapshot()
})
})

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

View File

@ -0,0 +1,47 @@
import {
useCallback,
useRef,
useState,
} from 'react'
import type { TextAreaRef } from 'rc-textarea'
export const useTextAreaHeight = () => {
const wrapperRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<TextAreaRef>(null)
const textValueRef = useRef<HTMLDivElement>(null)
const holdSpaceRef = useRef<HTMLDivElement>(null)
const [isMultipleLine, setIsMultipleLine] = useState(false)
const handleComputeHeight = useCallback(() => {
const textareaElement = textareaRef.current?.resizableTextArea.textArea
if (wrapperRef.current && textareaElement && textValueRef.current && holdSpaceRef.current) {
const { width: wrapperWidth } = wrapperRef.current.getBoundingClientRect()
const { height: textareaHeight } = textareaElement.getBoundingClientRect()
const { width: textValueWidth } = textValueRef.current.getBoundingClientRect()
const { width: holdSpaceWidth } = holdSpaceRef.current.getBoundingClientRect()
if (textareaHeight > 32) {
setIsMultipleLine(true)
}
else {
if (textValueWidth + holdSpaceWidth >= wrapperWidth)
setIsMultipleLine(true)
else
setIsMultipleLine(false)
}
}
}, [])
const handleTextareaResize = useCallback(() => {
handleComputeHeight()
}, [handleComputeHeight])
return {
wrapperRef,
textareaRef,
textValueRef,
holdSpaceRef,
handleTextareaResize,
isMultipleLine,
}
}

View File

@ -0,0 +1,209 @@
import {
useCallback,
useRef,
useState,
} from 'react'
import Textarea from 'rc-textarea'
import { useTranslation } from 'react-i18next'
import Recorder from 'js-audio-recorder'
import type {
EnableType,
OnSend,
} from '../../types'
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
import type { InputForm } from '../type'
import { useCheckInputsForms } from '../check-input-forms-hooks'
import { useTextAreaHeight } from './hooks'
import Operation from './operation'
import cn from '@/utils/classnames'
import { FileListInChatInput } from '@/app/components/base/file-uploader'
import { useFile } from '@/app/components/base/file-uploader/hooks'
import {
FileContextProvider,
useFileStore,
} from '@/app/components/base/file-uploader/store'
import VoiceInput from '@/app/components/base/voice-input'
import { useToastContext } from '@/app/components/base/toast'
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
import type { FileUpload } from '@/app/components/base/features/types'
import { TransferMethod } from '@/types/app'
type ChatInputAreaProps = {
showFeatureBar?: boolean
showFileUpload?: boolean
featureBarDisabled?: boolean
onFeatureBarClick?: (state: boolean) => void
visionConfig?: FileUpload
speechToTextConfig?: EnableType
onSend?: OnSend
inputs?: Record<string, any>
inputsForm?: InputForm[]
theme?: Theme | null
}
const ChatInputArea = ({
showFeatureBar,
showFileUpload,
featureBarDisabled,
onFeatureBarClick,
visionConfig,
speechToTextConfig = { enabled: true },
onSend,
inputs = {},
inputsForm = [],
theme,
}: ChatInputAreaProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const {
wrapperRef,
textareaRef,
textValueRef,
holdSpaceRef,
handleTextareaResize,
isMultipleLine,
} = useTextAreaHeight()
const [query, setQuery] = useState('')
const isUseInputMethod = useRef(false)
const [showVoiceInput, setShowVoiceInput] = useState(false)
const filesStore = useFileStore()
const {
handleDragFileEnter,
handleDragFileLeave,
handleDragFileOver,
handleDropFile,
handleClipboardPasteFile,
isDragActive,
} = useFile(visionConfig!)
const { checkInputsForm } = useCheckInputsForms()
const handleSend = () => {
if (onSend) {
const { files, setFiles } = filesStore.getState()
if (files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
return
}
if (!query || !query.trim()) {
notify({ type: 'info', message: t('appAnnotation.errorMessage.queryRequired') })
return
}
if (checkInputsForm(inputs, inputsForm)) {
onSend(query, files)
setQuery('')
setFiles([])
}
}
}
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
// prevent send message when using input method enter
if (!e.shiftKey && !isUseInputMethod.current)
handleSend()
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
isUseInputMethod.current = e.nativeEvent.isComposing
if (e.key === 'Enter' && !e.shiftKey) {
setQuery(query.replace(/\n$/, ''))
e.preventDefault()
}
}
const handleShowVoiceInput = useCallback(() => {
(Recorder as any).getPermission().then(() => {
setShowVoiceInput(true)
}, () => {
notify({ type: 'error', message: t('common.voiceInput.notAllow') })
})
}, [t, notify])
const operation = (
<Operation
ref={holdSpaceRef}
fileConfig={visionConfig}
speechToTextConfig={speechToTextConfig}
onShowVoiceInput={handleShowVoiceInput}
onSend={handleSend}
theme={theme}
/>
)
return (
<>
<div
className={cn(
'relative pb-[9px] bg-components-panel-bg-blur border border-components-chat-input-border rounded-xl shadow-md z-10',
isDragActive && 'border border-dashed border-components-option-card-option-selected-border',
)}
>
<div className='relative px-[9px] pt-[9px] max-h-[158px] overflow-x-hidden overflow-y-auto'>
<FileListInChatInput fileConfig={visionConfig!} />
<div
ref={wrapperRef}
className='flex items-center justify-between'
>
<div className='flex items-center relative grow w-full'>
<div
ref={textValueRef}
className='absolute w-auto h-auto p-1 leading-6 body-lg-regular pointer-events-none whitespace-pre invisible'
>
{query}
</div>
<Textarea
ref={textareaRef}
className={cn(
'p-1 w-full leading-6 body-lg-regular text-text-tertiary outline-none',
)}
placeholder={t('common.chat.inputPlaceholder') || ''}
autoSize={{ minRows: 1 }}
onResize={handleTextareaResize}
value={query}
onChange={(e) => {
setQuery(e.target.value)
handleTextareaResize()
}}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
onPaste={handleClipboardPasteFile}
onDragEnter={handleDragFileEnter}
onDragLeave={handleDragFileLeave}
onDragOver={handleDragFileOver}
onDrop={handleDropFile}
/>
</div>
{
!isMultipleLine && operation
}
</div>
{
showVoiceInput && (
<VoiceInput
onCancel={() => setShowVoiceInput(false)}
onConverted={text => setQuery(text)}
/>
)
}
</div>
{
isMultipleLine && (
<div className='px-[9px]'>{operation}</div>
)
}
</div>
{showFeatureBar && <FeatureBar showFileUpload={showFileUpload} disabled={featureBarDisabled} onFeatureBarClick={onFeatureBarClick} />}
</>
)
}
const ChatInputAreaWrapper = (props: ChatInputAreaProps) => {
return (
<FileContextProvider>
<ChatInputArea {...props} />
</FileContextProvider>
)
}
export default ChatInputAreaWrapper

View File

@ -0,0 +1,76 @@
import {
forwardRef,
memo,
} from 'react'
import {
RiMicLine,
RiSendPlane2Fill,
} from '@remixicon/react'
import type {
EnableType,
} from '../../types'
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
import Button from '@/app/components/base/button'
import ActionButton from '@/app/components/base/action-button'
import { FileUploaderInChatInput } from '@/app/components/base/file-uploader'
import type { FileUpload } from '@/app/components/base/features/types'
import cn from '@/utils/classnames'
type OperationProps = {
fileConfig?: FileUpload
speechToTextConfig?: EnableType
onShowVoiceInput?: () => void
onSend: () => void
theme?: Theme | null
}
const Operation = forwardRef<HTMLDivElement, OperationProps>(({
fileConfig,
speechToTextConfig,
onShowVoiceInput,
onSend,
theme,
}, ref) => {
return (
<div
className={cn(
'shrink-0 flex items-center justify-end',
)}
>
<div
className='flex items-center pl-1'
ref={ref}
>
<div className='flex items-center space-x-1'>
{fileConfig?.enabled && <FileUploaderInChatInput fileConfig={fileConfig} />}
{
speechToTextConfig?.enabled && (
<ActionButton
size='l'
onClick={onShowVoiceInput}
>
<RiMicLine className='w-5 h-5' />
</ActionButton>
)
}
</div>
<Button
className='ml-3 px-0 w-8'
variant='primary'
onClick={onSend}
style={
theme
? {
backgroundColor: theme.primaryColor,
}
: {}
}
>
<RiSendPlane2Fill className='w-4 h-4' />
</Button>
</div>
</div>
)
})
Operation.displayName = 'Operation'
export default memo(Operation)

View File

@ -1,258 +0,0 @@
import type { FC } from 'react'
import {
memo,
useRef,
useState,
} from 'react'
import { useContext } from 'use-context-selector'
import Recorder from 'js-audio-recorder'
import { useTranslation } from 'react-i18next'
import Textarea from 'rc-textarea'
import type {
EnableType,
OnSend,
VisionConfig,
} from '../types'
import { TransferMethod } from '../types'
import { useChatWithHistoryContext } from '../chat-with-history/context'
import type { Theme } from '../embedded-chatbot/theme/theme-context'
import { CssTransform } from '../embedded-chatbot/theme/utils'
import Tooltip from '@/app/components/base/tooltip'
import { ToastContext } from '@/app/components/base/toast'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import VoiceInput from '@/app/components/base/voice-input'
import { Microphone01 } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import { Microphone01 as Microphone01Solid } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { Send03 } from '@/app/components/base/icons/src/vender/solid/communication'
import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader'
import ImageList from '@/app/components/base/image-uploader/image-list'
import {
useClipboardUploader,
useDraggableUploader,
useImageFiles,
} from '@/app/components/base/image-uploader/hooks'
import cn from '@/utils/classnames'
type ChatInputProps = {
visionConfig?: VisionConfig
speechToTextConfig?: EnableType
onSend?: OnSend
theme?: Theme | null
noSpacing?: boolean
}
const ChatInput: FC<ChatInputProps> = ({
visionConfig,
speechToTextConfig,
onSend,
theme,
noSpacing,
}) => {
const { appData } = useChatWithHistoryContext()
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [voiceInputShow, setVoiceInputShow] = useState(false)
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const {
files,
onUpload,
onRemove,
onReUpload,
onImageLinkLoadError,
onImageLinkLoadSuccess,
onClear,
} = useImageFiles()
const { onPaste } = useClipboardUploader({ onUpload, visionConfig, files })
const { onDragEnter, onDragLeave, onDragOver, onDrop, isDragActive } = useDraggableUploader<HTMLTextAreaElement>({ onUpload, files, visionConfig })
const isUseInputMethod = useRef(false)
const [query, setQuery] = useState('')
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
setQuery(value)
}
const handleSend = () => {
if (onSend) {
if (files.find(item => item.type === TransferMethod.local_file && !item.fileId)) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForImgUpload') })
return
}
if (!query || !query.trim()) {
notify({ type: 'info', message: t('appAnnotation.errorMessage.queryRequired') })
return
}
onSend(query, files.filter(file => file.progress !== -1).map(fileItem => ({
type: 'image',
transfer_method: fileItem.type,
url: fileItem.url,
upload_file_id: fileItem.fileId,
})))
setQuery('')
onClear()
}
}
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
// prevent send message when using input method enter
if (!e.shiftKey && !isUseInputMethod.current)
handleSend()
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
isUseInputMethod.current = e.nativeEvent.isComposing
if (e.key === 'Enter' && !e.shiftKey) {
setQuery(query.replace(/\n$/, ''))
e.preventDefault()
}
}
const logError = (message: string) => {
notify({ type: 'error', message })
}
const handleVoiceInputShow = () => {
(Recorder as any).getPermission().then(() => {
setVoiceInputShow(true)
}, () => {
logError(t('common.voiceInput.notAllow'))
})
}
const [isActiveIconFocused, setActiveIconFocused] = useState(false)
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const sendIconThemeStyle = theme
? {
color: (isActiveIconFocused || query || (query.trim() !== '')) ? theme.primaryColor : '#d1d5db',
}
: {}
const sendBtn = (
<div
className='group flex items-center justify-center w-8 h-8 rounded-lg hover:bg-[#EBF5FF] cursor-pointer'
onMouseEnter={() => setActiveIconFocused(true)}
onMouseLeave={() => setActiveIconFocused(false)}
onClick={handleSend}
style={isActiveIconFocused ? CssTransform(theme?.chatBubbleColorStyle ?? '') : {}}
>
<Send03
style={sendIconThemeStyle}
className={`
w-5 h-5 text-gray-300 group-hover:text-primary-600
${!!query.trim() && 'text-primary-600'}
`}
/>
</div>
)
return (
<>
<div className={cn('relative', !noSpacing && 'px-8')}>
<div
className={`
p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto
${isDragActive && 'border-primary-600'} mb-2
`}
>
{
visionConfig?.enabled && (
<>
<div className={cn('absolute bottom-2 flex items-center', noSpacing ? 'left-2' : 'left-10')}>
<ChatImageUploader
settings={visionConfig}
onUpload={onUpload}
disabled={files.length >= visionConfig.number_limits}
/>
<div className='mx-1 w-[1px] h-4 bg-black/5' />
</div>
<div className='pl-[52px]'>
<ImageList
list={files}
onRemove={onRemove}
onReUpload={onReUpload}
onImageLinkLoadSuccess={onImageLinkLoadSuccess}
onImageLinkLoadError={onImageLinkLoadError}
/>
</div>
</>
)
}
<Textarea
ref={textAreaRef}
className={`
block w-full px-2 pr-[118px] py-[7px] leading-5 max-h-none text-sm text-gray-700 outline-none appearance-none resize-none
${visionConfig?.enabled && 'pl-12'}
`}
value={query}
onChange={handleContentChange}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
onPaste={onPaste}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
autoSize
/>
<div className={cn('absolute bottom-[7px] flex items-center h-8', noSpacing ? 'right-2' : 'right-10')}>
<div className='flex items-center px-1 h-5 rounded-md bg-gray-100 text-xs font-medium text-gray-500'>
{query.trim().length}
</div>
{
query
? (
<div className='flex justify-center items-center ml-2 w-8 h-8 cursor-pointer hover:bg-gray-100 rounded-lg' onClick={() => setQuery('')}>
<XCircle className='w-4 h-4 text-[#98A2B3]' />
</div>
)
: speechToTextConfig?.enabled
? (
<div
className='group flex justify-center items-center ml-2 w-8 h-8 hover:bg-primary-50 rounded-lg cursor-pointer'
onClick={handleVoiceInputShow}
>
<Microphone01 className='block w-4 h-4 text-gray-500 group-hover:hidden' />
<Microphone01Solid className='hidden w-4 h-4 text-primary-600 group-hover:block' />
</div>
)
: null
}
<div className='mx-2 w-[1px] h-4 bg-black opacity-5' />
{isMobile
? sendBtn
: (
<Tooltip
popupContent={
<div>
<div>{t('common.operation.send')} Enter</div>
<div>{t('common.operation.lineBreak')} Shift Enter</div>
</div>
}
>
{sendBtn}
</Tooltip>
)}
</div>
{
voiceInputShow && (
<VoiceInput
onCancel={() => setVoiceInputShow(false)}
onConverted={(text) => {
setQuery(text)
textAreaRef.current?.focus()
}}
/>
)
}
</div>
</div>
{appData?.site?.custom_disclaimer && <div className='text-xs text-gray-500 mt-1 text-center'>
{appData.site.custom_disclaimer}
</div>}
</>
)
}
export default memo(ChatInput)

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 }) => required)
if (requiredVars?.length) {
requiredVars.forEach(({ variable, label, type }) => {
if (hasEmptyInput)
return
if (fileIsUploading)
return
if (!inputs[variable])
hasEmptyInput = label as string
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputs[variable]) {
const files = inputs[variable]
if (Array.isArray(files))
fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
else
fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId
}
})
}
if (hasEmptyInput) {
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) })
return false
}
if (fileIsUploading) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
return
}
return true
}, [notify, t])
return {
checkInputsForm,
}
}

View File

@ -0,0 +1,32 @@
import type { InputForm } from './type'
import { InputVarType } from '@/app/components/workflow/types'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
export const processOpeningStatement = (openingStatement: string, inputs: Record<string, any>, inputsForm: InputForm[]) => {
if (!openingStatement)
return openingStatement
return openingStatement.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
const name = inputs[key]
if (name) { // has set value
return name
}
const valueObj = inputsForm.find(v => v.variable === key)
return valueObj ? `{{${valueObj.label}}}` : match
})
}
export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: InputForm[]) => {
const processedInputs = { ...inputs }
inputsForm.forEach((item) => {
if (item.type === InputVarType.multiFiles && inputs[item.variable])
processedInputs[item.variable] = getProcessedFiles(inputs[item.variable])
if (item.type === InputVarType.singleFile && inputs[item.variable])
processedInputs[item.variable] = getProcessedFiles([inputs[item.variable]])[0]
})
return processedInputs
}