InputContainer.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import { apiInterceptors, clearChatHistory } from '@/client/api';
  2. import { ChatHistoryResponse } from '@/types/chat';
  3. import { getUserId } from '@/utils';
  4. import { HEADER_USER_ID_KEY } from '@/utils/constants/index';
  5. import { ClearOutlined, LoadingOutlined, PauseCircleOutlined, RedoOutlined, SendOutlined } from '@ant-design/icons';
  6. import { EventStreamContentType, fetchEventSource } from '@microsoft/fetch-event-source';
  7. import { useRequest } from 'ahooks';
  8. import { Button, Input, Popover, Spin, Tag } from 'antd';
  9. import classnames from 'classnames';
  10. import { useSearchParams } from 'next/navigation';
  11. import React, { useContext, useEffect, useMemo, useState } from 'react';
  12. import { MobileChatContext } from '../';
  13. import ModelSelector from './ModelSelector';
  14. import Resource from './Resource';
  15. import Thermometer from './Thermometer';
  16. const tagColors = ['magenta', 'orange', 'geekblue', 'purple', 'cyan', 'green'];
  17. const InputContainer: React.FC = () => {
  18. // 从url上获取基本参数
  19. const searchParams = useSearchParams();
  20. const ques = searchParams?.get('ques') ?? '';
  21. const {
  22. history,
  23. model,
  24. scene,
  25. temperature,
  26. resource,
  27. conv_uid,
  28. appInfo,
  29. scrollViewRef,
  30. order,
  31. userInput,
  32. ctrl,
  33. canAbort,
  34. canNewChat,
  35. setHistory,
  36. setCanNewChat,
  37. setCarAbort,
  38. setUserInput,
  39. } = useContext(MobileChatContext);
  40. // 输入框聚焦
  41. const [isFocus, setIsFocus] = useState<boolean>(false);
  42. // 是否中文输入
  43. const [isZhInput, setIsZhInput] = useState<boolean>(false);
  44. // 处理会话
  45. const handleChat = async (content?: string) => {
  46. setUserInput('');
  47. ctrl.current = new AbortController();
  48. const params = {
  49. chat_mode: scene,
  50. model_name: model,
  51. user_input: content || userInput,
  52. conv_uid,
  53. temperature,
  54. app_code: appInfo?.app_code,
  55. ...(resource && { select_param: JSON.stringify(resource) }),
  56. };
  57. if (history && history.length > 0) {
  58. const viewList = history?.filter(item => item.role === 'view');
  59. order.current = viewList[viewList.length - 1].order + 1;
  60. }
  61. const tempHistory: ChatHistoryResponse = [
  62. {
  63. role: 'human',
  64. context: content || userInput,
  65. model_name: model,
  66. order: order.current,
  67. time_stamp: 0,
  68. },
  69. {
  70. role: 'view',
  71. context: '',
  72. model_name: model,
  73. order: order.current,
  74. time_stamp: 0,
  75. thinking: true,
  76. },
  77. ];
  78. const index = tempHistory.length - 1;
  79. setHistory([...history, ...tempHistory]);
  80. setCanNewChat(false);
  81. try {
  82. await fetchEventSource(`${process.env.API_BASE_URL ?? ''}/api/v1/chat/completions`, {
  83. method: 'POST',
  84. headers: {
  85. 'Content-Type': 'application/json',
  86. [HEADER_USER_ID_KEY]: getUserId() ?? '',
  87. },
  88. signal: ctrl.current.signal,
  89. body: JSON.stringify(params),
  90. openWhenHidden: true,
  91. async onopen(response) {
  92. if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
  93. return;
  94. }
  95. },
  96. onclose() {
  97. ctrl.current?.abort();
  98. setCanNewChat(true);
  99. setCarAbort(false);
  100. },
  101. onerror(err) {
  102. throw new Error(err);
  103. },
  104. onmessage: event => {
  105. let message = event.data;
  106. try {
  107. message = JSON.parse(message).vis;
  108. } catch {
  109. message.replaceAll('\\n', '\n');
  110. }
  111. if (message === '[DONE]') {
  112. setCanNewChat(true);
  113. setCarAbort(false);
  114. } else if (message?.startsWith('[ERROR]')) {
  115. tempHistory[index].context = message?.replace('[ERROR]', '');
  116. tempHistory[index].thinking = false;
  117. setHistory([...history, ...tempHistory]);
  118. setCanNewChat(true);
  119. setCarAbort(false);
  120. } else {
  121. setCarAbort(true);
  122. tempHistory[index].context = message;
  123. tempHistory[index].thinking = false;
  124. setHistory([...history, ...tempHistory]);
  125. }
  126. },
  127. });
  128. } catch {
  129. ctrl.current?.abort();
  130. tempHistory[index].context = 'Sorry, we meet some error, please try again later.';
  131. tempHistory[index].thinking = false;
  132. setHistory([...tempHistory]);
  133. setCanNewChat(true);
  134. setCarAbort(false);
  135. }
  136. };
  137. // 会话提问
  138. const onSubmit = async () => {
  139. if (!userInput.trim() || !canNewChat) {
  140. return;
  141. }
  142. await handleChat();
  143. };
  144. useEffect(() => {
  145. scrollViewRef.current?.scrollTo({
  146. top: scrollViewRef.current?.scrollHeight,
  147. behavior: 'auto',
  148. });
  149. }, [history, scrollViewRef]);
  150. // 功能类型
  151. const paramType = useMemo(() => {
  152. if (!appInfo) {
  153. return [];
  154. }
  155. const { param_need = [] } = appInfo;
  156. return param_need?.map(item => item.type);
  157. }, [appInfo]);
  158. // 是否展示推荐问题
  159. const showRecommendQuestion = useMemo(() => {
  160. // 只在没有对话的时候展示
  161. return history.length === 0 && appInfo && !!appInfo?.recommend_questions?.length;
  162. }, [history, appInfo]);
  163. // 暂停回复
  164. const abort = () => {
  165. if (!canAbort) {
  166. return;
  167. }
  168. ctrl.current?.abort();
  169. setTimeout(() => {
  170. setCarAbort(false);
  171. setCanNewChat(true);
  172. }, 100);
  173. };
  174. // 再来一次
  175. const redo = () => {
  176. if (!canNewChat || history.length === 0) {
  177. return;
  178. }
  179. const lastHuman = history.filter(i => i.role === 'human')?.slice(-1)?.[0];
  180. handleChat(lastHuman?.context || '');
  181. };
  182. const { run: clearHistoryRun, loading } = useRequest(async () => await apiInterceptors(clearChatHistory(conv_uid)), {
  183. manual: true,
  184. onSuccess: () => {
  185. setHistory([]);
  186. },
  187. });
  188. // 清除历史会话
  189. const clearHistory = () => {
  190. if (!canNewChat) {
  191. return;
  192. }
  193. clearHistoryRun();
  194. };
  195. // 如果url携带ques问题,则直接提问
  196. useEffect(() => {
  197. if (ques && model && conv_uid && appInfo) {
  198. handleChat(ques);
  199. }
  200. // eslint-disable-next-line react-hooks/exhaustive-deps
  201. }, [appInfo, conv_uid, model, ques]);
  202. return (
  203. <div className='flex flex-col'>
  204. {/* 推荐问题 */}
  205. {showRecommendQuestion && (
  206. <ul>
  207. {appInfo?.recommend_questions?.map((item, index) => (
  208. <li key={item.id} className='mb-3'>
  209. <Tag
  210. color={tagColors[index]}
  211. className='p-2 rounded-xl'
  212. onClick={async () => {
  213. handleChat(item.question);
  214. }}
  215. >
  216. {item.question}
  217. </Tag>
  218. </li>
  219. ))}
  220. </ul>
  221. )}
  222. {/* 功能区域 */}
  223. <div className='flex items-center justify-between gap-1'>
  224. <div className='flex gap-2 mb-1 w-full overflow-x-auto'>
  225. {/* 模型选择 */}
  226. {paramType?.includes('model') && <ModelSelector />}
  227. {/* 额外资源 */}
  228. {paramType?.includes('resource') && <Resource />}
  229. {/* 温度调控 */}
  230. {paramType?.includes('temperature') && <Thermometer />}
  231. </div>
  232. <div className='flex items-center justify-between text-lg font-bold'>
  233. <Popover content='暂停回复' trigger={['hover']}>
  234. <PauseCircleOutlined
  235. className={classnames('p-2 cursor-pointer', {
  236. 'text-[#0c75fc]': canAbort,
  237. 'text-gray-400': !canAbort,
  238. })}
  239. onClick={abort}
  240. />
  241. </Popover>
  242. <Popover content='再来一次' trigger={['hover']}>
  243. <RedoOutlined
  244. className={classnames('p-2 cursor-pointer', {
  245. 'text-gray-400': !history.length || !canNewChat,
  246. })}
  247. onClick={redo}
  248. />
  249. </Popover>
  250. {loading ? (
  251. <Spin spinning={loading} indicator={<LoadingOutlined style={{ fontSize: 18 }} spin />} className='p-2' />
  252. ) : (
  253. <Popover content='清除历史' trigger={['hover']}>
  254. <ClearOutlined
  255. className={classnames('p-2 cursor-pointer', {
  256. 'text-gray-400': !history.length || !canNewChat,
  257. })}
  258. onClick={clearHistory}
  259. />
  260. </Popover>
  261. )}
  262. </div>
  263. </div>
  264. {/* 输入框 */}
  265. <div
  266. className={classnames(
  267. 'flex py-2 px-3 items-center justify-between bg-white dark:bg-[#242733] dark:border-[#6f7f95] rounded-xl border',
  268. {
  269. 'border-[#0c75fc] dark:border-[rgba(12,117,252,0.8)]': isFocus,
  270. },
  271. )}
  272. >
  273. <Input.TextArea
  274. placeholder='可以问我任何问题'
  275. className='w-full resize-none border-0 p-0 focus:shadow-none'
  276. value={userInput}
  277. autoSize={{ minRows: 1 }}
  278. onKeyDown={e => {
  279. if (e.key === 'Enter') {
  280. if (e.shiftKey) {
  281. return;
  282. }
  283. if (isZhInput) {
  284. e.preventDefault();
  285. return;
  286. }
  287. if (!userInput.trim()) {
  288. return;
  289. }
  290. e.preventDefault();
  291. onSubmit();
  292. }
  293. }}
  294. onChange={e => {
  295. setUserInput(e.target.value);
  296. }}
  297. onFocus={() => {
  298. setIsFocus(true);
  299. }}
  300. onBlur={() => setIsFocus(false)}
  301. onCompositionStartCapture={() => {
  302. setIsZhInput(true);
  303. }}
  304. onCompositionEndCapture={() => {
  305. setTimeout(() => {
  306. setIsZhInput(false);
  307. }, 0);
  308. }}
  309. />
  310. <Button
  311. type='primary'
  312. className={classnames('flex items-center justify-center rounded-lg bg-button-gradient border-0 ml-2', {
  313. 'opacity-40 cursor-not-allowed': !userInput.trim() || !canNewChat,
  314. })}
  315. onClick={onSubmit}
  316. >
  317. {canNewChat ? <SendOutlined /> : <Spin indicator={<LoadingOutlined className='text-white' />} />}
  318. </Button>
  319. </div>
  320. </div>
  321. );
  322. };
  323. export default InputContainer;