editor-assistant.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import {
  2. useCallback, useEffect, useState, useRef,
  3. } from 'react';
  4. import { GlobalCodeMirrorEditorKey } from '@growi/editor';
  5. import { acceptChange, rejectChange, useTextSelectionEffect } from '@growi/editor/dist/client/services/unified-merge-view';
  6. import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
  7. import { useSecondaryYdocs } from '@growi/editor/dist/client/stores/use-secondary-ydocs';
  8. import { type Text as YText } from 'yjs';
  9. import {
  10. SseMessageSchema,
  11. SseDetectedDiffSchema,
  12. SseFinalizedSchema,
  13. isReplaceDiff,
  14. // isInsertDiff,
  15. // isDeleteDiff,
  16. // isRetainDiff,
  17. type SseMessage,
  18. type SseDetectedDiff,
  19. type SseFinalized,
  20. } from '~/features/openai/interfaces/editor-assistant/sse-schemas';
  21. import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
  22. import { useIsEnableUnifiedMergeView } from '~/stores-universal/context';
  23. import { useCurrentPageId } from '~/stores/page';
  24. interface PostMessage {
  25. (threadId: string, userMessage: string): Promise<Response>;
  26. }
  27. interface ProcessMessage {
  28. (data: unknown, handler: {
  29. onMessage: (data: SseMessage) => void;
  30. onDetectedDiff: (data: SseDetectedDiff) => void;
  31. onFinalized: (data: SseFinalized) => void;
  32. }): void;
  33. }
  34. type DetectedDiff = Array<{
  35. data: SseDetectedDiff,
  36. applied: boolean,
  37. id: string,
  38. }>
  39. type UseEditorAssistant = () => {
  40. isTextSelected: boolean,
  41. postMessage: PostMessage,
  42. processMessage: ProcessMessage,
  43. accept: () => void,
  44. reject: () => void,
  45. }
  46. const insertTextAtLine = (ytext: YText, lineNumber: number, textToInsert: string): void => {
  47. // Get the entire text content
  48. const content = ytext.toString();
  49. // Split by newlines to get all lines
  50. const lines = content.split('\n');
  51. // Calculate the index position for insertion
  52. let insertPosition = 0;
  53. // Sum the length of all lines before the target line (plus newline characters)
  54. for (let i = 0; i < lineNumber && i < lines.length; i++) {
  55. insertPosition += lines[i].length + 1; // +1 for the newline character
  56. }
  57. // Insert the text at the calculated position
  58. ytext.insert(insertPosition, textToInsert);
  59. };
  60. const getLineInfo = (ytext: YText, lineNumber: number): { text: string, startIndex: number } | null => {
  61. // Get the entire text content
  62. const content = ytext.toString();
  63. // Split by newlines to get all lines
  64. const lines = content.split('\n');
  65. // Check if the requested line exists
  66. if (lineNumber < 0 || lineNumber >= lines.length) {
  67. return null; // Line doesn't exist
  68. }
  69. // Get the text of the specified line
  70. const text = lines[lineNumber];
  71. // Calculate the start index of the line
  72. let startIndex = 0;
  73. for (let i = 0; i < lineNumber; i++) {
  74. startIndex += lines[i].length + 1; // +1 for the newline character
  75. }
  76. // Return comprehensive line information
  77. return {
  78. text,
  79. startIndex,
  80. };
  81. };
  82. export const useEditorAssistant: UseEditorAssistant = () => {
  83. // Refs
  84. // const positionRef = useRef<number>(0);
  85. const lineRef = useRef<number>(0);
  86. // States
  87. const [detectedDiff, setDetectedDiff] = useState<DetectedDiff>();
  88. const [selectedTextFirstLineNumber, setSelectedTextFirstLineNumber] = useState<number>();
  89. const [selectedText, setSelectedText] = useState<string>();
  90. const isTextSelected = selectedText != null && selectedText.length !== 0;
  91. // SWR Hooks
  92. const { data: currentPageId } = useCurrentPageId();
  93. const { data: isEnableUnifiedMergeView, mutate: mutateIsEnableUnifiedMergeView } = useIsEnableUnifiedMergeView();
  94. const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
  95. const ydocs = useSecondaryYdocs(isEnableUnifiedMergeView ?? false, { pageId: currentPageId ?? undefined, useSecondary: isEnableUnifiedMergeView ?? false });
  96. // Functions
  97. const postMessage: PostMessage = useCallback(async(threadId, userMessage) => {
  98. lineRef.current = selectedTextFirstLineNumber ?? 0;
  99. const response = await fetch('/_api/v3/openai/edit', {
  100. method: 'POST',
  101. headers: { 'Content-Type': 'application/json' },
  102. body: JSON.stringify({
  103. threadId,
  104. userMessage,
  105. markdown: selectedText,
  106. }),
  107. });
  108. setSelectedText(undefined);
  109. return response;
  110. }, [selectedText, selectedTextFirstLineNumber]);
  111. const processMessage: ProcessMessage = useCallback((data, handler) => {
  112. handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
  113. handler.onMessage(data);
  114. });
  115. handleIfSuccessfullyParsed(data, SseDetectedDiffSchema, (data: SseDetectedDiff) => {
  116. mutateIsEnableUnifiedMergeView(true);
  117. setDetectedDiff((prev) => {
  118. const newData = { data, applied: false, id: crypto.randomUUID() };
  119. if (prev == null) {
  120. return [newData];
  121. }
  122. return [...prev, newData];
  123. });
  124. handler.onDetectedDiff(data);
  125. });
  126. handleIfSuccessfullyParsed(data, SseFinalizedSchema, (data: SseFinalized) => {
  127. handler.onFinalized(data);
  128. });
  129. }, [mutateIsEnableUnifiedMergeView]);
  130. const accept = useCallback(() => {
  131. if (codeMirrorEditor?.view == null) {
  132. return;
  133. }
  134. acceptChange(codeMirrorEditor.view);
  135. mutateIsEnableUnifiedMergeView(false);
  136. }, [codeMirrorEditor?.view, mutateIsEnableUnifiedMergeView]);
  137. const reject = useCallback(() => {
  138. if (codeMirrorEditor?.view == null) {
  139. return;
  140. }
  141. rejectChange(codeMirrorEditor.view);
  142. mutateIsEnableUnifiedMergeView(false);
  143. }, [codeMirrorEditor?.view, mutateIsEnableUnifiedMergeView]);
  144. const selectTextHandler = useCallback((selectedText?: string, selectedTextFirstLineNumber?: number) => {
  145. setSelectedText(selectedText);
  146. setSelectedTextFirstLineNumber(selectedTextFirstLineNumber);
  147. }, []);
  148. // Effects
  149. useTextSelectionEffect(codeMirrorEditor, selectTextHandler);
  150. useEffect(() => {
  151. const pendingDetectedDiff: DetectedDiff | undefined = detectedDiff?.filter(diff => diff.applied === false);
  152. if (ydocs?.secondaryDoc != null && pendingDetectedDiff != null && pendingDetectedDiff.length > 0) {
  153. // For debug
  154. // const testDetectedDiff = [
  155. // {
  156. // data: { diff: { retain: 9 } },
  157. // applied: false,
  158. // id: crypto.randomUUID(),
  159. // },
  160. // {
  161. // data: { diff: { delete: 5 } },
  162. // applied: false,
  163. // id: crypto.randomUUID(),
  164. // },
  165. // {
  166. // data: { diff: { insert: 'growi' } },
  167. // applied: false,
  168. // id: crypto.randomUUID(),
  169. // },
  170. // ];
  171. const ytext = ydocs.secondaryDoc.getText('codemirror');
  172. ydocs.secondaryDoc.transact(() => {
  173. pendingDetectedDiff.forEach((detectedDiff) => {
  174. if (isReplaceDiff(detectedDiff.data)) {
  175. const lineInfo = getLineInfo(ytext, lineRef.current);
  176. if (lineInfo != null && lineInfo.text !== detectedDiff.data.diff.replace) {
  177. ytext.delete(lineInfo.startIndex, lineInfo.text.length);
  178. insertTextAtLine(ytext, lineRef.current, detectedDiff.data.diff.replace);
  179. }
  180. lineRef.current += 1;
  181. }
  182. // if (isInsertDiff(detectedDiff.data)) {
  183. // ytext.insert(positionRef.current, detectedDiff.data.diff.insert);
  184. // }
  185. // if (isDeleteDiff(detectedDiff.data)) {
  186. // ytext.delete(positionRef.current, detectedDiff.data.diff.delete);
  187. // }
  188. // if (isRetainDiff(detectedDiff.data)) {
  189. // positionRef.current += detectedDiff.data.diff.retain;
  190. // }
  191. });
  192. });
  193. // Mark as applied: true after applying to secondaryDoc
  194. setDetectedDiff((prev) => {
  195. const pendingDetectedDiffIds = pendingDetectedDiff.map(diff => diff.id);
  196. prev?.forEach((diff) => {
  197. if (pendingDetectedDiffIds.includes(diff.id)) {
  198. diff.applied = true;
  199. }
  200. });
  201. return prev;
  202. });
  203. // Set detectedDiff to undefined after applying all detectedDiff to secondaryDoc
  204. if (detectedDiff?.filter(detectedDiff => detectedDiff.applied === false).length === 0) {
  205. setDetectedDiff(undefined);
  206. lineRef.current = 0;
  207. // positionRef.current = 0;
  208. }
  209. }
  210. }, [codeMirrorEditor, detectedDiff, ydocs?.secondaryDoc]);
  211. return {
  212. isTextSelected,
  213. postMessage,
  214. processMessage,
  215. accept,
  216. reject,
  217. };
  218. };