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