feat(web): share chat & app chat support files
@@ -51,6 +51,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"recordrtc": "^5.6.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
@@ -73,6 +74,7 @@
|
||||
"@types/react-i18next": "^7.8.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/recordrtc": "^5.6.15",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.36.0",
|
||||
|
||||
@@ -29,3 +29,5 @@ export const deleteFileUrl = (file_id: string) => `/storage/files/${file_id}`
|
||||
export const deleteFile = (fileId: string) => {
|
||||
return request.delete(deleteFileUrl(fileId))
|
||||
}
|
||||
|
||||
export const shareFileUploadUrl = `${API_PREFIX}/storage/share/files`
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
22
web/src/assets/images/conversation/audio_hover.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>编组 15</title>
|
||||
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="红熊空间-记忆对话-默认对话" transform="translate(-745, -726)" stroke="#155EEF">
|
||||
<g id="聊天页面" transform="translate(258, 128)">
|
||||
<g id="输入框" transform="translate(16, 512)">
|
||||
<g id="编组-7" transform="translate(471, 86)">
|
||||
<g id="语音" transform="translate(4, 4)">
|
||||
<g id="编组-8" transform="translate(4, 2.25)">
|
||||
<line x1="4" y1="9" x2="4" y2="11.0054105" id="路径-46"></line>
|
||||
<line x1="2" y1="11.0054105" x2="5.995474" y2="11.0054105" id="路径-48" stroke-linecap="round"></line>
|
||||
<rect id="矩形" x="2" y="0" width="4" height="7" rx="2"></rect>
|
||||
<path d="M8,4 L8,5 C8,7.209139 6.209139,9 4,9 C1.790861,9 0,7.209139 0,5 L0,4" id="路径" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
web/src/assets/images/conversation/audio_ing.gif
Normal file
|
After Width: | Height: | Size: 72 KiB |
21
web/src/assets/images/conversation/audio_ing.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>编组 15</title>
|
||||
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="红熊空间-记忆对话-交互-2" transform="translate(-1208, -335)">
|
||||
<g id="输入框" transform="translate(737, 180)">
|
||||
<g id="编组-15" transform="translate(471, 155)">
|
||||
<rect id="矩形" fill-opacity="0.08" fill="#155EEF" x="0" y="0" width="24" height="24" rx="6"></rect>
|
||||
<g id="214声波、语音消息" transform="translate(4, 4)">
|
||||
<rect id="矩形" fill="#000000" fill-rule="nonzero" opacity="0" x="0" y="0" width="16" height="16"></rect>
|
||||
<rect id="矩形" fill="#155EEF" x="1.5" y="6" width="1" height="4" rx="0.5"></rect>
|
||||
<rect id="矩形" fill="#155EEF" x="13.5" y="6" width="1" height="4" rx="0.5"></rect>
|
||||
<rect id="矩形" fill="#155EEF" x="4.5" y="5" width="1" height="6" rx="0.5"></rect>
|
||||
<rect id="矩形" fill="#155EEF" x="10.5" y="5" width="1" height="6" rx="0.5"></rect>
|
||||
<rect id="矩形" fill="#155EEF" x="7.5" y="3" width="1" height="10" rx="0.5"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -5,7 +5,7 @@
|
||||
<g id="红熊空间-记忆对话-交互-2" transform="translate(-237, -514)">
|
||||
<g id="输入框备份" transform="translate(43, 506)">
|
||||
<g id="编组-3" transform="translate(194, 8)">
|
||||
<circle id="椭圆形" fill="#212332" cx="7" cy="7" r="7"></circle>
|
||||
<circle id="椭圆形" fill="#5B6167" cx="7" cy="7" r="7"></circle>
|
||||
<g id="关闭" transform="translate(2, 2)" fill="#FFFFFF" fill-rule="nonzero">
|
||||
<polygon id="路径" points="5.62500061 5 8.33333333 7.70833272 7.70833272 8.33333333 5 5.62500061 2.29166728 8.33333333 1.66666667 7.70833272 4.37499939 5 1.66666667 2.29166728 2.29166728 1.66666667 5 4.37499939 7.70833272 1.66666667 8.33333333 2.29166728"></polygon>
|
||||
</g>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
16
web/src/assets/images/conversation/delete_hover.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="14px" height="14px" viewBox="0 0 14 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>编组 3</title>
|
||||
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="红熊空间-记忆对话-交互-2" transform="translate(-237, -514)">
|
||||
<g id="输入框备份" transform="translate(43, 506)">
|
||||
<g id="编组-3" transform="translate(194, 8)">
|
||||
<circle id="椭圆形" fill="#212332" cx="7" cy="7" r="7"></circle>
|
||||
<g id="关闭" transform="translate(2, 2)" fill="#FFFFFF" fill-rule="nonzero">
|
||||
<polygon id="路径" points="5.62500061 5 8.33333333 7.70833272 7.70833272 8.33333333 5 5.62500061 2.29166728 8.33333333 1.66666667 7.70833272 4.37499939 5 1.66666667 2.29166728 2.29166728 1.66666667 5 4.37499939 7.70833272 1.66666667 8.33333333 2.29166728"></polygon>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
15
web/src/assets/images/conversation/excel.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>表格</title>
|
||||
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="红熊空间-记忆对话-交互-2" transform="translate(-188, -28)">
|
||||
<g id="表格" transform="translate(188, 28)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M10.0844745,0.55 L14.3181484,4.59605723 L14.45,15.5999999 C14.45,16.1108633 14.2429317,16.5733633 13.9081475,16.9081475 C13.5733633,17.2429317 13.1108633,17.45 12.5999999,17.45 L2.4000001,17.45 C1.88913668,17.45 1.42663665,17.2429317 1.09185248,16.9081475 C0.757068314,16.5733633 0.55,16.1108633 0.55,15.5999999 L0.55,2.4000001 C0.55,1.88913668 0.757068314,1.42663665 1.09185248,1.09185248 C1.42663665,0.757068314 1.88913668,0.55 2.4000001,0.55 L10.0844745,0.55 Z" id="蒙版" stroke="#212332" stroke-width="1.1"></path>
|
||||
<path d="M10.5,0.587164664 L10.5,3.5 C10.5,4.32842712 11.1715729,5 12,5 L14.6706929,5 L14.6706929,5" id="路径-8" stroke="#212332" stroke-width="1.1"></path>
|
||||
<path d="M3.88981909,7.05653906 L6.61926606,10.664 L6.61926606,10.664 L3.6935114,14.5404115 C3.64027751,14.6109425 3.6542996,14.7112738 3.72483063,14.7645077 C3.75259554,14.7854635 3.78643354,14.7968 3.82121911,14.7968 L5.04568513,14.7968 C5.17358166,14.7968 5.29376679,14.7356407 5.36904919,14.6322478 L7.5,11.7056 L7.5,11.7056 L9.63095081,14.6322478 C9.70623321,14.7356407 9.82641834,14.7968 9.95431487,14.7968 L11.1772617,14.7968 C11.2656273,14.7968 11.3372617,14.7251656 11.3372617,14.6368 C11.3372617,14.6018085 11.3257908,14.5677817 11.3046061,14.5399319 L8.35626911,10.664 L8.35626911,10.664 L11.1081665,7.05705047 C11.161766,6.98679688 11.1482651,6.88639412 11.0780115,6.83279463 C11.0501297,6.81152238 11.0160311,6.8 10.9809611,6.8 L9.75859621,6.8 C9.63069969,6.8 9.51051455,6.86115931 9.43523215,6.96455216 L7.5,9.6224 L7.5,9.6224 L5.56476785,6.96455216 C5.48948545,6.86115931 5.36930031,6.8 5.24140379,6.8 L4.01741303,6.8 C3.92904746,6.8 3.85741302,6.87163444 3.85741302,6.96 C3.85741302,6.99485016 3.86879158,7.02874738 3.88981909,7.05653906 Z" id="路径" fill="#212332" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
15
web/src/assets/images/conversation/excel_disabled.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>表格</title>
|
||||
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="红熊空间-记忆对话-交互-2" transform="translate(-188, -58)">
|
||||
<g id="表格" transform="translate(188, 58)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M10.0844745,0.55 L14.3181484,4.59605723 L14.45,15.5999999 C14.45,16.1108633 14.2429317,16.5733633 13.9081475,16.9081475 C13.5733633,17.2429317 13.1108633,17.45 12.5999999,17.45 L2.4000001,17.45 C1.88913668,17.45 1.42663665,17.2429317 1.09185248,16.9081475 C0.757068314,16.5733633 0.55,16.1108633 0.55,15.5999999 L0.55,2.4000001 C0.55,1.88913668 0.757068314,1.42663665 1.09185248,1.09185248 C1.42663665,0.757068314 1.88913668,0.55 2.4000001,0.55 L10.0844745,0.55 Z" id="蒙版" stroke="#A8A9AA" stroke-width="1.1"></path>
|
||||
<path d="M10.5,0.587164664 L10.5,3.5 C10.5,4.32842712 11.1715729,5 12,5 L14.6706929,5 L14.6706929,5" id="路径-8" stroke="#A8A9AA" stroke-width="1.1"></path>
|
||||
<path d="M3.88981909,7.05653906 L6.61926606,10.664 L6.61926606,10.664 L3.6935114,14.5404115 C3.64027751,14.6109425 3.6542996,14.7112738 3.72483063,14.7645077 C3.75259554,14.7854635 3.78643354,14.7968 3.82121911,14.7968 L5.04568513,14.7968 C5.17358166,14.7968 5.29376679,14.7356407 5.36904919,14.6322478 L7.5,11.7056 L7.5,11.7056 L9.63095081,14.6322478 C9.70623321,14.7356407 9.82641834,14.7968 9.95431487,14.7968 L11.1772617,14.7968 C11.2656273,14.7968 11.3372617,14.7251656 11.3372617,14.6368 C11.3372617,14.6018085 11.3257908,14.5677817 11.3046061,14.5399319 L8.35626911,10.664 L8.35626911,10.664 L11.1081665,7.05705047 C11.161766,6.98679688 11.1482651,6.88639412 11.0780115,6.83279463 C11.0501297,6.81152238 11.0160311,6.8 10.9809611,6.8 L9.75859621,6.8 C9.63069969,6.8 9.51051455,6.86115931 9.43523215,6.96455216 L7.5,9.6224 L7.5,9.6224 L5.56476785,6.96455216 C5.48948545,6.86115931 5.36930031,6.8 5.24140379,6.8 L4.01741303,6.8 C3.92904746,6.8 3.85741302,6.87163444 3.85741302,6.96 C3.85741302,6.99485016 3.86879158,7.02874738 3.88981909,7.05653906 Z" id="路径" fill="#A8A9AA" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
22
web/src/assets/images/conversation/link_hover.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>链接</title>
|
||||
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="红熊空间-记忆对话-默认对话" transform="translate(-284, -726)">
|
||||
<g id="聊天页面" transform="translate(258, 128)">
|
||||
<g id="输入框" transform="translate(16, 512)">
|
||||
<g id="链接" transform="translate(10, 86)">
|
||||
<rect id="矩形" stroke="#155EEF" x="0.5" y="0.5" width="23" height="23" rx="6"></rect>
|
||||
<g id="编组-6" transform="translate(5, 5)" stroke="#155EEF" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(7.1064, 7.1064) rotate(45) translate(-7.1064, -7.1064)translate(3.9564, 1.8564)" id="路径">
|
||||
<path d="M4.14483263e-14,7.00494523 L4.14483263e-14,2.33333333 C4.14483263e-14,1.04466892 0.992435471,0 2.21666667,0 C3.44089786,0 4.43333333,1.04466892 4.43333333,2.33333333 L4.43333333,5.85907842"></path>
|
||||
<path d="M6.3,2.518727 L6.3,7.175 C6.3,9.01134679 4.88969696,10.5 3.15,10.5 C1.41030304,10.5 0,9.01134679 0,7.175 L0,2.33333333"></path>
|
||||
<path d="M4.43333333,2.33333333 L4.43333333,7 C4.43333333,7.64433221 3.91099887,8.16666667 3.26666667,8.16666667 C2.62233446,8.16666667 2.1,7.64433221 2.1,7 L2.1,3.5201611"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
18
web/src/assets/images/conversation/pdf.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>PDF</title>
|
||||
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="红熊空间-记忆对话-交互-2" transform="translate(-244, -28)">
|
||||
<g id="PDF" transform="translate(244, 28)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M10.0844745,0.55 L14.3181484,4.59605723 L14.45,15.5999999 C14.45,16.1108633 14.2429317,16.5733633 13.9081475,16.9081475 C13.5733633,17.2429317 13.1108633,17.45 12.5999999,17.45 L2.4000001,17.45 C1.88913668,17.45 1.42663665,17.2429317 1.09185248,16.9081475 C0.757068314,16.5733633 0.55,16.1108633 0.55,15.5999999 L0.55,2.4000001 C0.55,1.88913668 0.757068314,1.42663665 1.09185248,1.09185248 C1.42663665,0.757068314 1.88913668,0.55 2.4000001,0.55 L10.0844745,0.55 Z" id="蒙版" stroke="#212332" stroke-width="1.1"></path>
|
||||
<path d="M10.5,0.587164664 L10.5,3.5 C10.5,4.32842712 11.1715729,5 12,5 L14.6706929,5 L14.6706929,5" id="路径-8" stroke="#212332" stroke-width="1.1"></path>
|
||||
<g id="pdf" transform="translate(2.5, 4.4167)" fill="#212332" fill-rule="nonzero">
|
||||
<rect id="矩形" opacity="0" x="0" y="0" width="10.4974603" height="10.6944444"></rect>
|
||||
<path d="M9.039482,7.98441642 C8.25217247,7.92484363 7.494018,7.6270425 6.88165932,7.09099208 C5.68612776,7.35901729 4.54889605,7.74616714 3.4116746,8.22266571 C2.50772435,9.86058242 1.66209447,10.6944444 0.933105306,10.6944444 C0.787309524,10.6944444 0.612348435,10.6646685 0.49571796,10.5753198 C0.174950838,10.4264192 0,10.0988422 0,9.77124417 C0,9.50321896 0.058320363,8.75871613 2.82848122,7.53770425 C3.46999497,6.34647877 3.96570267,5.12547736 4.37394522,3.84491364 C4.02402304,3.13017625 3.26586857,1.37311396 3.79074159,0.479710552 C3.96570267,0.152112552 4.3156146,-0.0265639412 4.69470209,0.00321198439 C4.98629365,0.00321198439 5.27788521,0.152123022 5.4528463,0.390361836 C5.83192354,0.926412255 5.80275823,2.05808588 5.30705052,3.72578899 C5.77360317,4.61920287 6.38595161,5.4232785 7.11494077,6.10823995 C7.72729946,5.98911531 8.33964789,5.8997666 8.95199633,5.8997666 C10.3225095,5.92955299 10.5266154,6.58472805 10.4974603,6.97186744 C10.4974603,7.98441642 9.5351897,7.98441642 9.039482,7.98441642 L9.039482,7.98441642 Z M0.874784943,9.83079602 L0.962270613,9.8010201 C1.3705029,9.65210906 1.69124952,9.35430793 1.92453097,8.96715807 C1.48714363,9.14584504 1.13722145,9.44364617 0.874784943,9.83080649 L0.874784943,9.83079602 Z M4.7530122,0.89663633 L4.66553678,0.89663633 C4.63638172,0.89663633 4.57806136,0.89663633 4.54889605,0.926412255 C4.43226558,1.43268675 4.519741,1.96873717 4.72385714,2.44522527 C4.89881823,1.93895077 4.89881823,1.40290035 4.7530122,0.89663633 Z M4.95713859,5.21481561 L4.92797329,5.2743884 L4.89881823,5.244602 C4.63638172,5.92955299 4.34477991,6.61450398 4.02402304,7.26967904 L4.08235365,7.23989264 L4.08235365,7.29946544 C4.72385714,7.06121615 5.42369125,6.85275326 6.06519474,6.70384223 L6.03603968,6.6740663 L6.1235151,6.6740663 C5.68612776,6.22735413 5.27788521,5.7210901 4.95713859,5.21481561 Z M8.92284127,6.79319094 C8.66040476,6.79319094 8.42712331,6.79319094 8.1646868,6.85275326 C8.45628862,7.00165383 8.74789043,7.06120568 9.039482,7.09099208 C9.24358789,7.12077847 9.44771429,7.09099208 9.62267537,7.03142976 C9.62267537,6.94209151 9.50603465,6.79319094 8.92284127,6.79319094 L8.92284127,6.79319094 Z" id="形状" stroke="#212332" stroke-width="0.08"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
18
web/src/assets/images/conversation/pdf_disabled.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>PDF</title>
|
||||
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="红熊空间-记忆对话-交互-2" transform="translate(-244, -58)">
|
||||
<g id="PDF" transform="translate(244, 58)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M10.0844745,0.55 L14.3181484,4.59605723 L14.45,15.5999999 C14.45,16.1108633 14.2429317,16.5733633 13.9081475,16.9081475 C13.5733633,17.2429317 13.1108633,17.45 12.5999999,17.45 L2.4000001,17.45 C1.88913668,17.45 1.42663665,17.2429317 1.09185248,16.9081475 C0.757068314,16.5733633 0.55,16.1108633 0.55,15.5999999 L0.55,2.4000001 C0.55,1.88913668 0.757068314,1.42663665 1.09185248,1.09185248 C1.42663665,0.757068314 1.88913668,0.55 2.4000001,0.55 L10.0844745,0.55 Z" id="蒙版" stroke="#A8A9AA" stroke-width="1.1"></path>
|
||||
<path d="M10.5,0.587164664 L10.5,3.5 C10.5,4.32842712 11.1715729,5 12,5 L14.6706929,5 L14.6706929,5" id="路径-8" stroke="#A8A9AA" stroke-width="1.1"></path>
|
||||
<g id="pdf" transform="translate(2.5, 4.4167)" fill="#A8A9AA" fill-rule="nonzero">
|
||||
<rect id="矩形" opacity="0" x="0" y="0" width="10.4974603" height="10.6944444"></rect>
|
||||
<path d="M9.039482,7.98441642 C8.25217247,7.92484363 7.494018,7.6270425 6.88165932,7.09099208 C5.68612776,7.35901729 4.54889605,7.74616714 3.4116746,8.22266571 C2.50772435,9.86058242 1.66209447,10.6944444 0.933105306,10.6944444 C0.787309524,10.6944444 0.612348435,10.6646685 0.49571796,10.5753198 C0.174950838,10.4264192 0,10.0988422 0,9.77124417 C0,9.50321896 0.058320363,8.75871613 2.82848122,7.53770425 C3.46999497,6.34647877 3.96570267,5.12547736 4.37394522,3.84491364 C4.02402304,3.13017625 3.26586857,1.37311396 3.79074159,0.479710552 C3.96570267,0.152112552 4.3156146,-0.0265639412 4.69470209,0.00321198439 C4.98629365,0.00321198439 5.27788521,0.152123022 5.4528463,0.390361836 C5.83192354,0.926412255 5.80275823,2.05808588 5.30705052,3.72578899 C5.77360317,4.61920287 6.38595161,5.4232785 7.11494077,6.10823995 C7.72729946,5.98911531 8.33964789,5.8997666 8.95199633,5.8997666 C10.3225095,5.92955299 10.5266154,6.58472805 10.4974603,6.97186744 C10.4974603,7.98441642 9.5351897,7.98441642 9.039482,7.98441642 L9.039482,7.98441642 Z M0.874784943,9.83079602 L0.962270613,9.8010201 C1.3705029,9.65210906 1.69124952,9.35430793 1.92453097,8.96715807 C1.48714363,9.14584504 1.13722145,9.44364617 0.874784943,9.83080649 L0.874784943,9.83079602 Z M4.7530122,0.89663633 L4.66553678,0.89663633 C4.63638172,0.89663633 4.57806136,0.89663633 4.54889605,0.926412255 C4.43226558,1.43268675 4.519741,1.96873717 4.72385714,2.44522527 C4.89881823,1.93895077 4.89881823,1.40290035 4.7530122,0.89663633 Z M4.95713859,5.21481561 L4.92797329,5.2743884 L4.89881823,5.244602 C4.63638172,5.92955299 4.34477991,6.61450398 4.02402304,7.26967904 L4.08235365,7.23989264 L4.08235365,7.29946544 C4.72385714,7.06121615 5.42369125,6.85275326 6.06519474,6.70384223 L6.03603968,6.6740663 L6.1235151,6.6740663 C5.68612776,6.22735413 5.27788521,5.7210901 4.95713859,5.21481561 Z M8.92284127,6.79319094 C8.66040476,6.79319094 8.42712331,6.79319094 8.1646868,6.85275326 C8.45628862,7.00165383 8.74789043,7.06120568 9.039482,7.09099208 C9.24358789,7.12077847 9.44771429,7.09099208 9.62267537,7.03142976 C9.62267537,6.94209151 9.50603465,6.79319094 8.92284127,6.79319094 L8.92284127,6.79319094 Z" id="形状" stroke="#A8A9AA" stroke-width="0.08"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
15
web/src/assets/images/conversation/word.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>文档</title>
|
||||
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="红熊空间-记忆对话-交互-2" transform="translate(-216, -28)">
|
||||
<g id="文档" transform="translate(216, 28)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M10.0844745,0.55 L14.3181484,4.59605723 L14.45,15.5999999 C14.45,16.1108633 14.2429317,16.5733633 13.9081475,16.9081475 C13.5733633,17.2429317 13.1108633,17.45 12.5999999,17.45 L2.4000001,17.45 C1.88913668,17.45 1.42663665,17.2429317 1.09185248,16.9081475 C0.757068314,16.5733633 0.55,16.1108633 0.55,15.5999999 L0.55,2.4000001 C0.55,1.88913668 0.757068314,1.42663665 1.09185248,1.09185248 C1.42663665,0.757068314 1.88913668,0.55 2.4000001,0.55 L10.0844745,0.55 Z" id="蒙版" stroke="#212332" stroke-width="1.1"></path>
|
||||
<path d="M10.5,0.587164664 L10.5,3.5 C10.5,4.32842712 11.1715729,5 12,5 L14.6706929,5 L14.6706929,5" id="路径-8" stroke="#212332" stroke-width="1.1"></path>
|
||||
<path d="M7.52510987,9.99555 L8.79075795,14.5924967 C8.82445294,14.7148879 8.93836525,14.8 9.06850364,14.8 L9.83008761,14.8 C9.96009684,14.8 10.0739374,14.7150739 10.1077614,14.5928467 L11.8901028,8.15284667 C11.8966741,8.1290932 11.9,8.10460012 11.9,8.08 C11.9,7.92537 11.7712401,7.8 11.612429,7.8 L10.7598767,7.8 C10.6258875,7.8 10.5096506,7.89009194 10.4796147,8.01723333 L9.38229158,12.6630167 L8.18983033,8.01219333 C8.15786879,7.88751154 8.04284405,7.8 7.91086246,7.8 L7.13940521,7.8 C7.00740575,7.8 6.89235528,7.88749466 6.86038941,8.01219333 L5.67032459,12.6538233 L4.56566836,8.01674333 C4.53544192,7.88983915 4.41932088,7.8 4.28552624,7.8 L3.43764699,7.8 C3.41243998,7.8 3.38734202,7.80322163 3.36299834,7.80959 C3.20962712,7.84972333 3.11870675,8.00332667 3.15992526,8.15266 L4.93742583,14.59266 C4.97117739,14.7149613 5.08503579,14.8 5.21509963,14.8 L5.9817161,14.8 C6.11185449,14.8 6.22576679,14.7148879 6.25946179,14.5924967 L7.52510987,9.99555 Z" id="路径" fill="#212332" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
15
web/src/assets/images/conversation/word_disabled.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>文档</title>
|
||||
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="红熊空间-记忆对话-交互-2" transform="translate(-216, -58)">
|
||||
<g id="文档" transform="translate(216, 58)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M10.0844745,0.55 L14.3181484,4.59605723 L14.45,15.5999999 C14.45,16.1108633 14.2429317,16.5733633 13.9081475,16.9081475 C13.5733633,17.2429317 13.1108633,17.45 12.5999999,17.45 L2.4000001,17.45 C1.88913668,17.45 1.42663665,17.2429317 1.09185248,16.9081475 C0.757068314,16.5733633 0.55,16.1108633 0.55,15.5999999 L0.55,2.4000001 C0.55,1.88913668 0.757068314,1.42663665 1.09185248,1.09185248 C1.42663665,0.757068314 1.88913668,0.55 2.4000001,0.55 L10.0844745,0.55 Z" id="蒙版" stroke="#A8A9AA" stroke-width="1.1"></path>
|
||||
<path d="M10.5,0.587164664 L10.5,3.5 C10.5,4.32842712 11.1715729,5 12,5 L14.6706929,5 L14.6706929,5" id="路径-8" stroke="#A8A9AA" stroke-width="1.1"></path>
|
||||
<path d="M7.52510987,9.99555 L8.79075795,14.5924967 C8.82445294,14.7148879 8.93836525,14.8 9.06850364,14.8 L9.83008761,14.8 C9.96009684,14.8 10.0739374,14.7150739 10.1077614,14.5928467 L11.8901028,8.15284667 C11.8966741,8.1290932 11.9,8.10460012 11.9,8.08 C11.9,7.92537 11.7712401,7.8 11.612429,7.8 L10.7598767,7.8 C10.6258875,7.8 10.5096506,7.89009194 10.4796147,8.01723333 L9.38229158,12.6630167 L8.18983033,8.01219333 C8.15786879,7.88751154 8.04284405,7.8 7.91086246,7.8 L7.13940521,7.8 C7.00740575,7.8 6.89235528,7.88749466 6.86038941,8.01219333 L5.67032459,12.6538233 L4.56566836,8.01674333 C4.53544192,7.88983915 4.41932088,7.8 4.28552624,7.8 L3.43764699,7.8 C3.41243998,7.8 3.38734202,7.80322163 3.36299834,7.80959 C3.20962712,7.84972333 3.11870675,8.00332667 3.15992526,8.15266 L4.93742583,14.59266 C4.97117739,14.7149613 5.08503579,14.8 5.21509963,14.8 L5.9817161,14.8 C6.11185449,14.8 6.22576679,14.7148879 6.25946179,14.5924967 L7.52510987,9.99555 Z" id="路径" fill="#A8A9AA" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
61
web/src/components/AudioRecorder/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { type FC, useRef, useState } from 'react'
|
||||
import RecordRTC from 'recordrtc'
|
||||
|
||||
import { fileUpload } from '@/api/fileStorage'
|
||||
|
||||
interface AudioRecorderProps {
|
||||
onRecordingComplete?: (file: { file_id: string; file_key: string; }, blob: Blob) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
onRecordingComplete,
|
||||
className = '',
|
||||
}) => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const recorderRef = useRef<RecordRTC | null>(null)
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
recorderRef.current = new RecordRTC(stream, {
|
||||
type: 'audio',
|
||||
mimeType: 'audio/webm'
|
||||
})
|
||||
recorderRef.current.startRecording()
|
||||
setIsRecording(true)
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.stopRecording(() => {
|
||||
const blob = recorderRef.current!.getBlob()
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, `recording_${Date.now()}.webm`)
|
||||
fileUpload(formData)
|
||||
.then(res => {
|
||||
onRecordingComplete?.(res as { file_id: string; file_key: string; }, blob)
|
||||
recorderRef.current?.destroy()
|
||||
recorderRef.current = null
|
||||
})
|
||||
})
|
||||
setIsRecording(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rb:size-5.5 rb:cursor-pointer rb:bg-cover ${className} ${
|
||||
isRecording
|
||||
? `rb:bg-[url('@/assets/images/conversation/audio_ing.gif')]`
|
||||
: `rb:bg-[url('@/assets/images/conversation/audio.svg')] rb:hover:bg-[url('@/assets/images/conversation/audio_hover.svg')]`
|
||||
}`}
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioRecorder
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:17
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-01-12 20:41:27
|
||||
* @Last Modified time: 2026-02-06 21:05:52
|
||||
*/
|
||||
import { type FC, useRef, useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -11,8 +11,8 @@ import type { ChatContentProps } from './types'
|
||||
import { Spin } from 'antd'
|
||||
|
||||
/**
|
||||
* 聊天内容显示组件
|
||||
* 负责渲染聊天消息列表,支持不同角色的消息样式和自动滚动
|
||||
* Chat Content Display Component
|
||||
* Responsible for rendering chat message list, supports different role message styles and auto-scrolling
|
||||
*/
|
||||
const ChatContent: FC<ChatContentProps> = ({
|
||||
classNames,
|
||||
@@ -25,10 +25,10 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
errorDesc,
|
||||
renderRuntime
|
||||
}) => {
|
||||
// 滚动容器引用,用于控制自动滚动到底部
|
||||
// Scroll container reference for controlling auto-scroll to bottom
|
||||
const scrollContainerRef = useRef<(HTMLDivElement | null)>(null)
|
||||
|
||||
// 当数据变化时,自动滚动到底部显示最新消息
|
||||
// Auto-scroll to bottom when data changes to show latest messages
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
@@ -39,37 +39,37 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
return (
|
||||
<div ref={scrollContainerRef} className={clsx("rb:relative rb:overflow-y-auto", classNames)}>
|
||||
{data.length === 0
|
||||
? empty // 显示空状态
|
||||
? empty // Display empty state
|
||||
: data.map((item, index) => (
|
||||
<div key={index} className={clsx("rb:relative", {
|
||||
'rb:mt-6': index !== 0, // 非第一条消息添加上边距
|
||||
'rb:right-0 rb:text-right': item.role === 'user', // 用户消息右对齐
|
||||
'rb:left-0 rb:text-left': item.role === 'assistant', // 助手消息左对齐
|
||||
'rb:mt-6': index !== 0, // Add top margin for non-first messages
|
||||
'rb:right-0 rb:text-right': item.role === 'user', // User messages right-aligned
|
||||
'rb:left-0 rb:text-left': item.role === 'assistant', // Assistant messages left-aligned
|
||||
})}>
|
||||
{/* 流式加载时且内容为空则不显示 */}
|
||||
{/* Don't display if streaming and content is empty */}
|
||||
{streamLoading && item.content === '' && !renderRuntime
|
||||
? <Spin />
|
||||
: <>
|
||||
{/* 顶部标签(如时间戳、用户名等) */}
|
||||
{/* Top label (such as timestamp, username, etc.) */}
|
||||
{labelPosition === 'top' &&
|
||||
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:font-regular">
|
||||
{labelFormat(item)}
|
||||
</div>
|
||||
}
|
||||
{/* 消息气泡框 */}
|
||||
{/* Message bubble */}
|
||||
<div className={clsx('rb:border rb:text-left rb:rounded-lg rb:mt-1.5 rb:leading-4.5 rb:p-[10px_12px_2px_12px] rb:inline-block rb:max-w-130 rb:wrap-break-word', contentClassNames, {
|
||||
// 错误消息样式(内容为null且非助手消息)
|
||||
// Error message style (content is null and not assistant message)
|
||||
'rb:border-[rgba(255,93,52,0.30)] rb:bg-[rgba(255,93,52,0.08)] rb:text-[#FF5D34]': errorDesc && item.role === 'assistant' && item.content === null && !renderRuntime,
|
||||
// 助手消息样式
|
||||
// Assistant message style
|
||||
'rb:bg-[rgba(21,94,239,0.08)] rb:border-[rgba(21,94,239,0.30)]': item.role === 'user',
|
||||
// 用户消息样式
|
||||
// User message style
|
||||
'rb:bg-[#FFFFFF] rb:border-[#EBEBEB]': item.role === 'assistant' && (item.content || item.content === '' || typeof renderRuntime === 'function'),
|
||||
})}>
|
||||
{item.subContent && renderRuntime && renderRuntime(item, index)}
|
||||
{/* 使用Markdown组件渲染消息内容 */}
|
||||
{/* Render message content using Markdown component */}
|
||||
<Markdown content={renderRuntime ? item.content ?? '' : item.content ?? errorDesc ?? ''} />
|
||||
</div>
|
||||
{/* 底部标签(如时间戳、用户名等) */}
|
||||
{/* Bottom label (such as timestamp, username, etc.) */}
|
||||
{labelPosition === 'bottom' &&
|
||||
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:font-regular rb:mt-2">
|
||||
{labelFormat(item)}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2025-12-20 15:38:40
|
||||
* @Last Modified time: 2026-02-06 21:05:09
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
import { type FC, useEffect, useMemo } from 'react'
|
||||
import { Flex, Input, Form } from 'antd'
|
||||
import SendIcon from '@/assets/images/conversation/send.svg'
|
||||
import SendDisabledIcon from '@/assets/images/conversation/sendDisabled.svg'
|
||||
@@ -12,15 +12,24 @@ import LoadingIcon from '@/assets/images/conversation/loading.svg'
|
||||
import type { ChatInputProps } from './types'
|
||||
|
||||
/**
|
||||
* 聊天输入框组件
|
||||
* 提供消息输入、发送功能,支持键盘快捷键和加载状态显示
|
||||
* Chat Input Component
|
||||
* Provides message input and send functionality, supports keyboard shortcuts and loading state display
|
||||
*/
|
||||
const ChatInput = ({ message, onChange, onSend, loading, children }: ChatInputProps) => {
|
||||
const ChatInput: FC<ChatInputProps> = ({
|
||||
message,
|
||||
onSend,
|
||||
loading,
|
||||
children,
|
||||
fileList,
|
||||
fileChange,
|
||||
className = '',
|
||||
onChange
|
||||
}) => {
|
||||
const [form] = Form.useForm()
|
||||
// 监听表单值变化,用于控制发送按钮状态
|
||||
const values = Form.useWatch([], form);
|
||||
const values = Form.useWatch([], form)
|
||||
// Monitor form value changes to control send button state
|
||||
|
||||
// 当外部message为空时,清空表单
|
||||
// Clear form when external message is empty
|
||||
useEffect(() => {
|
||||
if (!message) {
|
||||
form.setFieldsValue({
|
||||
@@ -29,7 +38,7 @@ const ChatInput = ({ message, onChange, onSend, loading, children }: ChatInputPr
|
||||
}
|
||||
}, [form, message])
|
||||
|
||||
// 当加载状态时,清空输入框
|
||||
// Clear input when loading
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
form.setFieldsValue({
|
||||
@@ -38,39 +47,93 @@ const ChatInput = ({ message, onChange, onSend, loading, children }: ChatInputPr
|
||||
}
|
||||
}, [loading])
|
||||
|
||||
|
||||
const handleDelete = (file: any) => {
|
||||
fileChange?.(fileList?.filter(item => item.uid !== file.uid) || [])
|
||||
}
|
||||
// Convert file object to preview URL
|
||||
const previewFileList = useMemo(() => {
|
||||
return fileList?.map(file => ({
|
||||
...file,
|
||||
url: file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : file.thumbUrl)
|
||||
})) || []
|
||||
}, [fileList])
|
||||
|
||||
const handleSend = () => {
|
||||
onSend(values.message)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rb:absolute rb:bottom-3 rb:left-0 rb:right-0">
|
||||
<div className={`rb:absolute rb:bottom-3 rb:left-0 rb:right-0 rb:w-full ${className}`}>
|
||||
<Flex vertical justify="space-between" className="rb:border rb:border-[#DFE4ED] rb:rounded-xl rb:min-h-30">
|
||||
{/* 消息输入表单 */}
|
||||
{previewFileList.length > 0 && <Flex gap={14} className="rb:mx-3! rb:mt-3!">
|
||||
{previewFileList.map((file) => {
|
||||
if (file.type.includes('image')) {
|
||||
return (
|
||||
<div key={file.uid} className="rb:inline-block rb:group rb:relative rb:rounded-lg">
|
||||
<img src={file.url} alt={file.name} className="rb:size-12! rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('src/assets/images/conversation/delete.svg')] rb:hover:bg-[url('src/assets/images/conversation/delete_hover.svg')]"
|
||||
onClick={() => handleDelete(file)}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={file.uid} className="rb:w-45 rb:text-[12px] rb:gap-2.5 rb:flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5">
|
||||
{(file.type.includes('word') || file.type.includes('wordprocessingml.document')) && <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('src/assets/images/conversation/word_disabled.svg')] rb:hover:bg-[url('src/assets/images/conversation/word.svg')]"
|
||||
></div>}
|
||||
{(file.type.includes('pdf')) && <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('src/assets/images/conversation/pdf_disabled.svg')] rb:hover:bg-[url('src/assets/images/conversation/pdf.svg')]"
|
||||
></div>}
|
||||
{(file.type.includes('excel') || file.type.includes('spreadsheetml.sheet') || file.type.includes('csv')) && <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('src/assets/images/conversation/excel_disabled.svg')] rb:hover:bg-[url('src/assets/images/conversation/excel.svg')]"
|
||||
></div>}
|
||||
<div className="rb:flex-1 rb:w-32.5">
|
||||
<div className="rb:leading-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.name}</div>
|
||||
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.type} · {file.size}</div>
|
||||
</div>
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('src/assets/images/conversation/delete.svg')] rb:hover:bg-[url('src/assets/images/conversation/delete_hover.svg')]"
|
||||
onClick={() => handleDelete(file)}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Flex>}
|
||||
{/* Message input form */}
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="message" noStyle>
|
||||
<Input.TextArea
|
||||
className="rb:m-[10px_12px_10px_12px]! rb:p-0! rb:w-[calc(100%-24px)]! rb:flex-[1_1_auto]"
|
||||
variant="borderless"
|
||||
autoSize={{ minRows: 2, maxRows: 2 }}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter键发送,Shift+Enter换行
|
||||
if (e.key === 'Enter' && !e.shiftKey && (e.target as HTMLTextAreaElement).value?.trim() !== '' && !loading) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="message" noStyle>
|
||||
<Input.TextArea
|
||||
className="rb:m-[10px_12px_10px_12px]! rb:p-0! rb:w-[calc(100%-24px)]! rb:flex-[1_1_auto]"
|
||||
variant="borderless"
|
||||
autoSize={{ minRows: 2, maxRows: 2 }}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter to send, Shift+Enter for new line
|
||||
if (e.key === 'Enter' && !e.shiftKey && (e.target as HTMLTextAreaElement).value?.trim() !== '' && !loading) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/* 底部操作区域 */}
|
||||
{/* Bottom action area */}
|
||||
<Flex align="center" justify="space-between" className="rb:m-[0_10px_10px_10px]!">
|
||||
{/* 子组件内容(如按钮等) */}
|
||||
{children}
|
||||
{/* 发送按钮 - 根据状态显示不同图标 */}
|
||||
{loading
|
||||
? <img src={LoadingIcon} className="rb:w-5.5 rb:h-5.5 rb:cursor-pointer" />
|
||||
: !values || !values?.message || values?.message?.trim() === ''
|
||||
? <img src={SendDisabledIcon} className="rb:w-5.5 rb:h-5.5 rb:cursor-pointer" />
|
||||
: <img src={SendIcon} className="rb:w-5.5 rb:h-5.5 rb:cursor-pointer" onClick={onSend} />
|
||||
}
|
||||
{/* Child component content (such as buttons) */}
|
||||
<div className="rb:flex-1">{children}</div>
|
||||
<div className="rb:flex rb:items-center">
|
||||
{/* Send button - display different icons based on state */}
|
||||
{loading
|
||||
? <img src={LoadingIcon} className="rb:w-5.5 rb:h-5.5 rb:cursor-pointer" />
|
||||
: !values || !values?.message || values?.message?.trim() === ''
|
||||
? <img src={SendDisabledIcon} className="rb:w-5.5 rb:h-5.5 rb:cursor-pointer" />
|
||||
: <img src={SendIcon} className="rb:w-5.5 rb:h-5.5 rb:cursor-pointer" onClick={handleSend} />
|
||||
}
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:09
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2025-12-11 13:43:51
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:05:09
|
||||
*/
|
||||
import { type FC } from 'react'
|
||||
import ChatInput from './ChatInput'
|
||||
@@ -10,35 +10,43 @@ import type { ChatProps } from './types'
|
||||
import ChatContent from './ChatContent'
|
||||
|
||||
/**
|
||||
* 聊天组件 - 主要组件,由内容区域和输入框组成
|
||||
* 提供完整的聊天界面功能,包括消息显示和输入交互
|
||||
* Chat Component - Main component consisting of content area and input box
|
||||
* Provides complete chat interface functionality, including message display and input interaction
|
||||
*/
|
||||
const Chat: FC<ChatProps> = ({
|
||||
empty,
|
||||
data,
|
||||
onChange,
|
||||
onSend,
|
||||
streamLoading = false,
|
||||
loading,
|
||||
onChange,
|
||||
onSend,
|
||||
streamLoading = false,
|
||||
loading,
|
||||
contentClassName = '',
|
||||
children,
|
||||
labelFormat,
|
||||
errorDesc
|
||||
errorDesc,
|
||||
fileList,
|
||||
fileChange
|
||||
}) => {
|
||||
return (
|
||||
<div className="rb:h-full rb:relative rb:pt-2">
|
||||
{/* 聊天内容显示区域 */}
|
||||
{/* Chat content display area */}
|
||||
<ChatContent
|
||||
classNames={contentClassName}
|
||||
data={data}
|
||||
classNames={contentClassName}
|
||||
data={data}
|
||||
streamLoading={streamLoading}
|
||||
empty={empty}
|
||||
labelFormat={labelFormat}
|
||||
errorDesc={errorDesc}
|
||||
/>
|
||||
|
||||
{/* 聊天输入框区域 */}
|
||||
<ChatInput onChange={onChange} onSend={onSend} loading={loading}>
|
||||
{/* Chat input area */}
|
||||
<ChatInput
|
||||
fileList={fileList}
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
loading={loading}
|
||||
fileChange={fileChange}
|
||||
>
|
||||
{children}
|
||||
</ChatInput>
|
||||
</div>
|
||||
|
||||
@@ -2,85 +2,95 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:45:54
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2025-12-11 13:43:52
|
||||
* @Last Modified time: 2026-02-06 21:05:09
|
||||
*/
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* 聊天消息项接口
|
||||
* Chat message item interface
|
||||
*/
|
||||
export interface ChatItem {
|
||||
/** 消息唯一标识 */
|
||||
/** Message unique identifier */
|
||||
id?: string;
|
||||
/** 会话ID */
|
||||
/** Conversation ID */
|
||||
conversation_id?: string | null;
|
||||
/** 消息角色:用户或助手 */
|
||||
/** Message role: user or assistant */
|
||||
role?: 'user' | 'assistant';
|
||||
/** 消息内容 */
|
||||
/** Message content */
|
||||
content?: string | null;
|
||||
/** 创建时间 */
|
||||
/** Creation time */
|
||||
created_at?: number | string;
|
||||
status?: string;
|
||||
subContent?: Record<string, any>[]
|
||||
subContent?: Record<string, any>[];
|
||||
files?: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天组件主要属性接口
|
||||
* Chat component main props interface
|
||||
*/
|
||||
export interface ChatProps {
|
||||
/** 空状态显示内容 */
|
||||
/** Empty state display content */
|
||||
empty?: ReactNode;
|
||||
/** 聊天数据列表 */
|
||||
/** Chat data list */
|
||||
data: ChatItem[];
|
||||
/** 输入内容变化回调 */
|
||||
/** Input content change callback */
|
||||
onChange: (message: string) => void;
|
||||
/** 发送消息回调 */
|
||||
/** Send message callback */
|
||||
onSend: () => void;
|
||||
/** 流式加载状态 */
|
||||
/** Streaming loading state */
|
||||
streamLoading?: boolean;
|
||||
/** 加载状态 */
|
||||
/** Loading state */
|
||||
loading: boolean;
|
||||
/** 内容区域自定义样式类名 */
|
||||
/** Content area custom class name */
|
||||
contentClassName?: string;
|
||||
/** 子组件内容 */
|
||||
/** Child component content */
|
||||
children?: ReactNode;
|
||||
/** 标签格式化函数 */
|
||||
/** Label format function */
|
||||
labelFormat: (item: ChatItem) => any;
|
||||
errorDesc?: string;
|
||||
/** Attachment list */
|
||||
fileList?: any[];
|
||||
/** Attachment update */
|
||||
fileChange?: (fileList: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天输入框组件属性接口
|
||||
* Chat input component props interface
|
||||
*/
|
||||
export interface ChatInputProps {
|
||||
/** 当前输入消息 */
|
||||
/** Current input message */
|
||||
message?: string;
|
||||
/** 输入内容变化回调 */
|
||||
onChange: (message: string) => void;
|
||||
/** 发送消息回调 */
|
||||
onSend: () => void;
|
||||
/** 加载状态 */
|
||||
/** Input content change callback */
|
||||
onChange?: (message: string) => void;
|
||||
/** Send message callback */
|
||||
onSend: (message?: string) => void;
|
||||
/** Loading state */
|
||||
loading: boolean;
|
||||
/** 子组件内容 */
|
||||
/** Child component content */
|
||||
children?: ReactNode;
|
||||
/** Attachment list */
|
||||
fileList?: any[];
|
||||
/** Attachment update */
|
||||
fileChange?: (fileList: any[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天内容区域组件属性接口
|
||||
* Chat content area component props interface
|
||||
*/
|
||||
export interface ChatContentProps {
|
||||
/** 自定义样式类名 */
|
||||
/** Custom class name */
|
||||
classNames?: string | Record<string, boolean>;
|
||||
contentClassNames?: string | Record<string, boolean>;
|
||||
/** 聊天数据列表 */
|
||||
/** Chat data list */
|
||||
data: ChatItem[];
|
||||
/** 流式加载状态 */
|
||||
/** Streaming loading state */
|
||||
streamLoading: boolean;
|
||||
/** 空状态显示内容 */
|
||||
/** Empty state display content */
|
||||
empty?: ReactNode;
|
||||
/** 标签位置:顶部或底部 */
|
||||
/** Label position: top or bottom */
|
||||
labelPosition?: 'top' | 'bottom';
|
||||
/** 标签格式化函数 */
|
||||
/** Label format function */
|
||||
labelFormat: (item: ChatItem) => any;
|
||||
errorDesc?: string;
|
||||
renderRuntime?: (item: ChatItem, index: number) => ReactNode;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle, useRef } from 'react';
|
||||
import { Upload, Button, Modal, Progress, App } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadProps, UploadFile } from 'antd';
|
||||
import type { UploadRequestOption } from 'rc-upload/lib/interface';
|
||||
// import { request } from '@/utils/request';
|
||||
import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface';
|
||||
import CloudUploadOutlined from '@/assets/images/CloudUploadOutlined.png'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cookieUtils } from '@/utils/request'
|
||||
import { fileUpload } from '@/api/fileStorage'
|
||||
|
||||
const { confirm } = Modal;
|
||||
const { Dragger } = Upload;
|
||||
@@ -61,6 +63,8 @@ const ALL_FILE_TYPE: {
|
||||
ttl: 'text/turtle',
|
||||
rdf: 'application/rdf+xml',
|
||||
xml: 'application/rdf+xml',
|
||||
yaml: 'application/x-yaml',
|
||||
yml: 'application/x-yaml',
|
||||
}
|
||||
export interface UploadFilesRef {
|
||||
fileList: UploadFile[];
|
||||
@@ -157,45 +161,6 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
|
||||
return isAutoUpload;
|
||||
};
|
||||
|
||||
// 自定义上传方法
|
||||
/*
|
||||
const customRequest: RcUploadProps['customRequest'] = ({ file, onSuccess, onError, onProgress }) => {
|
||||
setLoading(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file as RcFile);
|
||||
|
||||
// 添加额外的请求参数
|
||||
const requestData = requestConfig.data;
|
||||
if (requestData) {
|
||||
Object.keys(requestData).forEach(key => {
|
||||
const value = requestData[key];
|
||||
formData.append(key, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
request.post(action, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...requestConfig.headers,
|
||||
},
|
||||
...requestConfig,
|
||||
})
|
||||
.then((response) => {
|
||||
if (onSuccess) onSuccess(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
message.error('上传失败,请重试');
|
||||
if (onError) onError(error);
|
||||
// setFileList(fileList.filter((item) => item.uid !== (file as UploadFile).uid));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
*/
|
||||
|
||||
// 处理上传状态变化
|
||||
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList, event }) => {
|
||||
console.log('event', event)
|
||||
@@ -240,7 +205,7 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
fileList,
|
||||
beforeUpload,
|
||||
headers: {
|
||||
authorization: `Bearer ${cookieUtils.get('authToken')}`,
|
||||
authorization: `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
onRemove: handleRemove,
|
||||
onChange: handleChange,
|
||||
|
||||
@@ -1574,6 +1574,12 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
memory: 'Memory',
|
||||
memoryConversationAnalysisEmpty: 'No conversation analysis available.',
|
||||
memoryConversationAnalysisEmptySubTitle: 'Conversation analysis will appear here.',
|
||||
|
||||
uploadFile: 'Upload File',
|
||||
fileType: 'File Type',
|
||||
image: 'Image',
|
||||
fileUrl: 'File URL',
|
||||
addRemoteFile: 'Add Remote File'
|
||||
},
|
||||
login: {
|
||||
title: 'Red Bear Memory Science',
|
||||
|
||||
@@ -684,6 +684,13 @@ export const zh = {
|
||||
analyTask: '分析任务意图',
|
||||
dynamicMatchSkill: '动态匹配技能',
|
||||
executeTask: '执行任务',
|
||||
|
||||
upload: '上传与解析',
|
||||
complex: '兼容性分析',
|
||||
node: '节点映射',
|
||||
configCheck: '配置校验',
|
||||
sureInfo: '信息确认',
|
||||
completed: '完成导入',
|
||||
},
|
||||
role: {
|
||||
roleManagement: '角色管理',
|
||||
@@ -1648,6 +1655,12 @@ export const zh = {
|
||||
memory: '记忆',
|
||||
memoryConversationAnalysisEmpty: '目前没有可用的对话分析内容',
|
||||
memoryConversationAnalysisEmptySubTitle: '输入您的用户ID后,点击"测试记忆"查看对话记忆',
|
||||
|
||||
uploadFile: '上传文件',
|
||||
fileType: '文件类型',
|
||||
image: '图片',
|
||||
fileUrl: '文件链接',
|
||||
addRemoteFile: '添加远程文件'
|
||||
},
|
||||
login: {
|
||||
title: '红熊记忆科学',
|
||||
|
||||
@@ -178,7 +178,7 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
|
||||
const errorData = await response.json();
|
||||
errorData.error || i18n.t('common.serviceUpgrading');
|
||||
message.warning(errorData.error || i18n.t('common.serviceUpgrading'));
|
||||
break
|
||||
return;
|
||||
case 400:
|
||||
const error = await response.json();
|
||||
message.warning(error.error);
|
||||
@@ -186,7 +186,7 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
|
||||
case 504:
|
||||
const errorJson = await response.json();
|
||||
message.warning(errorJson.error || i18n.t('common.serverError'));
|
||||
break
|
||||
return;
|
||||
case 401:
|
||||
if (url?.includes('/public')) {
|
||||
return message.warning(i18n.t('common.publicApiCannotRefreshToken'));
|
||||
|
||||
@@ -140,6 +140,8 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
const values = Form.useWatch<Config>([], form)
|
||||
const [isSave, setIsSave] = useState(false)
|
||||
const initialized = useRef(false)
|
||||
|
||||
console.log('chatList', chatList)
|
||||
|
||||
// Initialization flag
|
||||
useEffect(() => {
|
||||
|
||||
@@ -10,13 +10,12 @@
|
||||
* Provides real-time streaming responses and conversation history
|
||||
*/
|
||||
|
||||
import { type FC, useEffect, useState } from 'react';
|
||||
import { type FC, useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx'
|
||||
import { Input, Form } from 'antd'
|
||||
import { Flex, Dropdown, type MenuProps } from 'antd'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
import ChatSendIcon from '@/assets/images/application/chatSend.svg'
|
||||
import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.png'
|
||||
import type { ChatData, Config } from '../types'
|
||||
import { runCompare, draftRun } from '@/api/application'
|
||||
@@ -24,6 +23,11 @@ import Empty from '@/components/Empty'
|
||||
import ChatContent from '@/components/Chat/ChatContent'
|
||||
import type { ChatItem } from '@/components/Chat/types'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import ChatInput from '@/components/Chat/ChatInput'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
// import AudioRecorder from '@/components/AudioRecorder'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
|
||||
/**
|
||||
* Component props
|
||||
@@ -47,22 +51,25 @@ interface ChatProps {
|
||||
*/
|
||||
const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, source = 'agent' }) => {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm<{ message: string }>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isCluster, setIsCluster] = useState(source === 'multi_agent')
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [compareLoading, setCompareLoading] = useState(false)
|
||||
const [fileList, setFileList] = useState<any[]>([])
|
||||
const [message, setMessage] = useState<string | undefined>(undefined)
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setIsCluster(source === 'multi_agent')
|
||||
}, [source])
|
||||
|
||||
/** Add user message to all chat lists */
|
||||
const addUserMessage = (message: string) => {
|
||||
const addUserMessage = (message: string, files: any[]) => {
|
||||
const newUserMessage: ChatItem = {
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
files
|
||||
};
|
||||
updateChatList(prev => prev.map(item => ({
|
||||
...item,
|
||||
@@ -151,17 +158,18 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
})
|
||||
}
|
||||
/** Send message for agent comparison mode */
|
||||
const handleSend = () => {
|
||||
const handleSend = (msg?: string) => {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
setCompareLoading(true)
|
||||
handleSave(false)
|
||||
.then(() => {
|
||||
const message = form.getFieldValue('message')
|
||||
const message = msg
|
||||
if (!message?.trim()) return
|
||||
|
||||
addUserMessage(message)
|
||||
form.setFieldsValue({ message: undefined })
|
||||
addUserMessage(message, fileList)
|
||||
setMessage(message)
|
||||
setFileList([])
|
||||
addAssistantMessage()
|
||||
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
@@ -187,6 +195,17 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
setTimeout(() => {
|
||||
runCompare(data.app_id, {
|
||||
message,
|
||||
files: fileList.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}
|
||||
}),
|
||||
models: chatList.map(item => ({
|
||||
model_config_id: item.model_config_id,
|
||||
label: item.label,
|
||||
@@ -267,16 +286,17 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
})
|
||||
}
|
||||
/** Send message for cluster mode */
|
||||
const handleClusterSend = () => {
|
||||
const handleClusterSend = (msg?: string) => {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
setCompareLoading(true)
|
||||
handleSave(false)
|
||||
.then(() => {
|
||||
const message = form.getFieldValue('message')
|
||||
const message = msg
|
||||
if (!message || message.trim() === '') return
|
||||
addUserMessage(message)
|
||||
form.setFieldsValue({ message: undefined })
|
||||
addUserMessage(message, fileList)
|
||||
setMessage(undefined)
|
||||
setFileList([])
|
||||
addClusterAssistantMessage()
|
||||
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
@@ -313,7 +333,18 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
{
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
stream: true
|
||||
stream: true,
|
||||
files: fileList.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
handleStreamMessage
|
||||
)
|
||||
@@ -330,9 +361,36 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
const handleDelete = (index: number) => {
|
||||
updateChatList(chatList.filter((_, voIndex) => voIndex !== index))
|
||||
}
|
||||
const handleMessageChange = (message: string) => {
|
||||
setMessage(message)
|
||||
}
|
||||
const [update, setUpdate] = useState(false)
|
||||
const fileChange = (file?: any) => {
|
||||
setFileList([...fileList, file])
|
||||
setUpdate(prev => !prev)
|
||||
}
|
||||
// const handleRecordingComplete = async (file: any) => {
|
||||
// console.log('file', file)
|
||||
// }
|
||||
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch(key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
const addFileList = (list?: any[]) => {
|
||||
if (!list || list.length <= 0) return
|
||||
setFileList([...fileList, ...(list || [])])
|
||||
}
|
||||
const updateFileList = (list?: any[]) => {
|
||||
setFileList([...list || []])
|
||||
}
|
||||
|
||||
console.log('chatList', chatList, fileList)
|
||||
return (
|
||||
<div className="rb:relative rb:h-[calc(100vh-110px)]">
|
||||
<div className="rb:relative rb:h-full rb:flex rb:flex-col">
|
||||
{chatList.length === 0
|
||||
? <Empty
|
||||
url={DebuggingEmpty}
|
||||
@@ -342,12 +400,9 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
className="rb:h-full"
|
||||
/>
|
||||
: <>
|
||||
<div className={clsx(`rb:grid rb:grid-cols-${chatList.length} rb:overflow-hidden rb:w-full`, {
|
||||
'rb:h-[calc(100vh-236px)]': !isCluster,
|
||||
'rb:h-[calc(100%-76px)]': isCluster,
|
||||
})}>
|
||||
<div className={clsx(`rb:relative rb:grid rb:grid-cols-${chatList.length} rb:overflow-hidden rb:w-full rb:flex-1 rb:min-h-0`)}>
|
||||
{chatList.map((chat, index) => (
|
||||
<div key={index} className={clsx('rb:h-full rb:flex rb:flex-col', {
|
||||
<div key={index} className={clsx('rb:flex rb:flex-col', {
|
||||
"rb:border-r rb:border-[#DFE4ED]": index !== chatList.length - 1 && chatList.length > 1,
|
||||
})}>
|
||||
{chat.label &&
|
||||
@@ -370,8 +425,8 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
<ChatContent
|
||||
classNames={{
|
||||
'rb:mx-[16px] rb:pt-[24px]': true,
|
||||
'rb:h-[calc(100vh-186px)]': isCluster,
|
||||
'rb:h-[calc(100vh-286px)]': !isCluster,
|
||||
'rb:h-[calc(100vh-258px)]': isCluster,
|
||||
'rb:h-[calc(100vh-356px)]': !isCluster,
|
||||
}}
|
||||
contentClassNames={{
|
||||
'rb:max-w-[400px]!': chatList.length === 1,
|
||||
@@ -386,26 +441,58 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
labelFormat={(item) => item.role === 'user' ? t('application.you') : chat.label}
|
||||
errorDesc={t('application.ReplyException')}
|
||||
/>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rb:flex rb:items-center rb:gap-2.5 rb:p-4">
|
||||
<Form form={form} style={{width: 'calc(100% - 54px)'}}>
|
||||
<Form.Item name="message" className="rb:mb-0!">
|
||||
<Input
|
||||
className="rb:h-11 rb:shadow-[0px_2px_8px_0px_rgba(33,35,50,0.1)]"
|
||||
placeholder={t('application.chatPlaceholder')}
|
||||
onPressEnter={isCluster ? handleClusterSend : handleSend}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<img src={ChatSendIcon} className={clsx("rb:w-11 rb:h-11 rb:cursor-pointer", {
|
||||
'rb:opacity-50': loading,
|
||||
})} onClick={isCluster ? handleClusterSend : handleSend} />
|
||||
<div className="rb:relative rb:flex rb:items-center rb:gap-2.5 rb:m-4 rb:mb-1">
|
||||
<ChatInput
|
||||
message={message}
|
||||
className="rb:relative!"
|
||||
loading={loading}
|
||||
fileChange={updateFileList}
|
||||
fileList={fileList}
|
||||
onSend={isCluster ? handleClusterSend : handleSend}
|
||||
onChange={handleMessageChange}
|
||||
>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']}
|
||||
onChange={fileChange}
|
||||
fileList={[]}
|
||||
update={update}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('src/assets/images/conversation/link.svg')] rb:hover:bg-[url('src/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
{/* <Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex> */}
|
||||
</Flex>
|
||||
</ChatInput>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { forwardRef, useImperativeHandle, useState, useMemo } from 'react';
|
||||
import { Form, Select, Steps, Flex, Alert, Row, Col, Statistic, Input, Button } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { UploadWorkflowModalData, UploadWorkflowModalRef } from '../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import UploadFiles from '@/components/Upload/UploadFiles'
|
||||
import { fileUploadUrl } from '@/api/fileStorage'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
|
||||
interface UploadWorkflowModalProps {
|
||||
refresh: () => void;
|
||||
}
|
||||
const steps = [
|
||||
'upload',
|
||||
'complex',
|
||||
'node',
|
||||
'configCheck',
|
||||
'sureInfo',
|
||||
'completed'
|
||||
]
|
||||
const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowModalProps>(({
|
||||
refresh
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm<UploadWorkflowModalData>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [current, setCurrent] = useState<number>(5);
|
||||
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
setLoading(false)
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
form.resetFields();
|
||||
setVisible(true);
|
||||
};
|
||||
// 封装保存方法,添加提交逻辑
|
||||
const handleSave = () => {
|
||||
switch(current) {
|
||||
case 0:
|
||||
setCurrent(1)
|
||||
break;
|
||||
case 1:
|
||||
setCurrent(2)
|
||||
break;
|
||||
case 2:
|
||||
setCurrent(3)
|
||||
break;
|
||||
case 3:
|
||||
setCurrent(4)
|
||||
break;
|
||||
case 4:
|
||||
setCurrent(5)
|
||||
break;
|
||||
case 5:
|
||||
break;
|
||||
default:
|
||||
setCurrent(prev => prev + 1)
|
||||
break;
|
||||
}
|
||||
// form
|
||||
// .validateFields()
|
||||
// .then(() => {
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// console.log('err', err)
|
||||
// });
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
const handleLastStep = () => {
|
||||
setCurrent(prev => prev - 1)
|
||||
}
|
||||
const handleJump = (type: string) => {
|
||||
switch(type) {
|
||||
case 'detail':
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const getFooter = useMemo(() => {
|
||||
switch(current) {
|
||||
case 0:
|
||||
return [
|
||||
<Button key="back" onClick={handleClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('application.nextStep')}
|
||||
</Button>
|
||||
]
|
||||
case 5:
|
||||
return [
|
||||
<Button key="back" onClick={() => handleJump('list')}>
|
||||
{t('application.gotoList')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => handleJump('detail')}
|
||||
>
|
||||
{t('application.gotoDetail')}
|
||||
</Button>
|
||||
]
|
||||
default:
|
||||
return [
|
||||
<Button onClick={handleClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>,
|
||||
<Button key="back" onClick={handleLastStep}>
|
||||
{t('application.lastStep')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('application.nextStep')}
|
||||
</Button>
|
||||
]
|
||||
}
|
||||
}, [current])
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('application.importWorkflow')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('application.nextStep')}
|
||||
onOk={handleSave}
|
||||
footer={getFooter}
|
||||
width={1000}
|
||||
>
|
||||
<div className='rb:p-3 rb:bg-[#FBFDFF] rb:rounded-lg rb:border rb:border-[#DFE4ED] rb:mb-3'>
|
||||
<Steps
|
||||
labelPlacement="vertical"
|
||||
size="small"
|
||||
current={current}
|
||||
items={steps.map(key => ({ title: t(`application.${key}`) }))}
|
||||
/>
|
||||
</div>
|
||||
{current === 0 &&
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item name="provider" label={t('application.workflowProvider')}>
|
||||
<Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={[
|
||||
{ label: 'Dify', value: 'dify' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="file" valuePropName="fileList" noStyle>
|
||||
<UploadFiles
|
||||
action={fileUploadUrl}
|
||||
isCanDrag={true}
|
||||
fileSize={100}
|
||||
multiple={true}
|
||||
maxCount={1}
|
||||
fileType={['yml', 'yaml', 'zip', 'json']}
|
||||
onChange={(fileList) => {
|
||||
console.log('文件列表变化:', fileList);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
}
|
||||
|
||||
{current === 1 &&
|
||||
<Flex vertical gap={12} className="rb:w-[70%]! rb:mx-auto!">
|
||||
{['fileType', 'parse', 'nodes', 'variable'].map(key => (
|
||||
<Alert key={key} message={key} type="success" showIcon />
|
||||
))}
|
||||
|
||||
<Row gutter={12}>
|
||||
{['complex', 'nodes', 'task'].map(key => (
|
||||
<Col key={key} span={8}>
|
||||
<Statistic title={key} value={0} className="rb:text-center rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:py-3!" />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Flex>
|
||||
}
|
||||
|
||||
{/* 节点映射 */}
|
||||
{current === 2 &&
|
||||
<Flex vertical gap={12} className="rb:w-[70%]! rb:mx-auto!">
|
||||
<RbCard>
|
||||
<Flex justify="space-around">
|
||||
<div> Left Node</div>
|
||||
→
|
||||
<div>
|
||||
<Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
className="rb:w-50"
|
||||
/>
|
||||
</div>
|
||||
</Flex>
|
||||
</RbCard>
|
||||
</Flex>
|
||||
}
|
||||
{current === 3 &&
|
||||
<Flex vertical gap={12} className="rb:w-[70%]! rb:mx-auto!">
|
||||
|
||||
</Flex>
|
||||
}
|
||||
{current === 4 &&
|
||||
<Form form={form} layout="horizontal" className="rb:w-[70%]! rb:mx-auto!">
|
||||
<div className="rb:text-[#5B6167] rb:font-medium">{t('application.baseInfo')}</div>
|
||||
<Form.Item name="name" label={t('application.workflowName')} rules={[{ required: true }]}>
|
||||
<Input placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="source" label={t('application.source')}>
|
||||
source
|
||||
</Form.Item>
|
||||
<Form.Item name="fileName" label={t('application.fileName')}>
|
||||
fileName
|
||||
</Form.Item>
|
||||
<Form.Item name="fileSize" label={t('application.fileSize')}>
|
||||
fileSize
|
||||
</Form.Item>
|
||||
<Form.Item name="desciption" label={t('application.desciption')}>
|
||||
<Input.TextArea placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
|
||||
<div className="rb:text-[#5B6167] rb:font-medium">{t('application.importStatistic')}</div>
|
||||
<Row gutter={12}>
|
||||
{['complex', 'nodes', 'task'].map(key => (
|
||||
<Col key={key} span={8}>
|
||||
<Statistic title={key} value={0} className="rb:text-center rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:py-3!" />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Form>
|
||||
}
|
||||
{current === 5 &&
|
||||
<Flex justify="center" vertical gap={12} className="rb:w-[70%]! rb:mx-auto! rb:text-center">
|
||||
<div>导入成功</div>
|
||||
<div>您的工作流已成功导入,可以在应用管理中查看和管理</div>
|
||||
</Flex>
|
||||
}
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default UploadWorkflowModal;
|
||||
@@ -12,18 +12,19 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Row, Col, App, Select } from 'antd';
|
||||
import { Button, Row, Col, App, Select, Space } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import type { Application, ApplicationModalRef, Query } from './types';
|
||||
import ApplicationModal, { types } from './components/ApplicationModal';
|
||||
import type { Application, ApplicationModalRef, Query, UploadWorkflowModalRef } from './types';
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import { getApplicationListUrl, deleteApplication } from '@/api/application'
|
||||
import PageScrollList, { type PageScrollListRef } from '@/components/PageScrollList'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import UploadWorkflowModal from './components/UploadWorkflowModal'
|
||||
|
||||
/**
|
||||
* Application management main component
|
||||
@@ -35,6 +36,7 @@ const ApplicationManagement: React.FC = () => {
|
||||
const [query, setQuery] = useState<Query>({} as Query);
|
||||
const applicationModalRef = useRef<ApplicationModalRef>(null);
|
||||
const scrollListRef = useRef<PageScrollListRef>(null)
|
||||
const uploadWorkflowModalRef = useRef<UploadWorkflowModalRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Convert URLSearchParams to a plain object for easier access
|
||||
@@ -80,6 +82,10 @@ const ApplicationManagement: React.FC = () => {
|
||||
const handleChangeType = (value?: string) => {
|
||||
setQuery(prev => ({...prev, type: value}))
|
||||
}
|
||||
|
||||
const handleImport = () => {
|
||||
uploadWorkflowModalRef.current?.handleOpen()
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Row gutter={16} className="rb:mb-4">
|
||||
@@ -104,9 +110,14 @@ const ApplicationManagement: React.FC = () => {
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} className="rb:text-right">
|
||||
<Button type="primary" onClick={handleCreate}>
|
||||
{t('application.createApplication')}
|
||||
</Button>
|
||||
<Space size={12}>
|
||||
<Button onClick={handleImport}>
|
||||
{t('application.importWorkflow')}
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleCreate}>
|
||||
{t('application.createApplication')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -156,8 +167,13 @@ const ApplicationManagement: React.FC = () => {
|
||||
ref={applicationModalRef}
|
||||
refresh={refresh}
|
||||
/>
|
||||
|
||||
<UploadWorkflowModal
|
||||
ref={uploadWorkflowModalRef}
|
||||
refresh={refresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplicationManagement;
|
||||
export default ApplicationManagement
|
||||
@@ -173,3 +173,10 @@ export interface ApiExtensionModalRef {
|
||||
/** Open API extension modal */
|
||||
handleOpen: () => void;
|
||||
}
|
||||
|
||||
|
||||
export interface UploadWorkflowModalData {
|
||||
}
|
||||
export interface UploadWorkflowModalRef {
|
||||
handleOpen: () => void;
|
||||
}
|
||||
251
web/src/views/Conversation/components/FileUpload.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:42
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:09:42
|
||||
*/
|
||||
/**
|
||||
* File Upload Component
|
||||
*
|
||||
* A reusable file upload component based on Ant Design Upload.
|
||||
* Supports single/multiple file uploads, drag-and-drop, file validation, and preview.
|
||||
*
|
||||
* Features:
|
||||
* - File type validation (images, documents, etc.)
|
||||
* - File size validation
|
||||
* - Auto-upload or manual upload modes
|
||||
* - Progress tracking
|
||||
* - Custom upload actions and headers
|
||||
* - File list management
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Upload, Progress, App } from 'antd';
|
||||
import type { UploadProps, UploadFile } from 'antd';
|
||||
import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cookieUtils } from '@/utils/request'
|
||||
import { fileUploadUrl } from '@/api/fileStorage'
|
||||
|
||||
interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
|
||||
/** Upload API endpoint */
|
||||
action?: string;
|
||||
/** Enable multiple file selection */
|
||||
multiple?: boolean;
|
||||
/** List of uploaded files */
|
||||
fileList?: UploadFile[];
|
||||
/** Callback when file list changes */
|
||||
onChange?: (fileList: UploadFile | UploadFile[]) => void;
|
||||
customRequest?: RcUploadProps['customRequest'];
|
||||
/** Custom upload request configuration */
|
||||
requestConfig?: {
|
||||
data?: Record<string, string | number | boolean>;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
/** Disable upload */
|
||||
disabled?: boolean;
|
||||
/** File size limit in MB */
|
||||
fileSize?: number;
|
||||
/** Allowed file types ['doc', 'xls', 'ppt', 'pdf'] */
|
||||
fileType?: string[];
|
||||
/** Auto-upload on file selection, default is true */
|
||||
isAutoUpload?: boolean;
|
||||
/** Maximum number of files allowed */
|
||||
maxCount?: number;
|
||||
/** Custom file removal callback */
|
||||
onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>;
|
||||
/** Trigger to reset file list */
|
||||
update?: boolean;
|
||||
}
|
||||
// Mapping of file extensions to MIME types
|
||||
const ALL_FILE_TYPE: {
|
||||
[key: string]: string;
|
||||
} = {
|
||||
// txt: 'text/plain',
|
||||
pdf: 'application/pdf',
|
||||
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
csv: 'text/csv',
|
||||
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
|
||||
// md: 'text/markdown',
|
||||
// htm: 'text/html',
|
||||
// html: 'text/html',
|
||||
// json: 'application/json',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
bmp: 'image/bmp',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
}
|
||||
export interface UploadFilesRef {
|
||||
/** Current file list */
|
||||
fileList: UploadFile[];
|
||||
/** Clear all uploaded files */
|
||||
clearFiles: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common upload component based on Ant Design Upload
|
||||
* Supports single/multiple file uploads, drag-and-drop, file validation, and preview
|
||||
*/
|
||||
const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
action = fileUploadUrl,
|
||||
multiple = false,
|
||||
fileList: propFileList = [],
|
||||
onChange,
|
||||
disabled = false,
|
||||
fileSize = 5,
|
||||
fileType = Object.entries(ALL_FILE_TYPE).map(([key]) => key),
|
||||
isAutoUpload = true,
|
||||
maxCount = 1,
|
||||
onRemove: customOnRemove,
|
||||
update,
|
||||
...props
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp()
|
||||
const [fileList, setFileList] = useState<UploadFile[]>(propFileList);
|
||||
const [accept, setAccept] = useState<string | undefined>();
|
||||
|
||||
// Reset file list when update prop changes
|
||||
useEffect(() => {
|
||||
setFileList([])
|
||||
}, [update])
|
||||
|
||||
/**
|
||||
* Validates file type and size before upload
|
||||
* @returns Upload.LIST_IGNORE to prevent upload, or true to proceed
|
||||
*/
|
||||
const beforeUpload: RcUploadProps['beforeUpload'] = (file) => {
|
||||
// Validate file size
|
||||
if (fileSize) {
|
||||
const isLtMaxSize = (file.size / 1024 / 1024) < fileSize;
|
||||
if (!isLtMaxSize) {
|
||||
message.error(t('common.fileSizeTip', { size: fileSize }));
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
}
|
||||
// Validate file type
|
||||
if (fileType && fileType.length > 0) {
|
||||
// Get file extension
|
||||
const fileName = file.name.toLowerCase();
|
||||
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||
|
||||
// Check if extension is in allowed types list
|
||||
const isValidExtension = fileType.some(type => type.toLowerCase() === fileExtension);
|
||||
|
||||
// Also check MIME type if available (as fallback validation)
|
||||
const isValidMimeType = file.type && accept ? accept.includes(file.type) : true;
|
||||
|
||||
if (!isValidExtension && !isValidMimeType) {
|
||||
message.error(`${t('common.fileAcceptTip')} ${fileExtension || file.type}`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAutoUpload) {
|
||||
const newFileList = [...fileList, file as UploadFile];
|
||||
setFileList(newFileList);
|
||||
onChange?.(newFileList);
|
||||
return Upload.LIST_IGNORE; // Prevent auto-upload
|
||||
}
|
||||
|
||||
return isAutoUpload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles upload state changes
|
||||
*/
|
||||
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList, event }) => {
|
||||
console.log('event', event)
|
||||
setFileList(newFileList);
|
||||
if (onChange) {
|
||||
onChange(maxCount === 1 ? newFileList[0] : newFileList);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears all uploaded files
|
||||
*/
|
||||
const clearFiles = () => {
|
||||
setFileList([]);
|
||||
if (onChange) {
|
||||
onChange([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Build accept string from file types (includes both MIME types and extensions)
|
||||
useEffect(() => {
|
||||
if (fileType && fileType.length > 0) {
|
||||
// Include both MIME types and file extensions
|
||||
const acceptArray: string[] = [];
|
||||
fileType.forEach((type: string) => {
|
||||
const lowerType = type.toLowerCase();
|
||||
// Add MIME type (if exists)
|
||||
const mimeType = ALL_FILE_TYPE[lowerType];
|
||||
if (mimeType) {
|
||||
acceptArray.push(mimeType);
|
||||
}
|
||||
// Add file extension (.md, .html, etc.)
|
||||
acceptArray.push(`.${lowerType}`);
|
||||
});
|
||||
setAccept(acceptArray.join(','));
|
||||
} else {
|
||||
setAccept(undefined);
|
||||
}
|
||||
}, [fileType])
|
||||
|
||||
// Generate upload component configuration
|
||||
const uploadProps: UploadProps = {
|
||||
action,
|
||||
multiple: multiple && maxCount > 1,
|
||||
fileList,
|
||||
beforeUpload,
|
||||
headers: {
|
||||
authorization: `Bearer ${cookieUtils.get('authToken')}`,
|
||||
},
|
||||
onChange: handleChange,
|
||||
accept,
|
||||
disabled,
|
||||
showUploadList: false,
|
||||
itemRender: (_, file, __, actions) => {
|
||||
return (
|
||||
<div key={file.uid} className="rb:relative rb:w-full rb:pt-2 rb:px-2.5 rb-pb-[10px] rb:border rb:border-[#EBEBEB] rb:rounded rb:p-2 rb:mt-2 rb:bg-white">
|
||||
<div className="rb:text-[12px] rb:flex rb:items-center rb:justify-between rb:mb-0.5">
|
||||
{file.name}
|
||||
<span className="rb:text-[#5B6167] rb:cursor-pointer" onClick={() => actions?.remove()}>Cancel</span>
|
||||
</div>
|
||||
<Progress percent={file.percent || 0} strokeColor={file.status === 'error' ? '#FF5D34' : '#155EEF'} size="small" showInfo={false} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
className: 'rb:-mb-1.5!',
|
||||
...props,
|
||||
};
|
||||
|
||||
// Expose methods to parent component via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
fileList,
|
||||
clearFiles
|
||||
}));
|
||||
|
||||
return (
|
||||
<Upload
|
||||
{...uploadProps}
|
||||
>
|
||||
{t('memoryConversation.uploadFile')}
|
||||
</Upload>
|
||||
);
|
||||
});
|
||||
|
||||
export default UploadFiles;
|
||||
135
web/src/views/Conversation/components/UploadFileListModal.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:47
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:09:47
|
||||
*/
|
||||
/**
|
||||
* Upload File List Modal Component
|
||||
*
|
||||
* A modal dialog for adding remote files via URL.
|
||||
* Allows users to specify file type and URL for files hosted externally.
|
||||
*
|
||||
* Features:
|
||||
* - Dynamic form fields for multiple file URLs
|
||||
* - File type selection (currently supports images)
|
||||
* - Form validation
|
||||
* - Add/remove file entries
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, Select, Button, Space } from 'antd';
|
||||
import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { UploadFileListModalRef } from '../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
interface UploadFileListModalProps {
|
||||
/** Callback to refresh parent component with new file list */
|
||||
refresh: (fileList?: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for adding remote files via URL
|
||||
*/
|
||||
const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListModalProps>(({
|
||||
refresh
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
/**
|
||||
* Closes the modal and resets loading state
|
||||
*/
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setLoading(false)
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the modal and resets form fields
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
setVisible(true);
|
||||
form.resetFields();
|
||||
};
|
||||
/**
|
||||
* Validates and saves the file list
|
||||
* Transforms form values into file objects with transfer_method: 'remote_url'
|
||||
*/
|
||||
const handleSave = () => {
|
||||
form.validateFields().then((values) => {
|
||||
const fileList = values.files?.map((file: any) => ({
|
||||
...file,
|
||||
uid: Math.random().toString(36).substr(2, 9),
|
||||
transfer_method: 'remote_url'
|
||||
})) || [];
|
||||
refresh(fileList)
|
||||
handleClose()
|
||||
})
|
||||
}
|
||||
|
||||
// Expose methods to parent component via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen
|
||||
}));
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('memoryConversation.addRemoteFile')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('common.save')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.List name="files">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{/* Render each file entry with type selector and URL input */}
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Space key={key} style={{ display: 'flex' }} align="baseline">
|
||||
<FormItem
|
||||
{...restField}
|
||||
name={[name, 'type']}
|
||||
initialValue="image"
|
||||
>
|
||||
<Select
|
||||
placeholder={t('memoryConversation.fileType')}
|
||||
options={[
|
||||
{ label: t('memoryConversation.image'), value: 'image' }
|
||||
]}
|
||||
className="rb:w-30"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
{...restField}
|
||||
name={[name, 'url']}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
>
|
||||
<Input placeholder={t('memoryConversation.fileUrl')} className="rb:w-82.5" />
|
||||
</FormItem>
|
||||
<MinusCircleOutlined onClick={() => remove(name)} style={{ marginTop: 30 }} />
|
||||
</Space>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default UploadFileListModal;
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:58:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:58:35
|
||||
* @Last Modified time: 2026-02-06 21:11:23
|
||||
*/
|
||||
/**
|
||||
* Conversation Page
|
||||
@@ -14,12 +14,12 @@ import { type FC, useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||
import { Flex, Skeleton, Form } from 'antd'
|
||||
import { Flex, Skeleton, Form, Dropdown, type MenuProps } from 'antd'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { getConversationHistory, sendConversation, getConversationDetail, getShareToken } from '@/api/application'
|
||||
import type { HistoryItem, QueryParams } from './types'
|
||||
import type { HistoryItem, QueryParams, UploadFileListModalRef } from './types'
|
||||
import Empty from '@/components/Empty'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import { randomString } from '@/utils/common'
|
||||
@@ -33,6 +33,10 @@ import OnlineIcon from '@/assets/images/conversation/online.svg'
|
||||
import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg'
|
||||
import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import UploadFiles from './components/FileUpload'
|
||||
// import AudioRecorder from '@/components/AudioRecorder'
|
||||
import { shareFileUploadUrl } from '@/api/fileStorage'
|
||||
import UploadFileListModal from './components/UploadFileListModal'
|
||||
|
||||
/**
|
||||
* Conversation component for shared applications
|
||||
@@ -58,6 +62,8 @@ const Conversation: FC = () => {
|
||||
|
||||
const [form] = Form.useForm<QueryParams>()
|
||||
const queryValues = Form.useWatch<QueryParams>([], form)
|
||||
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
useEffect(() => {
|
||||
const shareToken = localStorage.getItem(`shareToken_${token}`)
|
||||
setShareToken(shareToken)
|
||||
@@ -142,12 +148,13 @@ const Conversation: FC = () => {
|
||||
}, [conversation_id])
|
||||
|
||||
/** Add user message to chat */
|
||||
const addUserMessage = (message: string = '') => {
|
||||
const addUserMessage = (message: string = '', files?: any[]) => {
|
||||
const newUserMessage: ChatItem = {
|
||||
conversation_id,
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now()
|
||||
created_at: Date.now(),
|
||||
files
|
||||
};
|
||||
setChatList(prev => [...prev, newUserMessage])
|
||||
}
|
||||
@@ -189,9 +196,10 @@ const Conversation: FC = () => {
|
||||
if (!token || !shareToken) {
|
||||
return
|
||||
}
|
||||
const { files = [], ...rest } = queryValues || {}
|
||||
setLoading(true)
|
||||
setStreamLoading(true)
|
||||
addUserMessage(message)
|
||||
addUserMessage(message, files)
|
||||
addAssistantMessage()
|
||||
|
||||
let currentConversationId: string | null = null
|
||||
@@ -222,18 +230,54 @@ const Conversation: FC = () => {
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
form.setFieldValue('files', [])
|
||||
sendConversation({
|
||||
...queryValues,
|
||||
...rest,
|
||||
message: message || '',
|
||||
stream: true,
|
||||
conversation_id: conversation_id || null,
|
||||
files: files.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}
|
||||
})
|
||||
}, handleStreamMessage, shareToken)
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const [update, setUpdate] = useState(false)
|
||||
const fileChange = (file?: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), file])
|
||||
setUpdate(prev => !prev)
|
||||
}
|
||||
// const handleRecordingComplete = async (file: any) => {
|
||||
// console.log('file', file)
|
||||
// }
|
||||
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch(key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
const addFileList = (fileList?: any[]) => {
|
||||
if (!fileList || fileList.length <= 0) return
|
||||
form.setFieldValue('files', [...(queryValues.files || []), ...fileList])
|
||||
}
|
||||
const updateFileList = (fileList?: any[]) => {
|
||||
form.setFieldValue('files', [...(fileList || [])])
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex className="rb:w-full rb:p-[-16px]!">
|
||||
<div className="rb:w-86.25 rb:h-screen rb:overflow-hidden rb:border-r rb:border-[#EAECEE] rb:p-3">
|
||||
@@ -285,37 +329,75 @@ const Conversation: FC = () => {
|
||||
<div className='rb:w-190 rb:h-screen rb:mx-auto rb:pt-10'>
|
||||
<Chat
|
||||
empty={<Empty url={ChatEmpty} className="rb:h-full" size={[320,180]} title={t('memoryConversation.chatEmpty')} subTitle={t('memoryConversation.emptyDesc')} />}
|
||||
contentClassName="rb:h-[calc(100%-152px)] "
|
||||
contentClassName="rb:h-[calc(100%-180px)]"
|
||||
data={chatList}
|
||||
streamLoading={streamLoading}
|
||||
loading={loading}
|
||||
onChange={setMessage}
|
||||
onSend={handleSend}
|
||||
labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||
fileList={queryValues?.files || []}
|
||||
fileChange={updateFileList}
|
||||
>
|
||||
<Form form={form} initialValues={{ memory: false, web_search: false}}>
|
||||
<Flex gap={8}>
|
||||
<Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
|
||||
<ButtonCheckbox
|
||||
icon={OnlineIcon}
|
||||
checkedIcon={OnlineCheckedIcon}
|
||||
>
|
||||
{t(`memoryConversation.web_search`)}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="memory" valuePropName="checked" className="rb:mb-0!">
|
||||
<ButtonCheckbox
|
||||
icon={MemoryFunctionIcon}
|
||||
checkedIcon={MemoryFunctionCheckedIcon}
|
||||
>
|
||||
{t(`memoryConversation.memory`)}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Form.Item name="files" noStyle>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
action={shareFileUploadUrl}
|
||||
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']}
|
||||
onChange={fileChange}
|
||||
fileList={[]}
|
||||
update={update}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('src/assets/images/conversation/link.svg')] rb:hover:bg-[url('src/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Form.Item>
|
||||
<Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
|
||||
<ButtonCheckbox
|
||||
icon={OnlineIcon}
|
||||
checkedIcon={OnlineCheckedIcon}
|
||||
>
|
||||
{t(`memoryConversation.web_search`)}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="memory" valuePropName="checked" className="rb:mb-0!">
|
||||
<ButtonCheckbox
|
||||
icon={MemoryFunctionIcon}
|
||||
checkedIcon={MemoryFunctionCheckedIcon}
|
||||
>
|
||||
{t(`memoryConversation.memory`)}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
{/* <Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex> */}
|
||||
</Flex>
|
||||
</Form>
|
||||
</Chat>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:57:46
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:57:46
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:11:19
|
||||
*/
|
||||
/**
|
||||
* Type definitions for Conversation
|
||||
@@ -50,4 +50,9 @@ export interface QueryParams {
|
||||
stream: boolean;
|
||||
/** Current conversation ID */
|
||||
conversation_id?: string | null;
|
||||
files?: any[];
|
||||
}
|
||||
|
||||
export interface UploadFileListModalRef {
|
||||
handleOpen: (fileList?: any[]) => void;
|
||||
}
|
||||
@@ -1,7 +1,30 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:10:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:10:56
|
||||
*/
|
||||
/**
|
||||
* Workflow Chat Component
|
||||
*
|
||||
* A drawer-based chat interface for testing and debugging workflow executions.
|
||||
* Provides real-time streaming of workflow node execution status, input/output data,
|
||||
* and error messages. Supports variable configuration and file attachments.
|
||||
*
|
||||
* Key Features:
|
||||
* - Real-time workflow execution monitoring with SSE streaming
|
||||
* - Node-level execution tracking (start, end, error states)
|
||||
* - Variable configuration for workflow inputs
|
||||
* - File upload support (images and documents)
|
||||
* - Collapsible node execution details with input/output inspection
|
||||
* - Error handling and display
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import clsx from 'clsx'
|
||||
import { Input, Form, App, Space, Button, Collapse } from 'antd'
|
||||
import { App, Space, Button, Collapse, Flex, Dropdown, type MenuProps } from 'antd'
|
||||
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons'
|
||||
import CodeBlock from '@/components/Markdown/CodeBlock'
|
||||
|
||||
@@ -12,30 +35,43 @@ import { draftRun } from '@/api/application';
|
||||
import Empty from '@/components/Empty'
|
||||
import ChatContent from '@/components/Chat/ChatContent'
|
||||
import type { ChatItem } from '@/components/Chat/types'
|
||||
import ChatSendIcon from '@/assets/images/application/chatSend.svg'
|
||||
import dayjs from 'dayjs'
|
||||
import type { ChatRef, VariableConfigModalRef, GraphRef } from '../../types'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import type { Variable } from '../Properties/VariableList/types'
|
||||
import styles from './chat.module.css'
|
||||
import Markdown from '@/components/Markdown'
|
||||
import ChatInput from '@/components/Chat/ChatInput'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
// import AudioRecorder from '@/components/AudioRecorder'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
|
||||
const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId, graphRef }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const [form] = Form.useForm<{ message: string }>()
|
||||
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([])
|
||||
const [variables, setVariables] = useState<Variable[]>([])
|
||||
const [streamLoading, setStreamLoading] = useState(false)
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
// State management
|
||||
const [open, setOpen] = useState(false) // Drawer visibility
|
||||
const [loading, setLoading] = useState(false) // Send button loading state
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([]) // Chat message history
|
||||
const [variables, setVariables] = useState<Variable[]>([]) // Workflow input variables
|
||||
const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state
|
||||
const [conversationId, setConversationId] = useState<string | null>(null) // Current conversation ID
|
||||
const [fileList, setFileList] = useState<any[]>([]) // Uploaded files
|
||||
const [message, setMessage] = useState<string | undefined>(undefined) // Current input message
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
|
||||
/**
|
||||
* Opens the chat drawer and loads workflow variables from the start node
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
setOpen(true)
|
||||
getVariables()
|
||||
}
|
||||
/**
|
||||
* Extracts variables from the workflow's start node and merges with previous values
|
||||
*/
|
||||
const getVariables = () => {
|
||||
const nodes = graphRef.current?.getNodes()
|
||||
const list = nodes?.map(node => node.getData()) || []
|
||||
@@ -55,20 +91,42 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
setVariables(curVariables)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Closes the drawer and resets all state
|
||||
*/
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
setChatList([])
|
||||
setVariables([])
|
||||
setConversationId(null)
|
||||
}
|
||||
/**
|
||||
* Opens the variable configuration modal
|
||||
*/
|
||||
const handleEditVariables = () => {
|
||||
variableConfigModalRef.current?.handleOpen(variables)
|
||||
}
|
||||
/**
|
||||
* Saves updated variable values from the modal
|
||||
*/
|
||||
const handleSave = (values: Variable[]) => {
|
||||
setVariables([...values])
|
||||
}
|
||||
const handleSend = () => {
|
||||
/**
|
||||
* Sends a message to execute the workflow
|
||||
*
|
||||
* Process:
|
||||
* 1. Validates required variables
|
||||
* 2. Adds user message to chat
|
||||
* 3. Initiates SSE stream for workflow execution
|
||||
* 4. Handles real-time node execution updates
|
||||
* 5. Updates chat with results or errors
|
||||
*
|
||||
* @param msg - Optional message to send (uses state if not provided)
|
||||
*/
|
||||
const handleSend = async (msg?: string) => {
|
||||
if (loading || !appId) return
|
||||
// Validate required variables before sending
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (variables.length > 0) {
|
||||
@@ -90,8 +148,8 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const message = form.getFieldValue('message')
|
||||
// setLoading(true)
|
||||
const message = msg
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'user',
|
||||
content: message,
|
||||
@@ -104,6 +162,16 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
subContent: [],
|
||||
}])
|
||||
|
||||
/**
|
||||
* Handles SSE stream messages from workflow execution
|
||||
*
|
||||
* Events:
|
||||
* - message: Streaming text chunks for final output
|
||||
* - node_start: Node execution begins
|
||||
* - node_end: Node execution completes successfully
|
||||
* - node_error: Node execution fails
|
||||
* - workflow_end: Entire workflow completes
|
||||
*/
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
data.forEach(item => {
|
||||
const { chunk, conversation_id, node_id, input, output, error, elapsed_time, status } = item.data as {
|
||||
@@ -125,6 +193,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
console.log('node', node?.getData())
|
||||
|
||||
switch(item.event) {
|
||||
// Append streaming text chunks to assistant message
|
||||
case 'message':
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
@@ -138,6 +207,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
return newList
|
||||
})
|
||||
break
|
||||
// Track node execution start
|
||||
case 'node_start':
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
@@ -170,6 +240,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
return newList
|
||||
})
|
||||
break
|
||||
// Update node with execution results or errors
|
||||
case 'node_end':
|
||||
case 'node_error':
|
||||
setChatList(prev => {
|
||||
@@ -198,6 +269,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
return newList
|
||||
})
|
||||
break
|
||||
// Mark workflow as complete
|
||||
case 'workflow_end':
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
@@ -221,14 +293,27 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
})
|
||||
}
|
||||
|
||||
form.setFieldValue('message', undefined)
|
||||
setStreamLoading(true)
|
||||
draftRun(appId, {
|
||||
setMessage(undefined)
|
||||
setFileList([])
|
||||
const data = {
|
||||
message: message,
|
||||
variables: params,
|
||||
stream: true,
|
||||
conversation_id: conversationId
|
||||
}, handleStreamMessage)
|
||||
conversation_id: conversationId,
|
||||
files: fileList.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
setStreamLoading(true)
|
||||
draftRun(appId, data, handleStreamMessage)
|
||||
.catch((error) => {
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
@@ -243,29 +328,72 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
}
|
||||
return newList
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
}).finally(() => {
|
||||
setLoading(false)
|
||||
setStreamLoading(false)
|
||||
})
|
||||
}
|
||||
// 暴露给父组件的方法
|
||||
|
||||
/**
|
||||
* Updates the current input message
|
||||
*/
|
||||
const handleMessageChange = (message: string) => {
|
||||
setMessage(message)
|
||||
}
|
||||
const [update, setUpdate] = useState(false)
|
||||
/**
|
||||
* Handles file upload from local device
|
||||
*/
|
||||
const fileChange = (file?: any) => {
|
||||
setFileList([...fileList, file])
|
||||
setUpdate(prev => !prev)
|
||||
}
|
||||
// const handleRecordingComplete = async (file: any) => {
|
||||
// console.log('file', file)
|
||||
// }
|
||||
|
||||
/**
|
||||
* Handles dropdown menu actions for file upload
|
||||
*/
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch(key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Adds files from remote URL modal
|
||||
*/
|
||||
const addFileList = (list?: any[]) => {
|
||||
if (!list || list.length <= 0) return
|
||||
setFileList([...fileList, ...(list || [])])
|
||||
}
|
||||
/**
|
||||
* Updates the entire file list (used when removing files)
|
||||
*/
|
||||
const updateFileList = (list?: any[]) => {
|
||||
setFileList([...list || []])
|
||||
}
|
||||
|
||||
// Expose methods to parent component via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
/**
|
||||
* Returns CSS class for status-based text color
|
||||
*/
|
||||
const getStatus = (status?: string) => {
|
||||
return status === 'completed' ? 'rb:text-[#369F21]' : status === 'failed' ? 'rb:text-[#FF5D34]' : 'rb:text-[#5B6167]'
|
||||
}
|
||||
|
||||
console.log('chatList', chatList)
|
||||
return (
|
||||
<RbDrawer
|
||||
title={<div className="rb:flex rb:items-center rb:gap-2.5">
|
||||
{t('workflow.run')}
|
||||
{variables.length > 0 && <Space>
|
||||
<Button size="small" onClick={handleEditVariables}>变量</Button>
|
||||
<Button size="small" onClick={handleEditVariables}>{t('application.variable')}</Button>
|
||||
</Space>}
|
||||
</div>}
|
||||
classNames={{
|
||||
@@ -275,7 +403,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
onClose={handleClose}
|
||||
>
|
||||
<ChatContent
|
||||
classNames="rb:mx-[16px] rb:pt-[24px] rb:h-[calc(100%-76px)]"
|
||||
classNames="rb:mx-[16px] rb:pt-[24px] rb:h-[calc(100%-86px)]"
|
||||
contentClassNames="rb:max-w-[400px]!'"
|
||||
empty={<Empty url={ChatIcon} title={t('application.chatEmpty')} isNeedSubTitle={false} size={[240, 200]} className="rb:h-full" />}
|
||||
data={chatList}
|
||||
@@ -365,19 +493,47 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="rb:flex rb:items-center rb:gap-2.5 rb:p-4">
|
||||
<Form form={form} style={{width: 'calc(100% - 54px)'}}>
|
||||
<Form.Item name="message" className="rb:mb-0!">
|
||||
<Input
|
||||
className="rb:h-11 rb:shadow-[0px_2px_8px_0px_rgba(33,35,50,0.1)]"
|
||||
placeholder={t('application.chatPlaceholder')}
|
||||
onPressEnter={handleSend}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<img src={ChatSendIcon} className={clsx("rb:w-11 rb:h-11 rb:cursor-pointer", {
|
||||
'rb:opacity-50': loading,
|
||||
})} onClick={handleSend} />
|
||||
<div className="rb:relative rb:flex rb:items-center rb:gap-2.5 rb:m-4 rb:mb-1">
|
||||
<ChatInput
|
||||
message={message}
|
||||
className="rb:relative!"
|
||||
loading={loading}
|
||||
fileChange={updateFileList}
|
||||
fileList={fileList}
|
||||
onSend={handleSend}
|
||||
onChange={handleMessageChange}
|
||||
>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']}
|
||||
onChange={fileChange}
|
||||
fileList={[]}
|
||||
update={update}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('src/assets/images/conversation/link.svg')] rb:hover:bg-[url('src/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
{/* <Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex> */}
|
||||
</Flex>
|
||||
</ChatInput>
|
||||
</div>
|
||||
|
||||
<VariableConfigModal
|
||||
@@ -385,6 +541,11 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
refresh={handleSave}
|
||||
variables={variables}
|
||||
/>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
</RbDrawer>
|
||||
)
|
||||
})
|
||||
|
||||