message-selector.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { useEffect, useMemo, useState } from "react";
  2. import { ChatMessage, useAppConfig, useChatStore } from "../store";
  3. import { Updater } from "../typing";
  4. import { IconButton } from "./button";
  5. // Avatar组件替代实现
  6. import BotIcon from "../icons/bot.svg";
  7. import BlackBotIcon from "../icons/black-bot.svg";
  8. function Avatar(props: { model?: string; avatar?: string }) {
  9. if (props.model) {
  10. return (
  11. <div className="no-dark">
  12. {props.model?.startsWith("gpt-4") ? (
  13. <BlackBotIcon className="user-avatar" />
  14. ) : (
  15. <BotIcon className="user-avatar" />
  16. )}
  17. </div>
  18. );
  19. }
  20. return (
  21. <div className="user-avatar">
  22. {/* 移除emoji头像,使用默认bot图标 */}
  23. <BotIcon className="user-avatar" />
  24. </div>
  25. );
  26. }
  27. import { MaskAvatar } from "./mask";
  28. import Locale from "../locales";
  29. import styles from "./message-selector.module.scss";
  30. import { getMessageTextContent } from "../utils";
  31. function useShiftRange() {
  32. const [startIndex, setStartIndex] = useState<number>();
  33. const [endIndex, setEndIndex] = useState<number>();
  34. const [shiftDown, setShiftDown] = useState(false);
  35. const onClickIndex = (index: number) => {
  36. if (shiftDown && startIndex !== undefined) {
  37. setEndIndex(index);
  38. } else {
  39. setStartIndex(index);
  40. setEndIndex(undefined);
  41. }
  42. };
  43. useEffect(() => {
  44. const onKeyDown = (e: KeyboardEvent) => {
  45. if (e.key !== "Shift") return;
  46. setShiftDown(true);
  47. };
  48. const onKeyUp = (e: KeyboardEvent) => {
  49. if (e.key !== "Shift") return;
  50. setShiftDown(false);
  51. setStartIndex(undefined);
  52. setEndIndex(undefined);
  53. };
  54. window.addEventListener("keyup", onKeyUp);
  55. window.addEventListener("keydown", onKeyDown);
  56. return () => {
  57. window.removeEventListener("keyup", onKeyUp);
  58. window.removeEventListener("keydown", onKeyDown);
  59. };
  60. }, []);
  61. return {
  62. onClickIndex,
  63. startIndex,
  64. endIndex,
  65. };
  66. }
  67. export function useMessageSelector() {
  68. const [selection, setSelection] = useState(new Set<string>());
  69. const updateSelection: Updater<Set<string>> = (updater) => {
  70. const newSelection = new Set<string>(selection);
  71. updater(newSelection);
  72. setSelection(newSelection);
  73. };
  74. return {
  75. selection,
  76. updateSelection,
  77. };
  78. }
  79. export function MessageSelector(props: {
  80. selection: Set<string>;
  81. updateSelection: Updater<Set<string>>;
  82. defaultSelectAll?: boolean;
  83. onSelected?: (messages: ChatMessage[]) => void;
  84. }) {
  85. const chatStore = useChatStore();
  86. const session = chatStore.currentSession();
  87. const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
  88. const allMessages = useMemo(() => {
  89. let startIndex = Math.max(0, session.clearContextIndex ?? 0);
  90. if (startIndex === session.messages.length - 1) {
  91. startIndex = 0;
  92. }
  93. return session.messages.slice(startIndex);
  94. }, [session.messages, session.clearContextIndex]);
  95. const messages = useMemo(
  96. () =>
  97. allMessages.filter(
  98. (m, i) =>
  99. m.id && // message must have id
  100. isValid(m) &&
  101. (i >= allMessages.length - 1 || isValid(allMessages[i + 1])),
  102. ),
  103. [allMessages],
  104. );
  105. const messageCount = messages.length;
  106. const config = useAppConfig();
  107. const [searchInput, setSearchInput] = useState("");
  108. const [searchIds, setSearchIds] = useState(new Set<string>());
  109. const isInSearchResult = (id: string) => {
  110. return searchInput.length === 0 || searchIds.has(id);
  111. };
  112. const doSearch = (text: string) => {
  113. const searchResults = new Set<string>();
  114. if (text.length > 0) {
  115. messages.forEach((m) =>
  116. getMessageTextContent(m).includes(text)
  117. ? searchResults.add(m.id!)
  118. : null,
  119. );
  120. }
  121. setSearchIds(searchResults);
  122. };
  123. // for range selection
  124. const { startIndex, endIndex, onClickIndex } = useShiftRange();
  125. const selectAll = () => {
  126. props.updateSelection((selection) =>
  127. messages.forEach((m) => selection.add(m.id!)),
  128. );
  129. };
  130. useEffect(() => {
  131. if (props.defaultSelectAll) {
  132. selectAll();
  133. }
  134. // eslint-disable-next-line react-hooks/exhaustive-deps
  135. }, []);
  136. useEffect(() => {
  137. if (startIndex === undefined || endIndex === undefined) {
  138. return;
  139. }
  140. const [start, end] = [startIndex, endIndex].sort((a, b) => a - b);
  141. props.updateSelection((selection) => {
  142. for (let i = start; i <= end; i += 1) {
  143. selection.add(messages[i].id ?? i);
  144. }
  145. });
  146. // eslint-disable-next-line react-hooks/exhaustive-deps
  147. }, [startIndex, endIndex]);
  148. const LATEST_COUNT = 4;
  149. return (
  150. <div className={styles["message-selector"]}>
  151. <div className={styles["message-filter"]}>
  152. <input
  153. type="text"
  154. placeholder={Locale.Select.Search}
  155. className={styles["filter-item"] + " " + styles["search-bar"]}
  156. value={searchInput}
  157. onInput={(e) => {
  158. setSearchInput(e.currentTarget.value);
  159. doSearch(e.currentTarget.value);
  160. }}
  161. ></input>
  162. <div className={styles["actions"]}>
  163. <IconButton
  164. text={Locale.Select.All}
  165. bordered
  166. className={styles["filter-item"]}
  167. onClick={selectAll}
  168. />
  169. <IconButton
  170. text={Locale.Select.Latest}
  171. bordered
  172. className={styles["filter-item"]}
  173. onClick={() =>
  174. props.updateSelection((selection) => {
  175. selection.clear();
  176. messages
  177. .slice(messageCount - LATEST_COUNT)
  178. .forEach((m) => selection.add(m.id!));
  179. })
  180. }
  181. />
  182. <IconButton
  183. text={Locale.Select.Clear}
  184. bordered
  185. className={styles["filter-item"]}
  186. onClick={() =>
  187. props.updateSelection((selection) => selection.clear())
  188. }
  189. />
  190. </div>
  191. </div>
  192. <div className={styles["messages"]}>
  193. {messages.map((m, i) => {
  194. if (!isInSearchResult(m.id!)) return null;
  195. const id = m.id ?? i;
  196. const isSelected = props.selection.has(id);
  197. return (
  198. <div
  199. className={`${styles["message"]} ${
  200. props.selection.has(m.id!) && styles["message-selected"]
  201. }`}
  202. key={i}
  203. onClick={() => {
  204. props.updateSelection((selection) => {
  205. selection.has(id) ? selection.delete(id) : selection.add(id);
  206. });
  207. onClickIndex(i);
  208. }}
  209. >
  210. <div className={styles["avatar"]}>
  211. {m.role === "user" ? (
  212. <Avatar avatar={config.avatar}></Avatar>
  213. ) : (
  214. <MaskAvatar
  215. avatar={session.mask.avatar}
  216. model={m.model || session.mask.modelConfig.model}
  217. />
  218. )}
  219. </div>
  220. <div className={styles["body"]}>
  221. <div className={styles["date"]}>
  222. {new Date(m.date).toLocaleString()}
  223. </div>
  224. <div className={`${styles["content"]} one-line`}>
  225. {getMessageTextContent(m)}
  226. </div>
  227. </div>
  228. <div className={styles["checkbox"]}>
  229. <input type="checkbox" checked={isSelected} readOnly></input>
  230. </div>
  231. </div>
  232. );
  233. })}
  234. </div>
  235. </div>
  236. );
  237. }