Commit 79391f63 by Xianquan Committed by GitHub

Refactor chat UI (#7024)

* feat(chat): merge app menus into all apps

* style: simplify chat right pane

* style: adjust chat history sidebar

* chore: update chat title color

* chore: remove chat title bottom border

* refactor chat message bubbles

* refactor chat message bubble UI

* refactor chat feedback modal

* refactor chat header actions

* refactor mobile chat model selector

* refactor mobile chat slider

* add mobile chat new button

* refactor chat home mobile layout

* refactor chat citation UI

* refactor chat quote reader UI

* update mobile quote reader tabs

* fix chat input spacing

* refactor chat variable form

* fix chat variable form order

* feat: support editing chat messages

* refactor: update chat variable UI

* feat: collapse chat sidebar during generation

* fix: update mobile clear history confirm modal

* fix: show mobile home tool count label

* fix: prevent tts model label overflow

* fix: update desktop chat variable popover

* fix: prevent quick app modal layout overflow

* fix: add interactive response card border

* fix: adjust mobile edit voice input layout

* fix: move response tree arrow to left

* fix: polish chat ui spacing

* style: refine chat mobile ui details

* style: adjust collapsed chat sidebar gap

* style: collapse chat processing steps

* style: refine chat mobile ui details

* style: polish chat log detail panel

* style: narrow chat form cards
parent bbee34bc
......@@ -245,9 +245,13 @@ export const iconPaths = {
'core/chat/export/pdf': () => import('./icons/core/chat/export/pdf.svg'),
'core/chat/feedback/badLight': () => import('./icons/core/chat/feedback/badLight.svg'),
'core/chat/feedback/goodLight': () => import('./icons/core/chat/feedback/goodLight.svg'),
'core/chat/deepThinking': () => import('./icons/core/chat/deepThinking.svg'),
'core/chat/dotsHorizontal': () => import('./icons/core/chat/dotsHorizontal.svg'),
'core/chat/fileDownload': () => import('./icons/core/chat/fileDownload.svg'),
'core/chat/fileSelect': () => import('./icons/core/chat/fileSelect.svg'),
'core/chat/finishSpeak': () => import('./icons/core/chat/finishSpeak.svg'),
'core/chat/imgSelect': () => import('./icons/core/chat/imgSelect.svg'),
'core/chat/markdown': () => import('./icons/core/chat/markdown.svg'),
'core/chat/quoteFill': () => import('./icons/core/chat/quoteFill.svg'),
'core/chat/quoteSign': () => import('./icons/core/chat/quoteSign.svg'),
'core/chat/recordFill': () => import('./icons/core/chat/recordFill.svg'),
......@@ -262,9 +266,12 @@ export const iconPaths = {
'core/chat/sidebar/logout': () => import('./icons/core/chat/sidebar/logout.svg'),
'core/chat/sidebar/menu': () => import('./icons/core/chat/sidebar/menu.svg'),
'core/chat/sidebar/star': () => import('./icons/core/chat/sidebar/star.svg'),
'core/chat/soundWave': () => import('./icons/core/chat/soundWave.svg'),
'core/chat/speaking': () => import('./icons/core/chat/speaking.svg'),
'core/chat/stopSpeech': () => import('./icons/core/chat/stopSpeech.svg'),
'core/chat/terminal': () => import('./icons/core/chat/terminal.svg'),
'core/chat/think': () => import('./icons/core/chat/think.svg'),
'core/chat/var': () => import('./icons/core/chat/var.svg'),
'core/dataset/commonDataset': () => import('./icons/core/dataset/commonDataset.svg'),
'core/dataset/commonDatasetColor': () => import('./icons/core/dataset/commonDatasetColor.svg'),
'core/dataset/commonDatasetOutline': () =>
......
<svg viewBox="0 0 25 24" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M5.92221 15.9443V18.8729H8.77335L9.2079 19.0995C10.3103 19.6742 11.5641 20 12.8999 20C17.3181 20 20.8999 16.4183 20.8999 12C20.8999 7.58172 17.3181 4 12.8999 4C8.48159 4 4.89987 7.58172 4.89987 12C4.89987 13.2698 5.19426 14.4652 5.71668 15.5267L5.92221 15.9443ZM22.8999 12C22.8999 17.5228 18.4227 22 12.8999 22C11.2346 22 9.66436 21.5929 8.28328 20.8729H5.12221C4.45947 20.8729 3.92221 20.3356 3.92221 19.6729V16.4098C3.26757 15.0796 2.89987 13.5827 2.89987 12C2.89987 6.47715 7.37702 2 12.8999 2C18.4227 2 22.8999 6.47715 22.8999 12Z" />
<path
d="M9.49611 12C9.49611 12.7028 8.92637 13.2726 8.22356 13.2726C7.52075 13.2726 6.951 12.7028 6.951 12C6.951 11.2972 7.52075 10.7275 8.22356 10.7275C8.92637 10.7275 9.49611 11.2972 9.49611 12Z" />
<path
d="M14.0723 12C14.0723 12.7028 13.5026 13.2726 12.7997 13.2726C12.0969 13.2726 11.5272 12.7028 11.5272 12C11.5272 11.2972 12.0969 10.7275 12.7997 10.7275C13.5026 10.7275 14.0723 11.2972 14.0723 12Z" />
<path
d="M17.3759 13.2726C18.0787 13.2726 18.6484 12.7028 18.6484 12C18.6484 11.2972 18.0787 10.7275 17.3759 10.7275C16.673 10.7275 16.1033 11.2972 16.1033 12C16.1033 12.7028 16.673 13.2726 17.3759 13.2726Z" />
</svg>
\ No newline at end of file
<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M10.8833 2.86549C10.8833 3.27971 10.5475 3.61549 10.1333 3.61549H5.552C3.89483 3.61549 2.55127 4.95896 2.55127 6.61549V10.3215C2.55146 11.5943 3.3459 12.6857 4.46948 13.1201L4.69873 13.1978L5.04634 13.3006C5.26626 13.3656 5.44433 13.5277 5.5297 13.7406L5.66479 14.0774C5.79673 14.4057 6.18961 14.54 6.49491 14.3612L8.0945 13.4243C8.20944 13.357 8.34022 13.3215 8.47341 13.3215H12.3013C13.9064 13.3215 15.2176 12.0607 15.2983 10.4761L15.302 10.3215V7.97707C15.302 7.56285 15.6378 7.22707 16.052 7.22707C16.4662 7.22707 16.802 7.56285 16.802 7.97707V10.3215C16.8018 12.8066 14.7864 14.8215 12.3013 14.8215H8.88084C8.74752 14.8215 8.61662 14.8571 8.5016 14.9245L6.07422 16.3472C5.59997 16.6251 4.98894 16.4169 4.78369 15.907L4.40845 14.9729C4.32301 14.7603 4.14357 14.6019 3.9298 14.5193C2.24614 13.8685 1.05146 12.2348 1.05127 10.3215V6.61549C1.05127 4.13021 3.06672 2.11549 5.552 2.11549H10.1333C10.5475 2.11549 10.8833 2.45128 10.8833 2.86549Z"/>
<path d="M14.4026 1.52905C14.754 1.52915 15.0391 1.81407 15.0391 2.16553V3.43848H16.312C16.6635 3.43855 16.9485 3.72348 16.9485 4.07495C16.9484 4.42636 16.6634 4.71136 16.312 4.71143H15.0391V5.98438C15.039 6.33575 14.754 6.62075 14.4026 6.62085C14.0511 6.62085 13.7662 6.33581 13.7661 5.98438V4.71143H12.4932C12.1417 4.71143 11.8568 4.42641 11.8567 4.07495C11.8567 3.72343 12.1416 3.43848 12.4932 3.43848H13.7661V2.16553C13.7661 1.81401 14.0511 1.52905 14.4026 1.52905Z"/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0751 5.5509C13.5454 4.09178 13.2912 3.31806 12.989 3.01581C12.6867 2.71355 11.913 2.45941 10.4539 2.92966C10.0732 3.05236 9.67248 3.21789 9.25998 3.42506C9.86524 3.87324 10.4643 4.38811 11.0405 4.96428C11.6167 5.54045 12.1315 6.13955 12.5797 6.74481C12.7869 6.33231 12.9524 5.93162 13.0751 5.5509ZM2.06909 2.073C3.24845 0.893639 5.58121 1.18139 8.00044 2.5924C10.4197 1.18139 12.7524 0.893638 13.9318 2.073C15.1112 3.25236 14.8234 5.58512 13.4124 8.00435C14.8234 10.4236 15.1111 12.7563 13.9318 13.9357C12.7524 15.115 10.4197 14.8273 8.00044 13.4163C5.58122 14.8273 3.24846 15.115 2.06911 13.9357C0.889755 12.7563 1.1775 10.4236 2.5885 8.00435C1.17748 5.58512 0.88973 3.25236 2.06909 2.073ZM5.547 2.92966C5.92772 3.05236 6.3284 3.21789 6.7409 3.42506C6.13564 3.87324 5.53656 4.38811 4.96039 4.96428C4.38422 5.54045 3.86934 6.13955 3.42116 6.74481C3.21398 6.33231 3.04845 5.93163 2.92575 5.5509C2.45551 4.09178 2.70965 3.31806 3.0119 3.01581C3.31415 2.71355 4.08787 2.45942 5.547 2.92966ZM2.92577 10.4578C3.04847 10.0771 3.21399 9.67638 3.42116 9.2639C3.86934 9.86915 4.38421 10.4682 4.96037 11.0444C5.53654 11.6206 6.13564 12.1354 6.74089 12.5836C6.3284 12.7908 5.92773 12.9563 5.54701 13.079C4.08789 13.5493 3.31417 13.2951 3.01192 12.9929C2.70967 12.6906 2.45553 11.9169 2.92577 10.4578ZM5.90318 10.1016C6.58836 10.7868 7.29938 11.3686 8.00044 11.8419C8.7015 11.3686 9.41252 10.7868 10.0977 10.1016C10.7829 9.41642 11.3647 8.70541 11.838 8.00435C11.3647 7.30328 10.7829 6.59227 10.0977 5.90709C9.41251 5.22192 8.7015 4.64006 8.00044 4.16675C7.29938 4.64006 6.58837 5.22192 5.9032 5.90709C5.21803 6.59227 4.63616 7.30328 4.16285 8.00435C4.63616 8.70541 5.21802 9.41642 5.90318 10.1016ZM10.4539 13.079C10.0732 12.9563 9.67248 12.7908 9.25999 12.5836C9.86524 12.1354 10.4643 11.6206 11.0405 11.0444C11.6167 10.4682 12.1315 9.86914 12.5797 9.26389C12.7869 9.67638 12.9524 10.0771 13.0751 10.4578C13.5454 11.9169 13.2912 12.6906 12.989 12.9929C12.6867 13.2951 11.913 13.5493 10.4539 13.079ZM8.00049 9.33326C8.73853 9.33326 9.33683 8.73631 9.33683 7.99993C9.33683 7.26355 8.73853 6.6666 8.00049 6.6666C7.26245 6.6666 6.66414 7.26355 6.66414 7.99993C6.66414 8.73631 7.26245 9.33326 8.00049 9.33326Z" fill="#667085"/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.33333 9.33398C2.59695 9.33398 2 8.73703 2 8.00065C2 7.26427 2.59695 6.66732 3.33333 6.66732C4.06971 6.66732 4.66667 7.26427 4.66667 8.00065C4.66667 8.73703 4.06971 9.33398 3.33333 9.33398ZM8 9.33398C7.26362 9.33398 6.66667 8.73703 6.66667 8.00065C6.66667 7.26427 7.26362 6.66732 8 6.66732C8.73638 6.66732 9.33333 7.26427 9.33333 8.00065C9.33333 8.73703 8.73638 9.33398 8 9.33398ZM12.6667 9.33398C11.9303 9.33398 11.3333 8.73703 11.3333 8.00065C11.3333 7.26427 11.9303 6.66732 12.6667 6.66732C13.403 6.66732 14 7.26427 14 8.00065C14 8.73703 13.403 9.33398 12.6667 9.33398Z" fill="#485264"/>
</svg>
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.2959 3.6775C2.2959 2.38106 3.34687 1.33008 4.64332 1.33008H8.69359C9.31617 1.33008 9.91324 1.5774 10.3535 2.01762L13.0169 4.68101C13.4571 5.12124 13.7044 5.71832 13.7044 6.34089V12.3214C13.7044 13.6178 12.6534 14.6688 11.357 14.6688H4.64332C3.34687 14.6688 2.2959 13.6178 2.2959 12.3214V3.6775ZM4.64332 2.66341C4.08326 2.66341 3.62923 3.11743 3.62923 3.6775V12.3214C3.62923 12.8814 4.08325 13.3355 4.64332 13.3355H11.357C11.9171 13.3355 12.3711 12.8814 12.3711 12.3214V6.34089C12.3711 6.07194 12.2642 5.814 12.0741 5.62382L9.41066 2.96043C9.22048 2.77025 8.96255 2.66341 8.69359 2.66341H4.64332ZM8.00014 5.02597C7.63195 5.02597 7.33348 5.32445 7.33348 5.69264V8.70574L6.71278 8.08504C6.45243 7.8247 6.03032 7.8247 5.76997 8.08504C5.50962 8.34539 5.50962 8.7675 5.76997 9.02785L7.50791 10.7658C7.55463 10.8125 7.60656 10.8509 7.66183 10.8808C7.761 10.9393 7.87665 10.9729 8.00014 10.9729C8.05999 10.9729 8.118 10.965 8.17318 10.9502C8.30189 10.9268 8.42505 10.8653 8.52456 10.7658L10.2625 9.02785C10.5229 8.7675 10.5229 8.34539 10.2625 8.08504C10.0022 7.8247 9.58004 7.8247 9.31969 8.08504L8.66681 8.73793V5.69264C8.66681 5.32445 8.36833 5.02597 8.00014 5.02597Z" fill="currentColor"/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.77291 8.15253L2.01942 4.07031C1.84987 3.81895 1.53897 3.71996 1.26404 3.80781C0.928336 3.84239 0.666504 4.12609 0.666504 4.47094V11.3415C0.666504 11.7096 0.964981 12.0081 1.33317 12.0081C1.70136 12.0081 1.99984 11.7096 1.99984 11.3415L1.99984 6.42567L4.21471 9.70936C4.34448 9.90174 4.55704 10.0049 4.77291 10.0033C4.98878 10.0049 5.20135 9.90174 5.33111 9.70936L7.47242 6.53474V11.3415C7.47242 11.7096 7.7709 12.0081 8.13909 12.0081C8.50728 12.0081 8.80576 11.7096 8.80576 11.3415V4.47094C8.80576 4.19086 8.63305 3.95113 8.38832 3.85242C8.09136 3.69649 7.71832 3.78578 7.52641 4.07031L4.77291 8.15253Z" fill="#485264"/>
<path d="M13.3474 4.47094C13.3474 4.10275 13.0489 3.80427 12.6807 3.80427C12.3125 3.80427 12.014 4.10275 12.014 4.47094V10.0682L10.997 9.05115C10.7366 8.7908 10.3145 8.7908 10.0541 9.05115C9.79379 9.3115 9.79379 9.73361 10.0541 9.99396L12.0883 12.0281C12.2277 12.1676 12.4135 12.2323 12.596 12.2224C12.7785 12.2323 12.9643 12.1676 13.1037 12.0281L15.1379 9.99396C15.3983 9.73361 15.3983 9.3115 15.1379 9.05115C14.8776 8.7908 14.4554 8.7908 14.1951 9.05115L13.3474 9.89886V4.47094Z" fill="#485264"/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.21911 8.31244C4.00158 8.52701 3.64825 8.52152 3.42994 8.30019C3.21163 8.07886 3.211 7.72549 3.42853 7.51092C3.64607 7.29636 3.9994 7.30184 4.21771 7.52318C4.43602 7.74451 4.43665 8.09788 4.21911 8.31244Z" fill="#8A95A7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.58786 1.22255C9.82654 0.942199 10.2473 0.90842 10.5276 1.1471C12.7183 3.01219 13.3841 5.52254 13.3885 8.00828C13.393 10.5536 12.3861 12.9822 10.5861 14.7576C10.324 15.0162 9.90189 15.0133 9.64333 14.7512C9.38478 14.489 9.38768 14.0669 9.64981 13.8084C11.1927 12.2866 12.0591 10.2019 12.0552 8.01066C12.0511 5.75988 11.4565 3.68898 9.66331 2.16233C9.38296 1.92365 9.34918 1.5029 9.58786 1.22255ZM6.43621 3.93046C6.69834 3.67191 7.12044 3.67481 7.37899 3.93694C8.4441 5.01679 9.04565 6.46981 9.04834 7.97845C9.05103 9.48765 8.4539 10.929 7.38516 11.9831C7.12303 12.2417 6.70093 12.2388 6.44237 11.9767C6.18382 11.7145 6.18672 11.2924 6.44885 11.0339C7.26048 10.2333 7.71707 9.13593 7.71501 7.98083C7.71295 6.82517 7.25169 5.70659 6.42973 4.87325C6.17117 4.61111 6.17407 4.18901 6.43621 3.93046ZM3.74308 7.99133C3.77564 8.02435 3.84438 8.03715 3.89669 7.98556C3.94899 7.93396 3.93714 7.86506 3.90457 7.83204C3.872 7.79902 3.80327 7.78622 3.75096 7.83781C3.69865 7.8894 3.71051 7.95831 3.74308 7.99133ZM2.96038 7.03629C3.44776 6.55556 4.22315 6.57934 4.69234 7.05502C5.16154 7.53071 5.17465 8.30635 4.68727 8.78708C4.19989 9.2678 3.4245 9.24403 2.95531 8.76834C2.48611 8.29265 2.473 7.51701 2.96038 7.03629Z" fill="#8A95A7"/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M5.21216 5.58306C4.95181 5.32271 4.5297 5.32271 4.26935 5.58306C4.009 5.84341 4.009 6.26552 4.26935 6.52587L5.74357 8.00009L4.26935 9.47431C4.009 9.73466 4.009 10.1568 4.26935 10.4171C4.5297 10.6775 4.95181 10.6775 5.21216 10.4171L7.1362 8.49308C7.27187 8.35741 7.33684 8.17781 7.33111 8.00007C7.33683 7.82234 7.27186 7.64276 7.1362 7.5071L5.21216 5.58306Z"/>
<path d="M12.0543 9.83166C12.0543 10.1999 11.7558 10.4983 11.3876 10.4983H8.66658C8.29839 10.4983 7.99992 10.1999 7.99992 9.83166C7.99992 9.46347 8.2984 9.16499 8.66658 9.16499H11.3876C11.7558 9.16499 12.0543 9.46347 12.0543 9.83166Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.33325 5.85196C1.33325 4.50784 1.33325 3.83577 1.59484 3.32239C1.82493 2.8708 2.19209 2.50364 2.64367 2.27355C3.15706 2.01196 3.82913 2.01196 5.17325 2.01196H10.8266C12.1707 2.01196 12.8428 2.01196 13.3562 2.27355C13.8078 2.50364 14.1749 2.8708 14.405 3.32239C14.6666 3.83577 14.6666 4.50784 14.6666 5.85196V10.1482C14.6666 11.4923 14.6666 12.1644 14.405 12.6778C14.1749 13.1294 13.8078 13.4965 13.3562 13.7266C12.8428 13.9882 12.1707 13.9882 10.8266 13.9882H5.17325C3.82913 13.9882 3.15706 13.9882 2.64367 13.7266C2.19209 13.4965 1.82493 13.1294 1.59484 12.6778C1.33325 12.1644 1.33325 11.4923 1.33325 10.1482V5.85196ZM5.17325 3.3453H10.8266C11.5206 3.3453 11.961 3.34633 12.2943 3.37357C12.6121 3.39953 12.7134 3.4425 12.7508 3.46156C12.9515 3.56382 13.1147 3.727 13.217 3.92771C13.2361 3.96511 13.279 4.06647 13.305 4.38422C13.3322 4.71754 13.3333 5.1579 13.3333 5.85196V10.1482C13.3333 10.8423 13.3322 11.2826 13.305 11.616C13.279 11.9337 13.236 12.0351 13.217 12.0725C13.1147 12.2732 12.9515 12.4364 12.7508 12.5386C12.7134 12.5577 12.6121 12.6007 12.2943 12.6266C11.961 12.6538 11.5206 12.6549 10.8266 12.6549H5.17325C4.47919 12.6549 4.03883 12.6538 3.70551 12.6266C3.38776 12.6007 3.2864 12.5577 3.249 12.5386C3.04829 12.4364 2.88511 12.2732 2.78284 12.0725C2.76379 12.0351 2.72082 11.9337 2.69486 11.616C2.66762 11.2826 2.66659 10.8423 2.66659 10.1482V5.85196C2.66659 5.1579 2.66762 4.71754 2.69486 4.38422C2.72082 4.06647 2.76379 3.96511 2.78284 3.92771C2.88511 3.727 3.04829 3.56382 3.249 3.46156C3.2864 3.4425 3.38776 3.39953 3.70551 3.37357C4.03883 3.34633 4.47919 3.3453 5.17325 3.3453Z"/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.14185 13.3301C2.44263 13.6854 2.92418 13.8631 3.58651 13.8631V12.6173C3.32826 12.6173 3.14141 12.5263 3.02596 12.3442C2.91355 12.1621 2.85734 11.8535 2.85734 11.4182V10.0526C2.85734 8.96455 2.6234 8.28284 2.15552 8.00749V7.98084C2.6234 7.70106 2.85734 7.02823 2.85734 5.96237V4.54344C2.85734 3.77069 3.1004 3.38432 3.58651 3.38432V2.13859C2.91811 2.13859 2.43503 2.32512 2.13729 2.69817C1.83955 3.06678 1.69068 3.70852 1.69068 4.62338V6.10893C1.69068 6.95717 1.45977 7.3813 0.997969 7.3813V8.60704C1.45977 8.60704 1.69068 9.01562 1.69068 9.83278V11.4316C1.69068 12.342 1.84107 12.9748 2.14185 13.3301Z" fill="#485264"/>
<path d="M13.8588 13.3301C13.558 13.6854 13.0765 13.8631 12.4141 13.8631V12.6173C12.6724 12.6173 12.8592 12.5263 12.9747 12.3442C13.0871 12.1621 13.1433 11.8535 13.1433 11.4182V10.0526C13.1433 8.96455 13.3773 8.28284 13.8451 8.00749V7.98084C13.3773 7.70106 13.1433 7.02823 13.1433 5.96237V4.54344C13.1433 3.77069 12.9003 3.38432 12.4141 3.38432V2.13859C13.0826 2.13859 13.5656 2.32512 13.8634 2.69817C14.1611 3.06678 14.31 3.70852 14.31 4.62338V6.10893C14.31 6.95717 14.5409 7.3813 15.0027 7.3813V8.60704C14.5409 8.60704 14.31 9.01562 14.31 9.83278V11.4316C14.31 12.342 14.1596 12.9748 13.8588 13.3301Z" fill="#485264"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.66651 13.8631C3.66651 13.9072 3.63069 13.9431 3.58651 13.9431C2.91097 13.9431 2.40237 13.7617 2.08079 13.3818C1.76053 13.0035 1.61068 12.3454 1.61068 11.4316V9.83278C1.61068 9.42911 1.55319 9.13922 1.44785 8.95283C1.34631 8.77315 1.19945 8.68704 0.997969 8.68704C0.953786 8.68704 0.917969 8.65122 0.917969 8.60704V7.3813C0.917969 7.33711 0.953786 7.3013 0.997969 7.3013C1.10074 7.3013 1.18689 7.27785 1.25926 7.23355C1.3318 7.18913 1.39494 7.12101 1.44724 7.02495C1.55313 6.83044 1.61068 6.52841 1.61068 6.10893V4.62338C1.61068 3.70498 1.7591 3.03921 2.07491 2.64808C2.39326 2.24935 2.90414 2.05859 3.58651 2.05859C3.63069 2.05859 3.66651 2.09441 3.66651 2.13859V3.38432C3.66651 3.4285 3.63069 3.46432 3.58651 3.46432C3.36881 3.46432 3.21313 3.54855 3.10735 3.7167C2.99766 3.89107 2.93734 4.16287 2.93734 4.54344V5.96237C2.93734 6.97013 2.73021 7.66351 2.28036 7.99387C2.49248 8.14847 2.6502 8.38649 2.75756 8.69932C2.87846 9.05164 2.93734 9.50378 2.93734 10.0526V11.4182C2.93734 11.8508 2.99398 12.1398 3.09378 12.3018C3.19151 12.4556 3.34958 12.5373 3.58651 12.5373C3.63069 12.5373 3.66651 12.5732 3.66651 12.6173V13.8631ZM3.02596 12.3442C2.91355 12.1621 2.85734 11.8535 2.85734 11.4182V10.0526C2.85734 9.00716 2.64136 8.33685 2.20941 8.04171C2.19991 8.03522 2.19031 8.02891 2.1806 8.02278C2.17231 8.01755 2.16396 8.01245 2.15552 8.00749V7.98084C2.16381 7.97589 2.17203 7.97081 2.18017 7.9656C2.18994 7.95936 2.19959 7.95295 2.20914 7.94635C2.64128 7.64803 2.85734 6.9867 2.85734 5.96237V4.54344C2.85734 3.77069 3.1004 3.38432 3.58651 3.38432V2.13859C3.55954 2.13859 3.53288 2.1389 3.50651 2.1395C2.87943 2.15395 2.42302 2.34017 2.13729 2.69817C1.83955 3.06678 1.69068 3.70852 1.69068 4.62338V6.10893C1.69068 6.95717 1.45977 7.3813 0.997969 7.3813V8.60704C1.45977 8.60704 1.69068 9.01562 1.69068 9.83278V11.4316C1.69068 12.342 1.84107 12.9748 2.14185 13.3301C2.43038 13.671 2.88527 13.8483 3.50651 13.8622C3.53288 13.8628 3.55954 13.8631 3.58651 13.8631V12.6173C3.32826 12.6173 3.14141 12.5263 3.02596 12.3442ZM12.4141 13.9431C12.37 13.9431 12.3341 13.9072 12.3341 13.8631V12.6173C12.3341 12.5732 12.37 12.5373 12.4141 12.5373C12.6511 12.5373 12.8092 12.4556 12.9069 12.3018C13.0067 12.1398 13.0633 11.8507 13.0633 11.4182V10.0526C13.0633 9.50378 13.1222 9.05164 13.2431 8.69932C13.3505 8.38649 13.5082 8.14847 13.7203 7.99386C13.2705 7.6635 13.0633 6.97013 13.0633 5.96237V4.54344C13.0633 4.16287 13.003 3.89107 12.8933 3.7167C12.7875 3.54855 12.6319 3.46432 12.4141 3.46432C12.37 3.46432 12.3341 3.4285 12.3341 3.38432V2.13859C12.3341 2.09441 12.37 2.05859 12.4141 2.05859C13.0965 2.05859 13.6074 2.24934 13.9257 2.64804C14.2415 3.03917 14.39 3.70495 14.39 4.62338V6.10893C14.39 6.52841 14.4475 6.83044 14.5534 7.02495C14.6057 7.12101 14.6689 7.18913 14.7414 7.23355C14.8138 7.27785 14.8999 7.3013 15.0027 7.3013C15.0469 7.3013 15.0827 7.33711 15.0827 7.3813V8.60704C15.0827 8.65122 15.0469 8.68704 15.0027 8.68704C14.8012 8.68704 14.6544 8.77315 14.5528 8.95283C14.4475 9.13922 14.39 9.42911 14.39 9.83278V11.4316C14.39 12.3454 14.2401 13.0035 13.9199 13.3818C13.5983 13.7617 13.0897 13.9431 12.4141 13.9431ZM13.8634 2.69817C13.5776 2.34017 13.1212 2.15395 12.4941 2.1395C12.4678 2.1389 12.4411 2.13859 12.4141 2.13859V3.38432C12.9003 3.38432 13.1433 3.77069 13.1433 4.54344V5.96237C13.1433 6.98671 13.3594 7.64803 13.7915 7.94635C13.8011 7.95294 13.8107 7.95936 13.8205 7.9656C13.8286 7.97081 13.8368 7.97589 13.8451 7.98084V8.00749C13.8367 8.01245 13.8283 8.01755 13.8201 8.02278C13.8104 8.02891 13.8007 8.03522 13.7912 8.04171C13.3593 8.33685 13.1433 9.00715 13.1433 10.0526V11.4182C13.1433 11.8535 13.0871 12.1621 12.9747 12.3442C12.8592 12.5263 12.6724 12.6173 12.4141 12.6173V13.8631C12.4411 13.8631 12.4678 13.8628 12.4941 13.8622C13.1154 13.8483 13.5703 13.671 13.8588 13.3301C14.1596 12.9748 14.31 12.342 14.31 11.4316V9.83278C14.31 9.01562 14.5409 8.60704 15.0027 8.60704V7.3813C14.5409 7.3813 14.31 6.95717 14.31 6.10893V4.62338C14.31 3.70852 14.1611 3.06678 13.8634 2.69817Z" fill="#485264"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.17192 5.1724C5.43226 4.91205 5.85437 4.91205 6.11472 5.1724L8.00033 7.05801L9.88595 5.1724C10.1463 4.91205 10.5684 4.91205 10.8288 5.1724C11.0891 5.43274 11.0891 5.85485 10.8288 6.1152L8.94314 8.00081L10.8288 9.88644C11.0891 10.1468 11.0891 10.5689 10.8288 10.8292C10.5684 11.0896 10.1463 11.0896 9.88596 10.8292L8.00033 8.94362L6.11471 10.8292C5.85436 11.0896 5.43225 11.0896 5.1719 10.8292C4.91155 10.5689 4.91155 10.1468 5.1719 9.88644L7.05753 8.00081L5.17192 6.1152C4.91157 5.85485 4.91157 5.43274 5.17192 5.1724Z" fill="#485264"/>
</svg>
......@@ -31,6 +31,7 @@ export type MenuItemData = {
description?: string;
onClick?: () => any;
menuItemStyles?: MenuItemProps;
iconStyles?: AvatarProps;
disabled?: boolean;
disabledTip?: string;
}>;
......@@ -318,6 +319,7 @@ const MyMenu = ({
src={child.icon as any}
mr={2}
{...sizeMapStyle[size].iconStyle}
{...child.iconStyles}
color={
child.isActive
? 'inherit'
......
......@@ -14,6 +14,9 @@ type Props<T = string> = Omit<BoxProps, 'onChange'> & {
iconSize?: string;
labelSize?: string;
iconGap?: number;
itemHeight?: string;
outerPadding?: string;
outerHeight?: string;
};
const FillRowTabs = (
......@@ -26,6 +29,9 @@ const FillRowTabs = (
iconSize = '18px',
labelSize = 'sm',
iconGap = 2,
itemHeight,
outerPadding,
outerHeight,
...props
}: Props,
ref: React.Ref<HTMLDivElement>
......@@ -36,9 +42,17 @@ const FillRowTabs = (
display={'inline-flex'}
px={'3px'}
py={'3px'}
{...(outerPadding ? { p: outerPadding } : {})}
borderRadius={'sm'}
borderWidth={'1px'}
borderColor={'myGray.200'}
{...(outerHeight
? {
h: outerHeight,
borderWidth: 0,
boxShadow: 'inset 0 0 0 1px var(--chakra-colors-myGray-200)'
}
: {})}
bg={'myGray.50'}
gap={'4px'}
fontSize={'sm'}
......@@ -55,6 +69,7 @@ const FillRowTabs = (
borderRadius={'xs'}
px={px}
py={py}
{...(itemHeight ? { h: itemHeight } : {})}
userSelect={'none'}
whiteSpace={'noWrap'}
gap={iconGap}
......
......@@ -11,6 +11,9 @@ type Props<ValueType = string> = Omit<GridProps, 'onChange'> & {
inlineStyles?: FlexProps;
activeColor?: string;
defaultColor?: string;
itemHeight?: string;
outerPadding?: string;
outerHeight?: string;
onChange: (value: ValueType) => void;
};
......@@ -20,6 +23,9 @@ const LightRowTabs = <ValueType = string,>({
value,
activeColor = 'primary.600',
defaultColor = 'transparent',
itemHeight,
outerPadding,
outerHeight,
onChange,
inlineStyles,
...props
......@@ -53,6 +59,8 @@ const LightRowTabs = <ValueType = string,>({
<Grid
gridTemplateColumns={`repeat(${list.length},1fr)`}
p={sizeMap.outP}
{...(outerPadding ? { p: outerPadding } : {})}
{...(outerHeight ? { minH: outerHeight } : {})}
borderRadius={'sm'}
fontSize={sizeMap.fontSize}
userSelect={'none'}
......@@ -63,6 +71,7 @@ const LightRowTabs = <ValueType = string,>({
<Flex
key={item.value as string}
py={sizeMap.inlineP}
{...(itemHeight ? { h: itemHeight } : {})}
alignItems={'center'}
justifyContent={'center'}
borderBottom={'2px solid'}
......
......@@ -9,6 +9,7 @@
"agent_plan_status_set_plan": "Set plan",
"agent_plan_status_update_plan": "Update plan",
"agent_plan_updating": "Updating plan",
"ai_chat_loading": "AI Chat",
"ai_reasoning": "Thinking process",
"back_to_text": "Text input",
"balance_not_enough_pause": "Workflow paused due to insufficient AI points",
......@@ -18,6 +19,9 @@
"chat_history": "Chat history",
"chat_input_guide_lexicon_is_empty": "Lexicon not configured yet",
"chat_test_app": "Debug-{{name}}",
"citation_card_prefix": "",
"citation_card_suffix": " References",
"citation_card_title": "{{num}} References",
"citations": "{{num}} References",
"clear_input_value": "Clear input",
"click_to_add_url": "Enter file link",
......@@ -38,6 +42,8 @@
"config_input_guide_lexicon": "Set Up Lexicon",
"config_input_guide_lexicon_title": "Set Up Lexicon",
"confirm_clear_input_value": "Are you sure to clear the form content? \nDefault values ​​will be restored!",
"mobile_clear_history_confirm_title": "Clear history?",
"mobile_clear_history_confirm_tip": "This action cannot be undone",
"confirm_plan": "Confirm plan",
"confirm_to_clear_share_chat_history": "Are you sure you want to clear all chat history?",
"content_empty": "No Content",
......@@ -52,12 +58,14 @@
"download_all_files": "Download all",
"download_chunks": "Download data",
"empty_directory": "This directory is empty~",
"edit_message": "Edit message",
"error_message": "error message",
"file_amount_over": "Exceeded maximum file quantity {{max}}",
"file_compress_text": "File content compression",
"file_input": "File input",
"file_input_tip": "You can obtain the link to the corresponding file through the \"File Link\" of the [Plug-in Start] node",
"history_generating": "Thinking",
"go_to_dataset": "Go to knowledge base",
"history_slider.home.title": "chat",
"home.chat_app": "HomeChat-{{name}}",
"home.select_tools": "Select Tool",
......@@ -89,9 +97,13 @@
"plan_check_tip": "Plan generated. Continue the chat to refine the plan",
"plugins_output": "Plugin Output",
"press_to_speak": "Hold down to speak",
"processed": "Processed",
"processing": "Processing",
"query_extension_IO_tokens": "Problem Optimization Input/Output Tokens",
"query_extension_result": "Problem optimization results",
"question_tip": "From top to bottom, the response order of each module",
"quote_result_notice": "Results only reflect this search. Please note that information may become outdated.",
"quote_source_title": "Citation sources",
"read_file": "File parsing",
"read_raw_source": "Open the original text",
"reasoning_content": "Thinking process",
......@@ -212,12 +224,14 @@
"setting.home.quick_apps.add": "Configure quick applications",
"setting.home.quick_apps.placeholder": "Please select an application",
"setting.home.slogan": "Slogan",
"setting.home.slogan.default": "Hello 👋, I am FastGPT! Is there anything I can help you?",
"setting.home.slogan.default": "今天想做点什么?",
"setting.home.slogan_placeholder": "Please enter Slogan",
"setting.home.title": "Home",
"setting.log_details.title": "Home Log",
"setting.save": "Save",
"setting.save_success": "Save successfully",
"sidebar.all_apps": "All Apps",
"all_apps.title": "Apps",
"sidebar.favourite_apps": "Favourite Apps",
"sidebar.home": "Home",
"sidebar.team_apps": "Team Apps",
......@@ -232,6 +246,8 @@
"tool_response_compress": "Tool response compression",
"unsupported_file_type": "Unsupported file types",
"variable_invisable_in_share": "External variables are not visible in login-free links",
"view_all_citations": "View all",
"view_citations": "View References",
"run_detail": "Run Details",
"web_site_sync": "Web Site Sync"
}
......@@ -5,6 +5,7 @@
"All": "All",
"Cancel": "Cancel",
"Choose": "Choose",
"Clear": "Clear",
"Click_to_expand": "Click to expand",
"Close": "Close",
"Code": "Code",
......@@ -119,6 +120,7 @@
"cancel_bill_confirm": "Are you sure you want to cancel this order?",
"chart_mode_cumulative": "Cumulative",
"chart_mode_incremental": "Incremental",
"chat.quote_detail_title": "Citation details",
"chat_chatId": "Session Id: {{chatId}}",
"choosable": "Choosable",
"chose_condition": "Choose Condition",
......@@ -243,13 +245,13 @@
"core.ai.Support tool": "Tool call",
"core.ai.model.Dataset Agent Model": "File read model",
"core.ai.model.Vector Model": "Index model",
"core.ai.model.doc_index_and_dialog": "Document Index & Dialog Index",
"core.ai.model.multimodal": "Multimodal",
"core.ai.model.multimodal_tip": "Multimodal embedding models can generate vectors for images.",
"core.ai.model.capability_audio": "audio",
"core.ai.model.capability_image": "image",
"core.ai.model.capability_video": "video",
"core.ai.model.doc_index_and_dialog": "Document Index & Dialog Index",
"core.ai.model.multimodal": "Multimodal",
"core.ai.model.multimodal_support_tip": "Supports {{modalities}}",
"core.ai.model.multimodal_tip": "Multimodal embedding models can generate vectors for images.",
"core.ai.model.reasoning_tag": "Reasoning",
"core.app.Api request": "API Request",
"core.app.Api request desc": "Connect to existing systems via API",
......@@ -298,8 +300,8 @@
"core.app.outLink.Can Drag": "Icon Can Be Dragged",
"core.app.outLink.Default open": "Default Open",
"core.app.outLink.Iframe block title": "Copy the iframe below to add to your website",
"core.app.outLink.Link block title": "Copy the link below to open in the browser",
"core.app.outLink.Language isolated tip": "Language changes inside login-free links will not affect the system language.",
"core.app.outLink.Link block title": "Copy the link below to open in the browser",
"core.app.outLink.Script Close Icon": "Close Icon",
"core.app.outLink.Script Open Icon": "Open Icon",
"core.app.outLink.Script block title": "Add the code below to your website",
......@@ -470,13 +472,13 @@
"core.dataset.data.Updated": "Updated",
"core.dataset.data.group": " Groups",
"core.dataset.embedding model tip": "The index model converts knowledge base content into vectors for semantic search. Note that knowledge bases using different index models cannot be queried together. Switching the index model requires rebuilding all vector indexes, so choose carefully.",
"core.dataset.error.canNotEditAdminPermission": "Can not edit admin permission",
"core.dataset.error.Data not found": "Data Not Found or Deleted",
"core.dataset.error.Start Sync Failed": "Failed to Start Sync",
"core.dataset.error.canNotEditAdminPermission": "Can not edit admin permission",
"core.dataset.error.invalidVectorModelOrQAModel": "Invalid index model or QA model",
"core.dataset.error.noApiServer": "API server does not exist",
"core.dataset.error.notSupportSync": "This collection does not support synchronization",
"core.dataset.error.sameApiCollection": "The same API collection already exists",
"core.dataset.error.Start Sync Failed": "Failed to Start Sync",
"core.dataset.error.unAuthDataset": "Unauthorized to operate this dataset",
"core.dataset.error.unAuthDatasetCollection": "Unauthorized to operate this dataset collection",
"core.dataset.error.unAuthDatasetData": "Unauthorized to operate this dataset data",
......@@ -797,8 +799,8 @@
"error.send_auth_code_too_frequently": "Please do not obtain verification code frequently",
"error.too_many_request": "Too many request",
"error.tool_not_exist": "Tool deleted",
"error.unKnow": "An Unexpected Error Occurred",
"error.unAuthFile": "Unauthorized to read this file",
"error.unKnow": "An Unexpected Error Occurred",
"error.upload_file_error_filename": "{{name}} Upload Failed",
"error.upload_file_interval_limit": "Too many uploads in a short time or the current round limit was reached. Please try again later.",
"error.username_empty": "Account cannot be empty",
......@@ -850,6 +852,7 @@
"just_now": "just",
"key": "key",
"key_repetition": "Key Repetition",
"language_switch_failed": "Failed to switch language",
"last_step": "Previous",
"last_use_time": "Last Use Time",
"link.UnValid": "Invalid Link",
......@@ -1181,7 +1184,6 @@
"upload_file": "Upload File",
"upload_file_error": "File Upload Failed",
"usage_records": "Usage records",
"language_switch_failed": "Failed to switch language",
"user.Account": "Account",
"user.Application Name": "Project Name",
"user.Laf Account Setting": "Laf Account Configuration",
......
......@@ -9,6 +9,7 @@
"agent_plan_status_set_plan": "设置 plan",
"agent_plan_status_update_plan": "更新 plan",
"agent_plan_updating": "正在更新计划",
"ai_chat_loading": "AI 对话",
"ai_reasoning": "思考过程",
"back_to_text": "返回输入",
"balance_not_enough_pause": "由于 AI 积分不足,暂停运行工作流",
......@@ -18,6 +19,9 @@
"chat_history": "聊天记录",
"chat_input_guide_lexicon_is_empty": "还没有配置词库",
"chat_test_app": "调试-{{name}}",
"citation_card_prefix": "共 ",
"citation_card_suffix": " 条引用",
"citation_card_title": "共 {{num}} 条引用",
"citations": "{{num}}条引用",
"clear_input_value": "清空输入",
"click_to_add_url": "输入文件链接",
......@@ -38,6 +42,8 @@
"config_input_guide_lexicon": "配置词库",
"config_input_guide_lexicon_title": "配置词库",
"confirm_clear_input_value": "确认清空表单内容?将会恢复默认值!",
"mobile_clear_history_confirm_title": "确定清空历史记录?",
"mobile_clear_history_confirm_tip": "此操作不可撤销",
"confirm_plan": "确认计划",
"confirm_to_clear_share_chat_history": "确认清空所有聊天记录?",
"content_empty": "内容为空",
......@@ -52,12 +58,14 @@
"download_all_files": "下载所有",
"download_chunks": "下载数据",
"empty_directory": "这个目录已经没东西可选了~",
"edit_message": "编辑消息",
"error_message": "错误信息",
"file_amount_over": "超出最大文件数量 {{max}}",
"file_compress_text": "文件内容压缩",
"file_input": "系统文件",
"file_input_tip": "可通过【插件开始】节点的“文件链接”获取对应文件的链接",
"history_generating": "思考中",
"go_to_dataset": "前往知识库",
"history_slider.home.title": "聊天",
"home.chat_app": "首页聊天",
"home.select_tools": "选择工具",
......@@ -89,9 +97,13 @@
"plan_check_tip": "规划已生成,您可继续对话来微调当前规划",
"plugins_output": "插件输出",
"press_to_speak": "按住说话",
"processed": "已处理",
"processing": "处理中",
"query_extension_IO_tokens": "问题优化输入/输出 Tokens",
"query_extension_result": "问题优化结果",
"question_tip": "从上到下,为各个模块的响应顺序",
"quote_result_notice": "仅代表本次搜索时的结果,请注意信息的时效性",
"quote_source_title": "引用来源",
"read_file": "文件解析",
"read_raw_source": "打开原文",
"reasoning_content": "思考过程",
......@@ -212,12 +224,14 @@
"setting.home.quick_apps.add": "配置快捷应用",
"setting.home.quick_apps.placeholder": "请选择应用",
"setting.home.slogan": "Slogan",
"setting.home.slogan.default": "你好👋,我是 FastGPT ! 请问有什么可以帮你?",
"setting.home.slogan.default": "今天想做点什么?",
"setting.home.slogan_placeholder": "请输入 Slogan",
"setting.home.title": "首页配置",
"setting.log_details.title": "首页日志",
"setting.save": "保存",
"setting.save_success": "保存成功",
"sidebar.all_apps": "全部应用",
"all_apps.title": "应用",
"sidebar.favourite_apps": "精选应用",
"sidebar.home": "首页",
"sidebar.team_apps": "团队应用",
......@@ -232,6 +246,8 @@
"tool_response_compress": "工具响应压缩",
"unsupported_file_type": "不支持的文件类型",
"variable_invisable_in_share": "外部变量在免登录链接中不可见",
"view_all_citations": "查看全部",
"view_citations": "查看引用",
"run_detail": "运行详情",
"web_site_sync": "Web站点同步"
}
......@@ -5,6 +5,7 @@
"All": "全部",
"Cancel": "取消",
"Choose": "选择",
"Clear": "清空",
"Click_to_expand": "点击查看详情",
"Close": "关闭",
"Code": "源码",
......@@ -119,6 +120,7 @@
"cancel_bill_confirm": "确定要取消这个订单吗?",
"chart_mode_cumulative": "累积",
"chart_mode_incremental": "分时",
"chat.quote_detail_title": "引用详情",
"chat_chatId": "会话Id: {{chatId}}",
"choosable": "可选",
"chose_condition": "选择条件",
......@@ -243,13 +245,13 @@
"core.ai.Support tool": "工具调用",
"core.ai.model.Dataset Agent Model": "文本理解模型",
"core.ai.model.Vector Model": "索引模型",
"core.ai.model.doc_index_and_dialog": "文档索引 & 对话索引",
"core.ai.model.multimodal": "多模态",
"core.ai.model.multimodal_tip": "多模态索引模型可以给图片生成向量。",
"core.ai.model.capability_audio": "音频",
"core.ai.model.capability_image": "图片",
"core.ai.model.capability_video": "视频",
"core.ai.model.doc_index_and_dialog": "文档索引 & 对话索引",
"core.ai.model.multimodal": "多模态",
"core.ai.model.multimodal_support_tip": "支持{{modalities}}能力",
"core.ai.model.multimodal_tip": "多模态索引模型可以给图片生成向量。",
"core.ai.model.reasoning_tag": "思考",
"core.app.Api request": "API 访问",
"core.app.Api request desc": "通过 API 接入已有系统",
......@@ -298,8 +300,8 @@
"core.app.outLink.Can Drag": "图标可拖拽",
"core.app.outLink.Default open": "默认打开",
"core.app.outLink.Iframe block title": "复制下面 iframe 加入到你的网站中",
"core.app.outLink.Link block title": "将下面链接复制到浏览器打开",
"core.app.outLink.Language isolated tip": "免登录链接内语言切换不会影响系统语言",
"core.app.outLink.Link block title": "将下面链接复制到浏览器打开",
"core.app.outLink.Script Close Icon": "关闭图标",
"core.app.outLink.Script Open Icon": "打开图标",
"core.app.outLink.Script block title": "将下面代码加入到你的网站中",
......@@ -470,13 +472,13 @@
"core.dataset.data.Updated": "已更新",
"core.dataset.data.group": "组",
"core.dataset.embedding model tip": "索引模型可以将知识库内容转成向量,用于进行语义检索。注意,不同索引模型的知识库无法同时查询,切换索引模型需重建全量向量索引,请慎重选择。",
"core.dataset.error.canNotEditAdminPermission": "不能编辑管理员权限",
"core.dataset.error.Data not found": "数据不存在或已被删除",
"core.dataset.error.Start Sync Failed": "开始同步失败",
"core.dataset.error.canNotEditAdminPermission": "不能编辑管理员权限",
"core.dataset.error.invalidVectorModelOrQAModel": "索引模型或 QA 模型无效",
"core.dataset.error.noApiServer": "API 服务不存在",
"core.dataset.error.notSupportSync": "该集合不支持同步",
"core.dataset.error.sameApiCollection": "存在相同的 API 集合",
"core.dataset.error.Start Sync Failed": "开始同步失败",
"core.dataset.error.unAuthDataset": "无权操作该知识库",
"core.dataset.error.unAuthDatasetCollection": "无权操作该知识库集合",
"core.dataset.error.unAuthDatasetData": "无权操作该知识库数据",
......@@ -797,8 +799,8 @@
"error.send_auth_code_too_frequently": "请勿频繁获取验证码",
"error.too_many_request": "请求太频繁了,请稍后重试",
"error.tool_not_exist": "工具已删除",
"error.unKnow": "出现了点意外~",
"error.unAuthFile": "无权读取该文件",
"error.unKnow": "出现了点意外~",
"error.upload_file_error_filename": "{{name}} 上传失败",
"error.upload_file_interval_limit": "短时间内上传次数过多或已达本轮上限,请稍后再试",
"error.username_empty": "账号不能为空",
......@@ -850,6 +852,7 @@
"just_now": "刚刚",
"key": "键",
"key_repetition": "key 重复",
"language_switch_failed": "语言切换失败",
"last_step": "上一步",
"last_use_time": "最后使用时间",
"link.UnValid": "无效的链接",
......@@ -1182,7 +1185,6 @@
"upload_file": "上传文件",
"upload_file_error": "上传文件失败",
"usage_records": "使用记录",
"language_switch_failed": "语言切换失败",
"user.Account": "账号",
"user.Application Name": "项目名",
"user.Laf Account Setting": "laf 账号配置",
......
......@@ -9,6 +9,7 @@
"agent_plan_status_set_plan": "設定 plan",
"agent_plan_status_update_plan": "更新 plan",
"agent_plan_updating": "正在更新計畫",
"ai_chat_loading": "AI 對話",
"ai_reasoning": "思考過程",
"back_to_text": "返回輸入",
"balance_not_enough_pause": "由於 AI 積分不足,暫停運行工作流",
......@@ -18,6 +19,9 @@
"chat_history": "對話紀錄",
"chat_input_guide_lexicon_is_empty": "尚未設定詞彙庫",
"chat_test_app": "除錯-{{name}}",
"citation_card_prefix": "共 ",
"citation_card_suffix": " 筆引用",
"citation_card_title": "共 {{num}} 筆引用",
"citations": "{{num}} 筆引用",
"clear_input_value": "清空輸入",
"click_to_add_url": "輸入文件鏈接",
......@@ -38,6 +42,8 @@
"config_input_guide_lexicon": "設定詞彙庫",
"config_input_guide_lexicon_title": "設定詞彙庫",
"confirm_clear_input_value": "確認清空表單內容?\n將會恢復默認值!",
"mobile_clear_history_confirm_title": "確定清空歷史記錄?",
"mobile_clear_history_confirm_tip": "此操作不可撤銷",
"confirm_plan": "確認計劃",
"confirm_to_clear_share_chat_history": "確認清空所有聊天記錄?",
"content_empty": "無內容",
......@@ -52,12 +58,14 @@
"download_all_files": "下載所有",
"download_chunks": "下載資料",
"empty_directory": "此目錄中已無項目可選~",
"edit_message": "編輯訊息",
"error_message": "錯誤訊息",
"file_amount_over": "超出檔案數量上限 {{max}}",
"file_compress_text": "文件內容壓縮",
"file_input": "檔案輸入",
"file_input_tip": "可透過「外掛程式啟動」節點的「檔案連結」取得對應檔案的連結",
"history_generating": "思考中",
"go_to_dataset": "前往知識庫",
"history_slider.home.title": "聊天",
"home.chat_app": "首页聊天",
"home.select_tools": "選擇工具",
......@@ -88,9 +96,13 @@
"plan_check_tip": "規劃已生成,您可繼續對話來微調當前規劃",
"plugins_output": "外掛程式輸出",
"press_to_speak": "按住說話",
"processed": "已處理",
"processing": "處理中",
"query_extension_IO_tokens": "問題最佳化輸入/輸出 Tokens",
"query_extension_result": "問題優化結果",
"question_tip": "由上至下,各個模組的回應順序",
"quote_result_notice": "僅代表本次搜尋時的結果,請注意資訊的時效性",
"quote_source_title": "引用來源",
"read_file": "文件解析",
"read_raw_source": "開啟原文",
"reasoning_content": "思考過程",
......@@ -208,12 +220,14 @@
"setting.home.quick_apps.add": "配置快捷應用",
"setting.home.quick_apps.placeholder": "請選擇應用",
"setting.home.slogan": "Slogan",
"setting.home.slogan.default": "你好👋,我是 FastGPT ! 請問有什麼可以幫你?",
"setting.home.slogan.default": "今天想做點什麼?",
"setting.home.slogan_placeholder": "請輸入 Slogan",
"setting.home.title": "首頁配置",
"setting.log_details.title": "首頁日誌",
"setting.save": "保存",
"setting.save_success": "保存成功",
"sidebar.all_apps": "全部應用",
"all_apps.title": "應用",
"sidebar.favourite_apps": "精選應用",
"sidebar.home": "首頁",
"sidebar.team_apps": "團隊應用",
......@@ -228,6 +242,8 @@
"tool_response_compress": "工具回應壓縮",
"unsupported_file_type": "不支援的檔案類型",
"variable_invisable_in_share": "外部變量在免登錄鏈接中不可見",
"view_all_citations": "查看全部",
"view_citations": "檢視引用",
"run_detail": "運行詳情",
"web_site_sync": "網站同步"
}
......@@ -5,6 +5,7 @@
"All": "全部",
"Cancel": "取消",
"Choose": "選擇",
"Clear": "清空",
"Click_to_expand": "點選檢視詳細資訊",
"Close": "關閉",
"Code": "原始碼",
......@@ -118,6 +119,7 @@
"cancel_bill_confirm": "確定要取消這個訂單嗎?",
"chart_mode_cumulative": "累積",
"chart_mode_incremental": "分時",
"chat.quote_detail_title": "引用詳情",
"chat_chatId": "會話Id: {{chatId}}",
"choosable": "可選擇",
"chose_condition": "選擇條件",
......@@ -240,13 +242,13 @@
"core.ai.Support tool": "工具調用",
"core.ai.model.Dataset Agent Model": "檔案處理模型",
"core.ai.model.Vector Model": "索引模型",
"core.ai.model.doc_index_and_dialog": "文件索引與對話索引",
"core.ai.model.multimodal": "多模態",
"core.ai.model.multimodal_tip": "多模態索引模型可以給圖片生成向量。",
"core.ai.model.capability_audio": "音頻",
"core.ai.model.capability_image": "圖片",
"core.ai.model.capability_video": "視頻",
"core.ai.model.doc_index_and_dialog": "文件索引與對話索引",
"core.ai.model.multimodal": "多模態",
"core.ai.model.multimodal_support_tip": "支援{{modalities}}能力",
"core.ai.model.multimodal_tip": "多模態索引模型可以給圖片生成向量。",
"core.ai.model.reasoning_tag": "思考",
"core.app.Api request": "API 存取",
"core.app.Api request desc": "通過 API 接入已有系統",
......@@ -295,8 +297,8 @@
"core.app.outLink.Can Drag": "圖示可拖曳",
"core.app.outLink.Default open": "預設開啟",
"core.app.outLink.Iframe block title": "複製下方的 iframe 加入到您的網站中",
"core.app.outLink.Link block title": "複製下方連結在瀏覽器中開啟",
"core.app.outLink.Language isolated tip": "免登入連結內的語言切換不會影響系統語言",
"core.app.outLink.Link block title": "複製下方連結在瀏覽器中開啟",
"core.app.outLink.Script Close Icon": "關閉圖示",
"core.app.outLink.Script Open Icon": "開啟圖示",
"core.app.outLink.Script block title": "將下方程式碼加入到您的網站中",
......@@ -465,13 +467,13 @@
"core.dataset.data.Updated": "已更新",
"core.dataset.data.group": "組",
"core.dataset.embedding model tip": "索引模型可以將知識庫內容轉成向量,用於進行語意檢索。注意,不同索引模型的知識庫無法同時查詢,切換索引模型需重建全量向量索引,請慎重選擇。",
"core.dataset.error.canNotEditAdminPermission": "不能編輯管理員權限",
"core.dataset.error.Data not found": "資料不存在或已被刪除",
"core.dataset.error.Start Sync Failed": "開始同步失敗",
"core.dataset.error.canNotEditAdminPermission": "不能編輯管理員權限",
"core.dataset.error.invalidVectorModelOrQAModel": "索引模型或 QA 模型無效",
"core.dataset.error.noApiServer": "API 服務不存在",
"core.dataset.error.notSupportSync": "該集合不支援同步",
"core.dataset.error.sameApiCollection": "存在相同的 API 集合",
"core.dataset.error.Start Sync Failed": "開始同步失敗",
"core.dataset.error.unAuthDataset": "無權操作該知識庫",
"core.dataset.error.unAuthDatasetCollection": "無權操作該知識庫集合",
"core.dataset.error.unAuthDatasetData": "無權操作該知識庫資料",
......@@ -791,8 +793,8 @@
"error.send_auth_code_too_frequently": "請勿頻繁取得驗證碼",
"error.too_many_request": "請求太頻繁了,請稍後重試",
"error.tool_not_exist": "工具已刪除",
"error.unKnow": "發生未預期的錯誤",
"error.unAuthFile": "無權讀取該文件",
"error.unKnow": "發生未預期的錯誤",
"error.upload_file_error_filename": "{{name}} 上傳失敗",
"error.upload_file_interval_limit": "短時間內上傳次數過多或已達本輪上限,請稍後再試",
"error.username_empty": "帳號不能為空",
......@@ -844,6 +846,7 @@
"just_now": "剛剛",
"key": "鍵",
"key_repetition": "鍵值重複",
"language_switch_failed": "語言切換失敗",
"last_step": "上一步",
"last_use_time": "最後使用時間",
"link.UnValid": "無效的連結",
......@@ -1170,7 +1173,6 @@
"upgrade": "升級",
"upload_file": "上傳檔案",
"upload_file_error": "上傳檔案失敗",
"language_switch_failed": "語言切換失敗",
"user.Account": "帳戶",
"user.Application Name": "專案名稱",
"user.Laf Account Setting": "LAF 帳戶設定",
......
......@@ -75,6 +75,7 @@ catalog:
tsdown: 0.21.10
tsx: ^4.20.6
typescript: ^6.0.3
vaul: ^1.1.2
vitest: ^4.1.5
zod: ^4
......
......@@ -97,6 +97,7 @@
"sass": "^1.58.3",
"undici": "^7.18.2",
"use-context-selector": "^1.4.4",
"vaul": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
......
......@@ -103,12 +103,31 @@ const CiteLink = React.memo(function CiteLink({
gutter={4}
>
<PopoverTrigger>
<Button variant={'unstyled'} minH={0} minW={0} h={'auto'}>
<Button
variant={'unstyled'}
display={['none', 'inline-flex']}
minH={0}
minW={0}
ml={'4px'}
boxSize={'20px'}
p={'4px'}
borderRadius={'full'}
bg={'myGray.150'}
alignItems={'center'}
justifyContent={'center'}
cursor={'pointer'}
_hover={{
'.cite-link-icon': {
color: 'primary.600'
}
}}
>
<MyIcon
name={'core/chat/quoteSign'}
w={'1rem'}
color={'primary.700'}
cursor={'pointer'}
className="cite-link-icon"
name={'common/link'}
w={'12px'}
h={'12px'}
color={'myGray.400'}
/>
</Button>
</PopoverTrigger>
......@@ -155,7 +174,7 @@ const CiteLink = React.memo(function CiteLink({
});
}}
>
{t('common:all_quotes')}
{t('chat:view_all_citations')}
</Button>
</Flex>
<Box h={'300px'} overflow={'auto'} px={4}>
......
......@@ -342,7 +342,7 @@
}
.markdown hr {
margin: 10px 0;
border-color: var(--chakra-colors-gray-300);
border-color: var(--chakra-colors-myGray-200);
}
.markdown {
tab-size: 4;
......
......@@ -34,6 +34,7 @@ type Props = {
showAnimation?: boolean;
isDisabled?: boolean;
forbidZhFormat?: boolean;
className?: string;
} & AProps;
const Markdown = (props: Props) => {
const source = props.source || '';
......@@ -49,6 +50,7 @@ const MarkdownRender = ({
showAnimation,
isDisabled,
forbidZhFormat,
className,
chatAuthData,
onOpenCiteModal
......@@ -83,6 +85,7 @@ const MarkdownRender = ({
<Box position={'relative'}>
<ReactMarkdown
className={`markdown ${styles.markdown}
${className || ''}
${showAnimation ? `${formatSource ? styles.waitingAnimation : styles.animation}` : ''}
`}
remarkPlugins={[RemarkMath, [RemarkGfm, { singleTilde: false }], RemarkBreaks]}
......
......@@ -63,9 +63,11 @@ const TTSSelect = ({
alias: modelName,
avatar: providerData.avatar,
label: (
<HStack minW={0} maxW={'100%'} spacing={2}>
<Avatar borderRadius={'0'} w={'1.25rem'} src={providerData.avatar} />
<Box noOfLines={1}>{modelName}</Box>
<HStack minW={0} maxW={'100%'}>
<Avatar borderRadius={'0'} w={'1.25rem'} flexShrink={0} src={providerData.avatar} />
<Box minW={0} className={'textEllipsis'}>
{modelName}
</Box>
</HStack>
),
value: model.model,
......@@ -93,21 +95,24 @@ const TTSSelect = ({
const formLabel = useMemo(() => {
const provider = selectorList.find((item) => item.value === formatValue[0]) || selectorList[0];
const voice = provider.children.find((item) => item.value === formatValue[1]);
const providerText =
provider.alias ||
(typeof provider.label === 'string' ? provider.label : String(provider.value));
const selectedText = voice ? `${providerText} / ${voice.label}` : providerText;
return (
<Box minW={0} maxW={'100%'}>
<Flex maxW={['200px', '250px']} minW={0} alignItems={'center'}>
{provider.avatar && (
<Avatar borderRadius={'0'} w={'1.25rem'} flexShrink={0} mr={2} src={provider.avatar} />
)}
<Box minW={0} noOfLines={1}>
{selectedText}
</Box>
</Flex>
<Box w={'100%'} minW={0}>
{voice ? (
<Flex maxW={['180px', '250px']} minW={0} overflow={'hidden'} alignItems={'center'}>
<Box minW={0} flex={'1 1 auto'} overflow={'hidden'}>
{provider.label}
</Box>
<Box px={1} flexShrink={0}>
/
</Box>
<Box minW={0} flex={'1 1 auto'} className={'textEllipsis'}>
{voice.label}
</Box>
</Flex>
) : (
provider.label
)}
</Box>
);
}, [formatValue, selectorList]);
......@@ -140,17 +145,19 @@ const TTSSelect = ({
}, [cancelAudio, onClose]);
return (
<Flex alignItems={'center'}>
<Flex alignItems={'center'} w={'100%'} minW={0}>
<MyIcon name={'core/app/simpleMode/tts'} mr={2} w={'20px'} />
<FormLabel>{t('common:core.app.TTS')}</FormLabel>
<ChatFunctionTip type={'tts'} />
<Box flex={1} />
<Box flex={'1 1 0'} minW={3} />
<MyTooltip label={t('common:core.app.Select TTS')}>
<Button
variant={'transparentBase'}
iconSpacing={1}
size={'sm'}
mr={'-5px'}
minW={0}
maxW={['180px', '260px']}
onClick={onOpen}
color={'myGray.600'}
>
......
......@@ -82,33 +82,40 @@ const TimeInput: React.FC<TimeInputProps> = ({
const enableSecond = timeGranularity === 'second';
return (
<Flex alignItems={'center'} gap={2}>
<DateTimePicker
selectedDateTime={formatValue}
onChange={handleDateChange}
popPosition={popPosition}
disabled={[
...(minDate ? [{ before: minDate }] : []),
...(maxDate ? [{ after: maxDate }] : [])
]}
isDisabled={isDisabled}
w={'168px'}
h={8}
borderColor={'myGray.200'}
bg={'white'}
/>
<Box position={'relative'}>
<Flex
alignItems={'center'}
gap={2}
w={'100%'}
minW={0}
>
<Box flex={'1 1 0'} minW={0}>
<DateTimePicker
selectedDateTime={formatValue}
onChange={handleDateChange}
popPosition={popPosition}
disabled={[
...(minDate ? [{ before: minDate }] : []),
...(maxDate ? [{ after: maxDate }] : [])
]}
isDisabled={isDisabled}
w={'100%'}
h={8}
borderColor={'myGray.200'}
bg={'white'}
/>
</Box>
<Box position={'relative'} flex={'0 0 56px'} minW={0}>
<MyNumberInput
value={hour}
onChange={handleHourChange}
min={0}
max={23}
w={'48px'}
w={'100%'}
size={'sm'}
hideStepper
isDisabled={isDisabled || !enableHour}
inputFieldProps={{
pr: '20px',
pr: '18px',
pl: '8px',
bg: isDisabled || !enableHour ? 'myGray.100' : 'white',
color: isDisabled || !enableHour ? 'myGray.400' : 'inherit'
......@@ -127,18 +134,18 @@ const TimeInput: React.FC<TimeInputProps> = ({
{t('common:hour_unit')}
</Box>
</Box>
<Box position={'relative'}>
<Box position={'relative'} flex={'0 0 56px'} minW={0}>
<MyNumberInput
value={minute}
onChange={handleMinuteChange}
min={0}
max={59}
w={'48px'}
w={'100%'}
size={'sm'}
hideStepper
isDisabled={isDisabled || !enableMinute}
inputFieldProps={{
pr: '20px',
pr: '18px',
pl: '8px',
bg: isDisabled || !enableMinute ? 'myGray.100' : 'white',
color: isDisabled || !enableMinute ? 'myGray.400' : 'inherit'
......@@ -157,18 +164,18 @@ const TimeInput: React.FC<TimeInputProps> = ({
{t('common:minute_unit')}
</Box>
</Box>
<Box position={'relative'}>
<Box position={'relative'} flex={'0 0 56px'} minW={0}>
<MyNumberInput
value={second}
onChange={handleSecondChange}
min={0}
max={59}
w={'48px'}
w={'100%'}
size={'sm'}
hideStepper
isDisabled={isDisabled || !enableSecond}
inputFieldProps={{
pr: '20px',
pr: '18px',
pl: '8px',
bg: isDisabled || !enableSecond ? 'myGray.100' : 'white',
color: isDisabled || !enableSecond ? 'myGray.400' : 'inherit'
......
......@@ -11,7 +11,7 @@ import {
type SendPromptFnType,
type StopChatFnResult
} from '../type';
import { textareaMinH } from '../constants';
import { ChatInputDefaultHeight, textareaMinH } from '../constants';
import { useFieldArray, type UseFormReturn } from 'react-hook-form';
import { ChatBoxContext } from '../Provider';
import dynamic from 'next/dynamic';
......@@ -118,6 +118,8 @@ const ChatInput = ({
showSelectVideo ||
showSelectAudio ||
showSelectCustomFileExtension;
const isDefaultInputHeight =
!mobilePreSpeak && !inputValue && fileList.length === 0 && !chatInputGuide.open;
// Upload files
useRequest(uploadFiles, {
......@@ -162,9 +164,11 @@ const ChatInput = ({
<Textarea
ref={TextareaDom}
py={0}
mx={[2, 4]}
px={2}
mx={0}
px={0}
border={'none'}
borderRadius={0}
appearance={'none'}
_focusVisible={{
border: 'none'
}}
......@@ -191,8 +195,10 @@ const ChatInput = ({
letterSpacing={'0.5px'}
w={'100%'}
_placeholder={{
color: '#707070',
fontSize: 'sm'
color: 'myGray.400',
fontSize: 'inherit',
lineHeight: 'inherit',
letterSpacing: 'inherit'
}}
value={inputValue}
onChange={(e) => {
......@@ -280,8 +286,8 @@ const ChatInput = ({
const RenderButtonGroup = useMemo(() => {
const iconSize = {
w: isPc ? '20px' : '16px',
h: isPc ? '20px' : '16px'
w: '20px',
h: '20px'
};
return (
......@@ -290,9 +296,7 @@ const ChatInput = ({
justifyContent={'space-between'}
w={'100%'}
mt={0}
pr={[3, 4]}
pl={[3, 4]}
h={[8, 9]}
h={9}
gap={[0, 1]}
>
{/* 左侧自定义按钮组 */}
......@@ -303,14 +307,14 @@ const ChatInput = ({
{/* 右侧原有按钮组 */}
<Flex alignItems={'center'} gap={[0, 1]}>
{/* Attachment and Voice Group */}
<Flex alignItems={'center'} h={[8, 9]}>
<Flex alignItems={'center'} h={9}>
{/* file selector button */}
{canUploadFile && (
<Flex
alignItems={'center'}
justifyContent={'center'}
w={[8, 9]}
h={[8, 9]}
w={9}
h={9}
p={[1, 2]}
borderRadius={'sm'}
cursor={'pointer'}
......@@ -321,7 +325,7 @@ const ChatInput = ({
}}
>
<MyTooltip label={selectFileLabel}>
<MyIcon name={selectFileIcon as any} {...iconSize} color={'#707070'} />
<MyIcon name={selectFileIcon as any} {...iconSize} color={'myGray.500'} />
</MyTooltip>
<File onSelect={(files) => onSelectFile({ files })} />
</Flex>
......@@ -332,8 +336,8 @@ const ChatInput = ({
<Flex
alignItems={'center'}
justifyContent={'center'}
w={[8, 9]}
h={[8, 9]}
w={9}
h={9}
p={[1, 2]}
borderRadius={'sm'}
cursor={'pointer'}
......@@ -344,7 +348,7 @@ const ChatInput = ({
}}
>
<MyTooltip label={t('common:core.chat.Record')}>
<MyIcon name={'core/chat/recordFill'} {...iconSize} color={'#707070'} />
<MyIcon name={'core/chat/recordFill'} {...iconSize} color={'myGray.500'} />
</MyTooltip>
</Flex>
)}
......@@ -358,14 +362,14 @@ const ChatInput = ({
)}
{/* Send Button Container */}
<Flex alignItems={'center'} w={[8, 9]} h={[8, 9]} borderRadius={'lg'}>
<Flex alignItems={'center'} w={9} h={9} borderRadius={'lg'}>
<MyBox
isLoading={isStopping}
display={'flex'}
alignItems={'center'}
justifyContent={'center'}
w={[7, 9]}
h={[7, 9]}
w={9}
h={9}
p={[1, 2]}
bg={
isChatting ? 'primary.50' : canSendMessage ? 'primary.500' : 'rgba(17, 24, 36, 0.1)'
......@@ -417,11 +421,16 @@ const ChatInput = ({
const activeStyles: FlexProps = {
boxShadow: '0px 5px 20px -4px rgba(19, 51, 107, 0.13)',
border: '0.5px solid rgba(0, 0, 0, 0.24)'
border: '1px solid',
borderColor: 'myGray.250'
};
return (
<Box
w={'100%'}
maxW={['100%', '780px']}
mx={'auto'}
pb={['calc(12px + env(safe-area-inset-bottom))', 0]}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
......@@ -450,9 +459,10 @@ const ChatInput = ({
{/* Real Chat Input */}
<Flex
direction={'column'}
minH={mobilePreSpeak ? '48px' : ['96px', '120px']}
pt={fileList.length > 0 ? '0' : mobilePreSpeak ? [0, 4] : [3, 4]}
pb={InputLeftComponent ? 2 : 3}
h={isDefaultInputHeight ? ChatInputDefaultHeight : undefined}
minH={mobilePreSpeak ? '48px' : ChatInputDefaultHeight}
p={mobilePreSpeak ? [0, 4] : 4}
mb={4}
position={'relative'}
borderRadius={['xl', 'xxl']}
bg={'white'}
......@@ -461,7 +471,8 @@ const ChatInput = ({
? activeStyles
: {
_hover: activeStyles,
border: '0.5px solid rgba(0, 0, 0, 0.18)',
border: '1px solid',
borderColor: 'myGray.200',
boxShadow: `0px 5px 16px -4px rgba(19, 51, 107, 0.08)`
})}
onClick={() => TextareaDom?.current?.focus()}
......@@ -482,8 +493,8 @@ const ChatInput = ({
)}
{/* file preview */}
{(!mobilePreSpeak || isPc || inputValue) && (
<Box px={[2, 3]}>
<FilePreview fileList={fileList} removeFiles={removeFiles} />
<Box>
<FilePreview fileList={fileList} removeFiles={removeFiles} pt={0} />
</Box>
)}
......@@ -516,7 +527,7 @@ const ChatInput = ({
{!mobilePreSpeak && <Box>{RenderButtonGroup}</Box>}
</Flex>
<ComplianceTip type={'chat'} />
<ComplianceTip type={'chat'} pt={0} pb={[0, 6]} />
</Box>
);
};
......
......@@ -37,9 +37,9 @@ export type ChatProviderProps = {
chatType: ChatTypeEnum;
dialogTips?: string;
wideLogo?: string;
squareLogo?: string;
slogan?: string;
currentQuickAppId?: string;
quickAppList?: ChatQuickAppType[];
onSwitchQuickApp?: (appId: string) => Promise<void>;
};
......
import { Box, Flex } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import React from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus';
import ChatController, { type ChatControllerProps } from '../ChatController';
import { ChatBoxContext } from '../../Provider';
import { useContextSelector } from 'use-context-selector';
import { ChatTypeEnum } from '../../constants';
type AIChatBubbleActionsProps = {
chatControllerProps: ChatControllerProps;
questionGuides: string[];
showWholeResponse: boolean;
onOpenWholeModal: () => void;
durationSeconds: number;
};
const AIChatBubbleActions = ({
chatControllerProps,
questionGuides,
showWholeResponse,
onOpenWholeModal,
durationSeconds
}: AIChatBubbleActionsProps) => {
const { t } = useTranslation();
const { onRetry } = chatControllerProps;
const chatType = useContextSelector(ChatBoxContext, (v) => v.chatType);
const showRetry = chatType !== ChatTypeEnum.log && !!onRetry;
return (
<Box mt={4} maxW={'100%'}>
<Flex
alignItems={'center'}
flexWrap={'wrap'}
color={'myGray.400'}
fontSize={'12px'}
fontWeight={500}
lineHeight={'24px'}
whiteSpace={'nowrap'}
zIndex={1}
>
<Flex alignItems={'center'} gap={'4px'}>
<ChatController {...chatControllerProps} variant="footer" />
{showRetry && (
<MyIcon
name={'common/retryLight'}
w={'16px'}
p={'4px'}
cursor={'pointer'}
color={'myGray.400'}
_hover={{ color: 'primary.600' }}
onClick={onRetry}
/>
)}
{showWholeResponse && (
<Flex
alignItems={'center'}
gap={'4px'}
p={'4px'}
cursor={'pointer'}
color={'myGray.400'}
_hover={{ color: 'primary.600' }}
onClick={onOpenWholeModal}
>
<MyIcon name={'core/chat/terminal'} w={'16px'} />
<Box>{t('chat:run_detail')}</Box>
</Flex>
)}
</Flex>
{durationSeconds > 0 && (
<>
<Box display={['none', 'block']} mx={4} h={'14px'} w={'1px'} bg={'myGray.200'} />
<Box display={['none', 'block']} color={'myGray.400'}>
{durationSeconds.toFixed(2)} s
</Box>
</>
)}
</Flex>
{questionGuides.length > 0 && (
<Flex mt={4} flexDirection={'column'} alignItems={'flex-start'} gap={'8px'}>
{questionGuides.map((text) => (
<Flex
key={text}
alignItems={'center'}
gap={2}
maxW={'100%'}
px={['16px', '8px']}
py={['8px', '4px']}
borderRadius={'8px'}
border={'0.5px solid'}
borderColor={['myGray.250', 'transparent']}
bg={'transparent'}
color={'myGray.600'}
fontSize={'14px'}
lineHeight={'20px'}
fontWeight={500}
cursor={'pointer'}
_hover={{ bg: 'rgba(17, 24, 36, 0.05)' }}
onClick={() => eventBus.emit(EventNameEnum.sendQuestion, { text })}
>
<MyIcon name={'common/arrowRight'} w={'14px'} transform={'rotate(-45deg)'} />
<Box className="textEllipsis">{text}</Box>
</Flex>
))}
</Flex>
)}
</Box>
);
};
export default React.memo(AIChatBubbleActions);
import { Box, Flex } from '@chakra-ui/react';
import React from 'react';
import type {
AIChatItemValueItemType,
ChatHistoryItemResType
} from '@fastgpt/global/core/chat/type';
import type { OnOpenCiteModalProps } from '@/web/core/chat/context/chatItemContext';
import AIResponseBox from '../../../../components/AIResponseBox';
import RenderProcessingCollapse from '../../../../components/AIResponseBox/RenderProcessingCollapse';
import {
hasAiAnswerContent,
hasAiInteractiveContent,
hasAiProcessingContent
} from './utils';
type AIChatBubbleContentProps = {
dataId: string;
chatValue: AIChatItemValueItemType[];
responseData?: ChatHistoryItemResType[];
isLastChild: boolean;
isChatting: boolean;
onOpenCiteModal: (e?: OnOpenCiteModalProps) => void;
};
const AIChatBubbleContent = ({
chatValue,
responseData,
dataId,
isLastChild,
isChatting,
onOpenCiteModal
}: AIChatBubbleContentProps) => {
const renderValue = ({
value,
index,
wrapProcessing,
showProcessing = true,
showAnswer = true,
showInteractive = true
}: {
value: AIChatItemValueItemType;
index: number;
wrapProcessing: boolean;
showProcessing?: boolean;
showAnswer?: boolean;
showInteractive?: boolean;
}) => {
const isLastResponse = isLastChild && index === chatValue.length - 1;
return (
<AIResponseBox
chatItemDataId={dataId}
value={value}
responseData={responseData}
isLastResponseValue={isLastResponse}
isLastChild={isLastChild}
isChatting={isChatting}
onOpenCiteModal={onOpenCiteModal}
wrapProcessing={wrapProcessing}
showProcessing={showProcessing}
showAnswer={showAnswer}
showInteractive={showInteractive}
/>
);
};
const contentBlocks: React.ReactNode[] = [];
let processingGroup: Array<{ value: AIChatItemValueItemType; index: number }> = [];
const flushProcessingGroup = (isProcessing: boolean) => {
if (processingGroup.length === 0) return;
const group = processingGroup;
processingGroup = [];
if (group.length === 1) {
const { value, index } = group[0];
contentBlocks.push(
<Box key={`${dataId}-ai-${index}`} _notFirst={{ mt: 4 }}>
{renderValue({
value,
index,
wrapProcessing: false,
showAnswer: false,
showInteractive: false
})}
</Box>
);
return;
}
contentBlocks.push(
<Box key={`${dataId}-processing-${group[0].index}`} _notFirst={{ mt: 4 }}>
<RenderProcessingCollapse isProcessing={isProcessing}>
{group.map(({ value, index }) => (
<Box key={`${dataId}-ai-${index}`} _notFirst={{ mt: 4 }}>
{renderValue({
value,
index,
wrapProcessing: false,
showAnswer: false,
showInteractive: false
})}
</Box>
))}
</RenderProcessingCollapse>
</Box>
);
};
chatValue.forEach((value, index) => {
if (value.hideInUI) return;
const hasProcessing = hasAiProcessingContent(value);
const hasAnswer = hasAiAnswerContent(value);
const hasInteractive = hasAiInteractiveContent(value);
if (!hasProcessing && !hasAnswer && !hasInteractive) return;
if (hasProcessing) {
processingGroup.push({ value, index });
if (!hasAnswer && !hasInteractive) {
return;
}
}
flushProcessingGroup(isChatting && isLastChild && !hasAnswer && !hasInteractive);
contentBlocks.push(
<Box key={`${dataId}-ai-${index}`} _notFirst={{ mt: 4 }}>
{renderValue({ value, index, wrapProcessing: true, showProcessing: false })}
</Box>
);
});
flushProcessingGroup(isChatting && isLastChild);
return (
<Flex flexDirection={'column'} fontSize={'16px'} lineHeight={1.75}>
{contentBlocks}
</Flex>
);
};
export default React.memo(AIChatBubbleContent);
:global(.markdown).markdown {
font-size: 16px;
line-height: 1.75;
&,
* {
word-break: normal;
overflow-wrap: anywhere;
}
}
import { Box, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import dynamic from 'next/dynamic';
import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import type { OnOpenCiteModalProps } from '@/web/core/chat/context/chatItemContext';
import type { ChatSiteItemType } from '../../type';
import AIChatBubbleContent from './Content';
import AIChatBubbleActions from './Actions';
import type { ChatControllerProps } from '../ChatController';
import { useContextSelector } from 'use-context-selector';
import { ChatBoxContext } from '../../Provider';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import AIChatLoading from '../AIChatLoading';
const ResponseTags = dynamic(() => import('../ResponseTags'));
const WholeResponseModal = dynamic(() => import('../../../../components/WholeResponseModal'));
export { shouldFilterAiValue } from './utils';
type AIChatBubbleProps = {
chat: ChatSiteItemType;
chatValue: AIChatItemValueItemType[];
isPlanCard: boolean;
isLastChild: boolean;
isLastValueGroup: boolean;
isChatting: boolean;
loadingText?: string;
questionGuides: string[];
onOpenCiteModal: (e?: OnOpenCiteModalProps) => void;
chatControllerProps: ChatControllerProps;
children?: React.ReactNode;
};
const AIChatBubble = ({
chat,
chatValue,
isPlanCard,
isLastChild,
isLastValueGroup,
isChatting,
loadingText,
questionGuides,
onOpenCiteModal,
chatControllerProps,
children
}: AIChatBubbleProps) => {
const chatType = useContextSelector(ChatBoxContext, (v) => v.chatType);
const showWholeResponse = useContextSelector(ChatItemContext, (v) => v.showWholeResponse ?? true);
const {
isOpen: isOpenWholeModal,
onOpen: onOpenWholeModal,
onClose: onCloseWholeModal
} = useDisclosure();
const showFooterActions = isLastValueGroup && (!isLastChild || !isChatting);
const canShowWholeResponse = chatType !== 'share' && showWholeResponse;
const showLoading = isLastChild && isLastValueGroup && isChatting;
return (
<Box position={'relative'} w={'100%'} maxW={'100%'}>
<Box
w={'100%'}
maxW={'700px'}
color={'myGray.900'}
textAlign={'left'}
minW={isPlanCard ? ['100%', '50%'] : undefined}
>
<AIChatBubbleContent
chatValue={chatValue}
responseData={chat.responseData}
dataId={chat.dataId}
isLastChild={isLastChild && isLastValueGroup}
isChatting={isChatting}
onOpenCiteModal={onOpenCiteModal}
/>
{isLastValueGroup && (
<ResponseTags
showTags={!isLastChild || !isChatting}
historyItem={chat}
onOpenCiteModal={onOpenCiteModal}
showFooterMeta={false}
/>
)}
{children}
{showLoading && (
<Box mt={3}>
<AIChatLoading text={loadingText} size={'md'} />
</Box>
)}
</Box>
{showFooterActions && (
<AIChatBubbleActions
chatControllerProps={chatControllerProps}
questionGuides={isLastChild ? questionGuides : []}
showWholeResponse={canShowWholeResponse}
onOpenWholeModal={onOpenWholeModal}
durationSeconds={chat.durationSeconds || 0}
/>
)}
{isOpenWholeModal && <WholeResponseModal dataId={chat.dataId} onClose={onCloseWholeModal} />}
</Box>
);
};
export default React.memo(AIChatBubble);
import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
export const hasAiAnswerContent = (item: AIChatItemValueItemType) =>
Boolean(item.text?.content?.trim());
export const hasAiInteractiveContent = (item: AIChatItemValueItemType) => Boolean(item.interactive);
export const hasAiProcessingContent = (item: AIChatItemValueItemType) => {
if (item.hideInUI) return false;
const tools = item.tools || (item.tool ? [item.tool] : undefined);
return Boolean(
(item.reasoning?.content && !item.hideReason) ||
tools?.length ||
item.skills?.length ||
item.plan ||
item.planStatus?.status === 'generating'
);
};
export const shouldFilterAiValue = (item: AIChatItemValueItemType) => {
if (item.hideInUI) return true;
if (item.text?.content?.trim() || item.reasoning?.content?.trim()) return false;
if (!item.text && !item.reasoning) return false;
return !(
item.tools?.length ||
item.tool ||
item.skills?.length ||
item.interactive ||
item.plan ||
item.planStatus ||
item.agentPlanUpdate ||
item.agentAsk ||
item.agentStopGate ||
item.contextCheckpoint
);
};
import React, { useEffect, useMemo, useRef } from 'react';
import { Box, Flex, type BoxProps, type SpinnerProps } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
const config = {
rotate: true,
particleCount: 30,
trailSpan: 0.38,
durationMs: 3000,
rotationDurationMs: 16000,
pulseDurationMs: 4600,
orbitRadius: 7,
detailAmplitude: 2.7,
petalCount: 5,
curveScale: 3.9
};
const TWO_PI = Math.PI * 2;
const PETAL_K = Math.round(config.petalCount);
const particleSizeMap: Record<string, BoxProps['boxSize']> = {
xs: '24px',
sm: '32px',
md: '48px',
lg: '64px',
xl: '80px'
};
const point = (progress: number, detailScale: number) => {
const t = progress * TWO_PI;
const r = config.orbitRadius - config.detailAmplitude * detailScale * Math.cos(PETAL_K * t);
return {
x: 50 + Math.cos(t) * r * config.curveScale,
y: 50 + Math.sin(t) * r * config.curveScale
};
};
const normalizeProgress = (progress: number) => ((progress % 1) + 1) % 1;
const getDetailScale = (time: number) => {
const pulseProgress = (time % config.pulseDurationMs) / config.pulseDurationMs;
const pulseAngle = pulseProgress * TWO_PI;
return 0.52 + ((Math.sin(pulseAngle + 0.55) + 1) / 2) * 0.48;
};
const getRotation = (time: number) => {
if (!config.rotate) return 0;
return -((time % config.rotationDurationMs) / config.rotationDurationMs) * 360;
};
const getParticle = (index: number, progress: number, detailScale: number) => {
const tailOffset = index / (config.particleCount - 1);
const p = point(normalizeProgress(progress - tailOffset * config.trailSpan), detailScale);
const fade = Math.pow(1 - tailOffset, 0.56);
return {
x: p.x,
y: p.y,
radius: 0.6 + fade * 3.57,
opacity: 0.04 + fade * 0.96
};
};
const getParticleSize = (size: SpinnerProps['size']) => {
if (typeof size === 'string' && particleSizeMap[size]) {
return particleSizeMap[size];
}
return size || particleSizeMap.lg;
};
const AIChatParticleLoading = ({ size = 'lg' }: { size?: SpinnerProps['size'] }) => {
const groupRef = useRef<SVGGElement>(null);
const particleRefs = useRef<(SVGCircleElement | null)[]>([]);
const boxSize = getParticleSize(size);
const indices = useMemo(() => Array.from({ length: config.particleCount }, (_, i) => i), []);
useEffect(() => {
const startedAt = performance.now();
let rafId = 0;
const render = (now: number) => {
const time = now - startedAt;
const progress = (time % config.durationMs) / config.durationMs;
const detailScale = getDetailScale(time);
if (groupRef.current) {
groupRef.current.setAttribute('transform', `rotate(${getRotation(time)} 50 50)`);
}
for (let i = 0; i < particleRefs.current.length; i++) {
const node = particleRefs.current[i];
if (!node) continue;
const particle = getParticle(i, progress, detailScale);
node.setAttribute('cx', particle.x.toFixed(2));
node.setAttribute('cy', particle.y.toFixed(2));
node.setAttribute('r', particle.radius.toFixed(2));
node.setAttribute('opacity', particle.opacity.toFixed(3));
}
rafId = requestAnimationFrame(render);
};
rafId = requestAnimationFrame(render);
return () => cancelAnimationFrame(rafId);
}, []);
return (
<Box boxSize={boxSize} position="relative" flexShrink={0} display="grid" placeItems="center">
<Box
as="svg"
viewBox="8 8 84 84"
fill="none"
sx={{ width: '100%', height: '100%', overflow: 'visible' }}
aria-hidden="true"
>
<g ref={groupRef}>
{indices.map((i) => (
<circle
key={i}
ref={(el) => {
particleRefs.current[i] = el;
}}
fill="#3370FF"
/>
))}
</g>
</Box>
</Box>
);
};
const AIChatLoading = ({ text, size = 'lg' }: { text?: string; size?: SpinnerProps['size'] }) => {
const { t } = useTranslation();
const loadingText = text ?? t('chat:ai_chat_loading');
return (
<Flex alignItems={'center'} justifyContent={'flex-start'} gap={2}>
<AIChatParticleLoading size={size} />
{loadingText && (
<Box color={'myGray.600'} fontSize={'16px'} lineHeight={'24px'} fontWeight={'normal'}>
{loadingText}
</Box>
)}
</Flex>
);
};
export default React.memo(AIChatLoading);
import React, { type ChangeEvent, type MutableRefObject } from 'react';
import React, { type ChangeEvent, type MutableRefObject, useCallback, useMemo } from 'react';
import { Box, Checkbox } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import type { ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import ChatBoxDivider from '../../../Divider';
import DeletedItemsCollapse from '../../DeletedItemsCollapse';
import { formatChatValue2InputType } from '../utils/chatValue';
import type { ChatSiteItemType } from '../type';
import ChatItem from './ChatItem';
import TimeBox from './TimeBox';
import type { ChatBoxInputType } from '../type';
import {
hasAiAnswerContent,
hasAiInteractiveContent,
hasAiProcessingContent
} from './AIChatBubble/utils';
export type ChatRecordsListProps = {
records: ChatSiteItemType[];
expandedDeletedGroups: Set<string>;
itemRefs: MutableRefObject<Map<string, HTMLElement | null>>;
userAvatar?: string;
appAvatar?: string;
showVoiceIcon: boolean;
showMarkIcon: boolean;
statusBoxData:
......@@ -29,7 +33,7 @@ export type ChatRecordsListProps = {
questionGuides: string[];
onToggleDeletedGroup: (dataIds: string[]) => void;
onRetry: (dataId?: string) => (() => Promise<void>) | undefined;
onDelete: (dataId: string) => () => void;
onEdit: (dataId?: string) => ((input: ChatBoxInputType) => Promise<void>) | undefined;
onMark: (chat: ChatSiteItemType, q?: string) => (() => void) | undefined;
onAddUserLike: (chat: ChatSiteItemType) => (() => void) | undefined;
onAddUserDislike: (chat: ChatSiteItemType) => (() => void) | undefined;
......@@ -40,28 +44,12 @@ export type ChatRecordsListProps = {
onToggleFeedbackReadStatus: (chat: ChatSiteItemType) => (() => Promise<void>) | undefined;
};
const shouldShowTimeDivider = ({
records,
item,
index
}: {
records: ChatSiteItemType[];
item: ChatSiteItemType;
index: number;
}) => {
if (index === 0 || !item.time || records[index - 1].time === undefined) return false;
return (
new Date(item.time).getTime() - new Date(records[index - 1].time!).getTime() > 10 * 60 * 1000
);
};
/**
* 渲染 ChatBox 的聊天记录列表。
*
* 本组件只接收已经预处理好的 `records`,不负责 log 模式 deleted group 的计算,也不直接
* 调用删除、重试、反馈、标注 API。所有动作都由上层 hook 生成后作为 props 注入,组件内部
* 只负责把 human/AI 记录、折叠按钮、时间分隔、自定义反馈和 admin mark 展示拼成原来的 JSX。
* 只负责把 human/AI 记录、折叠按钮、自定义反馈和 admin mark 展示拼成原来的 JSX。
*
* 设计边界:
* - `expandedDeletedGroups` 只用于判断 deleted record 是否渲染,展开/收起状态更新仍在父组件。
......@@ -72,15 +60,13 @@ const ChatRecordsList = ({
records,
expandedDeletedGroups,
itemRefs,
userAvatar,
appAvatar,
showVoiceIcon,
showMarkIcon,
statusBoxData,
questionGuides,
onToggleDeletedGroup,
onRetry,
onDelete,
onEdit,
onMark,
onAddUserLike,
onAddUserDislike,
......@@ -89,10 +75,89 @@ const ChatRecordsList = ({
}: ChatRecordsListProps) => {
const { t } = useTranslation();
const canMergeAiProcessingRecord = useCallback((item: ChatSiteItemType) => {
if (item.obj !== ChatRoleEnum.AI || item.hideInUI || item.deleteTime) return false;
if (item.collapseTop || item.collapseBottom) return false;
let hasProcessing = false;
const hasBlockingContent = item.value.some((value) => {
const aiValue = value as AIChatItemValueItemType;
if (aiValue.hideInUI) return false;
hasProcessing = hasProcessing || hasAiProcessingContent(aiValue);
return hasAiAnswerContent(aiValue) || hasAiInteractiveContent(aiValue);
});
return hasProcessing && !hasBlockingContent;
}, []);
const canMergeAiAnswerRecord = useCallback((item: ChatSiteItemType) => {
if (item.obj !== ChatRoleEnum.AI || item.hideInUI || item.deleteTime) return false;
if (item.collapseTop || item.collapseBottom) return false;
return item.value.some((value) => hasAiAnswerContent(value as AIChatItemValueItemType));
}, []);
const renderRecords = useMemo(() => {
const result: Array<{ item: ChatSiteItemType; sourceIndex: number; lastSourceIndex: number }> =
[];
for (let index = 0; index < records.length; index++) {
const item = records[index];
const startIndex = index;
if (!canMergeAiProcessingRecord(item)) {
result.push({ item, sourceIndex: index, lastSourceIndex: index });
continue;
}
const mergedValues: AIChatItemValueItemType[] = [
...(item.value as AIChatItemValueItemType[])
];
const mergedResponseData = [...(item.responseData || [])];
let lastSourceIndex = index;
let cursor = index + 1;
let answerRecord: ChatSiteItemType | undefined;
while (cursor < records.length && canMergeAiProcessingRecord(records[cursor])) {
mergedValues.push(...(records[cursor].value as AIChatItemValueItemType[]));
mergedResponseData.push(...(records[cursor].responseData || []));
lastSourceIndex = cursor;
cursor++;
}
if (cursor < records.length && canMergeAiAnswerRecord(records[cursor])) {
answerRecord = records[cursor];
mergedValues.push(...(answerRecord.value as AIChatItemValueItemType[]));
mergedResponseData.push(...(answerRecord.responseData || []));
lastSourceIndex = cursor;
index = cursor;
} else {
index = lastSourceIndex;
}
result.push({
item: {
...(answerRecord || item),
value: mergedValues,
responseData: mergedResponseData
} as ChatSiteItemType,
sourceIndex: startIndex,
lastSourceIndex
});
}
return result;
}, [canMergeAiAnswerRecord, canMergeAiProcessingRecord, records]);
return (
<Box id={'history'}>
{records.map((item, index) => {
{renderRecords.map(({ item, sourceIndex, lastSourceIndex }) => {
const shouldRender = !item.deleteTime || expandedDeletedGroups.has(item.dataId);
const previousRecord = records[sourceIndex - 1];
const retryPreviousHuman =
previousRecord?.obj === ChatRoleEnum.Human ? onRetry(previousRecord.dataId) : undefined;
return (
<Box key={item.dataId}>
......@@ -111,30 +176,34 @@ const ChatRecordsList = ({
itemRefs.current.set(item.dataId, e);
}}
>
{shouldShowTimeDivider({ records, item, index }) && <TimeBox time={item.time!} />}
<Box py={item.hideInUI ? 0 : 6}>
<Box
pt={
item.hideInUI ? 0 : item.obj === ChatRoleEnum.Human ? 0 : '20px'
}
pb={
item.hideInUI ? 0 : item.obj === ChatRoleEnum.Human ? '20px' : '40px'
}
>
{item.obj === ChatRoleEnum.Human && !item.hideInUI && (
<ChatItem
avatar={userAvatar}
chat={item}
onRetry={onRetry(item.dataId)}
onDelete={onDelete(item.dataId)}
isLastChild={index === records.length - 1}
onEditSubmit={onEdit(item.dataId)}
isLastChild={lastSourceIndex === records.length - 1}
/>
)}
{item.obj === ChatRoleEnum.AI && (
<ChatItem
avatar={appAvatar}
chat={item}
isLastChild={index === records.length - 1}
isLastChild={lastSourceIndex === records.length - 1}
{...{
showVoiceIcon,
statusBoxData,
questionGuides,
onRetry: retryPreviousHuman,
onMark: onMark(
item,
formatChatValue2InputType(records[index - 1]?.value)?.text
formatChatValue2InputType(previousRecord?.value)?.text
),
onAddUserLike: onAddUserLike(item),
onAddUserDislike: onAddUserDislike(item),
......@@ -192,6 +261,7 @@ const ChatRecordsList = ({
</Box>
);
})}
{records.length > 0 && <Box h={'24px'} />}
</Box>
);
};
......
import React, { useMemo } from 'react';
import { Box, Button, Card, Flex } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import type { UseFormReturn } from 'react-hook-form';
import type { VariableItemType } from '@fastgpt/global/core/app/type';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import MyIcon from '@fastgpt/web/components/common/Icon';
import LabelAndFormRender from '@/components/core/app/formRender/LabelAndForm';
import { variableInputTypeToInputType } from '@/components/core/app/formRender/utils';
import type { ChatBoxInputFormType } from '../type';
import { ChatTypeEnum } from '../constants';
export type VariableGroups = {
commonVariableList: VariableItemType[];
externalVariableList: VariableItemType[];
internalVariableList: VariableItemType[];
};
type ChatVariableFormProps = {
variables: VariableItemType[];
variablesForm: UseFormReturn<any>;
chatForm: UseFormReturn<ChatBoxInputFormType>;
chatType: ChatTypeEnum;
showAvatar?: boolean;
chatStarted?: boolean;
fileUploading?: boolean;
};
const ChatVariableCard = ({ children }: { children: React.ReactNode }) => {
return (
<Card
w={['100%', '360px']}
maxW={'100%'}
bg={'white'}
border={'1px solid'}
borderColor={'myGray.200'}
borderRadius={'8px'}
boxShadow={'0 4px 10px 0 rgba(19, 51, 107, 0.08), 0 0 1px 0 rgba(19, 51, 107, 0.08)'}
p={4}
>
{children}
</Card>
);
};
const ChatVariableTip = ({ children }: { children: React.ReactNode }) => {
return (
<Flex
color={'primary.600'}
bg={'primary.50'}
mb={4}
px={3}
py={2}
gap={2}
fontSize={'sm'}
borderRadius={'6px'}
alignItems={'flex-start'}
>
<MyIcon name={'common/info'} color={'primary.600'} w={4} mt={'1px'} flexShrink={0} />
<Box>{children}</Box>
</Flex>
);
};
export const getChatVariableGroups = ({
variables,
chatType
}: {
variables: VariableItemType[];
chatType: ChatTypeEnum;
}): VariableGroups => {
const showExternalVariables = [
ChatTypeEnum.log,
ChatTypeEnum.test,
ChatTypeEnum.chat,
ChatTypeEnum.home
].includes(chatType);
const showInternalVariables = [ChatTypeEnum.log, ChatTypeEnum.test].includes(chatType);
const groups: VariableGroups = {
commonVariableList: [],
externalVariableList: [],
internalVariableList: []
};
variables.forEach((item) => {
if (item.type === VariableInputEnum.custom) {
groups.externalVariableList.push(item);
} else if (item.type === VariableInputEnum.internal) {
groups.internalVariableList.push(item);
} else {
groups.commonVariableList.push(item);
}
});
return {
externalVariableList: showExternalVariables ? groups.externalVariableList : [],
internalVariableList: showInternalVariables ? groups.internalVariableList : [],
commonVariableList: groups.commonVariableList
};
};
export const ChatVariableFields = ({
variables,
variablesForm,
isUnChange
}: {
variables: VariableItemType[];
variablesForm: UseFormReturn<any>;
isUnChange: boolean;
}) => {
return (
<>
{variables.map((item) => (
<LabelAndFormRender
{...item}
isUnChange={isUnChange}
key={item.key}
fieldName={`variables.${item.key}`}
description={item.description}
inputType={variableInputTypeToInputType(item.type, item.valueType)}
form={variablesForm}
bg={'white'}
/>
))}
</>
);
};
const ChatVariableStartButton = ({
fileUploading,
onClick
}: {
fileUploading?: boolean;
onClick: () => void;
}) => {
const { t } = useTranslation();
return (
<Flex justifyContent={'flex-end'} mt={6}>
<Button
w={'69px'}
h={'36px'}
px={'20px'}
py={'8px'}
borderRadius={'8px'}
fontSize={'14px'}
variant={'primary'}
isDisabled={fileUploading}
onClick={onClick}
>
{t('common:Submit')}
</Button>
</Flex>
);
};
/**
* 渲染 ChatBox 内的全局变量填写表单。
*
* Home 和普通 App 对话都复用这个组件:调用方只提供当前变量声明和表单实例,
* 组件内部按 chatType 保留原有 custom/internal/common 变量可见性规则,并统一卡片、
* 字段列表和提交按钮样式,避免两个入口后续样式继续漂移。
*/
const ChatVariableForm = ({
variables,
variablesForm,
chatForm,
chatType,
showAvatar = false,
chatStarted = false,
fileUploading
}: ChatVariableFormProps) => {
const { t } = useTranslation();
const { commonVariableList, externalVariableList, internalVariableList } = useMemo(
() => getChatVariableGroups({ variables, chatType }),
[chatType, variables]
);
const editableVariables = [...externalVariableList, ...commonVariableList];
const visibleVariables = [...internalVariableList, ...editableVariables];
const isUnChange = chatType === ChatTypeEnum.log;
const canStartChat = editableVariables.length > 0 && !chatStarted;
if (visibleVariables.length === 0) return null;
const renderFormCard = () => (
<ChatVariableCard>
{internalVariableList.length > 0 && (
<ChatVariableTip>{t('chat:internal_variables_tip')}</ChatVariableTip>
)}
{externalVariableList.length > 0 &&
chatType !== ChatTypeEnum.chat &&
chatType !== ChatTypeEnum.home && (
<ChatVariableTip>{t('chat:variable_invisable_in_share')}</ChatVariableTip>
)}
<ChatVariableFields
variables={visibleVariables}
variablesForm={variablesForm}
isUnChange={isUnChange}
/>
{canStartChat && (
<ChatVariableStartButton
fileUploading={fileUploading}
onClick={variablesForm.handleSubmit(() => {
chatForm.setValue('chatStarted', true);
})}
/>
)}
</ChatVariableCard>
);
return showAvatar ? (
<Box py={showAvatar ? 3 : 0}>
<Box className="chat-box-card" w={'100%'} maxW={['calc(100% - 25px)', '700px']} mx={'auto'}>
<Box textAlign={'left'}>{renderFormCard()}</Box>
</Box>
</Box>
) : (
<Box textAlign={'left'}>{renderFormCard()}</Box>
);
};
export default React.memo(ChatVariableForm);
import React from 'react';
import { Box } from '@chakra-ui/react';
import { Drawer } from 'vaul';
type FeedbackDrawerProps = {
children: React.ReactNode;
onClose: () => void;
};
/**
* 移动端点踩反馈底部抽屉。
*
* 只服务 ChatBox 的反馈场景:使用 vaul 提供原生拖拽关闭和收起动画,
* 避免把反馈弹窗的交互需求扩散到通用 PhoneDrawer。
*/
const FeedbackDrawer = ({ children, onClose }: FeedbackDrawerProps) => {
return (
<Drawer.Root open onOpenChange={(open) => !open && onClose()} direction="bottom" handleOnly>
<Drawer.Portal>
<Drawer.Overlay
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.16)',
zIndex: 1400
}}
/>
<Drawer.Content
style={{
position: 'fixed',
right: 0,
bottom: 0,
left: 0,
zIndex: 1401,
outline: 'none'
}}
>
<Box bg="white" borderTopRadius="16px" px="16px" pb="50px">
<Box py="16px">
<Drawer.Handle style={{ backgroundColor: 'var(--chakra-colors-myGray-400)' }} />
</Box>
{children}
</Box>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
);
};
export default React.memo(FeedbackDrawer);
import React, { useRef } from 'react';
import { ModalBody, Textarea, ModalFooter, Button } from '@chakra-ui/react';
import { Box, Button, Flex, Text, Textarea } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { useTranslation } from 'next-i18next';
import { updateChatUserFeedback } from '@/web/core/chat/feedback/api';
import { useContextSelector } from 'use-context-selector';
import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext';
import FeedbackDrawer from './FeedbackDrawer';
type FeedbackContentProps = {
textareaRef: React.RefObject<HTMLTextAreaElement>;
isLoading: boolean;
title: string;
placeholder: string;
submitText: string;
closeText: string;
titleFontSize: string;
titleLineHeight: string;
gap: string;
textareaHeight: string;
titleProps?: React.ComponentProps<typeof Text>;
showCloseButton?: boolean;
submitFullWidth?: boolean;
onClose: () => void;
onSubmit: () => void;
};
/**
* 点踩反馈弹窗内容。桌面 Modal 和移动端 Drawer 共用提交控件,
* 只通过布局参数区分不同端的标题、间距和 textarea 尺寸。
*/
const FeedbackContent = ({
textareaRef,
isLoading,
title,
placeholder,
submitText,
closeText,
titleFontSize,
titleLineHeight,
gap,
textareaHeight,
titleProps,
showCloseButton = true,
submitFullWidth = false,
onClose,
onSubmit
}: FeedbackContentProps) => {
return (
<Box display={'flex'} flexDirection={'column'} gap={gap}>
<Text
fontSize={titleFontSize}
fontWeight={500}
lineHeight={titleLineHeight}
color={'myGray.900'}
{...titleProps}
>
{title}
</Text>
<Textarea
ref={textareaRef}
h={textareaHeight}
minH={textareaHeight}
py={'8px'}
px={'12px'}
resize={'none'}
placeholder={placeholder}
/>
<Flex justifyContent={'flex-end'} gap={2}>
{showCloseButton && (
<Button variant={'whiteBase'} onClick={onClose}>
{closeText}
</Button>
)}
<Button w={submitFullWidth ? '100%' : 'auto'} isLoading={isLoading} onClick={onSubmit}>
{submitText}
</Button>
</Flex>
</Box>
);
};
const FeedbackModal = ({
appId,
......@@ -22,6 +97,7 @@ const FeedbackModal = ({
}) => {
const ref = useRef<HTMLTextAreaElement>(null);
const { t } = useTranslation();
const { isPc } = useSystem();
const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData);
const { runAsync, loading: isLoading } = useRequest(
......@@ -44,24 +120,49 @@ const FeedbackModal = ({
}
);
const contentText = {
title: t('common:core.chat.Feedback Modal'),
placeholder: t('common:core.chat.Feedback Modal Tip'),
submitText: t('common:core.chat.Feedback Submit'),
closeText: t('common:Close')
};
if (!isPc) {
return (
<FeedbackDrawer onClose={onClose}>
<FeedbackContent
textareaRef={ref}
isLoading={isLoading}
{...contentText}
titleFontSize={'16px'}
titleLineHeight={'20px'}
gap={'12px'}
textareaHeight={'110px'}
titleProps={{ textAlign: 'center', h: '24px', lineHeight: '24px' }}
showCloseButton={false}
submitFullWidth
onClose={onClose}
onSubmit={runAsync}
/>
</FeedbackDrawer>
);
}
return (
<MyModal
isOpen={true}
onClose={onClose}
iconSrc="/imgs/modal/badAnswer.svg"
title={t('common:core.chat.Feedback Modal')}
>
<ModalBody>
<Textarea ref={ref} rows={10} placeholder={t('common:core.chat.Feedback Modal Tip')} />
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} mr={2} onClick={onClose}>
{t('common:Close')}
</Button>
<Button isLoading={isLoading} onClick={runAsync}>
{t('common:core.chat.Feedback Submit')}
</Button>
</ModalFooter>
<MyModal isOpen onClose={onClose} w={'560px'} maxW={'560px'} minW={'560px'} minH={'214px'}>
<Box p={'32px'} display={'flex'} flexDirection={'column'} gap={'24px'}>
<FeedbackContent
textareaRef={ref}
isLoading={isLoading}
{...contentText}
titleFontSize={'20px'}
titleLineHeight={'25px'}
gap={'24px'}
textareaHeight={'110px'}
onClose={onClose}
onSubmit={runAsync}
/>
</Box>
</MyModal>
);
};
......
......@@ -8,15 +8,24 @@ import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { useWidthVariable } from '@fastgpt/web/hooks/useWidthVariable';
const FilesBlock = ({ files }: { files: UserInputFileItemType[] }) => {
const FilesBlock = ({
files,
singleColumn = false,
imageVariant = 'default'
}: {
files: UserInputFileItemType[];
singleColumn?: boolean;
imageVariant?: 'default' | 'chatBubble';
}) => {
const chartRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(400);
const { isPc } = useSystem();
const gridColumns = useWidthVariable({
const responsiveGridColumns = useWidthVariable({
width,
widthList: [300, 500, 700],
list: ['1fr', 'repeat(2, 1fr)', 'repeat(3, 1fr)']
});
const gridColumns = singleColumn ? '1fr' : responsiveGridColumns;
// sort files, document/audio/video->image
const sortFiles = useMemo(() => {
......@@ -26,6 +35,7 @@ const FilesBlock = ({ files }: { files: UserInputFileItemType[] }) => {
}, [files]);
const computedChatItemWidth = useCallback(() => {
if (singleColumn) return;
if (!chartRef.current) return;
// 一直找到 parent = markdown 的元素
......@@ -37,17 +47,41 @@ const FilesBlock = ({ files }: { files: UserInputFileItemType[] }) => {
const clientWidth = parent?.clientWidth ?? 400;
setWidth(clientWidth);
return parent;
}, [isPc]);
}, [isPc, singleColumn]);
useLayoutEffect(() => {
computedChatItemWidth();
}, [computedChatItemWidth]);
return (
<Grid ref={chartRef} gridTemplateColumns={gridColumns} gap={4} alignItems={'flex-start'}>
<Grid
ref={chartRef}
gridTemplateColumns={gridColumns}
gap={2}
alignItems={'flex-start'}
justifyItems={singleColumn ? 'start' : undefined}
w={singleColumn ? 'fit-content' : undefined}
maxW={'100%'}
>
{sortFiles.map(({ id, type, name, url, icon }, i) => (
<Box key={i} bg={'white'} borderRadius={'md'} overflow="hidden">
{type === 'image' && <MdImage src={url} minH={'100px'} my={0} />}
<Box
key={i}
bg={'white'}
borderRadius={imageVariant === 'chatBubble' ? 0 : 'md'}
overflow="hidden"
w={singleColumn ? 'fit-content' : undefined}
maxW={'100%'}
>
{type === 'image' && (
<MdImage
src={url}
minW={imageVariant === 'chatBubble' ? 'auto' : undefined}
minH={imageVariant === 'chatBubble' ? 'auto' : '100px'}
borderRadius={imageVariant === 'chatBubble' ? 0 : undefined}
maxW={'100%'}
my={0}
/>
)}
{type !== 'image' && (
<Flex
p={2}
......
import { Box, Flex } from '@chakra-ui/react';
import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import { formatTimeToChatItemTime } from '@fastgpt/global/common/string/time';
type HumanChatBubbleActionsProps = {
chatText: string;
chatTime?: Date;
onEdit: () => void;
isAlwaysVisible?: boolean;
};
const HumanChatBubbleActions = ({
chatText,
chatTime,
onEdit,
isAlwaysVisible = false
}: HumanChatBubbleActionsProps) => {
const { t } = useTranslation();
const { copyData } = useCopyData();
return (
<Flex
className="chat-controller-hover"
display={isAlwaysVisible ? 'flex' : 'none'}
position={'absolute'}
right={0}
top={'100%'}
alignItems={'center'}
gap={3}
pt={'10px'}
color={'myGray.400'}
fontSize={'12px'}
fontWeight={500}
lineHeight={'24px'}
whiteSpace={'nowrap'}
zIndex={1}
>
{chatTime && (
<Box>
{t(formatTimeToChatItemTime(chatTime) as any, {
time: dayjs(chatTime).format('HH:mm')
}).replace('#', ':')}
</Box>
)}
<Flex alignItems={'center'} gap={1}>
<MyIcon
w={'16px'}
p={'4px'}
cursor="pointer"
name={'copy'}
color={'myGray.400'}
_hover={{ color: 'primary.600' }}
onClick={() => copyData(chatText)}
/>
<MyIcon
w={'16px'}
p={'4px'}
cursor="pointer"
name={'edit'}
color={'myGray.400'}
_hover={{ color: 'primary.600' }}
onClick={onEdit}
/>
</Flex>
</Flex>
);
};
export default HumanChatBubbleActions;
import { Box, Flex } from '@chakra-ui/react';
import React from 'react';
import { isEqual } from 'lodash';
import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import { formatChatValue2InputType } from '../../utils/chatValue';
import FilesBlock from '../FilesBox';
type HumanChatBubbleContentProps = {
chatValue: UserChatItemValueItemType[];
};
const HumanChatBubbleContent = ({ chatValue }: HumanChatBubbleContentProps) => {
const { text, files = [] } = formatChatValue2InputType(chatValue);
return (
<Flex
flexDirection={'column'}
gap={2}
alignItems={'flex-start'}
w={'fit-content'}
maxW={'100%'}
>
{text && (
<Box
fontSize={'inherit'}
color={'inherit'}
whiteSpace={'pre-wrap'}
wordBreak={'break-word'}
maxW={'100%'}
>
{text}
</Box>
)}
{files.length > 0 && <FilesBlock files={files} singleColumn imageVariant={'chatBubble'} />}
</Flex>
);
};
export default React.memo(HumanChatBubbleContent, (prevProps, nextProps) =>
isEqual(prevProps.chatValue, nextProps.chatValue)
);
import { Box, Flex } from '@chakra-ui/react';
import React from 'react';
import { useTranslation } from 'next-i18next';
import { Drawer } from 'vaul';
import HumanChatBubbleEditForm from './EditForm';
import type { ChatBoxInputType } from '../../type';
type HumanChatBubbleEditDrawerProps = {
isOpen: boolean;
defaultValue: string;
defaultFiles?: ChatBoxInputType['files'];
onClose: () => void;
onSubmit?: (input: ChatBoxInputType) => void | Promise<void>;
};
/**
* 移动端用户消息编辑抽屉。
*
* 抽屉只负责移动端布局、标题和关闭入口,编辑表单本身继续复用桌面 inline 编辑态组件,
* 保证更新按钮禁用、文本收集和后续提交逻辑只有一份。
*/
const HumanChatBubbleEditDrawer = ({
isOpen,
defaultValue,
defaultFiles,
onClose,
onSubmit
}: HumanChatBubbleEditDrawerProps) => {
const { t } = useTranslation();
return (
<Drawer.Root open={isOpen} onOpenChange={(open) => !open && onClose()} direction="bottom">
<Drawer.Portal>
<Drawer.Overlay
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.18)',
zIndex: 1400
}}
/>
<Drawer.Content
style={{
position: 'fixed',
right: 0,
bottom: 0,
left: 0,
zIndex: 1401,
outline: 'none'
}}
>
<Box
bg={'white'}
borderTopRadius={'24px'}
px={'16px'}
pb={'calc(16px + env(safe-area-inset-bottom))'}
>
<Flex justifyContent={'center'} py={'16px'}>
<Drawer.Handle style={{ backgroundColor: 'var(--chakra-colors-myGray-400)' }} />
</Flex>
<Flex alignItems={'center'} justifyContent={'center'} mb={5}>
<Box fontSize={'16px'} lineHeight={'24px'} fontWeight={600} color={'myGray.900'}>
{t('chat:edit_message')}
</Box>
</Flex>
<HumanChatBubbleEditForm
defaultValue={defaultValue}
defaultFiles={defaultFiles}
onCancel={onClose}
onSubmit={(input) => {
onSubmit?.(input);
onClose();
}}
showCancel={false}
/>
</Box>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
);
};
export default React.memo(HumanChatBubbleEditDrawer);
import { Box, Button, Flex, Textarea } from '@chakra-ui/react';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useFieldArray, useForm } from 'react-hook-form';
import { useContextSelector } from 'use-context-selector';
import FilePreview from '../../../components/FilePreview';
import { WorkflowRuntimeContext } from '../../../context/workflowRuntimeContext';
import { ChatBoxContext } from '../../Provider';
import type { ChatBoxInputFormType, ChatBoxInputType, UserInputFileItemType } from '../../type';
import { useFileUpload } from '../../hooks/useFileUpload';
import VoiceInput, { type VoiceInputComponentRef } from '../../Input/VoiceInput';
type HumanChatBubbleEditFormProps = {
defaultValue: string;
defaultFiles?: UserInputFileItemType[];
onCancel: () => void;
onSubmit?: (input: ChatBoxInputType) => void | Promise<void>;
showCancel?: boolean;
};
/**
* 渲染用户消息的编辑态 UI。
*
* 当前组件只负责编辑态展示和本地文本收集;真正的“删除后续记录并重新发送”会在上层
* record action 接入后通过 `onSubmit` 注入,避免 UI 组件直接依赖聊天生成流程。
*/
const HumanChatBubbleEditForm = ({
defaultValue,
defaultFiles = [],
onCancel,
onSubmit,
showCancel = true
}: HumanChatBubbleEditFormProps) => {
const { t } = useTranslation();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const VoiceInputRef = useRef<VoiceInputComponentRef>(null);
const [value, setValue] = useState(defaultValue);
const trimmedValue = value.trim();
const [mobilePreSpeak, setMobilePreSpeak] = useState(false);
const fileSelectConfig = useContextSelector(ChatBoxContext, (v) => v.fileSelectConfig);
const whisperConfig = useContextSelector(ChatBoxContext, (v) => v.whisperConfig);
const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData);
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const editForm = useForm<ChatBoxInputFormType>({
defaultValues: {
input: '',
files: defaultFiles,
chatStarted: false,
variables: {}
}
});
const fileCtrl = useFieldArray({
control: editForm.control,
name: 'files'
});
const {
File,
onOpenSelectFile,
fileList,
onSelectFile,
uploadFiles,
showSelectFile,
showSelectImg,
showSelectVideo,
showSelectAudio,
showSelectCustomFileExtension,
removeFiles,
replaceFiles,
hasFileUploading
} = useFileUpload({
fileSelectConfig,
fileCtrl,
outLinkAuthData,
appId,
chatId
});
const canUploadFile =
showSelectFile ||
showSelectImg ||
showSelectVideo ||
showSelectAudio ||
showSelectCustomFileExtension;
const defaultFileUrls = new Set(defaultFiles.map((file) => file.url || file.id));
const filesChanged =
fileList.length !== defaultFiles.length ||
fileList.some((file) => !defaultFileUrls.has(file.url || file.id));
const canSubmit =
!hasFileUploading &&
trimmedValue.length > 0 &&
(trimmedValue !== defaultValue.trim() || filesChanged);
useRequest(uploadFiles, {
manual: false,
errorToast: t('common:upload_file_error'),
refreshDeps: [fileList, outLinkAuthData, chatId]
});
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.focus();
textarea.selectionStart = textarea.value.length;
textarea.selectionEnd = textarea.value.length;
}, []);
const renderVoiceInput = () => (
<VoiceInput
ref={VoiceInputRef}
handleSend={(text) => {
onSubmit?.({
text: text.trim(),
files: fileList
});
replaceFiles([]);
}}
resetInputVal={(text) => {
setMobilePreSpeak(false);
setValue(text);
}}
mobilePreSpeak={mobilePreSpeak}
setMobilePreSpeak={setMobilePreSpeak}
/>
);
return (
<Box w={'100%'} maxW={'100%'} pt={mobilePreSpeak ? '48px' : 0}>
<Box
position={'relative'}
h={mobilePreSpeak ? '48px' : 'auto'}
borderRadius={'12px'}
border={mobilePreSpeak ? 'none' : '2px solid'}
borderColor={mobilePreSpeak ? 'transparent' : 'primary.600'}
boxShadow={mobilePreSpeak ? 'none' : '0 0 0 3px rgba(51, 112, 255, 0.16)'}
bg={mobilePreSpeak ? 'transparent' : 'white'}
overflow={'hidden'}
>
{!mobilePreSpeak && (
<Box px={'12px'}>
<FilePreview fileList={fileList} removeFiles={removeFiles} />
</Box>
)}
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
h={'116px'}
rows={4}
resize={'none'}
px={'12px'}
pt={'10px'}
pb={'10px'}
border={'none'}
bg={'white'}
color={'myGray.900'}
fontSize={['md', '20px']}
lineHeight={'24px'}
fontWeight={400}
_focusVisible={{
border: 'none',
boxShadow: 'none'
}}
/>
{renderVoiceInput()}
</Box>
{!mobilePreSpeak && (
<Flex
pt={'8px'}
alignItems={'center'}
justifyContent={showCancel ? 'flex-end' : 'space-between'}
gap={'8px'}
>
<Flex alignItems={'center'} gap={'8px'} color={'myGray.500'}>
<Flex
alignItems={'center'}
justifyContent={'center'}
w={'36px'}
h={'36px'}
p={'8px'}
borderRadius={'sm'}
cursor={canUploadFile ? 'pointer' : 'not-allowed'}
opacity={canUploadFile ? 1 : 0.4}
_hover={canUploadFile ? { bg: 'rgba(0, 0, 0, 0.04)' } : undefined}
onClick={(e) => {
e.stopPropagation();
if (canUploadFile) {
onOpenSelectFile();
}
}}
>
<MyTooltip label={t('chat:select_file')}>
<MyIcon name={'core/chat/fileSelect'} w={'20px'} h={'20px'} color={'#707070'} />
</MyTooltip>
</Flex>
<File onSelect={(files) => onSelectFile({ files })} />
<Flex
alignItems={'center'}
justifyContent={'center'}
w={'36px'}
h={'36px'}
p={'8px'}
borderRadius={'sm'}
cursor={whisperConfig?.open ? 'pointer' : 'not-allowed'}
opacity={whisperConfig?.open ? 1 : 0.4}
_hover={whisperConfig?.open ? { bg: 'rgba(0, 0, 0, 0.04)' } : undefined}
onClick={(e) => {
e.stopPropagation();
VoiceInputRef.current?.onSpeak?.();
}}
>
<MyTooltip label={t('common:core.chat.Record')}>
<MyIcon name={'core/chat/recordFill'} w={'20px'} h={'20px'} color={'#707070'} />
</MyTooltip>
</Flex>
</Flex>
{showCancel && <Box h={'22px'} w={'1px'} bg={'myGray.200'} />}
{showCancel && (
<Button
variant={'unstyled'}
w={'69px'}
h={'36px'}
color={'primary.600'}
fontSize={'14px'}
fontWeight={500}
onClick={onCancel}
>
{t('common:Cancel')}
</Button>
)}
<Button
w={'69px'}
h={'36px'}
borderRadius={'8px'}
variant={'primary'}
fontSize={'14px'}
isDisabled={!canSubmit}
onClick={() => {
if (!canSubmit) return;
onSubmit?.({
text: trimmedValue,
files: fileList
});
}}
>
{t('common:Update')}
</Button>
</Flex>
)}
</Box>
);
};
export default React.memo(HumanChatBubbleEditForm);
import { Box, Card } from '@chakra-ui/react';
import React, { useMemo, useState } from 'react';
import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { MessageCardStyle } from '../../constants';
import { formatChatValue2InputType } from '../../utils/chatValue';
import HumanChatBubbleActions from './Actions';
import HumanChatBubbleContent from './Content';
import HumanChatBubbleEditDrawer from './EditDrawer';
import HumanChatBubbleEditForm from './EditForm';
import type { ChatBoxInputType } from '../../type';
type HumanChatBubbleProps = {
chatValue: UserChatItemValueItemType[];
chatTime?: Date;
onEditSubmit?: (input: ChatBoxInputType) => void | Promise<void>;
children?: React.ReactNode;
};
/**
* 渲染用户消息气泡。
*
* 用户气泡的宽度、附件单列展示和 hover 操作区与 AI 气泡完全隔离,避免影响流式
* AI 回复的原有宽度计算。编辑按钮当前只提供 UI 入口,后续需要接入消息编辑接口。
*/
const HumanChatBubble = ({ chatValue, chatTime, onEditSubmit, children }: HumanChatBubbleProps) => {
const { isPc } = useSystem();
const { text: chatText = '', files = [] } = useMemo(
() => formatChatValue2InputType(chatValue),
[chatValue]
);
const [isEditing, setIsEditing] = useState(false);
if (isPc && isEditing) {
return (
<Box w={'100%'} maxW={'100%'} textAlign={'left'}>
<HumanChatBubbleEditForm
defaultValue={chatText}
defaultFiles={files}
onCancel={() => setIsEditing(false)}
onSubmit={(input) => {
onEditSubmit?.(input);
setIsEditing(false);
}}
/>
</Box>
);
}
return (
<Box
display={'inline-block'}
position={'relative'}
w={'fit-content'}
maxW={'100%'}
_hover={{
'& .chat-controller-hover': {
display: 'flex'
}
}}
>
<Card
{...MessageCardStyle}
bg={'primary.50'}
color={'myGray.900'}
px={4}
pt={3}
pb={MessageCardStyle.py}
borderRadius={'12px'}
textAlign={'left'}
maxW={'100%'}
>
<HumanChatBubbleContent chatValue={chatValue} />
{children}
</Card>
<HumanChatBubbleActions
chatText={chatText}
chatTime={chatTime}
isAlwaysVisible={!isPc}
onEdit={() => setIsEditing(true)}
/>
{!isPc && isEditing && (
<HumanChatBubbleEditDrawer
isOpen={isEditing}
defaultValue={chatText}
defaultFiles={files}
onClose={() => setIsEditing(false)}
onSubmit={onEditSubmit}
/>
)}
</Box>
);
};
export default React.memo(HumanChatBubble);
import { Box } from '@chakra-ui/react';
import React from 'react';
import { useTranslation } from 'next-i18next';
import { formatTimeToChatItemTime } from '@fastgpt/global/common/string/time';
import dayjs from 'dayjs';
const TimeBox = ({ time }: { time: Date }) => {
const { t } = useTranslation();
return (
<Box w={'100%'} fontSize={'mini'} textAlign={'center'} color={'myGray.500'} fontWeight={'400'}>
{t(formatTimeToChatItemTime(time) as any, {
time: dayjs(time).format('HH#mm')
}).replace('#', ':')}
</Box>
);
};
export default TimeBox;
import React, { useMemo } from 'react';
import React from 'react';
import { type UseFormReturn } from 'react-hook-form';
import { useTranslation } from 'next-i18next';
import { Box, Button, Card, Flex } from '@chakra-ui/react';
import ChatAvatar from './ChatAvatar';
import { ChatTypeEnum } from '../constants';
import { MessageCardStyle } from '../constants';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { type ChatBoxInputFormType } from '../type';
import { useContextSelector } from 'use-context-selector';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import LabelAndFormRender from '@/components/core/app/formRender/LabelAndForm';
import { variableInputTypeToInputType } from '@/components/core/app/formRender/utils';
import type { VariableItemType } from '@fastgpt/global/core/app/type';
import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext';
import ChatVariableForm from './ChatVariableForm';
const VariableInputForm = ({
chatForm,
......@@ -24,9 +16,6 @@ const VariableInputForm = ({
chatStarted: boolean;
chatType: ChatTypeEnum;
}) => {
const { t } = useTranslation();
const appAvatar = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app?.avatar);
const variablesForm = useContextSelector(ChatItemContext, (v) => v.variablesForm);
const variables = useContextSelector(
ChatItemContext,
......@@ -34,191 +23,17 @@ const VariableInputForm = ({
);
const fileUploading = useContextSelector(WorkflowRuntimeContext, (v) => v.fileUploading);
const showExternalVariables = [ChatTypeEnum.log, ChatTypeEnum.test, ChatTypeEnum.chat].includes(
chatType
return (
<ChatVariableForm
variables={variables}
variablesForm={variablesForm}
chatForm={chatForm}
chatType={chatType}
showAvatar
chatStarted={chatStarted}
fileUploading={fileUploading}
/>
);
const showInternalVariables = [ChatTypeEnum.log, ChatTypeEnum.test].includes(chatType);
const { commonVariableList, externalVariableList, internalVariableList } = useMemo(() => {
const {
commonVariableList,
externalVariableList,
internalVariableList
}: {
commonVariableList: VariableItemType[];
externalVariableList: VariableItemType[];
internalVariableList: VariableItemType[];
} = {
commonVariableList: [],
externalVariableList: [],
internalVariableList: []
};
variables.forEach((item) => {
if (item.type === VariableInputEnum.custom) {
externalVariableList.push(item);
} else if (item.type === VariableInputEnum.internal) {
internalVariableList.push(item);
} else {
commonVariableList.push(item);
}
});
return {
externalVariableList: showExternalVariables ? externalVariableList : [],
internalVariableList: showInternalVariables ? internalVariableList : [],
commonVariableList
};
}, [showExternalVariables, showInternalVariables, variables]);
const hasVariables =
commonVariableList.length > 0 ||
internalVariableList.length > 0 ||
externalVariableList.length > 0;
const isUnChange = chatType === ChatTypeEnum.log;
return hasVariables ? (
<Box py={3}>
<ChatAvatar src={appAvatar} type={'AI'} />
{internalVariableList.length > 0 && (
<Box textAlign={'left'}>
<Card
order={2}
mt={2}
w={'400px'}
{...MessageCardStyle}
bg={'white'}
boxShadow={'0 0 8px rgba(0,0,0,0.15)'}
>
<Flex
color={'primary.600'}
bg={'primary.100'}
mb={3}
px={3}
py={1.5}
gap={1}
fontSize={'mini'}
rounded={'sm'}
>
<MyIcon name={'common/info'} color={'primary.600'} w={4} />
{t('chat:internal_variables_tip')}
</Flex>
{internalVariableList.map((item) => {
return (
<LabelAndFormRender
{...item}
isUnChange={isUnChange}
key={item.key}
description={item.description}
inputType={variableInputTypeToInputType(item.type, item.valueType)}
form={variablesForm}
fieldName={`variables.${item.key}`}
bg={'myGray.50'}
/>
);
})}
</Card>
</Box>
)}
{externalVariableList.length > 0 && (
<Box textAlign={'left'}>
<Card
order={2}
mt={2}
w={'400px'}
{...MessageCardStyle}
bg={'white'}
boxShadow={'0 0 8px rgba(0,0,0,0.15)'}
>
{chatType !== ChatTypeEnum.chat && (
<Flex
color={'primary.600'}
bg={'primary.100'}
mb={3}
px={3}
py={1.5}
gap={1}
fontSize={'mini'}
rounded={'sm'}
>
<MyIcon name={'common/info'} color={'primary.600'} w={4} />
{t('chat:variable_invisable_in_share')}
</Flex>
)}
{externalVariableList.map((item) => {
return (
<LabelAndFormRender
{...item}
isUnChange={isUnChange}
key={item.key}
description={item.description}
inputType={variableInputTypeToInputType(item.type, item.valueType)}
form={variablesForm}
fieldName={`variables.${item.key}`}
bg={'myGray.50'}
/>
);
})}
{!chatStarted && commonVariableList.length === 0 && (
<Button
leftIcon={<MyIcon name={'core/chat/chatFill'} w={'16px'} />}
size={'sm'}
maxW={'100px'}
mt={4}
isDisabled={fileUploading}
onClick={variablesForm.handleSubmit(() => {
chatForm.setValue('chatStarted', true);
})}
>
{t('chat:start_chat')}
</Button>
)}
</Card>
</Box>
)}
{commonVariableList.length > 0 && (
<Box textAlign={'left'}>
<Card
order={2}
mt={2}
w={'400px'}
{...MessageCardStyle}
bg={'white'}
boxShadow={'0 0 8px rgba(0,0,0,0.15)'}
>
{commonVariableList.map((item) => {
return (
<LabelAndFormRender
{...item}
isUnChange={isUnChange}
key={item.key}
description={item.description}
inputType={variableInputTypeToInputType(item.type, item.valueType)}
bg={'myGray.50'}
form={variablesForm}
fieldName={`variables.${item.key}`}
/>
);
})}
{!chatStarted && (
<Button
leftIcon={<MyIcon name={'core/chat/chatFill'} w={'16px'} />}
size={'sm'}
maxW={'100px'}
mt={4}
isDisabled={fileUploading}
onClick={variablesForm.handleSubmit(() => {
chatForm.setValue('chatStarted', true);
})}
>
{t('chat:start_chat')}
</Button>
)}
</Card>
</Box>
)}
</Box>
) : null;
};
export default VariableInputForm;
import React, { useState, useEffect } from 'react';
import LabelAndFormRender from '@/components/core/app/formRender/LabelAndForm';
import { ChatBoxContext } from '@/components/core/chat/ChatContainer/ChatBox/Provider';
import { Box, Flex, Card, Button } from '@chakra-ui/react';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import { useContextSelector } from 'use-context-selector';
import { useTranslation } from 'react-i18next';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { variableInputTypeToInputType } from '@/components/core/app/formRender/utils';
import MyIcon from '@fastgpt/web/components/common/Icon';
import type { UseFormReturn } from 'react-hook-form';
import type { ChatBoxInputFormType } from '@/components/core/chat/ChatContainer/ChatBox/type';
import { WorkflowRuntimeContext } from '../../../context/workflowRuntimeContext';
import ChatVariableForm from '../ChatVariableForm';
import { ChatTypeEnum } from '../../constants';
type Props = {
chatForm: UseFormReturn<ChatBoxInputFormType>;
};
const ChatHomeVariablesForm = ({ chatForm }: Props) => {
const { t } = useTranslation();
const variablesForm = useContextSelector(ChatItemContext, (v) => v.variablesForm);
const variableList = useContextSelector(ChatBoxContext, (v) => v.variableList);
const externalVariableList = variableList.filter(
(item) => item.type === VariableInputEnum.custom
);
const commonVariableList = variableList.filter(
(item) => item.type !== VariableInputEnum.custom && item.type !== VariableInputEnum.internal
);
const fileUploading = useContextSelector(WorkflowRuntimeContext, (v) => v.fileUploading);
return (
<Card
bg={'white'}
border={'sm'}
borderColor={'myGray.200'}
boxShadow={'0 0 8px rgba(0,0,0,0.05)'}
>
<Box p={3}>
{/* custom variables */}
{externalVariableList.length > 0 && (
<>
{externalVariableList.map((item) => (
<LabelAndFormRender
{...item}
key={item.key}
fieldName={`variables.${item.key}`}
description={item.description}
inputType={variableInputTypeToInputType(item.type, item.valueType)}
form={variablesForm}
bg={'myGray.50'}
/>
))}
</>
)}
{/* normal variables */}
{commonVariableList.length > 0 && (
<>
{commonVariableList.map((item) => (
<LabelAndFormRender
{...item}
key={item.key}
fieldName={`variables.${item.key}`}
description={item.description}
inputType={variableInputTypeToInputType(item.type, item.valueType)}
form={variablesForm}
bg={'myGray.50'}
/>
))}
</>
)}
<Button
leftIcon={<MyIcon name={'core/chat/sendLight'} w={'1rem'} />}
w={'100%'}
mt={6}
variant={'primaryOutline'}
isDisabled={fileUploading}
onClick={variablesForm.handleSubmit(() => {
chatForm.setValue('chatStarted', true);
})}
>
{t('chat:start_chat')}
</Button>
</Box>
</Card>
<ChatVariableForm
variables={variableList}
variablesForm={variablesForm}
chatForm={chatForm}
chatType={ChatTypeEnum.home}
fileUploading={fileUploading}
/>
);
};
......
......@@ -3,21 +3,32 @@ import { DEFAULT_LOGO_BANNER_URL } from '@/pageComponents/chat/constants';
import { Box, Flex, Image } from '@chakra-ui/react';
import { useContextSelector } from 'use-context-selector';
const WelcomeHomeBox = () => {
const DesktopHomeHero = () => {
const wideLogo = useContextSelector(ChatBoxContext, (v) => v.wideLogo);
const slogan = useContextSelector(ChatBoxContext, (v) => v.slogan);
const displaySlogan = slogan || '今天想做点什么?';
return (
<Flex flexDir="column" justifyContent="flex-end" alignItems="center" gap={4}>
<Image
alt="fastgpt logo"
maxW={['200px', '300px']}
w="224px"
maxW="100%"
src={wideLogo || DEFAULT_LOGO_BANNER_URL}
fallbackSrc={DEFAULT_LOGO_BANNER_URL}
/>
<Box color="myGray.500">{slogan}</Box>
<Box
color="#111824"
fontFamily='"PingFang SC"'
fontSize="32px"
fontStyle="normal"
fontWeight={400}
lineHeight="40px"
>
{displaySlogan}
</Box>
</Flex>
);
};
export default WelcomeHomeBox;
export default DesktopHomeHero;
import type { ReactNode } from 'react';
import { Box, Flex } from '@chakra-ui/react';
import DesktopHomeHero from './DesktopHomeHero';
import QuickApps from './QuickApps';
type DesktopHomeLayoutProps = {
inputSlot: ReactNode;
};
const DesktopHomeLayout = ({ inputSlot }: DesktopHomeLayoutProps) => {
return (
<Flex h="100%" flexDir="column" justifyContent="center" w="100%">
<DesktopHomeHero />
<Box mt={5} w="100%">
<QuickApps />
</Box>
{inputSlot}
</Flex>
);
};
export default DesktopHomeLayout;
import { DEFAULT_LOGO_BANNER_COLLAPSED_URL } from '@/pageComponents/chat/constants';
import { Box, Flex, Image } from '@chakra-ui/react';
import { ChatBoxContext } from '../../Provider';
import { useContextSelector } from 'use-context-selector';
const MobileHomeHero = () => {
const squareLogo = useContextSelector(ChatBoxContext, (v) => v.squareLogo);
const slogan = useContextSelector(ChatBoxContext, (v) => v.slogan);
const displaySlogan = slogan || '今天想做点什么?';
return (
<Flex flexDir="column" alignItems="flex-start" gap={4} pt="107px">
<Image
alt="fastgpt logo"
w="48px"
maxW="100%"
src={squareLogo || DEFAULT_LOGO_BANNER_COLLAPSED_URL}
fallbackSrc={DEFAULT_LOGO_BANNER_COLLAPSED_URL}
/>
<Box
color="#111824"
fontFamily='"PingFang SC"'
fontSize="32px"
fontStyle="normal"
fontWeight={400}
lineHeight="40px"
>
{displaySlogan}
</Box>
</Flex>
);
};
export default MobileHomeHero;
import type { ReactNode } from 'react';
import { Box, Flex } from '@chakra-ui/react';
import MobileHomeHero from './MobileHomeHero';
import QuickApps from './QuickApps';
type MobileHomeLayoutProps = {
inputSlot: ReactNode;
};
const MobileHomeLayout = ({ inputSlot }: MobileHomeLayoutProps) => {
return (
<>
<Box flex="1 0 0" h={0}>
<Flex flex="1 0 0" h={0} flexDir="column" pb="24px" w="100%">
<MobileHomeHero />
</Flex>
</Box>
<Box mb="24px" w="100%">
<QuickApps variant="mobile" />
</Box>
<Box flexShrink={0}>{inputSlot}</Box>
</>
);
};
export default MobileHomeLayout;
......@@ -4,39 +4,54 @@ import { ChatBoxContext } from '../../Provider';
import { Box, Flex } from '@chakra-ui/react';
import Avatar from '@fastgpt/web/components/common/Avatar';
const QuickApps = () => {
type QuickAppsProps = {
variant?: 'desktop' | 'mobile';
};
const QuickApps = ({ variant = 'desktop' }: QuickAppsProps) => {
const quickAppList = useContextSelector(ChatBoxContext, (v) => v.quickAppList);
const currentQuickAppId = useContextSelector(ChatBoxContext, (v) => v.currentQuickAppId);
const onSwitchQuickApp = useContextSelector(ChatBoxContext, (v) => v.onSwitchQuickApp);
const isMobile = variant === 'mobile';
return quickAppList && quickAppList.length > 0 ? (
<Flex mb="2" alignItems="center" gap={2} flexWrap="wrap">
<Flex
mb={isMobile ? 0 : '2'}
mx={isMobile ? 0 : 2}
alignItems={isMobile ? 'flex-start' : 'center'}
gap={isMobile ? '12px' : 2}
flexWrap={isMobile ? 'nowrap' : 'wrap'}
flexDir={isMobile ? 'column' : 'row'}
>
{quickAppList.map((q) => (
<Flex
key={q._id}
alignItems="center"
gap={1}
gap={isMobile ? '8px' : 1}
h={isMobile ? '44px' : undefined}
border="sm"
borderRadius="md"
px={2}
py={1}
px={isMobile ? '16px' : 2}
py={isMobile ? 0 : 1}
maxW={isMobile ? '100%' : undefined}
cursor="pointer"
_hover={{ bg: 'myGray.50' }}
{...(currentQuickAppId === q._id
? {
bg: 'primary.50',
color: 'primary.600',
borderColor: 'primary.200'
}
: {
bg: 'white',
color: 'myGray.600',
borderColor: 'myGray.200'
})}
bg="white"
color="myGray.600"
borderColor="myGray.200"
onClick={() => onSwitchQuickApp?.(q._id)}
>
<Avatar src={q.avatar} w={4} borderRadius="xs" />
<Box fontSize="xs" fontWeight="500" userSelect="none">
<Avatar src={q.avatar} w={isMobile ? '24px' : 4} borderRadius="xs" />
<Box
fontSize={isMobile ? '14px' : 'xs'}
fontWeight="500"
userSelect="none"
{...(isMobile
? {
className: 'textEllipsis',
minW: 0
}
: {})}
>
{q.name}
</Box>
</Flex>
......
......@@ -2,6 +2,23 @@ import { type BoxProps } from '@chakra-ui/react';
export const textareaMinH = '22px';
export const ChatInputDefaultHeight: BoxProps['h'] = '132px';
export const HomeChatMobileBottomGap = 0;
export const HomeChatContentWrapperStyle: BoxProps = {
px: ['16px', 4],
pb: [HomeChatMobileBottomGap, 0],
mx: 'auto',
w: '100%',
maxW: ['auto', 'min(820px, 100%)']
};
export const ChatInputWrapperStyle: BoxProps = {
...HomeChatContentWrapperStyle,
flexShrink: 0
};
export const MessageCardStyle: BoxProps = {
px: 4,
py: 3,
......
......@@ -34,7 +34,7 @@ type AdminMarkState = AdminMarkType & { dataId: string };
* 设计边界:
* - hook 负责动作和 modal 状态,`FeedbackModal`、`SelectMarkCollection` 的 JSX 仍留在
* `ChatBox/index.tsx`,避免本 PR 同时进入 UI 组件拆分。
* - 用户点赞/取消点赞、取消点踩、关闭 custom feedback 都沿用原来的乐观更新策略:
* - 用户点赞/取消点赞、点踩/取消点踩、关闭 custom feedback 都沿用原来的乐观更新策略:
* 先更新本地 `chatRecords`,API 异常保持静默,不在本次拆分里新增 toast 或回滚。
* - admin mark 的弹窗流程需要跨三步选择 dataset、collection、输入数据,因此 hook 只保存
* 当前 modal 数据和 success 回写逻辑,不改 `SelectMarkCollection` 内部交互。
......@@ -88,16 +88,11 @@ export const useChatFeedbackActions = ({
/**
* 生成用户点赞回调。
*
* 点赞只在 user feedback 模式、AI 消息、且当前没有点踩内容时可用。再次点击已点赞消息会取消
* `userGoodFeedback`,本地和服务端都写入 undefined,保留原有“点击切换”语义。
* 点赞只在 user feedback 模式和 AI 消息下可用。点赞与点踩互斥:点亮赞时清空踩,
* 再次点击已点赞消息会取消 `userGoodFeedback`,保留原有“点击切换”语义。
*/
const onAddUserLike = useMemoizedFn((chat: ChatSiteItemType) => {
if (
feedbackType !== FeedbackTypeEnum.user ||
chat.obj !== ChatRoleEnum.AI ||
chat.userBadFeedback
)
return;
if (feedbackType !== FeedbackTypeEnum.user || chat.obj !== ChatRoleEnum.AI) return;
return () => {
if (!chat.dataId || !chatId || !appId) return;
......@@ -108,7 +103,8 @@ export const useChatFeedbackActions = ({
chatItem.dataId === chat.dataId
? {
...chatItem,
userGoodFeedback: isGoodFeedback ? undefined : 'yes'
userGoodFeedback: isGoodFeedback ? undefined : 'yes',
userBadFeedback: undefined
}
: chatItem
)
......@@ -130,16 +126,11 @@ export const useChatFeedbackActions = ({
* 生成用户点踩回调。
*
* 没有点踩内容时返回打开 `FeedbackModal` 的回调,由弹窗收集具体原因;已有点踩内容时返回
* 取消点踩回调,清空本地 `userBadFeedback` 并同步服务端。存在点赞时不允许点踩,保持互斥。
* 取消点踩回调,清空本地 `userBadFeedback` 并同步服务端。点赞与点踩互斥,
* 点踩提交成功后会清空本地 `userGoodFeedback`。
*/
const onAddUserDislike = useMemoizedFn((chat: ChatSiteItemType) => {
if (
feedbackType !== FeedbackTypeEnum.user ||
chat.obj !== ChatRoleEnum.AI ||
chat.userGoodFeedback
) {
return;
}
if (feedbackType !== FeedbackTypeEnum.user || chat.obj !== ChatRoleEnum.AI) return;
if (chat.userBadFeedback) {
return () => {
......@@ -241,7 +232,9 @@ export const useChatFeedbackActions = ({
const onFeedbackSuccess = useMemoizedFn((content: string) => {
setChatRecords((state) =>
state.map((item) =>
item.dataId === feedbackId ? { ...item, userBadFeedback: content } : item
item.dataId === feedbackId
? { ...item, userGoodFeedback: undefined, userBadFeedback: content }
: item
)
);
setFeedbackId(undefined);
......
......@@ -8,7 +8,7 @@ import { delChatRecordById } from '@/web/core/chat/record/api';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext';
import { formatChatValue2InputType } from '../utils/chatValue';
import type { SendPromptFnType } from '../type';
import type { ChatBoxInputType, SendPromptFnType } from '../type';
type UseChatRecordActionsProps = {
sendPrompt: SendPromptFnType;
......@@ -20,6 +20,7 @@ type UseChatRecordActionsProps = {
*
* 这里的“记录级动作”特指会直接改变 chatRecords 的删除、重试等操作:
* - `retryInput` 会删除目标 human 消息及其后续记录,再用旧输入重新发送。
* - `editInput` 会删除目标 human 消息及其后续记录,再用编辑后的文本重新发送。
* - `delOneMessage` 会删除一条 human 消息,并顺带删除紧随其后的 AI 回复。
* - `onDelMessage` 是 hook 内部的删除通道,优先走外部覆盖的 `onDeleteChatItem`,
* 否则使用默认 `delChatRecordById` API,并自动带上当前 app/chat/outLink 鉴权信息。
......@@ -108,6 +109,49 @@ export const useChatRecordActions = ({
});
/**
* 生成某条 human 消息的“编辑后发送”回调。
*
* 编辑和重试的核心流程一致:先删除目标 human 消息及后续记录,再用裁剪前的 history
* 重新发起一轮对话。text/files 使用编辑表单提交的新内容;删除旧记录时仍保留文件,
* 避免编辑后的消息继续引用原附件或新附件时被提前清理。
*/
const editInput = useMemoizedFn((dataId?: string) => {
if (!dataId) return;
return async (input: ChatBoxInputType) => {
setIsRecordActionLoading(true);
const index = chatRecords.findIndex((item) => item.dataId === dataId);
const delHistory = chatRecords.slice(index);
try {
if (index < 0 || delHistory[0]?.obj !== ChatRoleEnum.Human) return;
await Promise.all(
delHistory.map((item) => {
if (item.dataId) {
return onDelMessage(item.dataId, false);
}
})
);
setChatRecords((state) => (index === 0 ? [] : state.slice(0, index)));
sendPrompt({
...formatChatValue2InputType(delHistory[0].value),
...input,
history: chatRecords.slice(0, index)
});
} catch (error) {
toast({
status: 'warning',
title: getErrText(error, 'Edit failed')
});
} finally {
setIsRecordActionLoading(false);
}
};
});
/**
* 生成单条 human 消息的删除回调。
*
* ChatBox 的一轮普通对话通常是 human 后面紧跟 AI 回复,因此删除 human 时需要同步删除
......@@ -137,6 +181,7 @@ export const useChatRecordActions = ({
return {
isRecordActionLoading,
retryInput,
editInput,
delOneMessage
};
};
......@@ -24,7 +24,12 @@ import {
ChatStatusEnum
} from '@fastgpt/global/core/chat/constants';
import { getInteractiveByHistories } from './utils/interactive';
import { ChatTypeEnum, FeedbackTypeEnum } from './constants';
import {
ChatInputWrapperStyle,
ChatTypeEnum,
FeedbackTypeEnum,
HomeChatContentWrapperStyle
} from './constants';
import ChatProvider, { ChatBoxContext, type ChatProviderProps } from './Provider';
import { WorkflowRuntimeContext } from '../context/workflowRuntimeContext';
import dynamic from 'next/dynamic';
......@@ -52,10 +57,11 @@ import { useChatFeedbackActions } from './hooks/useChatFeedbackActions';
import ChatBoxModals from './components/ChatBoxModals';
import type { ChatRecordsListProps } from './components/ChatRecordsList';
import AppChatMain from './components/AppChatMain';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
const ChatHomeVariablesForm = dynamic(() => import('./components/home/ChatHomeVariablesForm'));
const WelcomeHomeBox = dynamic(() => import('./components/home/WelcomeHomeBox'));
const QuickApps = dynamic(() => import('./components/home/QuickApps'));
const DesktopHomeLayout = dynamic(() => import('./components/home/DesktopHomeLayout'));
const MobileHomeLayout = dynamic(() => import('./components/home/MobileHomeLayout'));
const WorkorderEntrance = dynamic(() => import('@/pageComponents/chat/WorkorderEntrance'));
type Props = OutLinkChatAuthProps &
......@@ -101,6 +107,7 @@ const ChatBox = ({
onMarkChatRead
}: Props) => {
const { t } = useTranslation();
const { isPc } = useSystem();
const TextareaDom = useRef<HTMLTextAreaElement>(null);
const chatController = useRef(new AbortController());
const questionGuideController = useRef(new AbortController());
......@@ -113,8 +120,6 @@ const ChatBox = ({
const [expandedDeletedGroups, setExpandedDeletedGroups] = useState<Set<string>>(new Set());
const { ScrollContainerRef, scrollToBottom, generatingScroll } = useChatScroll();
const appAvatar = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app?.avatar);
const userAvatar = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.userAvatar);
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const ChatBoxRef = useContextSelector(ChatItemContext, (v) => v.ChatBoxRef);
......@@ -297,7 +302,7 @@ const ChatBox = ({
syncSidebarChatGenerateStatus(nextStatus, { hasBeenRead: false });
});
const { isRecordActionLoading, retryInput, delOneMessage } = useChatRecordActions({
const { isRecordActionLoading, retryInput, editInput } = useChatRecordActions({
sendPrompt,
onDeleteChatItem
});
......@@ -487,15 +492,13 @@ const ChatBox = ({
records: processedRecords,
expandedDeletedGroups,
itemRefs,
userAvatar,
appAvatar,
showVoiceIcon,
showMarkIcon,
statusBoxData,
questionGuides,
onToggleDeletedGroup: toggleDeletedGroup,
onRetry: retryInput,
onDelete: delOneMessage,
onEdit: editInput,
onMark,
onAddUserLike,
onAddUserDislike,
......@@ -506,15 +509,13 @@ const ChatBox = ({
processedRecords,
expandedDeletedGroups,
itemRefs,
userAvatar,
appAvatar,
showVoiceIcon,
showMarkIcon,
statusBoxData,
questionGuides,
toggleDeletedGroup,
retryInput,
delOneMessage,
editInput,
onMark,
onAddUserLike,
onAddUserDislike,
......@@ -522,17 +523,26 @@ const ChatBox = ({
onToggleFeedbackReadStatus
]
);
const HomeChatRenderBox = useMemo(() => {
return (
<>
<WelcomeHomeBox />
<Box mt={5} w={'100%'}>
<QuickApps />
const HomeChatInput = (
<>
{variableList.filter((item) => item.type !== VariableInputEnum.internal).length > 0 ? (
<Box w={'100%'}>
<ChatHomeVariablesForm chatForm={chatForm} />
</Box>
</>
);
}, []);
) : (
<ChatInput
onSendMessage={sendPrompt}
onStop={() => abortRequest('stop')}
onStopChat={requestStopChat}
onStopSettled={handleStopSettled}
disableSend={isRoundPending}
TextareaDom={TextareaDom}
resetInputVal={resetInputVal}
chatForm={chatForm}
/>
)}
</>
);
return (
<MyBox
......@@ -547,32 +557,17 @@ const ChatBox = ({
{isHomeRender ? (
<MyBox
isLoading={isLoadingRecords}
display="flex"
flexDirection="column"
flex={'1 0 0'}
h={0}
px={[2, 4]}
w="100%"
maxW={['auto', 'min(820px, 100%)']}
mx={'auto'}
{...HomeChatContentWrapperStyle}
>
<Flex h={'100%'} flexDir={'column'} justifyContent={'center'} w={'100%'}>
{HomeChatRenderBox}
{variableList.filter((item) => item.type !== VariableInputEnum.internal).length > 0 ? (
<Box w={'100%'}>
<ChatHomeVariablesForm chatForm={chatForm} />
</Box>
) : (
<ChatInput
onSendMessage={sendPrompt}
onStop={() => abortRequest('stop')}
onStopChat={requestStopChat}
onStopSettled={handleStopSettled}
disableSend={isRoundPending}
TextareaDom={TextareaDom}
resetInputVal={resetInputVal}
chatForm={chatForm}
/>
)}
</Flex>
{isPc ? (
<DesktopHomeLayout inputSlot={HomeChatInput} />
) : (
<MobileHomeLayout inputSlot={HomeChatInput} />
)}
</MyBox>
) : (
<>
......@@ -586,12 +581,7 @@ const ChatBox = ({
recordsListProps={recordsListProps}
/>
{canRenderChatInput && (
<Box
px={[3, 5]}
m={['0 auto 10px', '10px auto']}
w={'100%'}
maxW={['auto', 'min(820px, 100%)']}
>
<Box {...ChatInputWrapperStyle}>
{showWorkorder && <WorkorderEntrance />}
<ChatInput
......
......@@ -4,6 +4,7 @@ import type {
} from '@fastgpt/global/core/chat/type';
import { type ChatBoxInputType, type UserInputFileItemType } from '../type';
import { getFileIcon } from '@fastgpt/global/common/file/icon';
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
export const formatChatValue2InputType = (value?: ChatItemValueItemType[]): ChatBoxInputType => {
if (!value) {
......@@ -27,7 +28,10 @@ export const formatChatValue2InputType = (value?: ChatItemValueItemType[]): Chat
id: item.file.url,
type: item.file.type,
name: item.file.name,
icon: getFileIcon(item.file.name),
icon:
item.file.type === ChatFileTypeEnum.image
? item.file.url
: getFileIcon(item.file.name),
url: item.file.url,
key: item.file.key
}
......
import React from 'react';
import { type FieldArrayWithId } from 'react-hook-form';
import { type ChatBoxInputFormType } from '../ChatBox/type';
import { Box, CircularProgress, Flex, HStack } from '@chakra-ui/react';
import { Box, CircularProgress, Flex, HStack, type FlexProps } from '@chakra-ui/react';
import MyBox from '@fastgpt/web/components/common/MyBox';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
......@@ -11,10 +11,12 @@ import { getFileIcon } from '@fastgpt/global/common/file/icon';
const RenderFilePreview = ({
fileList,
removeFiles
removeFiles,
pt = [2, 3]
}: {
fileList: FieldArrayWithId<ChatBoxInputFormType, 'files', 'id'>[];
removeFiles?: (index?: number | number[]) => void;
pt?: FlexProps['pt'];
}) => {
const { isPc } = useSystem();
......@@ -22,7 +24,7 @@ const RenderFilePreview = ({
<Flex
overflow={'visible'}
wrap={'wrap'}
pt={[2, 3]}
pt={pt}
userSelect={'none'}
mb={fileList.length > 0 ? 2 : 0}
gap={'6px'}
......
import { Card } from '@chakra-ui/react';
import React from 'react';
const InteractiveCard = ({ children }: { children: React.ReactNode }) => {
return (
<Card
w={['100%', '360px']}
maxW={'100%'}
minW={'250px'}
bg={'white'}
border={'1px solid'}
borderColor={'myGray.200'}
borderRadius={'8px'}
boxShadow={'0 4px 10px 0 rgba(19, 51, 107, 0.08), 0 0 1px 0 rgba(19, 51, 107, 0.08)'}
p={4}
>
{children}
</Card>
);
};
export default InteractiveCard;
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Flex,
HStack
} from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import React from 'react';
const RenderProcessingCollapse = React.memo(function RenderProcessingCollapse({
children,
isProcessing = true
}: {
children: React.ReactNode;
isProcessing?: boolean;
}) {
const { t } = useTranslation();
return (
<Accordion allowToggle>
<AccordionItem borderTop={'none'} borderBottom={'none'}>
<Box w={'full'} pb={'4px'} borderBottom={'1px solid'} borderBottomColor={'myGray.200'}>
<AccordionButton
w={'auto'}
display={'inline-flex'}
p={0}
bg={'transparent'}
border={'none'}
boxShadow={'none'}
color={'myGray.600'}
_hover={{ color: 'myGray.600', bg: 'transparent' }}
_expanded={{ color: 'myGray.600' }}
>
<HStack mr={1} spacing="0">
<Box fontSize={'16px'} lineHeight={'24px'}>
{t(isProcessing ? 'chat:processing' : 'chat:processed')}
</Box>
</HStack>
<AccordionIcon ml={1} color={'myGray.500'} />
</AccordionButton>
</Box>
<AccordionPanel py={0} pr={0} pl={0} mt={4}>
<Flex flexDirection={'column'} gap={4}>
{children}
</Flex>
</AccordionPanel>
</AccordionItem>
</Accordion>
);
});
export default RenderProcessingCollapse;
......@@ -6,12 +6,21 @@ import {
AccordionItem,
AccordionPanel,
Box,
Flex,
HStack
} from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSize } from 'ahooks';
import { useTranslation } from 'next-i18next';
import React from 'react';
import { accordionButtonStyle } from './constants';
import React, { useRef, useState } from 'react';
const reasoningTypography = {
fontSize: '14px',
fontStyle: 'normal',
fontWeight: 400,
lineHeight: '20px',
letterSpacing: '0.25px'
};
const RenderReasoningContent = React.memo(function RenderReasoningContent({
content,
......@@ -26,29 +35,87 @@ const RenderReasoningContent = React.memo(function RenderReasoningContent({
}) {
const { t } = useTranslation();
const showAnimation = isChatting && isLastResponseValue;
const [contentExpanded, setContentExpanded] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const contentSize = useSize(contentRef);
const contentOverflow = (contentSize?.height || 0) > 80;
return (
<Accordion allowToggle defaultIndex={isLastResponseValue ? 0 : undefined}>
<AccordionItem borderTop={'none'} borderBottom={'none'}>
<AccordionButton {...accordionButtonStyle} py={1}>
<HStack mr={2} spacing={1}>
<MyIcon name={'core/chat/think'} w={'0.85rem'} />
<Box fontSize={'sm'}>{t('chat:ai_reasoning')}</Box>
<AccordionButton
w={'auto'}
display={'inline-flex'}
p={0}
bg={'transparent'}
border={'none'}
boxShadow={'none'}
color={'myGray.600'}
_hover={{ color: 'myGray.600', bg: 'transparent' }}
_expanded={{ color: 'myGray.600' }}
>
<HStack mr={1} spacing="0">
<Flex w="24px" h="24px" alignItems="center" justifyContent="flex-start">
<MyIcon name={'core/chat/deepThinking'} fill={'myGray.500'} />
</Flex>
<Box fontSize={'16px'}>{t('chat:ai_reasoning')}</Box>
</HStack>
{showAnimation && <MyIcon name={'common/loading'} w={'0.85rem'} />}
<AccordionIcon color={'myGray.600'} ml={5} />
<AccordionIcon ml={1} color={'myGray.500'} />
</AccordionButton>
<AccordionPanel
py={0}
pr={0}
pl={3}
mt={2}
borderLeft={'2px solid'}
borderColor={'myGray.300'}
color={'myGray.500'}
>
<Markdown source={content} showAnimation={showAnimation} isDisabled={isDisabled} />
<AccordionPanel py={0} pr={0} pl={0} mt={2} color={'myGray.500'}>
<Box
position={'relative'}
maxH={contentExpanded ? 'none' : '80px'}
overflow={contentExpanded ? 'visible' : 'hidden'}
ml={1.5}
pl={3}
borderLeft={'1px solid'}
borderColor={'myGray.200'}
sx={{
'.markdown': {
...reasoningTypography,
wordBreak: 'normal',
overflowWrap: 'anywhere'
},
'.markdown *': {
letterSpacing: reasoningTypography.letterSpacing,
wordBreak: 'normal',
overflowWrap: 'anywhere'
}
}}
>
<Box ref={contentRef}>
<Markdown source={content} showAnimation={showAnimation} isDisabled={isDisabled} />
</Box>
{!contentExpanded && contentOverflow && (
<Box
position={'absolute'}
left={0}
right={0}
bottom={0}
h={'32px'}
bgGradient={'linear(to-b, rgba(255,255,255,0), rgba(255,255,255,1.0))'}
pointerEvents={'none'}
/>
)}
</Box>
{!contentExpanded && contentOverflow && (
<Box
as={'button'}
type="button"
mt={2}
ml={1.5}
px={'8px'}
color={'primary.600'}
fontSize={'14px'}
fontWeight={500}
cursor={'pointer'}
onClick={() => setContentExpanded(true)}
>
{t('chat:log.error.expand')}
</Box>
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
......
......@@ -8,6 +8,7 @@ import { removeDatasetCiteText } from '@fastgpt/global/core/ai/llm/utils';
import { useCreation } from 'ahooks';
import React, { useMemo } from 'react';
import { useContextSelector } from 'use-context-selector';
import styles from '../../ChatContainer/ChatBox/components/AIChatBubble/index.module.scss';
const RenderText = React.memo(function RenderText({
showAnimation,
......@@ -42,6 +43,7 @@ const RenderText = React.memo(function RenderText({
return (
<Markdown
className={styles.markdown}
source={source}
showAnimation={showAnimation}
chatAuthData={chatAuthData}
......
......@@ -5,7 +5,9 @@ import {
AccordionIcon,
AccordionItem,
AccordionPanel,
Box
Box,
Flex,
HStack
} from '@chakra-ui/react';
import type { ToolModuleResponseItemType } from '@fastgpt/global/core/chat/type';
import Avatar from '@fastgpt/web/components/common/Avatar';
......@@ -37,15 +39,29 @@ const RenderTool = React.memo(
return (
<Accordion allowToggle>
<AccordionItem borderTop={'none'} borderBottom={'none'}>
<AccordionButton {...accordionButtonStyle}>
<Avatar src={tool.toolAvatar} w={'1.25rem'} h={'1.25rem'} borderRadius={'sm'} />
<Box mx={2} fontSize={'sm'} color={'myGray.900'}>
{t(tool.toolName)}
</Box>
<AccordionButton
{...accordionButtonStyle}
p={0}
bg={'transparent'}
border={'none'}
borderRadius={0}
boxShadow={'none'}
color={'myGray.600'}
_hover={{ bg: 'transparent', color: 'myGray.600' }}
_expanded={{ color: 'myGray.600' }}
>
<HStack mr={1} spacing="0">
<Flex w="24px" h="24px" alignItems="center" justifyContent="flex-start">
<Avatar src={tool.toolAvatar} w="16px" h="16px" borderRadius="sm" />
</Flex>
<Box fontSize="16px" lineHeight="24px" color="myGray.600">
{t(tool.toolName)}
</Box>
</HStack>
{showAnimation && tool.response === undefined && (
<MyIcon name={'common/loading'} w={'14px'} />
<MyIcon name={'common/loading'} w={'14px'} color="myGray.500" />
)}
<AccordionIcon color={'myGray.600'} ml={5} />
<AccordionIcon ml={1} color={'myGray.500'} />
</AccordionButton>
<AccordionPanel
py={0}
......
......@@ -6,6 +6,7 @@ import { useTranslation } from 'next-i18next';
import React, { useCallback, useMemo } from 'react';
import { normalizeFormInputResultFile } from '../FormInputResult';
import { FormInputComponent } from '../Interactive/InteractiveComponents';
import InteractiveCard from './InteractiveCard';
import { onSendPrompt } from './utils';
/** 恢复/渲染表单时,把 fileSelect 字段值归一化为 `{ name, url }[]`,与流恢复逻辑保持一致。 */
......@@ -115,25 +116,27 @@ const RenderUserFormInteractive = React.memo(function RenderUserFormInteractive(
);
return (
<Flex flexDirection={'column'} gap={2} minW={'250px'}>
<FormInputComponent
interactiveParams={{
...interactive.params,
// 如果不是最后一条消息,此时不能再提交了。
submitted: interactive.params.submitted || !isLastChild
}}
defaultValues={defaultValues}
SubmitButton={({ onSubmit, isFileUploading }) => (
<Button
onClick={() => onSubmit(handleFormSubmit)()}
isDisabled={isFileUploading}
isLoading={isFileUploading}
>
{t('common:Submit')}
</Button>
)}
/>
</Flex>
<InteractiveCard>
<Flex flexDirection={'column'} gap={2}>
<FormInputComponent
interactiveParams={{
...interactive.params,
// 如果不是最后一条消息,此时不能再提交了。
submitted: interactive.params.submitted || !isLastChild
}}
defaultValues={defaultValues}
SubmitButton={({ onSubmit, isFileUploading }) => (
<Button
onClick={() => onSubmit(handleFormSubmit)()}
isDisabled={isFileUploading}
isLoading={isFileUploading}
>
{t('common:Submit')}
</Button>
)}
/>
</Flex>
</InteractiveCard>
);
});
......
import type { UserSelectInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import React from 'react';
import { SelectOptionsComponent } from '../Interactive/InteractiveComponents';
import InteractiveCard from './InteractiveCard';
import { onSendPrompt } from './utils';
const RenderUserSelectInteractive = React.memo(function RenderUserSelectInteractive({
......@@ -9,12 +10,14 @@ const RenderUserSelectInteractive = React.memo(function RenderUserSelectInteract
interactive: UserSelectInteractive;
}) {
return (
<SelectOptionsComponent
interactiveParams={interactive.params}
onSelect={(value) => {
onSendPrompt(value);
}}
/>
<InteractiveCard>
<SelectOptionsComponent
interactiveParams={interactive.params}
onSelect={(value) => {
onSendPrompt(value);
}}
/>
</InteractiveCard>
);
});
......
......@@ -14,6 +14,7 @@ import RenderAgentPlanAskInteractive from './RenderAgentPlanAskInteractive';
import RenderPaymentPauseInteractive from './RenderPaymentPauseInteractive';
import RenderPlan from './RenderPlan';
import RenderPlanStatus from './RenderPlanStatus';
import RenderProcessingCollapse from './RenderProcessingCollapse';
import RenderReasoningContent from './RenderReasoningContent';
import RenderSkill from './RenderSkill';
import RenderText from './RenderText';
......@@ -28,7 +29,11 @@ const AIResponseBox = ({
isLastResponseValue,
isLastChild,
isChatting,
onOpenCiteModal
onOpenCiteModal,
wrapProcessing = true,
showProcessing = true,
showAnswer = true,
showInteractive = true
}: {
chatItemDataId: string;
value: AIChatItemValueItemType;
......@@ -37,6 +42,10 @@ const AIResponseBox = ({
isLastChild: boolean;
isChatting: boolean;
onOpenCiteModal?: (e?: OnOpenCiteModalProps) => void;
wrapProcessing?: boolean;
showProcessing?: boolean;
showAnswer?: boolean;
showInteractive?: boolean;
}) => {
const showRunningStatus = useContextSelector(ChatItemContext, (v) => v.showRunningStatus);
const showSkillReferences = useContextSelector(ChatItemContext, (v) => v.showSkillReferences);
......@@ -49,9 +58,10 @@ const AIResponseBox = ({
if (value.hideInUI) return null;
const responseBlocks: React.ReactNode[] = [];
const processingBlocks: React.ReactNode[] = [];
if (reasoningContent && !value.hideReason) {
responseBlocks.push(
if (showProcessing && reasoningContent && !value.hideReason) {
processingBlocks.push(
<RenderReasoningContent
key="reasoning"
isChatting={isChatting}
......@@ -62,21 +72,8 @@ const AIResponseBox = ({
);
}
if (value.text && textContent) {
responseBlocks.push(
<RenderText
key="text"
chatItemDataId={chatItemDataId}
showAnimation={isChatting && isLastResponseValue}
text={textContent}
onOpenCiteModal={onOpenCiteModal}
isDisabled={disableStreamingInteraction}
/>
);
}
if (tools && showRunningStatus) {
responseBlocks.push(
if (showProcessing && tools && showRunningStatus) {
processingBlocks.push(
<Box key="tools">
{tools.map((tool) => (
<Box key={tool.id} _notLast={{ mb: 2 }}>
......@@ -87,8 +84,8 @@ const AIResponseBox = ({
);
}
if (skills && showSkillReferences && showRunningStatus) {
responseBlocks.push(
if (showProcessing && skills && showSkillReferences && showRunningStatus) {
processingBlocks.push(
<Box key="skills">
{skills.map((skill) => (
<Box key={skill.id} _notLast={{ mb: 2 }}>
......@@ -99,7 +96,43 @@ const AIResponseBox = ({
);
}
if ('interactive' in value && value.interactive) {
if (showProcessing && 'plan' in value && value.plan) {
processingBlocks.push(<RenderPlan key="plan" plan={value.plan} />);
}
if (showProcessing && 'planStatus' in value && value.planStatus?.status === 'generating') {
processingBlocks.push(<RenderPlanStatus key="planStatus" planStatus={value.planStatus} />);
}
if (processingBlocks.length > 0) {
if (wrapProcessing) {
responseBlocks.push(
<RenderProcessingCollapse
key="processing"
isProcessing={isChatting && isLastResponseValue && !textContent && !value.interactive}
>
{processingBlocks}
</RenderProcessingCollapse>
);
} else {
responseBlocks.push(...processingBlocks);
}
}
if (showAnswer && value.text && textContent) {
responseBlocks.push(
<RenderText
key="text"
chatItemDataId={chatItemDataId}
showAnimation={isChatting && isLastResponseValue}
text={textContent}
onOpenCiteModal={onOpenCiteModal}
isDisabled={disableStreamingInteraction}
/>
);
}
if (showInteractive && 'interactive' in value && value.interactive) {
const interactive = extractDeepestInteractive(value.interactive);
if (interactive.type === 'userSelect') {
responseBlocks.push(
......@@ -132,21 +165,13 @@ const AIResponseBox = ({
}
}
if ('plan' in value && value.plan) {
responseBlocks.push(<RenderPlan key="plan" plan={value.plan} />);
}
if ('planStatus' in value && value.planStatus?.status === 'generating') {
responseBlocks.push(<RenderPlanStatus key="planStatus" planStatus={value.planStatus} />);
}
if (responseBlocks.length === 1) {
return responseBlocks[0];
}
if (responseBlocks.length > 1) {
return (
<Flex flexDirection={'column'} gap={2}>
<Flex flexDirection={'column'} gap={4}>
{responseBlocks}
</Flex>
);
......
......@@ -37,8 +37,19 @@ const NormalSideTabItem = ({
width={'100%'}
cursor={'pointer'}
borderRadius={'6px'}
position={'relative'}
>
{children && (
<Flex
h={'24px'}
w={'20px'}
flexShrink={0}
alignItems={'center'}
justifyContent={'center'}
mr={1}
>
{children}
</Flex>
)}
<Avatar
src={
sideBarItem.moduleLogo ||
......@@ -70,16 +81,6 @@ const NormalSideTabItem = ({
{t(sideBarItem.runningTime as any) + 's'}
</Box>
</Box>
<Box
h={'24px'}
w={'24px'}
position={'absolute'}
right={'4px'}
top={'50%'}
transform={'translateY(-50%)'}
>
{children}
</Box>
</Flex>
);
};
......
......@@ -104,8 +104,8 @@ const DetailLogsModal = ({
w={'100%'}
maxW={datasetCiteData ? ['100%', '1080px'] : ['100%', '600px']}
bg={'white'}
boxShadow={'3px 0 20px rgba(0,0,0,0.2)'}
borderRadius={'md'}
boxShadow={'0px 8px 24px rgba(19, 51, 107, 0.12), 0px 0px 1px rgba(19, 51, 107, 0.08)'}
borderRadius={'8px'}
overflow={'hidden'}
transition={'.2s ease'}
>
......@@ -148,10 +148,10 @@ const DetailLogsModal = ({
) : (
<Flex
alignItems={'center'}
px={[3, 5]}
h={['46px', '60px']}
px={[4, 5]}
h={['48px', '56px']}
borderBottom={'base'}
borderBottomColor={'gray.200'}
borderBottomColor={'myGray.200'}
color={'myGray.900'}
>
{isPc ? (
......@@ -177,7 +177,11 @@ const DetailLogsModal = ({
<SandboxEntryIcon size={'smSquare'} mr={2} onOpen={onOpenSandboxModal} />
<IconButton
variant={'whiteBase'}
size={'smSquare'}
w={'32px'}
h={'32px'}
minW={'32px'}
p={0}
borderColor={'myGray.250'}
aria-label="Close"
icon={<MyIcon name={'common/closeLight'} w={'16px'} />}
onClick={onClose}
......@@ -186,7 +190,7 @@ const DetailLogsModal = ({
)}
{/* Chat container */}
<Flex pt={2} flex={'1 0 0'} h={0} flexDirection={'column'}>
<Flex flex={'1 0 0'} h={0} flexDirection={'column'}>
<Flex flex={'1 0 0'} h={0}>
<Box flex={'1 0 0'} h={'100%'} overflow={'auto'}>
{isPlugin ? (
......@@ -230,7 +234,7 @@ const DetailLogsModal = ({
</Flex>
{/* Feedback filter bar - commented out, moved to Render component */}
<Flex bg="white" px={6} py={3} borderTop="1px solid" borderColor="gray.200">
<Flex bg="white" px={6} py={3} borderTop="1px solid" borderColor="myGray.200">
<DetailLogsModalFeedbackTypeFilter
feedbackType={feedbackType}
setFeedbackType={setFeedbackType}
......
import React, { useMemo, useState } from 'react';
import { Box, Flex, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
import { useTranslation } from 'react-i18next';
import ChatFavouriteApp from '@/pageComponents/chat/ChatFavouriteApp';
import ChatTeamApp from '@/pageComponents/chat/ChatTeamApp';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useContextSelector } from 'use-context-selector';
import { ChatContext } from '@/web/core/chat/context/chatContext';
import ChatSliderMobileDrawer from '@/pageComponents/chat/slider/ChatSliderMobileDrawer';
import { ChatPageContext } from '@/web/core/chat/context/chatPageContext';
enum ChatAllAppTabEnum {
FAVOURITE_APPS = 'favouriteApps',
TEAM_APPS = 'teamApps'
}
/**
* 全部应用页只负责承载原有精选应用和团队应用列表,保持两个已有组件的筛选、
* 搜索、移动端侧栏和跳转逻辑不变,便于第一阶段审阅新入口的页面形态。
*/
const ChatAllApp = () => {
const { t } = useTranslation();
const { isPc } = useSystem();
const [tab, setTab] = useState(ChatAllAppTabEnum.FAVOURITE_APPS);
const [showMobileSearch, setShowMobileSearch] = useState(false);
const [mobileSearchKey, setMobileSearchKey] = useState('');
const onOpenSlider = useContextSelector(ChatContext, (v) => v.onOpenSlider);
const wideLogoUrl = useContextSelector(ChatPageContext, (v) => v.chatSettings?.wideLogoUrl);
const tabOptions = useMemo(
() => [
{
label: t('chat:sidebar.favourite_apps'),
value: ChatAllAppTabEnum.FAVOURITE_APPS
},
{
label: t('chat:sidebar.team_apps'),
value: ChatAllAppTabEnum.TEAM_APPS
}
],
[t]
);
if (!isPc) {
return (
<Flex flexDirection="column" h="100%" bg="white">
<Flex
h="48px"
px="16px"
py="6px"
alignItems="center"
justifyContent="space-between"
flexShrink={0}
>
<Flex
w="36px"
h="36px"
alignItems="center"
justifyContent="center"
color="myGray.600"
cursor="pointer"
onClick={onOpenSlider}
>
<MyIcon w="24px" color="currentColor" name="core/chat/sidebar/menu" />
</Flex>
<Box fontSize="16px" fontWeight={600} color="myGray.900">
{t('chat:all_apps.title')}
</Box>
<Flex
w="36px"
h="36px"
alignItems="center"
justifyContent="center"
color="myGray.600"
cursor="pointer"
onClick={() => setShowMobileSearch((state) => !state)}
>
<MyIcon w="24px" color="currentColor" name="common/searchLight" />
</Flex>
</Flex>
<ChatSliderMobileDrawer
showList={false}
showMenu={false}
banner={wideLogoUrl}
menuConfirmButtonText={t('common:core.chat.Confirm to clear history')}
/>
<Box px="16px" py="8px" flexShrink={0}>
<FillRowTabs<ChatAllAppTabEnum>
list={tabOptions}
value={tab}
onChange={setTab}
outerPadding="4px"
outerHeight="40px"
itemHeight="32px"
labelSize="16px"
w="100%"
/>
</Box>
{showMobileSearch && (
<Box px={5} pt={4} flexShrink={0}>
<InputGroup>
<Input
h="36px"
lineHeight="36px"
py={0}
pr="36px"
bg="white"
placeholder={t('app:search_app')}
maxLength={30}
value={mobileSearchKey}
onChange={(e) => setMobileSearchKey(e.target.value)}
/>
{mobileSearchKey && (
<InputRightElement h="36px" w="36px">
<Flex
w="24px"
h="24px"
p="4px"
borderRadius="full"
bg="myGray.100"
alignItems="center"
justifyContent="center"
cursor="pointer"
onClick={() => setMobileSearchKey('')}
>
<MyIcon name="common/closeLight" w="16px" color="myGray.500" />
</Flex>
</InputRightElement>
)}
</InputGroup>
</Box>
)}
<Box flex="1 0 0" h={0} overflow="hidden">
{tab === ChatAllAppTabEnum.FAVOURITE_APPS && (
<ChatFavouriteApp hideMobileHeader mobileSearchKey={mobileSearchKey} />
)}
{tab === ChatAllAppTabEnum.TEAM_APPS && (
<ChatTeamApp hideMobileHeader mobileSearchKey={mobileSearchKey} />
)}
</Box>
</Flex>
);
}
return (
<Flex flexDirection="column" h="100%">
<Box px={[4, 6]} pt={[4, 6]}>
<FillRowTabs<ChatAllAppTabEnum>
list={tabOptions}
value={tab}
onChange={setTab}
outerPadding="4px"
outerHeight="40px"
itemHeight="32px"
labelSize="16px"
/>
</Box>
<Box flex="1 0 0" h={0}>
{tab === ChatAllAppTabEnum.FAVOURITE_APPS && <ChatFavouriteApp />}
{tab === ChatAllAppTabEnum.TEAM_APPS && <ChatTeamApp />}
</Box>
</Flex>
);
};
export default ChatAllApp;
......@@ -4,10 +4,7 @@ import {
Button,
Flex,
Grid,
GridItem,
Input,
InputGroup,
InputLeftElement,
HStack,
Tab,
TabIndicator,
TabList,
......@@ -19,7 +16,7 @@ import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { useContextSelector } from 'use-context-selector';
import { ChatPageContext } from '@/web/core/chat/context/chatPageContext';
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { ChatSettingTabOptionEnum, ChatSidebarPaneEnum } from '@/pageComponents/chat/constants';
import MyPopover from '@fastgpt/web/components/common/MyPopover';
......@@ -32,8 +29,15 @@ import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
import { useUserStore } from '@/web/support/user/useUserStore';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
const ChatFavouriteApp = () => {
type Props = {
hideMobileHeader?: boolean;
mobileSearchKey?: string;
};
const ChatFavouriteApp = ({ hideMobileHeader = false, mobileSearchKey }: Props) => {
const { isPc } = useSystem();
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
......@@ -66,7 +70,7 @@ const ChatFavouriteApp = () => {
[tags, t]
);
const { register, watch, setValue } = useForm<{ name: string; tag: string }>({
const { watch, setValue } = useForm<{ name: string; tag: string }>({
defaultValues: {
name: '',
tag: ''
......@@ -75,6 +79,11 @@ const ChatFavouriteApp = () => {
const searchAppName = watch('name');
const selectedTag = watch('tag');
useEffect(() => {
if (mobileSearchKey === undefined) return;
setValue('name', mobileSearchKey);
}, [mobileSearchKey, setValue]);
// load all favourites for checked state and saving
const { loading: isSearching, data: favouriteApps = [] } = useRequest(
async () => {
......@@ -104,11 +113,16 @@ const ChatFavouriteApp = () => {
px="1.5"
py="0.5"
cursor="text"
flex="0 1 auto"
minW="40px"
maxW="32%"
overflow="hidden"
justifyContent="center"
onClick={(e) => e.stopPropagation()}
>
{tag.name}
<Box minW={0} className="textEllipsis">
{tag.name}
</Box>
</Flex>
);
};
......@@ -117,7 +131,7 @@ const ChatFavouriteApp = () => {
<MyBox isLoading={isSearching} display="flex" flexDirection={'column'} h={'100%'}>
<NextHead title={homeTabTitle} icon={getWebReqUrl(feConfigs?.favicon)} />
{!isPc && (
{!isPc && !hideMobileHeader && (
<Flex
py={4}
color="myGray.900"
......@@ -134,15 +148,14 @@ const ChatFavouriteApp = () => {
/>
<Box w="70%">
<InputGroup w="100%">
<InputLeftElement h="36px">
<MyIcon name="common/searchLight" w="16px" color="myGray.500" />
</InputLeftElement>
<Input
placeholder={t('chat:setting.favourite.search_placeholder')}
{...register('name')}
/>
</InputGroup>
<SearchInput
h="36px"
lineHeight="36px"
py={0}
onChange={(e) => setValue('name', e.target.value)}
placeholder={t('chat:setting.favourite.search_placeholder')}
maxLength={30}
/>
</Box>
<ChatSliderMobileDrawer
......@@ -157,14 +170,19 @@ const ChatFavouriteApp = () => {
{/* header */}
<Flex
w="full"
p={['0 16px 0 16px', '24px 24px 0 24px']}
px={isPc ? 6 : 4}
pt={isPc ? '20px' : 2}
pb={isPc ? 0 : 2}
gap={4}
alignItems="center"
justifyContent="space-between"
>
{/* tag tabs */}
<Tabs variant="unstyled">
<Tabs variant="unstyled" w={['100%', 'auto']}>
<TabList
gap={5}
p="4px"
h="40px"
overflowX="auto"
overflowY="hidden"
flexWrap="nowrap"
......@@ -178,6 +196,7 @@ const ChatFavouriteApp = () => {
{tagOptions.map((option) => (
<Tab
px={0}
h="32px"
flexShrink="0"
key={option.value}
value={option.value}
......@@ -188,59 +207,83 @@ const ChatFavouriteApp = () => {
{option.label}
</Tab>
))}
<TabIndicator mt="36px" height="2px" bg="primary.600" borderRadius="1px" />
<TabIndicator bottom="0" height="2px" bg="primary.600" borderRadius="1px" />
</TabList>
</Tabs>
{/* search input */}
{isPc && (
<InputGroup maxW="300px">
<InputLeftElement h="36px">
<MyIcon name="common/searchLight" w="16px" color="myGray.500" />
</InputLeftElement>
<Input
placeholder={t('chat:setting.favourite.search_placeholder')}
{...register('name')}
/>
</InputGroup>
<SearchInput
maxW={['auto', '250px']}
h="36px"
lineHeight="36px"
py={0}
onChange={(e) => setValue('name', e.target.value)}
placeholder={t('chat:setting.favourite.search_placeholder')}
maxLength={30}
/>
)}
</Flex>
{/* list */}
{favouriteApps.length > 0 ? (
<Grid templateColumns={['1fr', 'repeat(3, 1fr)']} gap={4} p={['4', '6']} overflowY="auto">
<Grid
templateColumns={['minmax(0,1fr)', 'repeat(2,minmax(0,1fr))', 'repeat(3,minmax(0,1fr))']}
gap={5}
px={[4, 6]}
pt="16px"
pb={['4', '6']}
overflowY="auto"
alignItems={'stretch'}
>
{favouriteApps.map((app) => (
<GridItem key={app.appId} cursor="pointer">
<Flex
<MyTooltip key={app.appId} h="100%" label={t('app:go_to_chat')}>
<MyBox
lineHeight={1.5}
h="100%"
pt={5}
pb={3}
px={5}
cursor={'pointer'}
border={'base'}
bg={'white'}
borderRadius={'lg'}
position={'relative'}
display={'flex'}
flexDirection={'column'}
justifyContent="flex-start"
gap={2}
p={4}
borderRadius={8}
border="sm"
borderColor="myGray.200"
boxShadow="sm"
bg="white"
h="160px"
transition="all 0.1s ease-in-out"
minW={0}
_hover={{
borderColor: 'primary.300'
}}
onClick={() => handlePaneChange(ChatSidebarPaneEnum.RECENTLY_USED_APPS, app.appId)}
>
<Flex fontSize="16px" fontWeight="500" alignItems="center" gap={2}>
<Avatar src={app.avatar} borderRadius={8} />
<Flex>{app.name}</Flex>
</Flex>
<HStack minW={0}>
<Avatar src={app.avatar} borderRadius={'sm'} w={'1.5rem'} />
<Box flex={'1 0 0'} color={'myGray.900'} className="textEllipsis">
{app.name}
</Box>
</HStack>
<Box fontSize="xs" color="myGray.500" minH="0" noOfLines={2} overflow="hidden">
{app.intro || t('common:no_intro')}
<Box
flex={['1 0 60px', '1 0 72px']}
mt={3}
pr={8}
textAlign={'justify'}
wordBreak={'break-all'}
fontSize={'xs'}
color={'myGray.500'}
>
<Box className={'textEllipsis2'} whiteSpace={'pre-wrap'}>
{app.intro || t('common:no_intro')}
</Box>
</Box>
<Flex gap="2" flexWrap="wrap" mt="auto">
{app.favouriteTags.slice(0, 3).map((id) => (
<TagBox key={id} id={id} />
))}
<HStack h={'24px'} fontSize={'mini'} color={'myGray.500'} w="full" minW={0}>
<Flex flex="1 1 auto" gap="2" minW={0}>
{app.favouriteTags.slice(0, 3).map((id) => (
<TagBox key={id} id={id} />
))}
</Flex>
{app.favouriteTags.length > 3 && (
<MyPopover
......@@ -254,6 +297,7 @@ const ChatFavouriteApp = () => {
bg="myGray.100"
px="1.5"
py="0.5"
flexShrink={0}
onClick={(e) => e.stopPropagation()}
>
+{app.favouriteTags.length - 3}
......@@ -275,9 +319,9 @@ const ChatFavouriteApp = () => {
)}
</MyPopover>
)}
</Flex>
</Flex>
</GridItem>
</HStack>
</MyBox>
</MyTooltip>
))}
</Grid>
) : (
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment