message-selector.tsx 6.9 KB

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