ChatInput.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import { FormIcon } from "@/components/bs-icons/form";
  2. import { SendIcon } from "@/components/bs-icons/send";
  3. import { Textarea } from "@/components/bs-ui/input";
  4. import { useToast } from "@/components/bs-ui/toast/use-toast";
  5. import { locationContext } from "@/contexts/locationContext";
  6. import { useContext, useEffect, useRef, useState } from "react";
  7. import { useTranslation } from "react-i18next";
  8. import { useMessageStore } from "./messageStore";
  9. import GuideQuestions from "./GuideQuestions";
  10. import { ClearIcon } from "@/components/bs-icons/clear";
  11. import duihua_send from "../../../assets/chat/duihua-send.png";
  12. import { Button } from "@/components/bs-ui/button";
  13. import { StopCircle } from "lucide-react";
  14. export default function ChatInput({ clear, form, questions, inputForm, wsUrl, onBeforSend }) {
  15. const { toast } = useToast()
  16. const { t } = useTranslation()
  17. const { appConfig } = useContext(locationContext)
  18. const [formShow, setFormShow] = useState(false)
  19. const [showWhenLocked, setShowWhenLocked] = useState(false) // 强制开启表单按钮,不限制于input锁定
  20. const [inputLock, setInputLock] = useState({ locked: false, reason: '' })
  21. const { messages, chatId, createSendMsg, createWsMsg, updateCurrentMessage, destory, setShowGuideQuestion } = useMessageStore()
  22. const currentChatIdRef = useRef(null)
  23. const inputRef = useRef(null)
  24. // 停止状态
  25. const [isStop, setIsStop] = useState(true)
  26. /**
  27. * 记录会话切换状态,等待消息加载完成时,控制表单在新会话自动展开
  28. */
  29. const changeChatedRef = useRef(false)
  30. useEffect(() => {
  31. // console.log('message msg', messages, form);
  32. if (changeChatedRef.current) {
  33. changeChatedRef.current = false
  34. // 新建的 form 技能,弹出窗口并锁定 input
  35. if (form && messages.length === 0) {
  36. setInputLock({ locked: true, reason: '' })
  37. setFormShow(true)
  38. setShowWhenLocked(true)
  39. }
  40. }
  41. }, [messages])
  42. useEffect(() => {
  43. if (!chatId) return
  44. setInputLock({ locked: false, reason: '' })
  45. // console.log('message chatid', messages, form, chatId);
  46. setShowWhenLocked(false)
  47. currentChatIdRef.current = chatId
  48. changeChatedRef.current = true
  49. setFormShow(false)
  50. createWebSocket(chatId).then(() => {
  51. // 切换会话默认发送一条空消息
  52. const [wsMsg] = onBeforSend('', '')
  53. sendWsMsg(wsMsg)
  54. })
  55. }, [chatId])
  56. // 销毁
  57. useEffect(() => {
  58. return () => {
  59. destory()
  60. if (wsRef.current) {
  61. wsRef.current.close()
  62. }
  63. }
  64. }, [])
  65. const handleSendClick = async () => {
  66. // 解除锁定状态下 form 按钮开放的状态
  67. setShowWhenLocked(false)
  68. // 关闭引导词
  69. setShowGuideQuestion(false)
  70. // 收起表单
  71. // formShow && setFormShow(false)
  72. setFormShow(false)
  73. const value = inputRef.current.value
  74. if (value.trim() === '') return
  75. const event = new Event('input', { bubbles: true, cancelable: true });
  76. inputRef.current.value = ''
  77. inputRef.current.dispatchEvent(event); // 触发调节input高度
  78. const [wsMsg, inputKey] = onBeforSend('', value)
  79. // msg to store
  80. createSendMsg(wsMsg.inputs, inputKey)
  81. // 锁定 input
  82. setInputLock({ locked: true, reason: '' })
  83. await createWebSocket(chatId)
  84. // console.log(wsMsg,inputKey);
  85. sendWsMsg(wsMsg)
  86. // 滚动聊天到底
  87. const messageDom = document.getElementById('message-panne')
  88. if (messageDom) {
  89. messageDom.scrollTop = messageDom.scrollHeight;
  90. }
  91. }
  92. const stop = async () => {
  93. const [wsMsg] = onBeforSend('', '')
  94. wsMsg.action = "stop"
  95. sendWsMsg(wsMsg)
  96. // console.log(wsMsg);
  97. // sendWsMsg(wsMsg)
  98. }
  99. const sendWsMsg = async (msg) => {
  100. try {
  101. wsRef.current.send(JSON.stringify(msg))
  102. } catch (error) {
  103. toast({
  104. title: 'There was an error sending the message',
  105. variant: 'error',
  106. description: error.message
  107. });
  108. }
  109. }
  110. const wsRef = useRef(null)
  111. const createWebSocket = (chatId) => {
  112. // 单例
  113. if (wsRef.current) return Promise.resolve('ok');
  114. const isSecureProtocol = window.location.protocol === "https:";
  115. const webSocketProtocol = isSecureProtocol ? "wss" : "ws";
  116. return new Promise((res, rej) => {
  117. try {
  118. const ws = new WebSocket(`${webSocketProtocol}://${wsUrl}&chat_id=${chatId}`)
  119. wsRef.current = ws
  120. // websocket linsen
  121. ws.onopen = () => {
  122. console.log("WebSocket connection established!");
  123. res('ok')
  124. };
  125. ws.onmessage = (event) => {
  126. const data = JSON.parse(event.data);
  127. const errorMsg = data.category === 'error' ? data.intermediate_steps : ''
  128. // 异常类型处理,提示
  129. if (errorMsg) return setInputLock({ locked: true, reason: errorMsg })
  130. // 拦截会话串台情况
  131. if (currentChatIdRef.current && currentChatIdRef.current !== data.chat_id) return
  132. handleWsMessage(data)
  133. // 群聊@自己时,开启input
  134. if (['end', 'end_cover'].includes(data.type) && data.receiver?.is_self) {
  135. setInputLock({ locked: true, reason: '' })
  136. }
  137. }
  138. ws.onclose = (event) => {
  139. wsRef.current = null
  140. console.error('链接手动断开 event :>> ', event);
  141. if ([1005, 1008].includes(event.code)) {
  142. console.warn('即将废弃 :>> ');
  143. setInputLock({ locked: true, reason: event.reason })
  144. } else {
  145. if (event.reason) {
  146. toast({
  147. title: t('prompt'),
  148. variant: 'error',
  149. description: event.reason
  150. });
  151. }
  152. setInputLock({ locked: false, reason: '' })
  153. }
  154. };
  155. ws.onerror = (ev) => {
  156. wsRef.current = null
  157. console.error('链接异常error', ev);
  158. setIsStop(true)
  159. toast({
  160. title: `${t('chat.networkError')}:`,
  161. variant: 'error',
  162. description: [
  163. t('chat.networkErrorList1'),
  164. t('chat.networkErrorList2'),
  165. t('chat.networkErrorList3')
  166. ]
  167. });
  168. // reConnect(params)
  169. };
  170. } catch (err) {
  171. console.error('创建链接异常', err);
  172. rej(err)
  173. }
  174. })
  175. }
  176. // 接受 ws 消息
  177. const handleWsMessage = (data) => {
  178. // console.log(data)
  179. if (Array.isArray(data) && data.length) return
  180. if (data.type === "begin") {
  181. setIsStop(false)
  182. }else if (data.type === 'start') {
  183. createWsMsg(data)
  184. } else if (data.type === 'stream') {
  185. updateCurrentMessage({
  186. chat_id: data.chat_id,
  187. message: data.message,
  188. thought: data.intermediate_steps
  189. })
  190. } else if (['end', 'end_cover'].includes(data.type)) {
  191. updateCurrentMessage({
  192. ...data,
  193. end: true,
  194. thought: data.intermediate_steps || '',
  195. messageId: data.message_id,
  196. noAccess: false,
  197. liked: 0
  198. }, data.type === 'end_cover')
  199. } else if (data.type === "close") {
  200. setIsStop(true)
  201. setInputLock({ locked: false, reason: '' })
  202. }
  203. }
  204. // 监听重发消息事件
  205. useEffect(() => {
  206. const handleCustomEvent = (e) => {
  207. if (!showWhenLocked && inputLock.locked) return console.error('弹窗已锁定,消息无法发送')
  208. const { send, message } = e.detail
  209. inputRef.current.value = message
  210. if (send) handleSendClick()
  211. }
  212. document.addEventListener('userResendMsgEvent', handleCustomEvent)
  213. return () => {
  214. document.removeEventListener('userResendMsgEvent', handleCustomEvent)
  215. }
  216. }, [inputLock.locked, showWhenLocked])
  217. // 点击引导词
  218. const handleClickGuideWord = (message) => {
  219. if (!showWhenLocked && inputLock.locked) return console.error('弹窗已锁定,消息无法发送')
  220. inputRef.current.value = message
  221. handleSendClick()
  222. }
  223. // auto input height
  224. const handleTextAreaHeight = (e) => {
  225. const textarea = e.target
  226. textarea.style.height = 'auto'
  227. textarea.style.height = textarea.scrollHeight + 'px'
  228. // setInputEmpty(textarea.value.trim() === '')
  229. }
  230. return <div className="absolute bottom-0 w-full bg-[#fff] dark:bg-[#000000]">
  231. <div className={`relative pt-[10px]`}>
  232. {/* form */}
  233. {
  234. formShow && <div className="relative">
  235. <div className="absolute left-0 bottom-2 bg-[#1a1a1a] px-4 py-2 rounded-md w-[50%] min-w-80">
  236. {inputForm}
  237. </div>
  238. </div>
  239. }
  240. {/* 引导问题 */}
  241. <GuideQuestions
  242. locked={inputLock.locked}
  243. chatId={chatId}
  244. questions={questions}
  245. onClick={handleClickGuideWord}
  246. />
  247. {/* clear */}
  248. {/* <div className="flex absolute left-0 top-4 z-10">
  249. {
  250. clear && <div
  251. className={`w-6 h-6 rounded-sm hover:bg-gray-200 cursor-pointer flex justify-center items-center `}
  252. onClick={() => { !inputLock.locked && destory() }}
  253. ><ClearIcon className={!showWhenLocked && inputLock.locked ? 'text-gray-400' : 'text-gray-950'} ></ClearIcon></div>
  254. }
  255. </div> */}
  256. {/* form */}
  257. <div className="flex absolute left-3 top-4 z-10">
  258. {
  259. form && <div
  260. className={`w-6 h-6 rounded-sm hover:bg-gray-200 cursor-pointer flex justify-center items-center `}
  261. onClick={() => (showWhenLocked || !inputLock.locked) && setFormShow(!formShow)}
  262. ><FormIcon className={!showWhenLocked && inputLock.locked ? 'text-gray-400' : 'text-gray-950'}></FormIcon></div>
  263. }
  264. </div>
  265. {/* send */}
  266. <div className="flex gap-2 absolute right-[2.5%] z-10">
  267. <div
  268. id="bs-send-btn"
  269. className="w-[68px] h-[40px] bg-[#FFD54C] cursor-pointer flex justify-center items-center"
  270. onClick={() => { !inputLock.locked && handleSendClick() }}
  271. style={{borderRadius:"20px"}}
  272. >
  273. {/* <SendIcon className={inputLock.locked ? 'text-gray-400' : 'text-gray-950'}></SendIcon> */}
  274. <img src={duihua_send} className="w-[20px]" alt="" />
  275. </div>
  276. </div>
  277. {/* question */}
  278. <textarea
  279. id="bs-send-input"
  280. ref={inputRef}
  281. rows={1}
  282. style={{ height: 34 }}
  283. disabled={inputLock.locked}
  284. onInput={handleTextAreaHeight}
  285. placeholder={inputLock.locked ? inputLock.reason : t('chat.inputPlaceholder')}
  286. // className={"resize-none py-4 pr-10 text-md min-h-6 max-h-[200px] scrollbar-hide dark:bg-[#2A2B2E] text-gray-800" + (form && ' pl-10')}
  287. className="questionTextarea w-full resize-none border-none bg-transparent outline-none max-h-[160px]"
  288. onKeyDown={(event) => {
  289. if (event.key === "Enter" && !event.shiftKey) {
  290. event.preventDefault();
  291. !inputLock.locked && handleSendClick()
  292. }
  293. }}
  294. ></textarea>
  295. {!isStop && <div className=" absolute w-full flex justify-center bottom-32 pointer-events-none">
  296. <Button className="rounded-full pointer-events-auto" variant="outline" disabled={isStop} onClick={() => { setIsStop(true); stop(); }}><StopCircle className="mr-2" />Stop</Button>
  297. </div>}
  298. {/* <p className="w-[100%] text-center text-[#666666]">内容由AI生成,仅供参考</p> */}
  299. </div>
  300. <p className="text-center text-sm pt-2 pb-4 text-gray-400">{appConfig.dialogTips}</p>
  301. </div>
  302. };