Просмотр исходного кода

Merge pull request #9827 from weseek/feat/163549-apply-editor-assistant-responses-to-editor2

feat: Apply editor assistant responses to editor (only insert)
Yuki Takei 1 год назад
Родитель
Сommit
c3496be9bc

+ 3 - 1
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -27,7 +27,7 @@ import {
   useDefaultIndentSize, useCurrentUser,
   useCurrentPathname, useIsEnabledAttachTitleHeader,
   useIsEditable, useIsIndentSizeForced,
-  useAcceptedUploadFileType,
+  useAcceptedUploadFileType, useIsEnableUnifiedMergeView,
 } from '~/stores-universal/context';
 import { EditorMode, useEditorMode } from '~/stores-universal/ui';
 import { useNextThemes } from '~/stores-universal/use-next-themes';
@@ -111,6 +111,7 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
   const { mutate: mutateEditingUsers } = useEditingClients();
   const onConflict = useConflictResolver();
   const { data: reservedNextCaretLine, mutate: mutateReservedNextCaretLine } = useReservedNextCaretLine();
+  const { data: isEnableUnifiedMergeView } = useIsEnableUnifiedMergeView();
 
   const { data: rendererOptions } = usePreviewOptions();
 
@@ -365,6 +366,7 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
     <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
       <div className="page-editor-editor-container flex-expand-vert border-end">
         <CodeMirrorEditorMain
+          enableUnifiedMergeView={isEnableUnifiedMergeView}
           enableCollaboration={editorMode === EditorMode.Editor}
           onSave={saveWithShortcut}
           onUpload={uploadHandler}

+ 21 - 8
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx

@@ -3,6 +3,8 @@ import {
   type FC, memo, useRef, useEffect, useState, useCallback, useMemo,
 } from 'react';
 
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useForm, Controller } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import { Collapse, UncontrolledTooltip } from 'reactstrap';
@@ -10,7 +12,7 @@ import SimpleBar from 'simplebar-react';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
-import { useGrowiCloudUri } from '~/stores-universal/context';
+import { useGrowiCloudUri, useIsEnableUnifiedMergeView } from '~/stores-universal/context';
 import loggerFactory from '~/utils/logger';
 
 import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
@@ -73,9 +75,12 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
   const { data: growiCloudUri } = useGrowiCloudUri();
   const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData?._id);
   const { trigger: mutateMessageData } = useSWRMUTxMessages(aiAssistantData?._id, threadData?.threadId);
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
   const { postMessage: postMessageForKnowledgeAssistant, processMessage: processMessageForKnowledgeAssistant } = useKnowledgeAssistant();
