message-selector.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  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<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 messages = session.messages.filter(
  67. (m, i) =>
  68. m.id && // message must have 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<string>());
  76. const isInSearchResult = (id: string) => {
  77. return searchInput.length === 0 || searchIds.has(id);
  78. };
  79. const doSearch = (text: string) => {
  80. const searchResults = new Set<string>();
  81. if (text.length > 0) {
  82. messages.forEach((m) =>
  83. m.content.includes(text) ? searchResults.add(m.id!) : null,
  84. );
  85. }
  86. setSearchIds(searchResults);
  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. const LATEST_COUNT = 4;
  114. return (
  115. <div className={styles["message-selector"]}>
  116. <div className={styles["message-filter"]}>
  117. <input
  118. type="text"
  119. placeholder={Locale.Select.Search}
  120. className={styles["filter-item"] + " " + styles["search-bar"]}
  121. value={searchInput}
  122. onInput={(e) => {
  123. setSearchInput(e.currentTarget.value);
  124. doSearch(e.currentTarget.value);
  125. }}
  126. ></input>
  127. <div className={styles["actions"]}>
  128. <IconButton
  129. text={Locale.Select.All}
  130. bordered
  131. className={styles["filter-item"]}
  132. onClick={selectAll}
  133. />
  134. <IconButton
  135. text={Locale.Select.Latest}
  136. bordered
  137. className={styles["filter-item"]}
  138. onClick={() =>
  139. props.updateSelection((selection) => {
  140. selection.clear();
  141. messages
  142. .slice(messageCount - LATEST_COUNT)
  143. .forEach((m) => selection.add(m.id!));
  144. })
  145. }
  146. />
  147. <IconButton
  148. text={Locale.Select.Clear}
  149. bordered
  150. className={styles["filter-item"]}
  151. onClick={() =>
  152. props.updateSelection((selection) => selection.clear())
  153. }
  154. />
  155. </div>
  156. </div>
  157. <div className={styles["messages"]}>
  158. {messages.map((m, i) => {
  159. if (!isInSearchResult(m.id!)) return null;
  160. return (
  161. <div
  162. className={`${styles["message"]} ${
  163. props.selection.has(m.id!) && styles["message-selected"]
  164. }`}
  165. key={i}
  166. onClick={() => {
  167. props.updateSelection((selection) => {
  168. const id = m.id ?? i;
  169. selection.has(id) ? selection.delete(id) : selection.add(id);
  170. });
  171. onClickIndex(i);
  172. }}
  173. >
  174. <div className={styles["avatar"]}>
  175. {m.role === "user" ? (
  176. <Avatar avatar={config.avatar}></Avatar>
  177. ) : (
  178. <MaskAvatar mask={session.mask} />
  179. )}
  180. </div>
  181. <div className={styles["body"]}>
  182. <div className={styles["date"]}>
  183. {new Date(m.date).toLocaleString()}
  184. </div>
  185. <div className={`${styles["content"]} one-line`}>
  186. {m.content}
  187. </div>
  188. </div>
  189. </div>
  190. );
  191. })}
  192. </div>
  193. </div>
  194. );
  195. }