message-selector.tsx 6.8 KB

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