message-selector.tsx 6.3 KB

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