DeepSeekChat.tsx 59 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948
  1. import { useDebouncedCallback } from "use-debounce";
  2. import React, {
  3. useState,
  4. useRef,
  5. useEffect,
  6. useMemo,
  7. useCallback,
  8. Fragment,
  9. RefObject,
  10. } from "react";
  11. import LeftIcon from "../icons/left.svg";
  12. import SendWhiteIcon from "../icons/send-white.svg";
  13. import BrainIcon from "../icons/brain.svg";
  14. import CopyIcon from "../icons/copy.svg";
  15. import LoadingIcon from "../icons/three-dots.svg";
  16. import ResetIcon from "../icons/reload.svg";
  17. import DeleteIcon from "../icons/clear.svg";
  18. import ConfirmIcon from "../icons/confirm.svg";
  19. import CancelIcon from "../icons/cancel.svg";
  20. import SizeIcon from "../icons/size.svg";
  21. import avatar from "../icons/aiIcon.png";
  22. import sdsk from "../icons/sdsk.png";
  23. import sdsk_selected from "../icons/sdsk_selected.png";
  24. import hlw from "../icons/hlw.png";
  25. import hlw_selected from "../icons/hlw_selected.png";
  26. // Avatar组件替代实现
  27. import BotIcon from "../icons/bot.svg";
  28. import BlackBotIcon from "../icons/black-bot.svg";
  29. function Avatar(props: { model?: string; avatar?: string }) {
  30. if (props.model) {
  31. return (
  32. <div className="no-dark">
  33. {props.model?.startsWith("gpt-4") ? (
  34. <BlackBotIcon className="user-avatar" />
  35. ) : (
  36. <BotIcon className="user-avatar" />
  37. )}
  38. </div>
  39. );
  40. }
  41. return (
  42. <div className="user-avatar">
  43. {/* 移除emoji头像,使用默认bot图标 */}
  44. <BotIcon className="user-avatar" />
  45. </div>
  46. );
  47. }
  48. import { MaskAvatar } from "./mask";
  49. import {
  50. ChatMessage,
  51. SubmitKey,
  52. useChatStore,
  53. useAccessStore,
  54. Theme,
  55. useAppConfig,
  56. DEFAULT_TOPIC,
  57. ModelType,
  58. } from "../store";
  59. import {
  60. copyToClipboard,
  61. selectOrCopy,
  62. autoGrowTextArea,
  63. useMobileScreen,
  64. getMessageTextContent,
  65. getMessageImages,
  66. isVisionModel,
  67. isDalle3,
  68. } from "../utils";
  69. import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
  70. import dynamic from "next/dynamic";
  71. import { ChatControllerPool } from "../client/controller";
  72. import { DalleSize } from "../typing";
  73. import type { RequestMessage } from "../client/api";
  74. import { Prompt, usePromptStore } from "../store/prompt";
  75. import { useGlobalStore } from "../store";
  76. import Locale from "../locales";
  77. import { IconButton } from "./button";
  78. import styles from "./chat.module.scss";
  79. import {
  80. List,
  81. ListItem,
  82. Modal,
  83. Selector,
  84. showConfirm,
  85. showToast,
  86. } from "./ui-lib";
  87. import { useNavigate, useLocation } from "react-router-dom";
  88. import {
  89. CHAT_PAGE_SIZE,
  90. LAST_INPUT_KEY,
  91. Path,
  92. REQUEST_TIMEOUT_MS,
  93. UNFINISHED_INPUT,
  94. ServiceProvider,
  95. Plugin,
  96. } from "../constant";
  97. import { ContextPrompts, MaskConfig } from "./mask";
  98. import { useMaskStore } from "../store/mask";
  99. import { ChatCommandPrefix, useChatCommand, useCommand } from "../command";
  100. import { prettyObject } from "../utils/format";
  101. import { ExportMessageModal } from "./exporter";
  102. import { getClientConfig } from "../config/client";
  103. import { useAllModels } from "../utils/hooks";
  104. import { nanoid } from "nanoid";
  105. import { message, Upload, UploadProps, Tooltip, Drawer, Button } from "antd";
  106. import {
  107. PaperClipOutlined,
  108. SendOutlined,
  109. FileOutlined,
  110. FilePdfOutlined,
  111. FileTextOutlined,
  112. FileWordOutlined,
  113. RightOutlined
  114. } from '@ant-design/icons';
  115. export function createMessage(override: Partial<ChatMessage>): ChatMessage {
  116. return {
  117. id: nanoid(),
  118. date: new Date().toLocaleString(),
  119. role: "user",
  120. content: "",
  121. ...override,
  122. };
  123. }
  124. export const BOT_HELLO: ChatMessage = createMessage({
  125. role: "assistant",
  126. content: '你好,我是小智~\n' +
  127. '我可以帮助你快速查询作业指导书、规范条文、公司信息等内容,如需获取上述内容,请点击上方导航栏中的「专业知识」或「职能管理」,选择相应的智能体进行提问。无论是现场技术,还是制度流程,我都会尽力为你解答!\n' +
  128. '请注意:在这个对话框内,我只能请DeepSeek来帮忙回答常见通用问题哦!',
  129. });
  130. const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
  131. loading: () => <LoadingIcon />,
  132. });
  133. export function SessionConfigModel(props: { onClose: () => void }) {
  134. const chatStore = useChatStore();
  135. const session = chatStore.currentSession();
  136. const maskStore = useMaskStore();
  137. const navigate = useNavigate();
  138. return (
  139. <div className="modal-mask">
  140. <Modal
  141. title={Locale.Context.Edit}
  142. onClose={() => props.onClose()}
  143. actions={[
  144. <IconButton
  145. key="reset"
  146. icon={<ResetIcon />}
  147. bordered
  148. text={Locale.Chat.Config.Reset}
  149. onClick={async () => {
  150. if (await showConfirm(Locale.Memory.ResetConfirm)) {
  151. chatStore.updateCurrentSession(
  152. (session) => (session.memoryPrompt = ""),
  153. );
  154. }
  155. }}
  156. />,
  157. <IconButton
  158. key="copy"
  159. icon={<CopyIcon />}
  160. bordered
  161. text={Locale.Chat.Config.SaveAs}
  162. onClick={() => {
  163. navigate(Path.Masks);
  164. setTimeout(() => {
  165. maskStore.create(session.mask);
  166. }, 500);
  167. }}
  168. />,
  169. ]}
  170. >
  171. <MaskConfig
  172. mask={session.mask}
  173. updateMask={(updater) => {
  174. const mask = { ...session.mask };
  175. updater(mask);
  176. chatStore.updateCurrentSession((session) => (session.mask = mask));
  177. }}
  178. shouldSyncFromGlobal
  179. extraListItems={
  180. session.mask.modelConfig.sendMemory ? (
  181. <ListItem
  182. className="copyable"
  183. title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
  184. subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
  185. ></ListItem>
  186. ) : (
  187. <></>
  188. )
  189. }
  190. ></MaskConfig>
  191. </Modal>
  192. </div>
  193. );
  194. }
  195. // 提示词
  196. const CallWord = (props: {
  197. setUserInput: (value: string) => void,
  198. doSubmit: (userInput: string) => void,
  199. }) => {
  200. const { setUserInput, doSubmit } = props
  201. const list = [
  202. {
  203. title: '信息公布',
  204. // text: '在哪里查看招聘信息?',
  205. text: '今年上海建科工程咨询的校园招聘什么时候开始?如何查阅相关招聘信息?',
  206. },
  207. {
  208. title: '招聘岗位',
  209. // text: '今年招聘的岗位有哪些?',
  210. text: '今年招聘的岗位有哪些?',
  211. },
  212. {
  213. title: '专业要求',
  214. // text: '招聘的岗位有什么专业要求?',
  215. text: '招聘的岗位有什么专业要求?',
  216. },
  217. {
  218. title: '工作地点',
  219. // text: '全国都有工作地点吗?',
  220. text: '工作地点是如何确定的?',
  221. },
  222. {
  223. title: '薪资待遇',
  224. // text: '企业可提供的薪资与福利待遇如何?',
  225. text: '企业可提供的薪资与福利待遇如何?',
  226. },
  227. {
  228. title: '职业发展',
  229. // text: '我应聘贵单位,你们能提供怎样的职业发展规划?',
  230. text: '公司有哪些职业发展通道?',
  231. },
  232. {
  233. title: '落户政策',
  234. // text: '公司是否能协助我落户?',
  235. text: '关于落户支持?',
  236. }
  237. ]
  238. return (
  239. <>
  240. {
  241. list.map((item, index) => {
  242. return <span
  243. key={index}
  244. style={{
  245. padding: '5px 10px',
  246. background: '#f6f7f8',
  247. color: '#5e5e66',
  248. borderRadius: 4,
  249. margin: '0 5px 10px 0',
  250. cursor: 'pointer',
  251. fontSize: 12
  252. }}
  253. onClick={() => {
  254. const plan: string = '2';
  255. if (plan === '1') {
  256. // 方案1.点击后出现在输入框内,用户自己点击发送
  257. setUserInput(item.text);
  258. } else {
  259. // 方案2.点击后直接发送
  260. doSubmit(item.text)
  261. }
  262. }}
  263. >
  264. {item.title}
  265. </span>
  266. })
  267. }
  268. </>
  269. )
  270. }
  271. function PromptToast(props: {
  272. showToast?: boolean;
  273. showModal?: boolean;
  274. setShowModal: (_: boolean) => void;
  275. }) {
  276. const chatStore = useChatStore();
  277. const session = chatStore.currentSession();
  278. const context = session.mask.context;
  279. return (
  280. <div className={styles["prompt-toast"]} key="prompt-toast">
  281. {props.showToast && (
  282. <div
  283. className={styles["prompt-toast-inner"] + " clickable"}
  284. role="button"
  285. onClick={() => props.setShowModal(true)}
  286. >
  287. <BrainIcon />
  288. <span className={styles["prompt-toast-content"]}>
  289. {Locale.Context.Toast(context.length)}
  290. </span>
  291. </div>
  292. )}
  293. {props.showModal && (
  294. <SessionConfigModel onClose={() => props.setShowModal(false)} />
  295. )}
  296. </div>
  297. );
  298. }
  299. function useSubmitHandler() {
  300. const config = useAppConfig();
  301. const submitKey = config.submitKey;
  302. const isComposing = useRef(false);
  303. useEffect(() => {
  304. const onCompositionStart = () => {
  305. isComposing.current = true;
  306. };
  307. const onCompositionEnd = () => {
  308. isComposing.current = false;
  309. };
  310. window.addEventListener("compositionstart", onCompositionStart);
  311. window.addEventListener("compositionend", onCompositionEnd);
  312. return () => {
  313. window.removeEventListener("compositionstart", onCompositionStart);
  314. window.removeEventListener("compositionend", onCompositionEnd);
  315. };
  316. }, []);
  317. const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  318. // Fix Chinese input method "Enter" on Safari
  319. if (e.keyCode == 229) return false;
  320. if (e.key !== "Enter") return false;
  321. if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current))
  322. return false;
  323. return (
  324. (config.submitKey === SubmitKey.AltEnter && e.altKey) ||
  325. (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
  326. (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
  327. (config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
  328. (config.submitKey === SubmitKey.Enter &&
  329. !e.altKey &&
  330. !e.ctrlKey &&
  331. !e.shiftKey &&
  332. !e.metaKey)
  333. );
  334. };
  335. return {
  336. submitKey,
  337. shouldSubmit,
  338. };
  339. }
  340. export type RenderPrompt = Pick<Prompt, "title" | "content">;
  341. export function PromptHints(props: {
  342. prompts: RenderPrompt[];
  343. onPromptSelect: (prompt: RenderPrompt) => void;
  344. }) {
  345. const noPrompts = props.prompts.length === 0;
  346. const [selectIndex, setSelectIndex] = useState(0);
  347. const selectedRef = useRef<HTMLDivElement>(null);
  348. useEffect(() => {
  349. setSelectIndex(0);
  350. }, [props.prompts.length]);
  351. useEffect(() => {
  352. const onKeyDown = (e: KeyboardEvent) => {
  353. if (noPrompts || e.metaKey || e.altKey || e.ctrlKey) {
  354. return;
  355. }
  356. // arrow up / down to select prompt
  357. const changeIndex = (delta: number) => {
  358. e.stopPropagation();
  359. e.preventDefault();
  360. const nextIndex = Math.max(
  361. 0,
  362. Math.min(props.prompts.length - 1, selectIndex + delta),
  363. );
  364. setSelectIndex(nextIndex);
  365. selectedRef.current?.scrollIntoView({
  366. block: "center",
  367. });
  368. };
  369. if (e.key === "ArrowUp") {
  370. changeIndex(1);
  371. } else if (e.key === "ArrowDown") {
  372. changeIndex(-1);
  373. } else if (e.key === "Enter") {
  374. const selectedPrompt = props.prompts.at(selectIndex);
  375. if (selectedPrompt) {
  376. props.onPromptSelect(selectedPrompt);
  377. }
  378. }
  379. };
  380. window.addEventListener("keydown", onKeyDown);
  381. return () => window.removeEventListener("keydown", onKeyDown);
  382. // eslint-disable-next-line react-hooks/exhaustive-deps
  383. }, [props.prompts.length, selectIndex]);
  384. if (noPrompts) return null;
  385. return (
  386. <div className={styles["prompt-hints"]}>
  387. {props.prompts.map((prompt, i) => (
  388. <div
  389. ref={i === selectIndex ? selectedRef : null}
  390. className={
  391. styles["prompt-hint"] +
  392. ` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
  393. }
  394. key={prompt.title + i.toString()}
  395. onClick={() => props.onPromptSelect(prompt)}
  396. onMouseEnter={() => setSelectIndex(i)}
  397. >
  398. <div className={styles["hint-title"]}>{prompt.title}</div>
  399. <div className={styles["hint-content"]}>{prompt.content}</div>
  400. </div>
  401. ))}
  402. </div>
  403. );
  404. }
  405. function ClearContextDivider() {
  406. const chatStore = useChatStore();
  407. return (
  408. <div
  409. className={styles["clear-context"]}
  410. onClick={() =>
  411. chatStore.updateCurrentSession(
  412. (session) => (session.clearContextIndex = undefined),
  413. )
  414. }
  415. >
  416. <div className={styles["clear-context-tips"]}>{Locale.Context.Clear}</div>
  417. <div className={styles["clear-context-revert-btn"]}>
  418. {Locale.Context.Revert}
  419. </div>
  420. </div>
  421. );
  422. }
  423. export function ChatAction(props: {
  424. text: string;
  425. icon: JSX.Element;
  426. onClick: () => void;
  427. }) {
  428. const iconRef = useRef<HTMLDivElement>(null);
  429. const textRef = useRef<HTMLDivElement>(null);
  430. const [width, setWidth] = useState({
  431. full: 16,
  432. icon: 16,
  433. });
  434. function updateWidth() {
  435. if (!iconRef.current || !textRef.current) return;
  436. const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width;
  437. const textWidth = getWidth(textRef.current);
  438. const iconWidth = getWidth(iconRef.current);
  439. setWidth({
  440. full: textWidth + iconWidth,
  441. icon: iconWidth,
  442. });
  443. }
  444. return (
  445. <div
  446. className={`${styles["chat-input-action"]} clickable`}
  447. onClick={() => {
  448. props.onClick();
  449. setTimeout(updateWidth, 1);
  450. }}
  451. onMouseEnter={updateWidth}
  452. onTouchStart={updateWidth}
  453. style={
  454. {
  455. "--icon-width": `${width.icon}px`,
  456. "--full-width": `${width.full}px`,
  457. } as React.CSSProperties
  458. }
  459. >
  460. <div ref={iconRef} className={styles["icon"]}>
  461. {props.icon}
  462. </div>
  463. <div className={styles["text"]} ref={textRef}>
  464. {props.text}
  465. </div>
  466. </div>
  467. );
  468. }
  469. function useScrollToBottom(
  470. scrollRef: RefObject<HTMLDivElement>,
  471. detach: boolean = false,
  472. ) {
  473. // for auto-scroll
  474. const [autoScroll, setAutoScroll] = useState(true);
  475. function scrollDomToBottom() {
  476. const dom = scrollRef.current;
  477. if (dom) {
  478. requestAnimationFrame(() => {
  479. setAutoScroll(true);
  480. dom.scrollTo(0, dom.scrollHeight);
  481. });
  482. }
  483. }
  484. // auto scroll
  485. useEffect(() => {
  486. if (autoScroll && !detach) {
  487. scrollDomToBottom();
  488. }
  489. });
  490. return {
  491. scrollRef,
  492. autoScroll,
  493. setAutoScroll,
  494. scrollDomToBottom,
  495. };
  496. }
  497. export function ChatActions(props: {
  498. setUserInput: (value: string) => void;
  499. doSubmit: (userInput: string) => void;
  500. uploadImage: () => void;
  501. setAttachImages: (images: string[]) => void;
  502. setUploading: (uploading: boolean) => void;
  503. showPromptModal: () => void;
  504. scrollToBottom: () => void;
  505. showPromptHints: () => void;
  506. hitBottom: boolean;
  507. uploading: boolean;
  508. }) {
  509. const config = useAppConfig();
  510. const navigate = useNavigate();
  511. const chatStore = useChatStore();
  512. // switch themes
  513. const theme = config.theme;
  514. function nextTheme() {
  515. const themes = [Theme.Auto, Theme.Light, Theme.Dark];
  516. const themeIndex = themes.indexOf(theme);
  517. const nextIndex = (themeIndex + 1) % themes.length;
  518. const nextTheme = themes[nextIndex];
  519. config.update((config) => (config.theme = nextTheme));
  520. }
  521. // stop all responses
  522. const couldStop = ChatControllerPool.hasPending();
  523. const stopAll = () => ChatControllerPool.stopAll();
  524. // switch model
  525. const currentModel = chatStore.currentSession().mask.modelConfig.model;
  526. const currentProviderName =
  527. chatStore.currentSession().mask.modelConfig?.providerName ||
  528. ServiceProvider.OpenAI;
  529. const allModels = useAllModels();
  530. const models = useMemo(() => {
  531. const filteredModels = allModels.filter((m) => m.available);
  532. const defaultModel = filteredModels.find((m) => m.isDefault);
  533. if (defaultModel) {
  534. const arr = [
  535. defaultModel,
  536. ...filteredModels.filter((m) => m !== defaultModel),
  537. ];
  538. return arr;
  539. } else {
  540. return filteredModels;
  541. }
  542. }, [allModels]);
  543. const currentModelName = useMemo(() => {
  544. const model = models.find(
  545. (m) =>
  546. m.name == currentModel &&
  547. m?.provider?.providerName == currentProviderName,
  548. );
  549. return model?.displayName ?? "";
  550. }, [models, currentModel, currentProviderName]);
  551. const [showModelSelector, setShowModelSelector] = useState(false);
  552. const [showPluginSelector, setShowPluginSelector] = useState(false);
  553. const [showUploadImage, setShowUploadImage] = useState(false);
  554. type GuessList = string[]
  555. const [guessList, setGuessList] = useState<GuessList>([]);
  556. const [showSizeSelector, setShowSizeSelector] = useState(false);
  557. const dalle3Sizes: DalleSize[] = ["1024x1024", "1792x1024", "1024x1792"];
  558. const currentSize =
  559. chatStore.currentSession().mask.modelConfig?.size ?? "1024x1024";
  560. const session = chatStore.currentSession();
  561. useEffect(() => {
  562. const show = isVisionModel(currentModel);
  563. setShowUploadImage(show);
  564. if (!show) {
  565. props.setAttachImages([]);
  566. props.setUploading(false);
  567. }
  568. // if current model is not available
  569. // switch to first available model
  570. const isUnavaliableModel = !models.some((m) => m.name === currentModel);
  571. if (isUnavaliableModel && models.length > 0) {
  572. // show next model to default model if exist
  573. let nextModel = models.find((model) => model.isDefault) || models[0];
  574. chatStore.updateCurrentSession((session) => {
  575. session.mask.modelConfig.model = nextModel.name;
  576. session.mask.modelConfig.providerName = nextModel?.provider?.providerName as ServiceProvider;
  577. });
  578. showToast(
  579. nextModel?.provider?.providerName == "ByteDance"
  580. ? nextModel.displayName
  581. : nextModel.name,
  582. );
  583. }
  584. }, [chatStore, currentModel, models]);
  585. return (
  586. <div className={styles["chat-input-actions"]}>
  587. {showModelSelector && (
  588. <Selector
  589. defaultSelectedValue={`${currentModel}@${currentProviderName}`}
  590. items={models.map((m) => ({
  591. title: `${m.displayName}${m?.provider?.providerName
  592. ? "(" + m?.provider?.providerName + ")"
  593. : ""
  594. }`,
  595. value: `${m.name}@${m?.provider?.providerName}`,
  596. }))}
  597. onClose={() => setShowModelSelector(false)}
  598. onSelection={(s) => {
  599. if (s.length === 0) return;
  600. const [model, providerName] = s[0].split("@");
  601. chatStore.updateCurrentSession((session) => {
  602. session.mask.modelConfig.model = model as ModelType;
  603. session.mask.modelConfig.providerName =
  604. providerName as ServiceProvider;
  605. session.mask.syncGlobalConfig = false;
  606. });
  607. if (providerName == "ByteDance") {
  608. const selectedModel = models.find(
  609. (m) =>
  610. m.name == model && m?.provider?.providerName == providerName,
  611. );
  612. showToast(selectedModel?.displayName ?? "");
  613. } else {
  614. showToast(model);
  615. }
  616. }}
  617. />
  618. )}
  619. {isDalle3(currentModel) && (
  620. <ChatAction
  621. onClick={() => setShowSizeSelector(true)}
  622. text={currentSize}
  623. icon={<SizeIcon />}
  624. />
  625. )}
  626. {showSizeSelector && (
  627. <Selector
  628. defaultSelectedValue={currentSize}
  629. items={dalle3Sizes.map((m) => ({
  630. title: m,
  631. value: m,
  632. }))}
  633. onClose={() => setShowSizeSelector(false)}
  634. onSelection={(s) => {
  635. if (s.length === 0) return;
  636. const size = s[0];
  637. chatStore.updateCurrentSession((session) => {
  638. session.mask.modelConfig.size = size;
  639. });
  640. showToast(size);
  641. }}
  642. />
  643. )}
  644. {showPluginSelector && (
  645. <Selector
  646. multiple
  647. defaultSelectedValue={chatStore.currentSession().mask?.plugin}
  648. items={[
  649. {
  650. title: Locale.Plugin.Artifacts,
  651. value: Plugin.Artifacts,
  652. },
  653. ]}
  654. onClose={() => setShowPluginSelector(false)}
  655. onSelection={(s) => {
  656. const plugin = s[0];
  657. chatStore.updateCurrentSession((session) => {
  658. session.mask.plugin = s;
  659. });
  660. if (plugin) {
  661. showToast(plugin);
  662. }
  663. }}
  664. />
  665. )}
  666. </div>
  667. );
  668. }
  669. export function EditMessageModal(props: { onClose: () => void }) {
  670. const chatStore = useChatStore();
  671. const session = chatStore.currentSession();
  672. const [messages, setMessages] = useState(session.messages.slice());
  673. return (
  674. <div className="modal-mask">
  675. <Modal
  676. title={Locale.Chat.EditMessage.Title}
  677. onClose={props.onClose}
  678. actions={[
  679. <IconButton
  680. text={Locale.UI.Cancel}
  681. icon={<CancelIcon />}
  682. key="cancel"
  683. onClick={() => {
  684. props.onClose();
  685. }}
  686. />,
  687. <IconButton
  688. type="primary"
  689. text={Locale.UI.Confirm}
  690. icon={<ConfirmIcon />}
  691. key="ok"
  692. onClick={() => {
  693. chatStore.updateCurrentSession(
  694. (session) => (session.messages = messages),
  695. );
  696. props.onClose();
  697. }}
  698. />,
  699. ]}
  700. >
  701. <List>
  702. <ListItem
  703. title={Locale.Chat.EditMessage.Topic.Title}
  704. subTitle={Locale.Chat.EditMessage.Topic.SubTitle}
  705. >
  706. <input
  707. type="text"
  708. value={session.topic}
  709. onInput={(e) =>
  710. chatStore.updateCurrentSession(
  711. (session) => (session.topic = e.currentTarget.value),
  712. )
  713. }
  714. ></input>
  715. </ListItem>
  716. </List>
  717. <ContextPrompts
  718. context={messages}
  719. updateContext={(updater) => {
  720. const newMessages = messages.slice();
  721. updater(newMessages);
  722. setMessages(newMessages);
  723. }}
  724. />
  725. </Modal>
  726. </div>
  727. );
  728. }
  729. export function DeleteImageButton(props: { deleteImage: () => void }) {
  730. return (
  731. <div className={styles["delete-image"]} onClick={props.deleteImage}>
  732. <DeleteIcon />
  733. </div>
  734. );
  735. }
  736. function _Chat() {
  737. type RenderMessage = ChatMessage & { preview?: boolean };
  738. const chatStore = useChatStore();
  739. const session = chatStore.currentSession();
  740. const config = useAppConfig();
  741. config.sendPreviewBubble = false;
  742. const fontSize = config.fontSize;
  743. const fontFamily = config.fontFamily;
  744. const [showExport, setShowExport] = useState(false);
  745. const inputRef = useRef<HTMLTextAreaElement>(null);
  746. const [userInput, setUserInput] = useState("");
  747. const [isLoading, setIsLoading] = useState(false);
  748. const { submitKey, shouldSubmit } = useSubmitHandler();
  749. const scrollRef = useRef<HTMLDivElement>(null);
  750. const isScrolledToBottom = scrollRef?.current
  751. ? Math.abs(
  752. scrollRef.current.scrollHeight -
  753. (scrollRef.current.scrollTop + scrollRef.current.clientHeight),
  754. ) <= 1
  755. : false;
  756. const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
  757. scrollRef,
  758. isScrolledToBottom,
  759. );
  760. const [hitBottom, setHitBottom] = useState(true);
  761. const isMobileScreen = useMobileScreen();
  762. const navigate = useNavigate();
  763. const [attachImages, setAttachImages] = useState<string[]>([]);
  764. const [uploading, setUploading] = useState(false);
  765. // prompt hints
  766. const promptStore = usePromptStore();
  767. const [promptHints, setPromptHints] = useState<RenderPrompt[]>([]);
  768. const onSearch = useDebouncedCallback(
  769. (text: string) => {
  770. const matchedPrompts = promptStore.search(text);
  771. setPromptHints(matchedPrompts);
  772. },
  773. 100,
  774. { leading: true, trailing: true },
  775. );
  776. useEffect(() => {
  777. chatStore.updateCurrentSession((session) => {
  778. session.appId = '1881269958412521255';
  779. });
  780. }, [])
  781. const [inputRows, setInputRows] = useState(2);
  782. const measure = useDebouncedCallback(
  783. () => {
  784. const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
  785. const inputRows = Math.min(
  786. 20,
  787. Math.max(2 + Number(!isMobileScreen), rows),
  788. );
  789. setInputRows(inputRows);
  790. },
  791. 100,
  792. {
  793. leading: true,
  794. trailing: true,
  795. },
  796. );
  797. // eslint-disable-next-line react-hooks/exhaustive-deps
  798. useEffect(measure, [userInput]);
  799. // chat commands shortcuts
  800. const chatCommands = useChatCommand({
  801. new: () => chatStore.newSession(),
  802. // newm: () => navigate(Path.MaskChat), // 关闭mask入口 ,后续有需求再二开
  803. prev: () => chatStore.nextSession(-1),
  804. next: () => chatStore.nextSession(1),
  805. clear: () =>
  806. chatStore.updateCurrentSession(
  807. (session) => (session.clearContextIndex = session.messages.length),
  808. ),
  809. del: () => chatStore.deleteSession(chatStore.currentSessionIndex),
  810. });
  811. // only search prompts when user input is short
  812. const SEARCH_TEXT_LIMIT = 30;
  813. const onInput = (text: string) => {
  814. setUserInput(text);
  815. const n = text.trim().length;
  816. // clear search results
  817. if (n === 0) {
  818. setPromptHints([]);
  819. } else if (text.match(ChatCommandPrefix)) {
  820. setPromptHints(chatCommands.search(text));
  821. } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
  822. // check if need to trigger auto completion
  823. if (text.startsWith("/")) {
  824. let searchText = text.slice(1);
  825. onSearch(searchText);
  826. }
  827. }
  828. };
  829. const doSubmit = (userInput: string) => {
  830. if (userInput.trim() === "") return;
  831. const matchCommand = chatCommands.match(userInput);
  832. if (matchCommand.matched) {
  833. setUserInput("");
  834. setPromptHints([]);
  835. matchCommand.invoke();
  836. return;
  837. }
  838. setIsLoading(true);
  839. chatStore.onUserInput(fileList, userInput, attachImages).then(() => setIsLoading(false));
  840. setAttachImages([]);
  841. localStorage.setItem(LAST_INPUT_KEY, userInput);
  842. setUserInput("");
  843. setPromptHints([]);
  844. if (!isMobileScreen) inputRef.current?.focus();
  845. setAutoScroll(true);
  846. };
  847. const onPromptSelect = (prompt: RenderPrompt) => {
  848. setTimeout(() => {
  849. setPromptHints([]);
  850. const matchedChatCommand = chatCommands.match(prompt.content);
  851. if (matchedChatCommand.matched) {
  852. // if user is selecting a chat command, just trigger it
  853. matchedChatCommand.invoke();
  854. setUserInput("");
  855. } else {
  856. // or fill the prompt
  857. setUserInput(prompt.content);
  858. }
  859. inputRef.current?.focus();
  860. }, 30);
  861. };
  862. // stop response
  863. const onUserStop = (messageId: string) => {
  864. ChatControllerPool.stop(session.id, messageId);
  865. };
  866. useEffect(() => {
  867. chatStore.updateCurrentSession((session) => {
  868. const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
  869. session.messages.forEach((m) => {
  870. // check if should stop all stale messages
  871. if (m.isError || new Date(m.date).getTime() < stopTiming) {
  872. if (m.streaming) {
  873. m.streaming = false;
  874. }
  875. if (m.content.length === 0) {
  876. m.isError = true;
  877. m.content = prettyObject({
  878. error: true,
  879. message: "empty response",
  880. });
  881. }
  882. }
  883. });
  884. // auto sync mask config from global config
  885. if (session.mask.syncGlobalConfig) {
  886. console.log("[Mask] syncing from global, name = ", session.mask.name);
  887. session.mask.modelConfig = { ...config.modelConfig };
  888. }
  889. });
  890. // eslint-disable-next-line react-hooks/exhaustive-deps
  891. }, []);
  892. // check if should send message
  893. const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  894. if (
  895. e.key === "ArrowUp" &&
  896. userInput.length <= 0 &&
  897. !(e.metaKey || e.altKey || e.ctrlKey)
  898. ) {
  899. setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
  900. e.preventDefault();
  901. return;
  902. }
  903. if (shouldSubmit(e) && promptHints.length === 0) {
  904. doSubmit(userInput);
  905. e.preventDefault();
  906. }
  907. };
  908. const onRightClick = (e: any, message: ChatMessage) => {
  909. // copy to clipboard
  910. if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) {
  911. if (userInput.length === 0) {
  912. setUserInput(getMessageTextContent(message));
  913. }
  914. e.preventDefault();
  915. }
  916. };
  917. const deleteMessage = (msgId?: string) => {
  918. chatStore.updateCurrentSession(
  919. (session) =>
  920. (session.messages = session.messages.filter((m) => m.id !== msgId)),
  921. );
  922. };
  923. const onDelete = (msgId: string) => {
  924. deleteMessage(msgId);
  925. };
  926. const onResend = (message: ChatMessage) => {
  927. // when it is resending a message
  928. // 1. for a user's message, find the next bot response
  929. // 2. for a bot's message, find the last user's input
  930. // 3. delete original user input and bot's message
  931. // 4. resend the user's input
  932. const resendingIndex = session.messages.findIndex(
  933. (m) => m.id === message.id,
  934. );
  935. if (resendingIndex < 0 || resendingIndex >= session.messages.length) {
  936. console.error("[Chat] failed to find resending message", message);
  937. return;
  938. }
  939. let userMessage: ChatMessage | undefined;
  940. let botMessage: ChatMessage | undefined;
  941. if (message.role === "assistant") {
  942. // if it is resending a bot's message, find the user input for it
  943. botMessage = message;
  944. for (let i = resendingIndex; i >= 0; i -= 1) {
  945. if (session.messages[i].role === "user") {
  946. userMessage = session.messages[i];
  947. break;
  948. }
  949. }
  950. } else if (message.role === "user") {
  951. // if it is resending a user's input, find the bot's response
  952. userMessage = message;
  953. for (let i = resendingIndex; i < session.messages.length; i += 1) {
  954. if (session.messages[i].role === "assistant") {
  955. botMessage = session.messages[i];
  956. break;
  957. }
  958. }
  959. }
  960. if (userMessage === undefined) {
  961. console.error("[Chat] failed to resend", message);
  962. return;
  963. }
  964. // delete the original messages
  965. deleteMessage(userMessage.id);
  966. deleteMessage(botMessage?.id);
  967. // resend the message
  968. setIsLoading(true);
  969. const textContent = getMessageTextContent(userMessage);
  970. const images = getMessageImages(userMessage);
  971. chatStore.onUserInput([], textContent, images).then(() => setIsLoading(false));
  972. inputRef.current?.focus();
  973. };
  974. const onPinMessage = (message: ChatMessage) => {
  975. chatStore.updateCurrentSession((session) =>
  976. session.mask.context.push(message),
  977. );
  978. showToast(Locale.Chat.Actions.PinToastContent, {
  979. text: Locale.Chat.Actions.PinToastAction,
  980. onClick: () => {
  981. setShowPromptModal(true);
  982. },
  983. });
  984. };
  985. const context: RenderMessage[] = useMemo(() => {
  986. return session.mask.hideContext ? [] : session.mask.context.slice();
  987. }, [session.mask.context, session.mask.hideContext]);
  988. const accessStore = useAccessStore();
  989. if (
  990. context.length === 0 &&
  991. session.messages.at(0)?.content !== BOT_HELLO.content
  992. ) {
  993. const copiedHello = Object.assign({}, BOT_HELLO);
  994. if (!accessStore.isAuthorized()) {
  995. copiedHello.content = Locale.Error.Unauthorized;
  996. }
  997. context.push(copiedHello);
  998. }
  999. // preview messages
  1000. const renderMessages = useMemo(() => {
  1001. return context.concat(session.messages as RenderMessage[]).concat(
  1002. isLoading
  1003. ? [
  1004. {
  1005. ...createMessage({
  1006. role: "assistant",
  1007. content: "……",
  1008. }),
  1009. preview: true,
  1010. },
  1011. ]
  1012. : [],
  1013. ).concat(
  1014. userInput.length > 0 && config.sendPreviewBubble
  1015. ? [
  1016. {
  1017. ...createMessage({
  1018. role: "user",
  1019. content: userInput,
  1020. }),
  1021. preview: true,
  1022. },
  1023. ]
  1024. : [],
  1025. );
  1026. }, [
  1027. config.sendPreviewBubble,
  1028. context,
  1029. isLoading,
  1030. session.messages,
  1031. userInput,
  1032. ]);
  1033. const [msgRenderIndex, _setMsgRenderIndex] = useState(
  1034. Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
  1035. );
  1036. function setMsgRenderIndex(newIndex: number) {
  1037. newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex);
  1038. newIndex = Math.max(0, newIndex);
  1039. _setMsgRenderIndex(newIndex);
  1040. }
  1041. const messages = useMemo(() => {
  1042. const endRenderIndex = Math.min(
  1043. msgRenderIndex + 3 * CHAT_PAGE_SIZE,
  1044. renderMessages.length,
  1045. );
  1046. return renderMessages.slice(msgRenderIndex, endRenderIndex);
  1047. }, [msgRenderIndex, renderMessages]);
  1048. const onChatBodyScroll = (e: HTMLElement) => {
  1049. const bottomHeight = e.scrollTop + e.clientHeight;
  1050. const edgeThreshold = e.clientHeight;
  1051. const isTouchTopEdge = e.scrollTop <= edgeThreshold;
  1052. const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold;
  1053. const isHitBottom =
  1054. bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10);
  1055. const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE;
  1056. const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE;
  1057. if (isTouchTopEdge && !isTouchBottomEdge) {
  1058. setMsgRenderIndex(prevPageMsgIndex);
  1059. } else if (isTouchBottomEdge) {
  1060. setMsgRenderIndex(nextPageMsgIndex);
  1061. }
  1062. setHitBottom(isHitBottom);
  1063. setAutoScroll(isHitBottom);
  1064. };
  1065. function scrollToBottom() {
  1066. setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
  1067. scrollDomToBottom();
  1068. }
  1069. // clear context index = context length + index in messages
  1070. const clearContextIndex =
  1071. (session.clearContextIndex ?? -1) >= 0
  1072. ? session.clearContextIndex! + context.length - msgRenderIndex
  1073. : -1;
  1074. const [showPromptModal, setShowPromptModal] = useState(false);
  1075. const clientConfig = useMemo(() => getClientConfig(), []);
  1076. const autoFocus = !isMobileScreen; // wont auto focus on mobile screen
  1077. const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
  1078. useCommand({
  1079. fill: setUserInput,
  1080. submit: (text) => {
  1081. doSubmit(text);
  1082. },
  1083. code: (text) => {
  1084. if (accessStore.disableFastLink) return;
  1085. console.log("[Command] got code from url: ", text);
  1086. showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => {
  1087. if (res) {
  1088. accessStore.update((access) => (access.accessCode = text));
  1089. }
  1090. });
  1091. },
  1092. settings: (text) => {
  1093. if (accessStore.disableFastLink) return;
  1094. try {
  1095. const payload = JSON.parse(text) as {
  1096. key?: string;
  1097. url?: string;
  1098. };
  1099. console.log("[Command] got settings from url: ", payload);
  1100. if (payload.key || payload.url) {
  1101. showConfirm(
  1102. Locale.URLCommand.Settings +
  1103. `\n${JSON.stringify(payload, null, 4)}`,
  1104. ).then((res) => {
  1105. if (!res) return;
  1106. if (payload.key) {
  1107. accessStore.update(
  1108. (access) => (access.openaiApiKey = payload.key!),
  1109. );
  1110. }
  1111. if (payload.url) {
  1112. accessStore.update((access) => (access.openaiUrl = payload.url!));
  1113. }
  1114. accessStore.update((access) => (access.useCustomConfig = true));
  1115. });
  1116. }
  1117. } catch {
  1118. console.error("[Command] failed to get settings from url: ", text);
  1119. }
  1120. },
  1121. });
  1122. // edit / insert message modal
  1123. const [isEditingMessage, setIsEditingMessage] = useState(false);
  1124. // remember unfinished input
  1125. useEffect(() => {
  1126. // try to load from local storage
  1127. const key = UNFINISHED_INPUT(session.id);
  1128. const mayBeUnfinishedInput = localStorage.getItem(key);
  1129. if (mayBeUnfinishedInput && userInput.length === 0) {
  1130. setUserInput(mayBeUnfinishedInput);
  1131. localStorage.removeItem(key);
  1132. }
  1133. const dom = inputRef.current;
  1134. return () => {
  1135. localStorage.setItem(key, dom?.value ?? "");
  1136. };
  1137. // eslint-disable-next-line react-hooks/exhaustive-deps
  1138. }, []);
  1139. const handlePaste = useCallback(
  1140. async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
  1141. const currentModel = chatStore.currentSession().mask.modelConfig.model;
  1142. if (!isVisionModel(currentModel)) {
  1143. return;
  1144. }
  1145. const items = (event.clipboardData || window.clipboardData).items;
  1146. for (const item of items) {
  1147. if (item.kind === "file" && item.type.startsWith("image/")) {
  1148. event.preventDefault();
  1149. const file = item.getAsFile();
  1150. if (file) {
  1151. const images: string[] = [];
  1152. images.push(...attachImages);
  1153. images.push(
  1154. ...(await new Promise<string[]>((res, rej) => {
  1155. setUploading(true);
  1156. const imagesData: string[] = [];
  1157. uploadImageRemote(file).then((dataUrl) => {
  1158. imagesData.push(dataUrl);
  1159. setUploading(false);
  1160. res(imagesData);
  1161. }).catch((e) => {
  1162. setUploading(false);
  1163. rej(e);
  1164. });
  1165. })),
  1166. );
  1167. const imagesLength = images.length;
  1168. if (imagesLength > 3) {
  1169. images.splice(3, imagesLength - 3);
  1170. }
  1171. setAttachImages(images);
  1172. }
  1173. }
  1174. }
  1175. },
  1176. [attachImages, chatStore],
  1177. );
  1178. async function uploadImage() {
  1179. const images: string[] = [];
  1180. images.push(...attachImages);
  1181. images.push(
  1182. ...(await new Promise<string[]>((res, rej) => {
  1183. const fileInput = document.createElement("input");
  1184. fileInput.type = "file";
  1185. fileInput.accept =
  1186. "image/png, image/jpeg, image/webp, image/heic, image/heif";
  1187. fileInput.multiple = true;
  1188. fileInput.onchange = (event: any) => {
  1189. setUploading(true);
  1190. const files = event.target.files;
  1191. const imagesData: string[] = [];
  1192. for (let i = 0; i < files.length; i++) {
  1193. const file = event.target.files[i];
  1194. uploadImageRemote(file).then((dataUrl) => {
  1195. imagesData.push(dataUrl);
  1196. if (
  1197. imagesData.length === 3 ||
  1198. imagesData.length === files.length
  1199. ) {
  1200. setUploading(false);
  1201. res(imagesData);
  1202. }
  1203. }).catch((e) => {
  1204. setUploading(false);
  1205. rej(e);
  1206. });
  1207. }
  1208. };
  1209. fileInput.click();
  1210. })),
  1211. );
  1212. const imagesLength = images.length;
  1213. if (imagesLength > 3) {
  1214. images.splice(3, imagesLength - 3);
  1215. }
  1216. setAttachImages(images);
  1217. }
  1218. const [fileList, setFileList] = useState<any[]>([]);
  1219. // 上传配置
  1220. const uploadConfig: UploadProps = {
  1221. action: '/deepseek-api' + '/upload/file',
  1222. method: 'POST',
  1223. accept: ['.pdf', '.txt', '.doc', '.docx'].join(','),
  1224. };
  1225. interface FileIconProps {
  1226. fileName: string;
  1227. }
  1228. const FileIcon: React.FC<FileIconProps> = (props: FileIconProps) => {
  1229. const style = {
  1230. fontSize: '30px',
  1231. color: '#3875f6',
  1232. }
  1233. let icon = <FileOutlined style={style} />
  1234. if (props.fileName) {
  1235. const suffix = props.fileName.split('.').pop() || '';
  1236. switch (suffix) {
  1237. case 'pdf':
  1238. icon = <FilePdfOutlined style={style} />
  1239. break;
  1240. case 'txt':
  1241. icon = <FileTextOutlined style={style} />
  1242. break;
  1243. case 'doc':
  1244. case 'docx':
  1245. icon = <FileWordOutlined style={style} />
  1246. break;
  1247. default:
  1248. break;
  1249. }
  1250. }
  1251. return icon;
  1252. }
  1253. const [isDeepThink, setIsDeepThink] = useState<boolean>(chatStore.isDeepThink);
  1254. // 切换聊天窗口后清理上传文件信息
  1255. useEffect(() => {
  1256. setFileList([])
  1257. }, [chatStore.currentSession()])
  1258. const couldStop = ChatControllerPool.hasPending();
  1259. const stopAll = () => ChatControllerPool.stopAll();
  1260. // 切换聊天窗口后清理上传文件信息
  1261. useEffect(() => {
  1262. setWebSearch(false);
  1263. }, [chatStore.currentSession()])
  1264. const [webSearch, setWebSearch] = useState<boolean>(chatStore.web_search);
  1265. const [drawerOpen, setDrawerOpen] = useState(false);
  1266. type DrawerList = {
  1267. title: string,
  1268. content: string,
  1269. web_url: string,
  1270. }[]
  1271. const [drawerList, setDrawerList] = useState<DrawerList>([]);
  1272. interface NetworkDrawerProps {
  1273. list: DrawerList,
  1274. }
  1275. const NetworkDrawer: React.FC<NetworkDrawerProps> = (props) => {
  1276. return (
  1277. <Drawer
  1278. title='网页搜索'
  1279. open={drawerOpen}
  1280. onClose={() => {
  1281. setDrawerOpen(false);
  1282. }}
  1283. >
  1284. {props.list.map((item, index) => {
  1285. return <div
  1286. style={{
  1287. padding: 10,
  1288. background: '#fafafa',
  1289. borderRadius: 4,
  1290. marginBottom: 10,
  1291. cursor: 'pointer',
  1292. }}
  1293. key={index}
  1294. onClick={() => {
  1295. window.open(item.web_url);
  1296. }}
  1297. >
  1298. <div style={{
  1299. margin: '5px 0',
  1300. fontSize: 16,
  1301. display: '-webkit-box',
  1302. WebkitBoxOrient: 'vertical',
  1303. WebkitLineClamp: 2,// 限制显示两行
  1304. overflow: 'hidden',
  1305. }}>
  1306. {item.title}
  1307. </div>
  1308. <div style={{
  1309. color: '#afafaf',
  1310. display: '-webkit-box',
  1311. WebkitBoxOrient: 'vertical',
  1312. WebkitLineClamp: 4,// 限制显示两行
  1313. overflow: 'hidden',
  1314. textOverflow: 'ellipsis',
  1315. }}>
  1316. {item.content}
  1317. </div>
  1318. </div>
  1319. })
  1320. }
  1321. </Drawer >
  1322. )
  1323. }
  1324. return (
  1325. <div className={styles.chat} key={session.id}>
  1326. {
  1327. isMobileScreen && location.pathname !== '/' &&
  1328. <div className="window-header" data-tauri-drag-region>
  1329. <div style={{ display: 'flex', alignItems: 'center' }}
  1330. className={`window-header-title ${styles["chat-body-title"]}`}>
  1331. <div>
  1332. <IconButton
  1333. style={{ padding: 0, marginRight: 20 }}
  1334. icon={<LeftIcon />}
  1335. text={Locale.NewChat.Return}
  1336. onClick={() => navigate('/deepseekChat')}
  1337. />
  1338. </div>
  1339. </div>
  1340. </div>
  1341. }
  1342. <div
  1343. className={styles["chat-body"]}
  1344. ref={scrollRef}
  1345. onScroll={(e) => onChatBodyScroll(e.currentTarget)}
  1346. onMouseDown={() => inputRef.current?.blur()}
  1347. onTouchStart={() => {
  1348. inputRef.current?.blur();
  1349. setAutoScroll(false);
  1350. }}
  1351. >
  1352. <>
  1353. {messages.map((message, i) => {
  1354. const isUser = message.role === "user";
  1355. const isContext = i < context.length;
  1356. const showActions =
  1357. i > 0 &&
  1358. !(message.preview || message.content.length === 0) &&
  1359. !isContext;
  1360. const showTyping = message.preview || message.streaming;
  1361. const shouldShowClearContextDivider = i === clearContextIndex - 1;
  1362. return (
  1363. <Fragment key={message.id}>
  1364. <div
  1365. className={
  1366. isUser ? styles["chat-message-user"] : styles["chat-message"]
  1367. }
  1368. >
  1369. <div className={styles["chat-message-container"]}
  1370. style={{ display: 'flex', flexDirection: 'column' }}>
  1371. <div className={styles["chat-message-header"]}>
  1372. <div className={styles["chat-message-avatar"]}>
  1373. {isUser ? (
  1374. // 在这里换头像
  1375. <div style={{ position: 'relative' }}>
  1376. <div
  1377. style={{
  1378. position: 'absolute',
  1379. zIndex: 2,
  1380. top: '50%',
  1381. left: '50%',
  1382. transform: ' translate(-110%, -100%)',
  1383. fontSize: 14,
  1384. }}>
  1385. </div>
  1386. </div>
  1387. ) : (
  1388. <>
  1389. {["system"].includes(message.role) ? (
  1390. <Avatar avatar="2699-fe0f" />
  1391. ) : (
  1392. <MaskAvatar
  1393. avatar={session.mask.avatar}
  1394. model={
  1395. message.model || session.mask.modelConfig.model
  1396. }
  1397. />
  1398. )}
  1399. </>
  1400. )}
  1401. </div>
  1402. </div>
  1403. {
  1404. isUser && message.document && message.document.id &&
  1405. <a style={{
  1406. padding: '10px',
  1407. background: '#f7f7f7',
  1408. borderRadius: '10px',
  1409. textDecoration: 'none',
  1410. color: '#24292f',
  1411. display: 'flex',
  1412. alignItems: 'center'
  1413. }} href={message.document.url} target="_blank">
  1414. <FileIcon fileName={message.document.name} />
  1415. <div style={{ marginLeft: 8, fontSize: '14px' }}>
  1416. {message.document.name}
  1417. </div>
  1418. </a>
  1419. }
  1420. {/* {showTyping && (
  1421. <div className={styles["chat-message-status"]}>
  1422. 正在输入…
  1423. </div>
  1424. )} */}
  1425. {
  1426. message.networkInfo && message.networkInfo.list.length > 0 &&
  1427. <div style={{ marginTop: 10 }}>
  1428. <Button
  1429. icon={<RightOutlined />}
  1430. iconPosition='end'
  1431. onClick={() => {
  1432. setDrawerList(message.networkInfo!.list);
  1433. setDrawerOpen(true);
  1434. }}
  1435. >
  1436. 搜索到{message.networkInfo.list.length}篇相关资料
  1437. </Button>
  1438. {
  1439. drawerOpen &&
  1440. <NetworkDrawer
  1441. list={message.networkInfo.list}
  1442. />
  1443. }
  1444. </div>
  1445. }
  1446. <div className={styles["chat-message-item"]}>
  1447. <Markdown
  1448. key={message.streaming ? "loading" : "done"}
  1449. content={getMessageTextContent(message)}
  1450. loading={
  1451. (message.preview || message.streaming) &&
  1452. message.content.length === 0 &&
  1453. !isUser
  1454. }
  1455. onDoubleClickCapture={() => {
  1456. if (!isMobileScreen) return;
  1457. setUserInput(getMessageTextContent(message));
  1458. }}
  1459. fontSize={fontSize}
  1460. fontFamily={fontFamily}
  1461. parentRef={scrollRef}
  1462. defaultShow={i >= messages.length - 6}
  1463. />
  1464. {getMessageImages(message).length == 1 && (
  1465. <img
  1466. className={styles["chat-message-item-image"]}
  1467. src={getMessageImages(message)[0]}
  1468. alt=""
  1469. />
  1470. )}
  1471. {getMessageImages(message).length > 1 && (
  1472. <div
  1473. className={styles["chat-message-item-images"]}
  1474. style={
  1475. {
  1476. "--image-count": getMessageImages(message).length,
  1477. } as React.CSSProperties
  1478. }
  1479. >
  1480. {getMessageImages(message).map((image, index) => {
  1481. return (
  1482. <img
  1483. className={
  1484. styles["chat-message-item-image-multi"]
  1485. }
  1486. key={index}
  1487. src={image}
  1488. alt=""
  1489. />
  1490. );
  1491. })}
  1492. </div>
  1493. )}
  1494. </div>
  1495. </div>
  1496. </div>
  1497. {shouldShowClearContextDivider && <ClearContextDivider />}
  1498. </Fragment>
  1499. );
  1500. })}
  1501. </>
  1502. </div>
  1503. <div className={styles["chat-input-panel"]}>
  1504. <ChatActions
  1505. setUserInput={setUserInput}
  1506. doSubmit={doSubmit}
  1507. uploadImage={uploadImage}
  1508. setAttachImages={setAttachImages}
  1509. setUploading={setUploading}
  1510. showPromptModal={() => setShowPromptModal(true)}
  1511. scrollToBottom={scrollToBottom}
  1512. hitBottom={hitBottom}
  1513. uploading={uploading}
  1514. showPromptHints={() => {
  1515. if (promptHints.length > 0) {
  1516. setPromptHints([]);
  1517. return;
  1518. }
  1519. inputRef.current?.focus();
  1520. setUserInput("/");
  1521. onSearch("");
  1522. }}
  1523. />
  1524. {
  1525. fileList.length > 0 &&
  1526. <div style={{ marginBottom: 20 }}>
  1527. <Upload
  1528. fileList={fileList}
  1529. onRemove={(file) => {
  1530. setFileList(fileList.filter(item => item.uid !== file.uid));
  1531. }}
  1532. />
  1533. </div>
  1534. }
  1535. <label
  1536. className={`${styles["chat-input-panel-inner"]} ${attachImages.length != 0
  1537. ? styles["chat-input-panel-inner-attach"]
  1538. : ""
  1539. }`}
  1540. htmlFor="chat-input"
  1541. >
  1542. <textarea
  1543. id="chat-input"
  1544. ref={inputRef}
  1545. className={styles["chat-input2"]}
  1546. placeholder={Locale.Chat.Input(submitKey)}
  1547. onInput={(e) => onInput(e.currentTarget.value)}
  1548. value={userInput}
  1549. onKeyDown={onInputKeyDown}
  1550. onFocus={scrollToBottom}
  1551. onClick={scrollToBottom}
  1552. onPaste={handlePaste}
  1553. rows={inputRows}
  1554. autoFocus={autoFocus}
  1555. style={{
  1556. fontSize: config.fontSize,
  1557. fontFamily: config.fontFamily,
  1558. }}
  1559. />
  1560. {attachImages.length != 0 && (
  1561. <div className={styles["attach-images"]}>
  1562. {attachImages.map((image, index) => {
  1563. return (
  1564. <div
  1565. key={index}
  1566. className={styles["attach-image"]}
  1567. style={{ backgroundImage: `url("${image}")` }}
  1568. >
  1569. <div className={styles["attach-image-mask"]}>
  1570. <DeleteImageButton
  1571. deleteImage={() => {
  1572. setAttachImages(
  1573. attachImages.filter((_, i) => i !== index),
  1574. );
  1575. }}
  1576. />
  1577. </div>
  1578. </div>
  1579. );
  1580. })}
  1581. </div>
  1582. )}
  1583. </label>
  1584. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 10 }}>
  1585. <div style={{ display: 'flex', alignItems: 'center' }}>
  1586. {/*深度思考R1按钮*/}
  1587. <Tooltip
  1588. title={
  1589. <span style={{
  1590. fontSize: 12,
  1591. lineHeight: 1.4,
  1592. minHeight: 24,
  1593. padding: '4px 8px',
  1594. }}>
  1595. {isDeepThink ? '关闭深度思考模式' : '启用深度思考模式 (R1模型)'}
  1596. </span>
  1597. }
  1598. placement="left"
  1599. >
  1600. <div
  1601. style={{
  1602. padding: '0 12px',
  1603. height: 28,
  1604. borderRadius: 18,
  1605. fontSize: 12,
  1606. display: 'flex',
  1607. justifyContent: 'center',
  1608. alignItems: 'center',
  1609. marginRight: 10,
  1610. cursor: 'pointer',
  1611. background: isDeepThink ? '#dee9fc' : '#f3f4f6',
  1612. color: isDeepThink ? '#3875f6' : '#000000',
  1613. border: `1px solid ${isDeepThink ? '#3875f6' : 'transparent'}`,
  1614. transition: 'all 0.2s ease',
  1615. userSelect: 'none'
  1616. }}
  1617. onClick={() => {
  1618. setIsDeepThink(!isDeepThink);
  1619. chatStore.setIsDeepThink(!isDeepThink);
  1620. }}
  1621. >
  1622. <img
  1623. src={isDeepThink ? sdsk_selected.src : sdsk.src}
  1624. style={{
  1625. width: 16,
  1626. height: 16,
  1627. }}
  1628. />
  1629. <div style={{ fontSize: 11, marginLeft: 5 }}>
  1630. 深度思考 (R1)
  1631. </div>
  1632. </div>
  1633. </Tooltip>
  1634. {/*联网搜索按钮*/}
  1635. <div style={{
  1636. padding: '0 12px',
  1637. height: 28,
  1638. borderRadius: 18,
  1639. fontSize: 12,
  1640. display: 'flex',
  1641. justifyContent: 'center',
  1642. alignItems: 'center',
  1643. marginRight: 10,
  1644. cursor: 'pointer',
  1645. background: webSearch ? '#dee9fc' : '#f3f4f6',
  1646. color: webSearch ? '#3875f6' : '#000000',
  1647. border: `1px solid ${webSearch ? '#3875f6' : 'transparent'}`,
  1648. transition: 'all 0.2s ease',
  1649. userSelect: 'none'
  1650. }}
  1651. onClick={() => {
  1652. setWebSearch(!webSearch);
  1653. chatStore.setWebSearch(!webSearch);
  1654. }}
  1655. >
  1656. <img
  1657. src={webSearch ? hlw_selected.src : hlw.src}
  1658. style={{
  1659. width: 16,
  1660. height: 16,
  1661. }}
  1662. />
  1663. <div style={{ fontSize: 11, marginLeft: 5 }}>
  1664. 联网搜索
  1665. </div>
  1666. </div>
  1667. </div>
  1668. <div style={{ display: 'flex', alignItems: 'center' }}>
  1669. {
  1670. !webSearch &&
  1671. <div style={{ marginRight: 10 }}>
  1672. <Upload
  1673. {...uploadConfig}
  1674. showUploadList={false}
  1675. maxCount={1}
  1676. onChange={(info) => {
  1677. const fileList = info.fileList.map((file) => {
  1678. const data = file.response;
  1679. return {
  1680. ...file,
  1681. url: data?.document_url || file.url,
  1682. documentId: data?.document_id || '',
  1683. }
  1684. });
  1685. setFileList(fileList);
  1686. if (info.file.status === 'done') {// 上传成功
  1687. const { code, message: msg } = info.file.response;
  1688. if (code === 200) {
  1689. message.success('上传成功');
  1690. } else {
  1691. message.error(msg);
  1692. }
  1693. } else if (info.file.status === 'error') {// 上传失败
  1694. message.error('上传失败');
  1695. }
  1696. }}
  1697. >
  1698. <Tooltip
  1699. title={
  1700. <div style={{ padding: '4px 8px' }}>
  1701. <div style={{
  1702. fontSize: 12,
  1703. lineHeight: 1.4,
  1704. marginBottom: 6,
  1705. }}>
  1706. 上传附件 (识别文本和图表中的内容)
  1707. </div>
  1708. <div style={{
  1709. fontSize: 10,
  1710. color: '#8c8c8c',
  1711. lineHeight: 1.4,
  1712. }}>
  1713. <span>
  1714. 仅支持单个PDF/Word/TXT文件格式
  1715. </span>
  1716. <span>
  1717. (单个文件≤50MB)
  1718. </span>
  1719. </div>
  1720. </div>
  1721. }
  1722. placement="top"
  1723. >
  1724. <div
  1725. style={{
  1726. width: 28,
  1727. height: 28,
  1728. borderRadius: '50%',
  1729. background: '#4357d2',
  1730. display: 'flex',
  1731. justifyContent: 'center',
  1732. alignItems: 'center',
  1733. cursor: 'pointer',
  1734. transition: 'all 0.2s ease',
  1735. userSelect: 'none'
  1736. }}
  1737. >
  1738. <PaperClipOutlined style={{ color: '#FFFFFF', fontSize: '18px' }} />
  1739. </div>
  1740. </Tooltip>
  1741. </Upload>
  1742. </div>
  1743. }
  1744. <div
  1745. style={{
  1746. width: 28,
  1747. height: 28,
  1748. borderRadius: '50%',
  1749. background: '#4357d2',
  1750. display: 'flex',
  1751. justifyContent: 'center',
  1752. alignItems: 'center',
  1753. cursor: 'pointer',
  1754. }}
  1755. onClick={() => {
  1756. if (couldStop) {
  1757. stopAll();
  1758. } else {
  1759. doSubmit(userInput);
  1760. }
  1761. }}
  1762. >
  1763. {
  1764. couldStop ?
  1765. <div style={{ width: 13, height: 13, background: '#FFFFFF', borderRadius: 2 }}></div>
  1766. :
  1767. <div style={{ transform: 'rotate(-45deg)', padding: '0px 0px 3px 5px' }}>
  1768. <SendOutlined style={{ color: '#FFFFFF' }} />
  1769. </div>
  1770. }
  1771. <div>
  1772. </div>
  1773. </div>
  1774. </div>
  1775. </div>
  1776. <div style={{ marginTop: 10, textAlign: 'center', color: '#888888', fontSize: 12 }}>
  1777. 内容由AI生成,仅供参考
  1778. </div>
  1779. </div>
  1780. {
  1781. showExport && (
  1782. <ExportMessageModal onClose={() => setShowExport(false)} />
  1783. )
  1784. }
  1785. {
  1786. isEditingMessage && (
  1787. <EditMessageModal
  1788. onClose={() => {
  1789. setIsEditingMessage(false);
  1790. }}
  1791. />
  1792. )
  1793. }
  1794. </div >
  1795. );
  1796. }
  1797. export function Chat() {
  1798. const globalStore = useGlobalStore();
  1799. const chatStore = useChatStore();
  1800. const sessionIndex = chatStore.currentSessionIndex;
  1801. useEffect(() => {
  1802. globalStore.setShowMenu(true);
  1803. chatStore.setModel('DeepSeek');
  1804. chatStore.setWebSearch(false);
  1805. }, []);
  1806. return <_Chat key={sessionIndex}></_Chat>;
  1807. }