message-selector.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. import { useEffect, 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<number>());
  47. const updateSelection: Updater<Set<number>> = (updater) => {
  48. const newSelection = new Set<number>(selection);
  49. updater(newSelection);
  50. setSelection(newSelection);
  51. };
  52. return {
  53. selection,
  54. updateSelection,
  55. };
  56. }
  57. export function MessageSelector(props: {
  58. selection: Set<number>;
  59. updateSelection: Updater<Set<number>>;
  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 messages = session.messages.filter(
  67. (m, i) =>
  68. m.id && // messsage must has id
  69. isValid(m) &&
  70. (i >= session.messages.length - 1 || isValid(session.messages[i + 1])),
  71. );
  72. const messageCount = messages.length;
  73. const config = useAppConfig();
  74. const [searchInput, setSearchInput] = useState("");
  75. const [searchIds, setSearchIds] = useState(new Set<number>());
  76. const isInSearchResult = (id: number) => {
  77. return searchInput.length === 0 || searchIds.has(id);
  78. };
  79. const doSearch = (text: string) => {
  80. const searchResuts = new Set<number>();
  81. if (text.length > 0) {
  82. messages.forEach((m) =>
  83. m.content.includes(text) ? searchResuts.add(m.id!) : null,
  84. );
  85. }
  86. setSearchIds(searchResuts);
  87. };
  88. // for range selection
  89. const { startIndex, endIndex, onClickIndex } = useShiftRange();
  90. const selectAll = () => {
  91. props.updateSelection((selection) =>
  92. messages.forEach((m) => selection.add(m.id!)),
  93. );
  94. };
  95. useEffect(() => {
  96. if (props.defaultSelectAll) {
  97. selectAll();
  98. }
  99. // eslint-disable-next-line react-hooks/exhaustive-deps
  100. }, []);
  101. useEffect(() => {
  102. if (startIndex === undefined || endIndex === undefined) {
  103. return;
  104. }
  105. const [start, end] = [startIndex, endIndex].sort((a, b) => a - b);
  106. props.updateSelection((selection) => {
  107. for (let i = start; i <= end; i += 1) {
  108. selection.add(messages[i].id ?? i);
  109. }
  110. });
  111. // eslint-disable-next-line react-hooks/exhaustive-deps
  112. }, [startIndex, endIndex]);
  113. return (
  114. <div className={styles["message-selector"]}>
  115. <div className={styles["message-filter"]}>
  116. <input
  117. type="text"
  118. placeholder={Locale.Select.Search}
  119. className={styles["filter-item"] + " " + styles["search-bar"]}
  120. value={searchInput}
  121. onInput={(e) => {
  122. setSearchInput(e.currentTarget.value);
  123. doSearch(e.currentTarget.value);
  124. }}
  125. ></input>
  126. <div className={styles["actions"]}>
  127. <IconButton
  128. text={Locale.Select.All}
  129. bordered
  130. className={styles["filter-item"]}
  131. onClick={selectAll}
  132. />
  133. <IconButton
  134. text={Locale.Select.Latest}
  135. bordered
  136. className={styles["filter-item"]}
  137. onClick={() =>
  138. props.updateSelection((selection) => {
  139. selection.clear();
  140. messages
  141. .slice(messageCount - 10)
  142. .forEach((m) => selection.add(m.id!));
  143. })
  144. }
  145. />
  146. <IconButton
  147. text={Locale.Select.Clear}
  148. bordered
  149. className={styles["filter-item"]}
  150. onClick={() =>
  151. props.updateSelection((selection) => selection.clear())
  152. }
  153. />
  154. </div>
  155. </div>
  156. <div className={styles["messages"]}>
  157. {messages.map((m, i) => {
  158. if (!isInSearchResult(m.id!)) return null;
  159. return (
  160. <div
  161. className={`${styles["message"]} ${
  162. props.selection.has(m.id!) && styles["message-selected"]
  163. }`}
  164. key={i}
  165. onClick={() => {
  166. props.updateSelection((selection) => {
  167. const id = m.id ?? i;
  168. selection.has(id) ? selection.delete(id) : selection.add(id);
  169. });
  170. onClickIndex(i);
  171. }}
  172. >
  173. <div className={styles["avatar"]}>
  174. {m.role === "user" ? (
  175. <Avatar avatar={config.avatar}></Avatar>
  176. ) : (
  177. <MaskAvatar mask={session.mask} />
  178. )}
  179. </div>
  180. <div className={styles["body"]}>
  181. <div className={styles["date"]}>
  182. {new Date(m.date).toLocaleString()}
  183. </div>
  184. <div className={`${styles["content"]} one-line`}>
  185. {m.content}
  186. </div>
  187. </div>
  188. </div>
  189. );
  190. })}
  191. </div>
  192. </div>
  193. );
  194. }