-  const { postMessage: postMessageForEditorAssistant, processMessage: processMessageForEditorAssistant } = useEditorAssistant();
+  const {
+    postMessage: postMessageForEditorAssistant, processMessage: processMessageForEditorAssistant, accept, reject,
+  } = useEditorAssistant();
 
   const form = useForm<FormData>({
     defaultValues: {
@@ -192,7 +197,8 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
 
       const response = await (async() => {
         if (isEditorAssistant) {
-          return postMessageForEditorAssistant(currentThreadId_, data.input, '# markdown');
+          const markdown = codeMirrorEditor?.getDoc();
+          return postMessageForEditorAssistant(currentThreadId_, data.input, markdown ?? '');
         }
         if (aiAssistantData?._id != null) {
           return postMessageForKnowledgeAssistant(aiAssistantData._id, currentThreadId_, data.input, data.summaryMode);
@@ -295,7 +301,7 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     }
 
   // eslint-disable-next-line max-len
-  }, [isGenerating, messageLogs, form, currentThreadId, aiAssistantData?._id, isEditorAssistant, mutateThreadData, t, postMessageForEditorAssistant, selectedAiAssistant?._id, postMessageForKnowledgeAssistant, processMessageForKnowledgeAssistant, processMessageForEditorAssistant, growiCloudUri]);
+  }, [isGenerating, messageLogs, form, currentThreadId, isEditorAssistant, selectedAiAssistant?._id, aiAssistantData?._id, mutateThreadData, t, codeMirrorEditor, postMessageForEditorAssistant, postMessageForKnowledgeAssistant, processMessageForKnowledgeAssistant, processMessageForEditorAssistant, growiCloudUri]);
 
   const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
     if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
@@ -308,12 +314,12 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
   }, [submit]);
 
   const clickAcceptHandler = useCallback(() => {
-    // todo: implement
-  }, []);
+    accept();
+  }, [accept]);
 
   const clickDiscardHandler = useCallback(() => {
-    // todo: implement
-  }, []);
+    reject();
+  }, [reject]);
 
   const selectAiAssistantHandler = useCallback((aiAssistant?: AiAssistantHasId) => {
     setSelectedAiAssistant(aiAssistant);
@@ -489,6 +495,7 @@ export const AiAssistantSidebar: FC = memo((): JSX.Element => {
   const sidebarScrollerRef = useRef<HTMLDivElement>(null);
 
   const { data: aiAssistantSidebarData, close: closeAiAssistantSidebar } = useAiAssistantSidebar();
+  const { mutate: mutateIsEnableUnifiedMergeView } = useIsEnableUnifiedMergeView();
 
   const aiAssistantData = aiAssistantSidebarData?.aiAssistantData;
   const threadData = aiAssistantSidebarData?.threadData;
@@ -508,6 +515,12 @@ export const AiAssistantSidebar: FC = memo((): JSX.Element => {
     };
   }, [closeAiAssistantSidebar, isOpened]);
 
+  useEffect(() => {
+    if (!aiAssistantSidebarData?.isOpened) {
+      mutateIsEnableUnifiedMergeView(false);
+    }
+  }, [aiAssistantSidebarData?.isOpened, mutateIsEnableUnifiedMergeView]);
+
   if (!isOpened) {
     return <></>;
   }

+ 82 - 3
apps/app/src/features/openai/client/services/editor-assistant.ts

@@ -1,14 +1,24 @@
-import { useCallback } from 'react';
+import { useCallback, useEffect, useState } from 'react';
+
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { acceptChange, rejectChange } from '@growi/editor/dist/client/services/unified-merge-view';
+import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
+import { useSecondaryYdocs } from '@growi/editor/dist/client/stores/use-secondary-ydocs';
 
 import {
   SseMessageSchema,
   SseDetectedDiffSchema,
   SseFinalizedSchema,
+  isInsertDiff,
+  isDeleteDiff,
+  isRetainDiff,
   type SseMessage,
   type SseDetectedDiff,
   type SseFinalized,
 } from '~/features/openai/interfaces/editor-assistant/sse-schemas';
 import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
+import { useIsEnableUnifiedMergeView } from '~/stores-universal/context';
+import { useCurrentPageId } from '~/stores/page';
 
 interface PostMessage {
   (threadId: string, userMessage: string, markdown: string): Promise<Response>;
@@ -21,7 +31,20 @@ interface ProcessMessage {
   }): void;
 }
 
-export const useEditorAssistant = (): { postMessage: PostMessage, processMessage: ProcessMessage } => {
+type DetectedDiff = Array<{
+  data: SseDetectedDiff,
+  applied: boolean,
+  id: string,
+}>
+
+export const useEditorAssistant = (): {postMessage: PostMessage, processMessage: ProcessMessage, accept: () => void, reject: () => void } => {
+  const [detectedDiff, setDetectedDiff] = useState<DetectedDiff>();
+
+  const { data: currentPageId } = useCurrentPageId();
+  const { data: isEnableUnifiedMergeView, mutate: mutateIsEnableUnifiedMergeView } = useIsEnableUnifiedMergeView();
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+  const ydocs = useSecondaryYdocs(isEnableUnifiedMergeView ?? false, { pageId: currentPageId ?? undefined, useSecondary: isEnableUnifiedMergeView ?? false });
+
   const postMessage: PostMessage = useCallback(async(threadId, userMessage, markdown) => {
     const response = await fetch('/_api/v3/openai/edit', {
       method: 'POST',
@@ -40,15 +63,71 @@ export const useEditorAssistant = (): { postMessage: PostMessage, processMessage
       handler.onMessage(data);
     });
     handleIfSuccessfullyParsed(data, SseDetectedDiffSchema, (data: SseDetectedDiff) => {
+      mutateIsEnableUnifiedMergeView(true);
+      setDetectedDiff((prev) => {
+        const newData = { data, applied: false, id: crypto.randomUUID() };
+        if (prev == null) {
+          return [newData];
+        }
+        return [...prev, newData];
+      });
       handler.onDetectedDiff(data);
     });
     handleIfSuccessfullyParsed(data, SseFinalizedSchema, (data: SseFinalized) => {
       handler.onFinalized(data);
     });
-  }, []);
+  }, [mutateIsEnableUnifiedMergeView]);
+
+  const accept = useCallback(() => {
+    acceptChange(codeMirrorEditor?.view);
+    mutateIsEnableUnifiedMergeView(false);
+  }, [codeMirrorEditor?.view, mutateIsEnableUnifiedMergeView]);
+
+  const reject = useCallback(() => {
+    rejectChange(codeMirrorEditor?.view);
+    mutateIsEnableUnifiedMergeView(false);
+  }, [codeMirrorEditor?.view, mutateIsEnableUnifiedMergeView]);
+
+  useEffect(() => {
+    const pendingDetectedDiff: DetectedDiff | undefined = detectedDiff?.filter(diff => diff.applied === false);
+    if (ydocs?.secondaryDoc != null && pendingDetectedDiff != null && pendingDetectedDiff.length > 0) {
+
+      // Apply detectedDiffs to secondaryDoc
+      const ytext = ydocs.secondaryDoc.getText('codemirror');
+      pendingDetectedDiff.forEach((detectedDiff) => {
+        if (isInsertDiff(detectedDiff.data)) {
+          ytext.insert(0, detectedDiff.data.diff.insert);
+        }
+        if (isDeleteDiff(detectedDiff.data)) {
+          // TODO: https://redmine.weseek.co.jp/issues/163945
+        }
+        if (isRetainDiff(detectedDiff.data)) {
+          // TODO: https://redmine.weseek.co.jp/issues/163945
+        }
+      });
+
+      // Mark as applied: true after applying to secondaryDoc
+      setDetectedDiff((prev) => {
+        const pendingDetectedDiffIds = pendingDetectedDiff.map(diff => diff.id);
+        prev?.forEach((diff) => {
+          if (pendingDetectedDiffIds.includes(diff.id)) {
+            diff.applied = true;
+          }
+        });
+        return prev;
+      });
+
+      // Set detectedDiff to undefined after applying all detectedDiff to secondaryDoc
+      if (detectedDiff?.filter(detectedDiff => detectedDiff.applied === false).length === 0) {
+        setDetectedDiff(undefined);
+      }
+    }
+  }, [detectedDiff, ydocs?.secondaryDoc]);
 
   return {
     postMessage,
     processMessage,
+    accept,
+    reject,
   };
 };

+ 13 - 0
apps/app/src/features/openai/interfaces/editor-assistant/sse-schemas.ts

@@ -28,3 +28,16 @@ export const SseFinalizedSchema = z
 export type SseMessage = z.infer<typeof SseMessageSchema>;
 export type SseDetectedDiff = z.infer<typeof SseDetectedDiffSchema>;
 export type SseFinalized = z.infer<typeof SseFinalizedSchema>;
+
+// Type guard for SseDetectedDiff
+export const isInsertDiff = (diff: SseDetectedDiff): diff is { diff: { insert: string } } => {
+  return 'insert' in diff.diff;
+};
+
+export const isDeleteDiff = (diff: SseDetectedDiff): diff is { diff: { delete: number } } => {
+  return 'delete' in diff.diff;
+};
+
+export const isRetainDiff = (diff: SseDetectedDiff): diff is { diff : { retain: number} } => {
+  return 'retain' in diff.diff;
+};

+ 4 - 0
apps/app/src/stores-universal/context.tsx

@@ -224,6 +224,10 @@ export const useLimitLearnablePageCountPerAssistant = (initialData?: number): SW
   return useContextSWR('limitLearnablePageCountPerAssistant', initialData);
 };
 
+export const useIsEnableUnifiedMergeView = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useSWRStatic<boolean, Error>('isEnableUnifiedMergeView', initialData, { fallbackData: false });
+};
+
 /** **********************************************************
  *                     Computed contexts
  *********************************************************** */

+ 14 - 0
packages/editor/src/client/services/unified-merge-view/index.ts

@@ -0,0 +1,14 @@
+import {
+  acceptChunk,
+  rejectChunk,
+} from '@codemirror/merge';
+
+import type { EditorView } from 'src';
+
+export const acceptChange = (view: EditorView): boolean => {
+  return acceptChunk(view);
+};
+
+export const rejectChange = (view: EditorView): boolean => {
+  return rejectChunk(view);
+};