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

Merge pull request #9643 from weseek/feat/unified-merge-view

feat: Unified merge view
Shun Miyazawa 11 месяцев назад
Родитель
Сommit
44807324e9
90 измененных файлов с 3193 добавлено и 644 удалено
  1. 3 1
      apps/app/package.json
  2. 20 2
      apps/app/public/static/locales/en_US/translation.json
  3. 20 3
      apps/app/public/static/locales/fr_FR/translation.json
  4. 21 4
      apps/app/public/static/locales/ja_JP/translation.json
  5. 20 3
      apps/app/public/static/locales/zh_CN/translation.json
  6. 9 9
      apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx
  7. 3 3
      apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx
  8. 33 0
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorAssistantToggleButton.tsx
  9. 0 0
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.module.scss
  10. 9 7
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx
  11. 0 0
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.tsx
  12. 0 0
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/OptionsSelector.tsx
  13. 4 3
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx
  14. 1 0
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/index.ts
  15. 8 7
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  16. 0 1
      apps/app/src/client/components/SavePageControls/GrantSelector/index.ts
  17. 4 4
      apps/app/src/components/Layout/BasicLayout.tsx
  18. 0 79
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.tsx
  19. 47 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView.tsx
  20. 74 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown.tsx
  21. 2 2
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.module.scss
  22. 197 140
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx
  23. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.module.scss
  24. 134 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.tsx
  25. 40 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/QuickMenuList.tsx
  26. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/ResizableTextArea.tsx
  27. 4 4
      apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.tsx
  28. 4 18
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx
  29. 387 0
      apps/app/src/features/openai/client/services/editor-assistant.tsx
  30. 193 0
      apps/app/src/features/openai/client/services/knowledge-assistant.tsx
  31. 25 9
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  32. 2 2
      apps/app/src/features/openai/client/stores/message.tsx
  33. 3 3
      apps/app/src/features/openai/client/stores/thread.tsx
  34. 17 0
      apps/app/src/features/openai/client/utils/get-share-scope-Icon.ts
  35. 32 0
      apps/app/src/features/openai/interfaces/editor-assistant/llm-response-schemas.ts
  36. 47 0
      apps/app/src/features/openai/interfaces/editor-assistant/sse-schemas.ts
  37. 16 0
      apps/app/src/features/openai/interfaces/knowledge-assistant/sse-schemas.ts
  38. 6 0
      apps/app/src/features/openai/interfaces/message.ts
  39. 9 0
      apps/app/src/features/openai/interfaces/thread-relation.ts
  40. 6 2
      apps/app/src/features/openai/server/models/thread-relation.ts
  41. 146 0
      apps/app/src/features/openai/server/routes/edit/README.ja.md
  42. 325 0
      apps/app/src/features/openai/server/routes/edit/index.ts
  43. 4 0
      apps/app/src/features/openai/server/routes/index.ts
  44. 9 13
      apps/app/src/features/openai/server/routes/thread.ts
  45. 56 0
      apps/app/src/features/openai/server/routes/utils/sse-helper.ts
  46. 25 12
      apps/app/src/features/openai/server/services/assistant/assistant.ts
  47. 9 7
      apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts
  48. 1 1
      apps/app/src/features/openai/server/services/client-delegator/interfaces.ts
  49. 9 7
      apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts
  50. 1 0
      apps/app/src/features/openai/server/services/editor-assistant/index.ts
  51. 242 0
      apps/app/src/features/openai/server/services/editor-assistant/llm-response-stream-processor.ts
  52. 5 0
      apps/app/src/features/openai/server/services/normalize-data/normalize-thread-relation-expired-at/normalize-thread-relation-expired-at.integ.ts
  53. 8 7
      apps/app/src/features/openai/server/services/openai.ts
  54. 10 0
      apps/app/src/features/openai/utils/handle-if-successfully-parsed.ts
  55. 1 3
      apps/app/src/server/service/yjs/sync-ydoc.ts
  56. 4 0
      apps/app/src/stores-universal/context.tsx
  57. 7 0
      apps/app/src/stores/use-editing-clients.ts
  58. 0 33
      apps/app/src/stores/use-editing-users.ts
  59. 4 0
      packages/core-styles/scss/bootstrap/mixins/_button-outline-variant.scss
  60. 2 0
      packages/editor/package.json
  61. 0 2
      packages/editor/src/@types/y-codemirror.next.d.ts
  62. 5 1
      packages/editor/src/client/components-internal/CodeMirrorEditor/CodeMirrorEditor.tsx
  63. 33 9
      packages/editor/src/client/components-internal/playground/Playground.tsx
  64. 12 112
      packages/editor/src/client/components-internal/playground/PlaygroundController.tsx
  65. 27 0
      packages/editor/src/client/components-internal/playground/controller/InitEditorValueRow.tsx
  66. 19 0
      packages/editor/src/client/components-internal/playground/controller/KeymapControl.tsx
  67. 24 0
      packages/editor/src/client/components-internal/playground/controller/OutlineSecondaryButtons.tsx
  68. 19 0
      packages/editor/src/client/components-internal/playground/controller/PasteModeControl.tsx
  69. 41 0
      packages/editor/src/client/components-internal/playground/controller/SetCaretLineRow.tsx
  70. 19 0
      packages/editor/src/client/components-internal/playground/controller/ThemeControl.tsx
  71. 17 0
      packages/editor/src/client/components-internal/playground/controller/UnifiedMergeViewControl.tsx
  72. 17 5
      packages/editor/src/client/components/CodeMirrorEditorMain.tsx
  73. 1 0
      packages/editor/src/client/services-internal/index.ts
  74. 98 0
      packages/editor/src/client/services-internal/unified-merge-view/README.ja.md
  75. 4 0
      packages/editor/src/client/services-internal/unified-merge-view/index.ts
  76. 39 0
      packages/editor/src/client/services-internal/unified-merge-view/use-customized-button-styles.ts
  77. 37 0
      packages/editor/src/client/services-internal/unified-merge-view/use-unified-merge-view.module.scss
  78. 141 0
      packages/editor/src/client/services-internal/unified-merge-view/use-unified-merge-view.ts
  79. 60 0
      packages/editor/src/client/services/unified-merge-view/index.ts
  80. 0 1
      packages/editor/src/client/stores/codemirror-editor.ts
  81. 98 92
      packages/editor/src/client/stores/use-collaborative-editor-mode.ts
  82. 39 28
      packages/editor/src/client/stores/use-editor-settings.ts
  83. 68 0
      packages/editor/src/client/stores/use-secondary-ydocs.ts
  84. 2 0
      packages/editor/src/interfaces/delta.ts
  85. 7 0
      packages/editor/src/interfaces/editing-client.ts
  86. 2 0
      packages/editor/src/interfaces/index.ts
  87. 8 1
      packages/editor/src/main.scss
  88. 33 0
      packages/editor/src/utils/delta-to-changespecs.ts
  89. 29 1
      packages/editor/vite.config.ts
  90. 26 3
      pnpm-lock.yaml

+ 3 - 1
apps/app/package.json

@@ -145,6 +145,7 @@
     "is-iso-date": "^0.0.1",
     "js-tiktoken": "^1.0.15",
     "js-yaml": "^4.1.0",
+    "jsonrepair": "^3.12.0",
     "katex": "^0.16.21",
     "ldapjs": "^3.0.2",
     "lucene-query-parser": "^1.2.0",
@@ -246,7 +247,8 @@
     "xss": "^1.0.15",
     "y-mongodb-provider": "^0.2.0",
     "y-socket.io": "^1.1.3",
-    "yjs": "^13.6.18"
+    "yjs": "^13.6.18",
+    "zod": "^3.24.2"
   },
   "// comments for defDependencies": {
     "bootstrap": "v5.3.3 has a bug. refs: https://github.com/twbs/bootstrap/issues/39798",

+ 20 - 2
apps/app/public/static/locales/en_US/translation.json

@@ -154,6 +154,7 @@
   "In-App Notification": "Notifications",
   "AI Assistant": "AI Assistant",
   "Knowledge Assistant": "Knowledge Assistant (Beta)",
+  "Editor Assistant": "Editor Assistant (Beta)",
   "original_path": "Original path",
   "new_path": "New path",
   "duplicated_path": "Duplicated path",
@@ -344,6 +345,7 @@
       "file": "File only"
     },
     "editor_config": "Editor Config",
+    "editor_assistant": "Editor Assistant",
     "Show active line": "Show active line",
     "auto_format_table": "Auto format table",
     "overwrite_scopes": "{{operation}} and Overwrite scopes of all descendants",
@@ -493,10 +495,12 @@
     "latest_revision": "theirs",
     "selected_editable_revision": "Selected Page Body (Editable)"
   },
-  "sidebar_aichat": {
+  "sidebar_ai_assistant": {
     "instruction_label": "Assistant instructions",
     "reference_pages_label": "Reference pages",
     "placeholder": "Ask me anything.",
+    "knowledge_assistant_placeholder": "Ask me anything.",
+    "editor_assistant_placeholder": "Can I help you with anything?",
     "summary_mode_label": "Summary mode",
     "summary_mode_help": "Concise answer within 2-3 sentences",
     "caution_against_hallucination": "Please verify the information and check the sources.",
@@ -505,7 +509,21 @@
     "budget_exceeded": "You have reached your usage limit for OpenAI's API. To use the Knowledge Assistant again, please add credits from the OpenAI billing page.",
     "budget_exceeded_for_growi_cloud": "You have reached your OpenAI API usage limit. To use the Knowledge Assistant again, please add credits from the GROWI.cloud admin page for Hosted users or from the OpenAI billing page for Owned users.",
     "error_message": "An error has occurred",
-    "show_error_detail": "Show error details"
+    "show_error_detail": "Show error details",
+    "discard": "Discard",
+    "accept": "Accept",
+    "use_assistant": "Use Assistant",
+    "remove_assistant": "Deselect the selected assistant",
+    "preset_menu": {
+      "summarize": {
+        "title": "Summarize this article",
+        "prompt": "Please summarize the markdown content"
+      },
+      "correct": {
+        "title": "Correct errors in the text",
+        "prompt": "Please correct the errors in the markdown text"
+      }
+    }
   },
   "modal_ai_assistant": {
     "header": {

+ 20 - 3
apps/app/public/static/locales/fr_FR/translation.json

@@ -155,6 +155,7 @@
   "In-App Notification": "Notifications",
   "AI Assistant": "Assistant IA",
   "Knowledge Assistant": "Assistant de Connaissances (Bêta)",
+  "Editor Assistant": "Assistante de rédaction (Bêta)",
   "original_path": "Chemin originel",
   "new_path": "Nouveau chemin",
   "duplicated_path": "Chemin dupliqué",
@@ -345,6 +346,7 @@
       "file": "Fichier seulement"
     },
     "editor_config": "Préférences de l'éditeur",
+    "editor_assistant": "Assistant d'édition",
     "Show active line": "Surligner la ligne active",
     "auto_format_table": "Formatter les tableaux",
     "overwrite_scopes": "{{operation}} et écraser les scopes des pages enfants",
@@ -488,10 +490,11 @@
     "latest_revision": "les autres",
     "selected_editable_revision": "Corps de page sélectionné (Modifiable)"
   },
-  "sidebar_aichat": {
+  "sidebar_ai_assistant": {
     "instruction_label": "Instructions pour l'assistant",
     "reference_pages_label": "Pages de référence",
-    "placeholder": "Demandez-moi n'importe quoi.",
+    "knowledge_assistant_placeholder": "Demandez-moi n'importe quoi.",
+    "editor_assistant_placeholder": "Puis-je vous aider ?",
     "summary_mode_label": "Mode résumé",
     "summary_mode_help": "Réponse concise en 2-3 phrases",
     "caution_against_hallucination": "Veuillez vérifier les informations et consulter les sources.",
@@ -500,7 +503,21 @@
     "budget_exceeded": "Vous avez atteint votre limite d'utilisation de l'API de l'OpenAI. Pour utiliser à nouveau l'assistant de connaissance, veuillez ajouter des crédits à partir de la page de facturation d'OpenAI.",
     "budget_exceeded_for_growi_cloud": "Vous avez atteint votre limite d'utilisation de l'API de l'OpenAI. Pour utiliser à nouveau l'assistant de connaissance, veuillez ajouter des crédits à partir de la page d'administration de GROWI.cloud pour les utilisateurs hébergés ou à partir de la page de facturation de l'OpenAI pour les utilisateurs propriétaires.",
     "error_message": "Erreur",
-    "show_error_detail": "Détails de l'exposition"
+    "show_error_detail": "Détails de l'exposition",
+    "discard": "Annuler",
+    "accept": "Accepter",
+    "use_assistant": "Utiliser l'assistant",
+    "remove_assistant": "Désélectionner l'assistant sélectionné",
+    "preset_menu": {
+      "summarize": {
+        "title": "Résumer cet article'",
+        "prompt": "Veuillez résumer le contenu markdown"
+      },
+      "correct": {
+        "title": "Corriger les erreurs du texte",
+        "prompt": "Veuillez corriger les erreurs dans le texte markdown"
+      }
+    }
   },
   "modal_ai_assistant": {
     "header": {

+ 21 - 4
apps/app/public/static/locales/ja_JP/translation.json

@@ -155,6 +155,7 @@
   "In-App Notification": "通知",
   "AI Assistant": "AI アシスタント",
   "Knowledge Assistant": "ナレッジアシスタント (ベータ版)",
+  "Editor Assistant": "エディターアシスタント (ベータ版)",
   "original_path": "元のパス",
   "new_path": "新しいパス",
   "duplicated_path": "重複したパス",
@@ -376,7 +377,8 @@
       "text": "テキストのみ",
       "file": "ファイルのみ"
     },
-    "editor_config": "エディタ設定",
+    "editor_config": "エディター設定",
+    "editor_assistant": "エディターアシスタント",
     "Show active line": "アクティブ行をハイライト",
     "auto_format_table": "表の自動整形",
     "overwrite_scopes": "{{operation}}と同時に全ての配下ページのスコープを上書き",
@@ -526,10 +528,11 @@
     "latest_revision": "最新の本文",
     "selected_editable_revision": "保存するページ本文(編集可能)"
   },
-  "sidebar_aichat": {
+  "sidebar_ai_assistant": {
     "instruction_label": "アシスタントへの指示",
     "reference_pages_label": "参照するページ",
-    "placeholder": "ききたいことを入力してください",
+    "knowledge_assistant_placeholder": "ききたいことを入力してください",
+    "editor_assistant_placeholder": "お手伝いできることはありますか?",
     "summary_mode_label": "要約モード",
     "summary_mode_help": "2~3文以内の簡潔な回答",
     "caution_against_hallucination": "情報が正しいか出典を確認しましょう",
@@ -538,7 +541,21 @@
     "budget_exceeded": "OpenAI の API の利用上限に達しました。ナレッジアシスタントを再度利用するには OpenAI の請求ページからクレジットを追加してください。",
     "budget_exceeded_for_growi_cloud": "OpenAI の API の利用上限に達しました。ナレッジアシスタントを再度利用するには Hosted の場合は GROWI.cloud の管理画面から Owned の場合は OpenAI の請求ページからクレジットを追加してください。",
     "error_message": "エラーが発生しました",
-    "show_error_detail": "詳細を表示"
+    "show_error_detail": "詳細を表示",
+    "discard": "破棄",
+    "accept": "採用",
+    "use_assistant": "アシスタントを使用する",
+    "remove_assistant": "選択されているアシスタントの解除",
+    "preset_menu": {
+      "summarize": {
+        "title": "この記事の要約をつくる",
+        "prompt": "マークダウンの内容を要約してください"
+      },
+      "correct": {
+        "title": "文章の誤りを修正する",
+        "prompt": "マークダウンの内の文章の誤りを修正してください"
+      }
+    }
   },
   "modal_ai_assistant": {
     "header": {

+ 20 - 3
apps/app/public/static/locales/zh_CN/translation.json

@@ -160,6 +160,7 @@
   "In-App Notification": "通知",
   "AI Assistant": "AI助手",
   "Knowledge Assistant": "知识助手 (测试版)",
+  "Editor Assistant": "编辑助理 (测试版)",
   "original_path": "Original path",
   "new_path": "New path",
   "duplicated_path": "Duplicated path",
@@ -334,6 +335,7 @@
       "file": "仅文件"
     },
     "editor_config": "编辑器配置",
+    "editor_assistant": "编辑助手",
 		"Show active line": "显示活动行",
 		"auto_format_table": "自动格式化表格",
 		"overwrite_scopes": "{{operation}和覆盖所有子体的作用域",
@@ -483,10 +485,11 @@
     "latest_revision": "最新页面正文",
     "selected_editable_revision": "选定的可编辑页面正文"
   },
-  "sidebar_aichat": {
+  "sidebar_ai_assistant": {
     "instruction_label": "助手指令",
     "reference_pages_label": "参考页面",
-    "placeholder": "问我任何问题。",
+    "knowledge_assistant_placeholder": "问我任何问题。",
+    "editor_assistant_placeholder": "有什么需要帮忙的吗?",
     "summary_mode_label": "摘要模式",
     "summary_mode_help": "简洁回答在2-3句话内",
     "caution_against_hallucination": "请核实信息并检查来源。",
@@ -495,7 +498,21 @@
     "budget_exceeded": "您已达到 OpenAI API 的使用上限。要再次使用知识助手,请从 OpenAI 账单页面添加点数。",
     "budget_exceeded_for_growi_cloud": "您已达到 OpenAI API 使用上限。如需再次使用知识助手,请从GROWI.cloud管理页面为托管用户添加点数,或从OpenAI计费页面为自有用户添加点数。",
     "error_message": "错误",
-    "show_error_detail": "显示详情"
+    "show_error_detail": "显示详情",
+    "discard": "丢弃",
+    "accept": "接受",
+    "use_assistant": "使用助手",
+    "remove_assistant": "取消选定的助手",
+    "preset_menu": {
+      "summarize": {
+        "title": "为此文章创建摘要",
+        "prompt": "请总结这个 markdown 内容"
+      },
+      "correct": {
+        "title": "修正文本中的错误",
+        "prompt": "请修正 markdown 中的文本错误"
+      }
+    }
   },
   "modal_ai_assistant": {
     "header": {

+ 9 - 9
apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx

@@ -1,6 +1,6 @@
 import { type FC, useState } from 'react';
 
-import type { IUserHasId } from '@growi/core';
+import type { EditingClient } from '@growi/editor';
 import { UserPicture } from '@growi/ui/dist/components';
 import { Popover, PopoverBody } from 'reactstrap';
 
@@ -11,28 +11,28 @@ import styles from './EditingUserList.module.scss';
 const userListPopoverClass = styles['user-list-popover'] ?? '';
 
 type Props = {
-  userList: IUserHasId[]
+  clientList: EditingClient[]
 }
 
-export const EditingUserList: FC<Props> = ({ userList }) => {
+export const EditingUserList: FC<Props> = ({ clientList }) => {
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
 
-  const firstFourUsers = userList.slice(0, 4);
-  const remainingUsers = userList.slice(4);
+  const firstFourUsers = clientList.slice(0, 4);
+  const remainingUsers = clientList.slice(4);
 
-  if (userList.length === 0) {
+  if (clientList.length === 0) {
     return <></>;
   }
 
   return (
     <div className="d-flex flex-column justify-content-start justify-content-sm-end">
       <div className="d-flex justify-content-start justify-content-sm-end">
-        {firstFourUsers.map(user => (
-          <div key={user._id} className="ms-1">
+        {firstFourUsers.map(editingClient => (
+          <div key={editingClient.clientId} className="ms-1">
             <UserPicture
-              user={user}
+              user={editingClient.userId}
               noLink
               className="border border-info"
             />

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

@@ -1,7 +1,7 @@
 import type { JSX } from 'react';
 
 import { PageHeader } from '~/client/components/PageHeader';
-import { useEditingUsers } from '~/stores/use-editing-users';
+import { useEditingClients } from '~/stores/use-editing-clients';
 
 import { EditingUserList } from './EditingUserList';
 
@@ -10,10 +10,10 @@ import styles from './EditorNavbar.module.scss';
 const moduleClass = styles['editor-navbar'] ?? '';
 
 const EditingUsers = (): JSX.Element => {
-  const { data: editingUsers } = useEditingUsers();
+  const { data: editingClients } = useEditingClients();
   return (
     <EditingUserList
-      userList={editingUsers?.userList ?? []}
+      clientList={editingClients ?? []}
     />
   );
 };

+ 33 - 0
apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorAssistantToggleButton.tsx

@@ -0,0 +1,33 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useAiAssistantSidebar } from '~/features/openai/client/stores/ai-assistant';
+
+export const EditorAssistantToggleButton = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data, close, openEditor } = useAiAssistantSidebar();
+  const { isOpened } = data ?? {};
+
+  const toggle = useCallback(() => {
+    if (isOpened) {
+      close();
+      return;
+    }
+
+    openEditor();
+  }, [isOpened, openEditor, close]);
+
+  return (
+    <button
+      type="button"
+      className={`btn btn-sm btn-outline-neutral-secondary py-0 ${data?.isOpened ? 'active' : ''}`}
+      onClick={toggle}
+    >
+      <span className="d-flex align-items-center">
+        <span className="material-symbols-outlined">support_agent</span>
+        <span className="ms-1 me-1">{t('page_edit.editor_assistant')}</span>
+      </span>
+    </button>
+  );
+};

+ 0 - 0
apps/app/src/client/components/PageEditor/EditorNavbarBottom.module.scss → apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.module.scss


+ 9 - 7
apps/app/src/client/components/PageEditor/EditorNavbarBottom.tsx → apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx

@@ -1,19 +1,22 @@
 import type { JSX } from 'react';
 
+import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 
 import { useDrawerOpened } from '~/stores/ui';
 
+import { EditorAssistantToggleButton } from './EditorAssistantToggleButton';
+
 import styles from './EditorNavbarBottom.module.scss';
 
 
 const moduleClass = styles['grw-editor-navbar-bottom'];
 
-const SavePageControls = dynamic(() => import('~/client/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
-const OptionsSelector = dynamic(() => import('~/client/components/PageEditor/OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
-
-const EditorNavbarBottom = (): JSX.Element => {
+const SavePageControls = dynamic(() => import('./SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
+const OptionsSelector = dynamic(() => import('./OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
 
+export const EditorNavbarBottom = (): JSX.Element => {
+  const { t } = useTranslation();
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
 
   return (
@@ -26,8 +29,9 @@ const EditorNavbarBottom = (): JSX.Element => {
         >
           <span className="material-symbols-outlined fs-2">reorder</span>
         </a>
-        <form className="me-auto">
+        <form className="me-auto d-flex gap-2">
           <OptionsSelector />
+          <EditorAssistantToggleButton />
         </form>
         <form>
           <SavePageControls />
@@ -36,5 +40,3 @@ const EditorNavbarBottom = (): JSX.Element => {
     </div>
   );
 };
-
-export default EditorNavbarBottom;

+ 0 - 0
apps/app/src/client/components/SavePageControls/GrantSelector/GrantSelector.tsx → apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.tsx


+ 0 - 0
apps/app/src/client/components/PageEditor/OptionsSelector.tsx → apps/app/src/client/components/PageEditor/EditorNavbarBottom/OptionsSelector.tsx


+ 4 - 3
apps/app/src/client/components/SavePageControls.tsx → apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx

@@ -23,9 +23,10 @@ import { useSWRxCurrentPage, useCurrentPagePath } from '~/stores/page';
 import { useIsDeviceLargerThanMd, useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
-import { NotAvailable } from './NotAvailable';
-import { GrantSelector } from './SavePageControls/GrantSelector';
-import { SlackNotification } from './SlackNotification';
+import { NotAvailable } from '../../NotAvailable';
+import { SlackNotification } from '../../SlackNotification';
+
+import { GrantSelector } from './GrantSelector';
 
 
 declare global {

+ 1 - 0
apps/app/src/client/components/PageEditor/EditorNavbarBottom/index.ts

@@ -0,0 +1 @@
+export * from './EditorNavbarBottom';

+ 8 - 7
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';
@@ -44,11 +44,11 @@ import {
 import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
 import { usePreviewOptions } from '~/stores/renderer';
 import { useIsUntitledPage, useSelectedGrant } from '~/stores/ui';
-import { useEditingUsers } from '~/stores/use-editing-users';
+import { useEditingClients } from '~/stores/use-editing-clients';
 import loggerFactory from '~/utils/logger';
 
 import { EditorNavbar } from './EditorNavbar';
-import EditorNavbarBottom from './EditorNavbarBottom';
+import { EditorNavbarBottom } from './EditorNavbarBottom';
 import Preview from './Preview';
 import { useScrollSync } from './ScrollSyncHelper';
 import { useConflictResolver, useConflictEffect, type ConflictHandler } from './conflict';
@@ -108,9 +108,10 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
   const { data: editorSettings } = useEditorSettings();
   const { mutate: mutateIsGrantNormalized } = useSWRxCurrentGrantData(currentPage?._id);
   const { data: user } = useCurrentUser();
-  const { onEditorsUpdated } = useEditingUsers();
+  const { mutate: mutateEditingUsers } = useEditingClients();
   const onConflict = useConflictResolver();
   const { data: reservedNextCaretLine, mutate: mutateReservedNextCaretLine } = useReservedNextCaretLine();
+  const { data: isEnableUnifiedMergeView } = useIsEnableUnifiedMergeView();
 
   const { data: rendererOptions } = usePreviewOptions();
 
@@ -365,7 +366,8 @@ 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
-          isEditorMode={editorMode === EditorMode.Editor}
+          enableUnifiedMergeView={isEnableUnifiedMergeView}
+          enableCollaboration={editorMode === EditorMode.Editor}
           onSave={saveWithShortcut}
           onUpload={uploadHandler}
           acceptedUploadFileType={acceptedUploadFileType}
@@ -373,9 +375,8 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
           indentSize={currentIndentSize ?? defaultIndentSize}
           user={user ?? undefined}
           pageId={pageId ?? undefined}
-          initialValue={initialValue}
           editorSettings={editorSettings}
-          onEditorsUpdated={onEditorsUpdated}
+          onEditorsUpdated={mutateEditingUsers}
           cmProps={cmProps}
         />
       </div>

+ 0 - 1
apps/app/src/client/components/SavePageControls/GrantSelector/index.ts

@@ -1 +0,0 @@
-export * from './GrantSelector';

+ 4 - 4
apps/app/src/components/Layout/BasicLayout.tsx

@@ -8,9 +8,9 @@ import { RawLayout } from './RawLayout';
 
 import styles from './BasicLayout.module.scss';
 
-const AiAssistantChatSidebar = dynamic(
-  () => import('~/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar')
-    .then(mod => mod.AiAssistantChatSidebar), { ssr: false },
+const AiAssistantSidebar = dynamic(
+  () => import('~/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar')
+    .then(mod => mod.AiAssistantSidebar), { ssr: false },
 );
 
 
@@ -67,7 +67,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
           {children}
         </div>
 
-        <AiAssistantChatSidebar />
+        <AiAssistantSidebar />
       </div>
 
       <GrowiNavbarBottom />

+ 0 - 79
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.tsx

@@ -1,79 +0,0 @@
-import { useCallback, type JSX } from 'react';
-
-import type { LinkProps } from 'next/link';
-import { useTranslation } from 'react-i18next';
-import ReactMarkdown from 'react-markdown';
-
-import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
-
-import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
-
-import styles from './MessageCard.module.scss';
-
-const moduleClass = styles['message-card'] ?? '';
-
-
-const userMessageCardModuleClass = styles['user-message-card'] ?? '';
-
-const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
-  <div className={`card d-inline-flex align-self-end bg-success-subtle bg-info-subtle ${moduleClass} ${userMessageCardModuleClass}`}>
-    <div className="card-body">
-      <ReactMarkdown>{children}</ReactMarkdown>
-    </div>
-  </div>
-);
-
-
-const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
-
-const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
-  const { close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
-
-  const onClick = useCallback(() => {
-    closeAiAssistantChatSidebar();
-  }, [closeAiAssistantChatSidebar]);
-
-  return (
-    <NextLink href={props.href} onClick={onClick} className="link-primary">
-      {props.children}
-    </NextLink>
-  );
-};
-const AssistantMessageCard = ({ children }: { children: string }): JSX.Element => {
-  const { t } = useTranslation();
-
-  return (
-    <div className={`card border-0 ${moduleClass} ${assistantMessageCardModuleClass}`}>
-      <div className="card-body d-flex">
-        <div className="me-2 me-lg-3">
-          <span className="growi-custom-icons grw-ai-icon rounded-pill">growi_ai</span>
-        </div>
-        <div>
-          { children.length > 0
-            ? (
-              <ReactMarkdown components={{ a: NextLinkWrapper }}>{children}</ReactMarkdown>
-            )
-            : (
-              <span className="text-thinking">
-                {t('sidebar_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
-              </span>
-            )
-          }
-        </div>
-      </div>
-    </div>
-  );
-};
-
-type Props = {
-  role: 'user' | 'assistant',
-  children: string,
-}
-
-export const MessageCard = (props: Props): JSX.Element => {
-  const { role, children } = props;
-
-  return role === 'user'
-    ? <UserMessageCard>{children}</UserMessageCard>
-    : <AssistantMessageCard>{children}</AssistantMessageCard>;
-};

+ 47 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView.tsx

@@ -0,0 +1,47 @@
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+  description: string,
+  additionalInstruction: string,
+  pagePathPatterns: string[],
+}
+
+export const AiAssistantChatInitialView: React.FC<Props> = ({ description, additionalInstruction, pagePathPatterns }: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <p className="fs-6 text-body-secondary mb-0">
+        {description}
+      </p>
+
+      <div>
+        <p className="text-body-secondary">{t('sidebar_ai_assistant.instruction_label')}</p>
+        <div className="card bg-body-tertiary border-0">
+          <div className="card-body p-3">
+            <p className="fs-6 text-body-secondary mb-0">
+              {additionalInstruction}
+            </p>
+          </div>
+        </div>
+      </div>
+
+      <div>
+        <div className="d-flex align-items-center">
+          <p className="text-body-secondary mb-0">{t('sidebar_ai_assistant.reference_pages_label')}</p>
+        </div>
+        <div className="d-flex flex-column gap-1">
+          { pagePathPatterns.map(pagePathPattern => (
+            <a
+              key={pagePathPattern}
+              href="#"
+              className="fs-6 text-body-secondary text-decoration-none"
+            >
+              {pagePathPattern}
+            </a>
+          ))}
+        </div>
+      </div>
+    </>
+  );
+};

+ 74 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown.tsx

@@ -0,0 +1,74 @@
+
+import React, { useMemo, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  UncontrolledDropdown,
+  DropdownToggle,
+  DropdownMenu,
+  DropdownItem,
+} from 'reactstrap';
+
+import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+import { useSWRxAiAssistants } from '../../../stores/ai-assistant';
+import { getShareScopeIcon } from '../../../utils/get-share-scope-Icon';
+
+type Props = {
+  selectedAiAssistant?: AiAssistantHasId;
+  onSelect(aiAssistant?: AiAssistantHasId): void
+}
+
+export const AiAssistantDropdown = ({ selectedAiAssistant, onSelect }: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: aiAssistantData } = useSWRxAiAssistants();
+
+  const allAiAssistants = useMemo(() => {
+    if (aiAssistantData == null) {
+      return [];
+    }
+    return [...aiAssistantData.myAiAssistants, ...aiAssistantData.teamAiAssistants];
+  }, [aiAssistantData]);
+
+  const getAiAssistantLabel = useCallback((aiAssistant: AiAssistantHasId) => {
+    return (
+      <>
+        <span className="material-symbols-outlined fs-5 me-1">
+          {getShareScopeIcon(aiAssistant.shareScope, aiAssistant.accessScope)}
+        </span>
+        {aiAssistant.name}
+      </>
+    );
+  }, []);
+
+  const selectAiAssistantHandler = useCallback((aiAssistant?: AiAssistantHasId) => {
+    onSelect(aiAssistant);
+  }, [onSelect]);
+
+  return (
+    <UncontrolledDropdown>
+      <DropdownToggle className="btn btn-outline-secondary" disabled={allAiAssistants.length === 0}>
+        {selectedAiAssistant != null
+          ? getAiAssistantLabel(selectedAiAssistant)
+          : <><span className="material-symbols-outlined fs-5">Add</span>{t('sidebar_ai_assistant.use_assistant')}</>
+        }
+      </DropdownToggle>
+      <DropdownMenu>
+        {allAiAssistants.map((aiAssistant) => {
+          return (
+            <DropdownItem
+              key={aiAssistant._id}
+              active={selectedAiAssistant?._id === aiAssistant._id}
+              onClick={() => selectAiAssistantHandler(aiAssistant)}
+            >
+              {getAiAssistantLabel(aiAssistant)}
+            </DropdownItem>
+          );
+        })}
+        <DropdownItem divider />
+        <DropdownItem onClick={() => selectAiAssistantHandler()}>
+          {t('sidebar_ai_assistant.remove_assistant')}
+        </DropdownItem>
+      </DropdownMenu>
+    </UncontrolledDropdown>
+  );
+};

+ 2 - 2
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.module.scss → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.module.scss

@@ -2,7 +2,7 @@
 @use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '@growi/ui/scss/atoms/btn-muted';
 
-.grw-ai-assistant-chat-sidebar :global {
+.grw-ai-assistant-sidebar :global {
   z-index: bs.$zindex-fixed + 2;
   width: 100%;
 
@@ -20,7 +20,7 @@
 }
 
 // == Colors
-.grw-ai-assistant-chat-sidebar :global {
+.grw-ai-assistant-sidebar :global {
   .growi-ai-chat-icon {
     color: growi-official-colors.$growi-ai-purple;
   }

+ 197 - 140
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx

@@ -1,6 +1,6 @@
 import type { KeyboardEvent, JSX } from 'react';
 import {
-  type FC, memo, useRef, useEffect, useState, useCallback,
+  type FC, memo, useRef, useEffect, useState, useCallback, useMemo,
 } from 'react';
 
 import { useForm, Controller } from 'react-hook-form';
@@ -8,60 +8,90 @@ import { useTranslation } from 'react-i18next';
 import { Collapse, UncontrolledTooltip } from 'reactstrap';
 import SimpleBar from 'simplebar-react';
 
-import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
-import { MessageErrorCode, StreamErrorCode } from '~/features/openai/interfaces/message-error';
-import type { IThreadRelationHasId } from '~/features/openai/interfaces/thread-relation';
-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';
-import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
-import { useSWRMUTxMessages } from '../../../stores/message';
-import { useSWRMUTxThreads } from '../../../stores/thread';
+import type { MessageLog } from '../../../../interfaces/message';
+import { MessageErrorCode, StreamErrorCode } from '../../../../interfaces/message-error';
+import type { IThreadRelationHasId } from '../../../../interfaces/thread-relation';
+import {
+  useEditorAssistant,
+  useAiAssistantSidebarCloseEffect as useAiAssistantSidebarCloseEffectForEditorAssistant,
+} from '../../../services/editor-assistant';
+import {
+  useKnowledgeAssistant,
+  useFetchAndSetMessageDataEffect,
+  useAiAssistantSidebarCloseEffect as useAiAssistantSidebarCloseEffectForKnowledgeAssistant,
+} from '../../../services/knowledge-assistant';
+import { useAiAssistantSidebar } from '../../../stores/ai-assistant';
 
-import { MessageCard } from './MessageCard';
+import { MessageCard, type MessageCardRole } from './MessageCard';
 import { ResizableTextarea } from './ResizableTextArea';
 
-import styles from './AiAssistantChatSidebar.module.scss';
+import styles from './AiAssistantSidebar.module.scss';
 
-const logger = loggerFactory('growi:openai:client:components:AiAssistantChatSidebar');
+const logger = loggerFactory('growi:openai:client:components:AiAssistantSidebar');
 
-const moduleClass = styles['grw-ai-assistant-chat-sidebar'] ?? '';
-
-type Message = {
-  id: string,
-  content: string,
-  isUserMessage?: boolean,
-}
+const moduleClass = styles['grw-ai-assistant-sidebar'] ?? '';
 
-type FormData = {
+export type FormData = {
   input: string;
   summaryMode?: boolean;
 };
 
-type AiAssistantChatSidebarSubstanceProps = {
-  aiAssistantData: AiAssistantHasId;
+type AiAssistantSidebarSubstanceProps = {
+  isEditorAssistant: boolean;
+  aiAssistantData?: AiAssistantHasId;
   threadData?: IThreadRelationHasId;
-  closeAiAssistantChatSidebar: () => void
+  closeAiAssistantSidebar: () => void
 }
 
-const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceProps> = (props: AiAssistantChatSidebarSubstanceProps) => {
+const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> = (props: AiAssistantSidebarSubstanceProps) => {
   const {
-    aiAssistantData, threadData, closeAiAssistantChatSidebar,
+    isEditorAssistant,
+    aiAssistantData,
+    threadData,
+    closeAiAssistantSidebar,
   } = props;
 
-  const [currentThreadTitle, setCurrentThreadTitle] = useState<string | undefined>(threadData?.title);
+  // States
   const [currentThreadId, setCurrentThreadId] = useState<string | undefined>(threadData?.threadId);
-  const [messageLogs, setMessageLogs] = useState<Message[]>([]);
-  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
+  const [messageLogs, setMessageLogs] = useState<MessageLog[]>([]);
+  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<MessageLog>();
   const [errorMessage, setErrorMessage] = useState<string | undefined>();
   const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
 
+  // Hooks
   const { t } = useTranslation();
   const { data: growiCloudUri } = useGrowiCloudUri();
-  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData._id);
-  const { trigger: mutateMessageData } = useSWRMUTxMessages(aiAssistantData._id, threadData?.threadId);
+
+  const {
+    createThread: createThreadForKnowledgeAssistant,
+    postMessage: postMessageForKnowledgeAssistant,
+    processMessage: processMessageForKnowledgeAssistant,
+
+    // Views
+    initialView: initialViewForKnowledgeAssistant,
+    generateMessageCard: generateMessageCardForKnowledgeAssistant,
+    headerIcon: headerIconForKnowledgeAssistant,
+    headerText: headerTextForKnowledgeAssistant,
+    placeHolder: placeHolderForKnowledgeAssistant,
+  } = useKnowledgeAssistant();
+
+  const {
+    createThread: createThreadForEditorAssistant,
+    postMessage: postMessageForEditorAssistant,
+    processMessage: processMessageForEditorAssistant,
+
+    // Views
+    generateInitialView: generateInitialViewForEditorAssistant,
+    generateMessageCard: generateMessageCardForEditorAssistant,
+    headerIcon: headerIconForEditorAssistant,
+    headerText: headerTextForEditorAssistant,
+    placeHolder: placeHolderForEditorAssistant,
+  } = useEditorAssistant();
 
   const form = useForm<FormData>({
     defaultValues: {
@@ -70,30 +100,33 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
     },
   });
 
-  useEffect(() => {
-    const fetchAndSetMessageData = async() => {
-      const messageData = await mutateMessageData();
-      if (messageData != null) {
-        const normalizedMessageData = messageData.data
-          .reverse()
-          .filter(message => message.metadata?.shouldHideMessage !== 'true');
-
-        setMessageLogs(() => {
-          return normalizedMessageData.map((message, index) => (
-            {
-              id: index.toString(),
-              content: message.content[0].type === 'text' ? message.content[0].text.value : '',
-              isUserMessage: message.role === 'user',
-            }
-          ));
-        });
-      }
-    };
+  // Effects
+  useFetchAndSetMessageDataEffect(setMessageLogs, threadData?.threadId);
 
-    if (threadData != null) {
-      fetchAndSetMessageData();
+  // Functions
+  const createThread = useCallback(async(initialUserMessage: string) => {
+    if (isEditorAssistant) {
+      const thread = await createThreadForEditorAssistant();
+      return thread;
     }
-  }, [mutateMessageData, threadData]);
+
+    if (aiAssistantData == null) {
+      return;
+    }
+    const thread = await createThreadForKnowledgeAssistant(aiAssistantData._id, initialUserMessage);
+    return thread;
+  }, [aiAssistantData, createThreadForEditorAssistant, createThreadForKnowledgeAssistant, isEditorAssistant]);
+
+  const postMessage = useCallback(async(currentThreadId: string, input: string, summaryMode?: boolean) => {
+    if (isEditorAssistant) {
+      const response = await postMessageForEditorAssistant(currentThreadId, input);
+      return response;
+    }
+    if (aiAssistantData?._id != null) {
+      const response = postMessageForKnowledgeAssistant(aiAssistantData._id, currentThreadId, input, summaryMode);
+      return response;
+    }
+  }, [aiAssistantData?._id, isEditorAssistant, postMessageForEditorAssistant, postMessageForKnowledgeAssistant]);
 
   const isGenerating = generatingAnswerMessage != null;
   const submit = useCallback(async(data: FormData) => {
@@ -125,36 +158,30 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
     let currentThreadId_ = currentThreadId;
     if (currentThreadId_ == null) {
       try {
-        const res = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
-          aiAssistantId: aiAssistantData._id,
-          initialUserMessage: newUserMessage.content,
-        });
-
-        const thread = res.data;
+        const thread = await createThread(newUserMessage.content);
+        if (thread == null) {
+          return;
+        }
 
         setCurrentThreadId(thread.threadId);
-        setCurrentThreadTitle(thread.title);
-
         currentThreadId_ = thread.threadId;
-
-        // No need to await because data is not used
-        mutateThreadData();
       }
       catch (err) {
         logger.error(err.toString());
-        toastError(t('sidebar_aichat.failed_to_create_or_retrieve_thread'));
+        toastError(t('sidebar_ai_assistant.failed_to_create_or_retrieve_thread'));
       }
     }
 
     // post message
     try {
-      const response = await fetch('/_api/v3/openai/message', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          userMessage: data.input, threadId: currentThreadId_, summaryMode: data.summaryMode, aiAssistantId: aiAssistantData._id,
-        }),
-      });
+      if (currentThreadId_ == null) {
+        return;
+      }
+
+      const response = await postMessage(currentThreadId_, data.input, data.summaryMode);
+      if (response == null) {
+        return;
+      }
 
       if (!response.ok) {
         const resJson = await response.json();
@@ -165,7 +192,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
 
           const hasThreadIdNotSetError = resJson.errors.some(err => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET);
           if (hasThreadIdNotSetError) {
-            toastError(t('sidebar_aichat.failed_to_create_or_retrieve_thread'));
+            toastError(t('sidebar_ai_assistant.failed_to_create_or_retrieve_thread'));
           }
         }
         setGeneratingAnswerMessage(undefined);
@@ -195,18 +222,35 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
         const textValues: string[] = [];
         const lines = chunk.split('\n\n');
         lines.forEach((line) => {
-          const trimedLine = line.trim();
-          if (trimedLine.startsWith('data:')) {
+          const trimmedLine = line.trim();
+          if (trimmedLine.startsWith('data:')) {
             const data = JSON.parse(line.replace('data: ', ''));
-            textValues.push(data.content[0].text.value);
+
+            processMessageForKnowledgeAssistant(data, {
+              onMessage: (data) => {
+                textValues.push(data.content[0].text.value);
+              },
+            });
+
+            processMessageForEditorAssistant(data, {
+              onMessage: (data) => {
+                textValues.push(data.appendedMessage);
+              },
+              onDetectedDiff: (data) => {
+                console.log('sse diff', { data });
+              },
+              onFinalized: (data) => {
+                console.log('sse finalized', { data });
+              },
+            });
           }
-          else if (trimedLine.startsWith('error:')) {
+          else if (trimmedLine.startsWith('error:')) {
             const error = JSON.parse(line.replace('error: ', ''));
             logger.error(error.errorMessage);
             form.setError('input', { type: 'manual', message: error.message });
 
             if (error.code === StreamErrorCode.BUDGET_EXCEEDED) {
-              setErrorMessage(growiCloudUri != null ? 'sidebar_aichat.budget_exceeded_for_growi_cloud' : 'sidebar_aichat.budget_exceeded');
+              setErrorMessage(growiCloudUri != null ? 'sidebar_ai_assistant.budget_exceeded_for_growi_cloud' : 'sidebar_ai_assistant.budget_exceeded');
             }
           }
         });
@@ -230,7 +274,8 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
       form.setError('input', { type: 'manual', message: err.toString() });
     }
 
-  }, [isGenerating, messageLogs, form, currentThreadId, aiAssistantData._id, mutateThreadData, t, growiCloudUri]);
+  // eslint-disable-next-line max-len
+  }, [isGenerating, messageLogs, form, currentThreadId, createThread, t, postMessage, processMessageForKnowledgeAssistant, processMessageForEditorAssistant, growiCloudUri]);
 
   const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
     if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
@@ -238,28 +283,74 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
     }
   };
 
+  // Views
+  const headerIcon = useMemo(() => {
+    return isEditorAssistant
+      ? headerIconForEditorAssistant
+      : headerIconForKnowledgeAssistant;
+  }, [headerIconForEditorAssistant, headerIconForKnowledgeAssistant, isEditorAssistant]);
+
+  const headerText = useMemo(() => {
+    return isEditorAssistant
+      ? headerTextForEditorAssistant
+      : headerTextForKnowledgeAssistant;
+  }, [isEditorAssistant, headerTextForEditorAssistant, headerTextForKnowledgeAssistant]);
+
+  const placeHolder = useMemo(() => {
+    if (form.formState.isSubmitting) {
+      return '';
+    }
+    return t(isEditorAssistant
+      ? placeHolderForEditorAssistant
+      : placeHolderForKnowledgeAssistant);
+  }, [form.formState.isSubmitting, isEditorAssistant, placeHolderForEditorAssistant, placeHolderForKnowledgeAssistant, t]);
+
+  const initialView = useMemo(() => {
+    if (isEditorAssistant) {
+      return generateInitialViewForEditorAssistant(submit);
+    }
+
+    return initialViewForKnowledgeAssistant;
+  }, [generateInitialViewForEditorAssistant, initialViewForKnowledgeAssistant, isEditorAssistant, submit]);
+
+  const messageCard = useCallback(
+    (role: MessageCardRole, children: string, messageId?: string, messageLogs?: MessageLog[], generatingAnswerMessage?: MessageLog) => {
+      if (isEditorAssistant) {
+        if (messageId == null || messageLogs == null) {
+          return <></>;
+        }
+        return generateMessageCardForEditorAssistant(role, children, messageId, messageLogs, generatingAnswerMessage);
+      }
+
+      return generateMessageCardForKnowledgeAssistant(role, children);
+    }, [generateMessageCardForEditorAssistant, generateMessageCardForKnowledgeAssistant, isEditorAssistant],
+  );
+
   return (
     <>
       <div className="d-flex flex-column vh-100">
         <div className="d-flex align-items-center p-3 border-bottom position-sticky top-0 bg-body z-1">
-          <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>
-          <h5 className="mb-0 fw-bold flex-grow-1 text-truncate">{currentThreadTitle ?? aiAssistantData.name}</h5>
+          {headerIcon}
+          <h5 className="mb-0 fw-bold flex-grow-1 text-truncate">
+            {headerText}
+          </h5>
           <button
             type="button"
             className="btn btn-link p-0 border-0"
-            onClick={closeAiAssistantChatSidebar}
+            onClick={closeAiAssistantSidebar}
           >
             <span className="material-symbols-outlined">close</span>
           </button>
         </div>
         <div className="p-4 d-flex flex-column gap-4 vh-100">
 
-
           { currentThreadId != null
             ? (
               <div className="vstack gap-4 pb-2">
                 { messageLogs.map(message => (
-                  <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
+                  <>
+                    {messageCard(message.isUserMessage ? 'user' : 'assistant', message.content, message.id, messageLogs, generatingAnswerMessage)}
+                  </>
                 )) }
                 { generatingAnswerMessage != null && (
                   <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
@@ -267,47 +358,14 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
                 { messageLogs.length > 0 && (
                   <div className="d-flex justify-content-center">
                     <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
-                      {t('sidebar_aichat.caution_against_hallucination')}
+                      {t('sidebar_ai_assistant.caution_against_hallucination')}
                     </span>
                   </div>
                 )}
               </div>
             )
             : (
-              <>
-                <p className="fs-6 text-body-secondary mb-0">
-                  {aiAssistantData.description}
-                </p>
-
-                <div>
-                  <p className="text-body-secondary">{t('sidebar_aichat.instruction_label')}</p>
-                  <div className="card bg-body-tertiary border-0">
-                    <div className="card-body p-3">
-                      <p className="fs-6 text-body-secondary mb-0">
-                        {aiAssistantData.additionalInstruction}
-                      </p>
-                    </div>
-                  </div>
-                </div>
-
-                <div>
-                  <div className="d-flex align-items-center">
-                    <p className="text-body-secondary mb-0">{t('sidebar_aichat.reference_pages_label')}</p>
-                  </div>
-                  <div className="d-flex flex-column gap-1">
-                    { aiAssistantData.pagePathPatterns.map(pagePathPattern => (
-                      <a
-                        key={pagePathPattern}
-                        href="#"
-                        className="fs-6 text-body-secondary text-decoration-none"
-                      >
-                        {pagePathPattern}
-                      </a>
-                    ))}
-                  </div>
-                </div>
-
-              </>
+              <>{ initialView }</>
             )
           }
 
@@ -324,7 +382,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
                       className="form-control textarea-ask"
                       style={{ resize: 'none' }}
                       rows={1}
-                      placeholder={!form.formState.isSubmitting ? t('sidebar_aichat.placeholder') : ''}
+                      placeholder={placeHolder}
                       onKeyDown={keyDownHandler}
                       disabled={form.formState.isSubmitting}
                     />
@@ -348,7 +406,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
                   disabled={form.formState.isSubmitting || isGenerating}
                 />
                 <label className="form-check-label" htmlFor="swSummaryMode">
-                  {t('sidebar_aichat.summary_mode_label')}
+                  {t('sidebar_ai_assistant.summary_mode_label')}
                 </label>
 
                 {/* Help */}
@@ -362,7 +420,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
                 <UncontrolledTooltip
                   target="tooltipForHelpOfSummaryMode"
                 >
-                  {t('sidebar_aichat.summary_mode_help')}
+                  {t('sidebar_ai_assistant.summary_mode_help')}
                 </UncontrolledTooltip>
               </div>
             </form>
@@ -371,7 +429,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
               <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
                 <div>
                   <span className="material-symbols-outlined text-danger me-2">error</span>
-                  <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('sidebar_aichat.error_message') }</span>
+                  <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('sidebar_ai_assistant.error_message') }</span>
                 </div>
 
                 <button
@@ -383,7 +441,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
                   <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
                     chevron_right
                   </span>
-                  <span className="small">{t('sidebar_aichat.show_error_detail')}</span>
+                  <span className="small">{t('sidebar_ai_assistant.show_error_detail')}</span>
                 </button>
 
                 <Collapse isOpen={isErrorDetailCollapsed}>
@@ -406,28 +464,26 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
 };
 
 
-export const AiAssistantChatSidebar: FC = memo((): JSX.Element => {
+export const AiAssistantSidebar: FC = memo((): JSX.Element => {
   const sidebarRef = useRef<HTMLDivElement>(null);
   const sidebarScrollerRef = useRef<HTMLDivElement>(null);
 
-  const { data: aiAssistantChatSidebarData, close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+  const { data: aiAssistantSidebarData, close: closeAiAssistantSidebar } = useAiAssistantSidebar();
+  const { mutate: mutateIsEnableUnifiedMergeView } = useIsEnableUnifiedMergeView();
 
-  const aiAssistantData = aiAssistantChatSidebarData?.aiAssistantData;
-  const threadData = aiAssistantChatSidebarData?.threadData;
-  const isOpened = aiAssistantChatSidebarData?.isOpened && aiAssistantData != null;
+  const aiAssistantData = aiAssistantSidebarData?.aiAssistantData;
+  const threadData = aiAssistantSidebarData?.threadData;
+  const isOpened = aiAssistantSidebarData?.isOpened;
+  const isEditorAssistant = aiAssistantSidebarData?.isEditorAssistant ?? false;
 
-  useEffect(() => {
-    const handleClickOutside = (event: MouseEvent) => {
-      if (isOpened && sidebarRef.current && !sidebarRef.current.contains(event.target as Node)) {
-        closeAiAssistantChatSidebar();
-      }
-    };
+  useAiAssistantSidebarCloseEffectForEditorAssistant();
+  useAiAssistantSidebarCloseEffectForKnowledgeAssistant(sidebarRef);
 
-    document.addEventListener('mousedown', handleClickOutside);
-    return () => {
-      document.removeEventListener('mousedown', handleClickOutside);
-    };
-  }, [closeAiAssistantChatSidebar, isOpened]);
+  useEffect(() => {
+    if (!aiAssistantSidebarData?.isOpened) {
+      mutateIsEnableUnifiedMergeView(false);
+    }
+  }, [aiAssistantSidebarData?.isOpened, mutateIsEnableUnifiedMergeView]);
 
   if (!isOpened) {
     return <></>;
@@ -444,10 +500,11 @@ export const AiAssistantChatSidebar: FC = memo((): JSX.Element => {
         className="h-100 position-relative"
         autoHide
       >
-        <AiAssistantChatSidebarSubstance
+        <AiAssistantSidebarSubstance
+          isEditorAssistant={isEditorAssistant}
           threadData={threadData}
           aiAssistantData={aiAssistantData}
-          closeAiAssistantChatSidebar={closeAiAssistantChatSidebar}
+          closeAiAssistantSidebar={closeAiAssistantSidebar}
         />
       </SimpleBar>
     </div>

+ 0 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.module.scss → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.module.scss


+ 134 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.tsx

@@ -0,0 +1,134 @@
+import { useCallback, useState, type JSX } from 'react';
+
+import type { LinkProps } from 'next/link';
+import { useTranslation } from 'react-i18next';
+import ReactMarkdown from 'react-markdown';
+
+import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
+
+import { useAiAssistantSidebar } from '../../../stores/ai-assistant';
+
+import styles from './MessageCard.module.scss';
+
+const moduleClass = styles['message-card'] ?? '';
+
+
+const userMessageCardModuleClass = styles['user-message-card'] ?? '';
+
+const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
+  <div className={`card d-inline-flex align-self-end bg-success-subtle bg-info-subtle ${moduleClass} ${userMessageCardModuleClass}`}>
+    <div className="card-body">
+      <ReactMarkdown>{children}</ReactMarkdown>
+    </div>
+  </div>
+);
+
+
+const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
+
+const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
+  const { close: closeAiAssistantSidebar } = useAiAssistantSidebar();
+
+  const onClick = useCallback(() => {
+    closeAiAssistantSidebar();
+  }, [closeAiAssistantSidebar]);
+
+  return (
+    <NextLink href={props.href} onClick={onClick} className="link-primary">
+      {props.children}
+    </NextLink>
+  );
+};
+
+const AssistantMessageCard = ({
+  children, showActionButtons, onAccept, onDiscard,
+}: {
+  children: string,
+  showActionButtons?: boolean
+  onAccept?: () => void,
+  onDiscard?: () => void,
+}): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [isActionButtonClicked, setIsActionButtonClicked] = useState(false);
+
+  const clickActionButtonHandler = useCallback((action: 'accept' | 'discard') => {
+    setIsActionButtonClicked(true);
+    if (action === 'accept') {
+      onAccept?.();
+      return;
+    }
+
+    onDiscard?.();
+  }, [onAccept, onDiscard]);
+
+  return (
+    <div className={`card border-0 ${moduleClass} ${assistantMessageCardModuleClass}`}>
+      <div className="card-body d-flex">
+        <div className="me-2 me-lg-3">
+          <span className="growi-custom-icons grw-ai-icon rounded-pill">growi_ai</span>
+        </div>
+        <div>
+          { children.length > 0
+            ? (
+              <>
+                <ReactMarkdown components={{ a: NextLinkWrapper }}>{children}</ReactMarkdown>
+
+                {showActionButtons && !isActionButtonClicked && (
+                  <div className="d-flex mt-2 justify-content-start">
+                    <button
+                      type="button"
+                      className="btn btn-outline-secondary me-2"
+                      onClick={() => clickActionButtonHandler('discard')}
+                    >
+                      {t('sidebar_ai_assistant.discard')}
+                    </button>
+                    <button
+                      type="button"
+                      className="btn btn-success"
+                      onClick={() => clickActionButtonHandler('accept')}
+                    >
+                      {t('sidebar_ai_assistant.accept')}
+                    </button>
+                  </div>
+                )}
+              </>
+            )
+            : (
+              <span className="text-thinking">
+                {t('sidebar_ai_assistant.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
+              </span>
+            )
+          }
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export type MessageCardRole = 'user' | 'assistant';
+
+type Props = {
+  role: MessageCardRole,
+  children: string,
+  showActionButtons?: boolean,
+  onDiscard?: () => void,
+  onAccept?: () => void,
+}
+
+export const MessageCard = (props: Props): JSX.Element => {
+  const {
+    role, children, showActionButtons, onAccept, onDiscard,
+  } = props;
+
+  return role === 'user'
+    ? <UserMessageCard>{children}</UserMessageCard>
+    : (
+      <AssistantMessageCard
+        showActionButtons={showActionButtons}
+        onAccept={onAccept}
+        onDiscard={onDiscard}
+      >{children}
+      </AssistantMessageCard>
+    );
+};

+ 40 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/QuickMenuList.tsx

@@ -0,0 +1,40 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+  onClick: (presetPrompt: string) => void
+}
+
+const presetMenus = [
+  'summarize',
+  'correct',
+];
+
+export const QuickMenuList: React.FC<Props> = ({ onClick }: Props) => {
+  const { t } = useTranslation();
+
+  const clickQuickMenuHandler = useCallback((quickMenu: string) => {
+    onClick(t(`sidebar_ai_assistant.preset_menu.${quickMenu}.prompt`));
+  }, [onClick, t]);
+
+  return (
+    <div className="container">
+      <div className="d-flex flex-column gap-3">
+        {presetMenus.map(presetMenu => (
+          <button
+            type="button"
+            key={presetMenu}
+            onClick={() => clickQuickMenuHandler(presetMenu)}
+            className="btn text-body-secondary p-3 rounded-3 border border-1"
+          >
+            <div className="d-flex align-items-center">
+              <span className="material-symbols-outlined fs-5 me-3">lightbulb</span>
+              <span className="fs-6">{t(`sidebar_ai_assistant.preset_menu.${presetMenu}.title`)}</span>
+            </div>
+          </button>
+        ))}
+      </div>
+    </div>
+  );
+};

+ 0 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/ResizableTextArea.tsx → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/ResizableTextArea.tsx


+ 4 - 4
apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.tsx

@@ -6,7 +6,7 @@ import { NotAvailable } from '~/client/components/NotAvailable';
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
 import { useIsAiEnabled } from '~/stores-universal/context';
 
-import { useAiAssistantChatSidebar, useSWRxAiAssistants } from '../../stores/ai-assistant';
+import { useAiAssistantSidebar, useSWRxAiAssistants } from '../../stores/ai-assistant';
 
 import styles from './OpenDefaultAiAssistantButton.module.scss';
 
@@ -14,7 +14,7 @@ const OpenDefaultAiAssistantButton = (): JSX.Element => {
   const { t } = useTranslation();
   const { data: isAiEnabled } = useIsAiEnabled();
   const { data: aiAssistantData } = useSWRxAiAssistants();
-  const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+  const { openChat } = useAiAssistantSidebar();
 
   const defaultAiAssistant = useMemo(() => {
     if (aiAssistantData == null) {
@@ -30,8 +30,8 @@ const OpenDefaultAiAssistantButton = (): JSX.Element => {
       return;
     }
 
-    openAiAssistantChatSidebar(defaultAiAssistant);
-  }, [defaultAiAssistant, openAiAssistantChatSidebar]);
+    openChat(defaultAiAssistant);
+  }, [defaultAiAssistant, openChat]);
 
   if (!isAiEnabled) {
     return <></>;

+ 4 - 18
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx

@@ -9,13 +9,13 @@ import type { IThreadRelationHasId } from '~/features/openai/interfaces/thread-r
 import { useCurrentUser } from '~/stores-universal/context';
 import loggerFactory from '~/utils/logger';
 
-import type { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant';
 import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
 import { determineShareScope } from '../../../../utils/determine-share-scope';
 import { deleteAiAssistant, setDefaultAiAssistant } from '../../../services/ai-assistant';
 import { deleteThread } from '../../../services/thread';
-import { useAiAssistantChatSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+import { useAiAssistantSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
 import { useSWRMUTxThreads, useSWRxThreads } from '../../../stores/thread';
+import { getShareScopeIcon } from '../../../utils/get-share-scope-Icon';
 
 import styles from './AiAssistantTree.module.scss';
 
@@ -125,20 +125,6 @@ const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantData, onThreadClic
 /*
 *  AiAssistantItem
 */
-const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAssistantAccessScope): string => {
-  const determinedSharedScope = determineShareScope(shareScope, accessScope);
-  switch (determinedSharedScope) {
-    case AiAssistantShareScope.OWNER:
-      return 'lock';
-    case AiAssistantShareScope.GROUPS:
-      return 'account_tree';
-    case AiAssistantShareScope.PUBLIC_ONLY:
-      return 'group';
-    case AiAssistantShareScope.SAME_AS_ACCESS_SCOPE:
-      return '';
-  }
-};
-
 type AiAssistantItemProps = {
   currentUser?: IUserHasId | null;
   aiAssistant: AiAssistantHasId;
@@ -298,7 +284,7 @@ type AiAssistantTreeProps = {
 
 export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onUpdated, onDeleted }) => {
   const { data: currentUser } = useCurrentUser();
-  const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+  const { openChat } = useAiAssistantSidebar();
   const { open: openAiAssistantManagementModal } = useAiAssistantManagementModal();
 
   return (
@@ -309,7 +295,7 @@ export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants,
           currentUser={currentUser}
           aiAssistant={assistant}
           onEditClick={openAiAssistantManagementModal}
-          onItemClick={openAiAssistantChatSidebar}
+          onItemClick={openChat}
           onUpdated={onUpdated}
           onDeleted={onDeleted}
         />

+ 387 - 0
apps/app/src/features/openai/client/services/editor-assistant.tsx

@@ -0,0 +1,387 @@
+import {
+  useCallback, useEffect, useState, useRef, useMemo,
+} from 'react';
+
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import {
+  acceptAllChunks, useTextSelectionEffect,
+} 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 { useTranslation } from 'react-i18next';
+import { type Text as YText } from 'yjs';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import {
+  SseMessageSchema,
+  SseDetectedDiffSchema,
+  SseFinalizedSchema,
+  isReplaceDiff,
+  // 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 { EditorMode, useEditorMode } from '~/stores-universal/ui';
+import { useCurrentPageId } from '~/stores/page';
+
+import type { AiAssistantHasId } from '../../interfaces/ai-assistant';
+import type { MessageLog } from '../../interfaces/message';
+import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
+import { ThreadType } from '../../interfaces/thread-relation';
+import { AiAssistantDropdown } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown';
+import { type FormData } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar';
+import { MessageCard, type MessageCardRole } from '../components/AiAssistant/AiAssistantSidebar/MessageCard';
+import { QuickMenuList } from '../components/AiAssistant/AiAssistantSidebar/QuickMenuList';
+import { useAiAssistantSidebar } from '../stores/ai-assistant';
+
+interface CreateThread {
+  (): Promise<IThreadRelationHasId>;
+}
+interface PostMessage {
+  (threadId: string, userMessage: string): Promise<Response>;
+}
+interface ProcessMessage {
+  (data: unknown, handler: {
+    onMessage: (data: SseMessage) => void;
+    onDetectedDiff: (data: SseDetectedDiff) => void;
+    onFinalized: (data: SseFinalized) => void;
+  }): void;
+}
+
+interface GenerateInitialView {
+  (onSubmit: (data: FormData) => Promise<void>): JSX.Element;
+}
+interface GenerateMessageCard {
+  (role: MessageCardRole, children: string, messageId: string, messageLogs: MessageLog[], generatingAnswerMessage?: MessageLog): JSX.Element;
+}
+
+type DetectedDiff = Array<{
+  data: SseDetectedDiff,
+  applied: boolean,
+  id: string,
+}>
+
+type UseEditorAssistant = () => {
+  createThread: CreateThread,
+  postMessage: PostMessage,
+  processMessage: ProcessMessage,
+
+  // Views
+  generateInitialView: GenerateInitialView,
+  generateMessageCard: GenerateMessageCard,
+  headerIcon: JSX.Element,
+  headerText: JSX.Element,
+  placeHolder: string,
+}
+
+const insertTextAtLine = (yText: YText, lineNumber: number, textToInsert: string): void => {
+  // Get the entire text content
+  const content = yText.toString();
+
+  // Split by newlines to get all lines
+  const lines = content.split('\n');
+
+  // Calculate the index position for insertion
+  let insertPosition = 0;
+
+  // Sum the length of all lines before the target line (plus newline characters)
+  for (let i = 0; i < lineNumber && i < lines.length; i++) {
+    insertPosition += lines[i].length + 1; // +1 for the newline character
+  }
+
+  // Insert the text at the calculated position
+  yText.insert(insertPosition, textToInsert);
+};
+
+const appendTextLastLine = (yText: YText, textToAppend: string) => {
+  const content = yText.toString();
+  const insertPosition = content.length;
+  yText.insert(insertPosition, `\n\n${textToAppend}`);
+};
+
+const getLineInfo = (yText: YText, lineNumber: number): { text: string, startIndex: number } | null => {
+  // Get the entire text content
+  const content = yText.toString();
+
+  // Split by newlines to get all lines
+  const lines = content.split('\n');
+
+  // Check if the requested line exists
+  if (lineNumber < 0 || lineNumber >= lines.length) {
+    return null; // Line doesn't exist
+  }
+
+  // Get the text of the specified line
+  const text = lines[lineNumber];
+
+  // Calculate the start index of the line
+  let startIndex = 0;
+  for (let i = 0; i < lineNumber; i++) {
+    startIndex += lines[i].length + 1; // +1 for the newline character
+  }
+
+  // Return comprehensive line information
+  return {
+    text,
+    startIndex,
+  };
+};
+
+export const useEditorAssistant: UseEditorAssistant = () => {
+  // Refs
+  // const positionRef = useRef<number>(0);
+  const lineRef = useRef<number>(0);
+
+  // States
+  const [detectedDiff, setDetectedDiff] = useState<DetectedDiff>();
+  const [selectedText, setSelectedText] = useState<string>();
+  const [selectedAiAssistant, setSelectedAiAssistant] = useState<AiAssistantHasId>();
+
+  // Hooks
+  const { t } = useTranslation();
+  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 { data: aiAssistantSidebarData } = useAiAssistantSidebar();
+
+  // Functions
+  const createThread: CreateThread = useCallback(async() => {
+    const response = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
+      type: ThreadType.EDITOR,
+      aiAssistantId: selectedAiAssistant?._id,
+    });
+    return response.data;
+  }, [selectedAiAssistant?._id]);
+
+  const postMessage: PostMessage = useCallback(async(threadId, userMessage) => {
+    const response = await fetch('/_api/v3/openai/edit', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        threadId,
+        userMessage,
+        markdown: selectedText != null && selectedText.length !== 0
+          ? selectedText
+          : undefined,
+      }),
+    });
+
+    return response;
+  }, [selectedText]);
+
+  const processMessage: ProcessMessage = useCallback((data, handler) => {
+    handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
+      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 selectTextHandler = useCallback((selectedText: string, selectedTextFirstLineNumber: number) => {
+    setSelectedText(selectedText);
+    lineRef.current = selectedTextFirstLineNumber;
+  }, []);
+
+  // Effects
+  useTextSelectionEffect(codeMirrorEditor, selectTextHandler);
+
+  useEffect(() => {
+    const pendingDetectedDiff: DetectedDiff | undefined = detectedDiff?.filter(diff => diff.applied === false);
+    if (yDocs?.secondaryDoc != null && pendingDetectedDiff != null && pendingDetectedDiff.length > 0) {
+
+      // For debug
+      // const testDetectedDiff = [
+      //   {
+      //     data: { diff: { retain: 9 } },
+      //     applied: false,
+      //     id: crypto.randomUUID(),
+      //   },
+      //   {
+      //     data: { diff: { delete: 5 } },
+      //     applied: false,
+      //     id: crypto.randomUUID(),
+      //   },
+      //   {
+      //     data: { diff: { insert: 'growi' } },
+      //     applied: false,
+      //     id: crypto.randomUUID(),
+      //   },
+      // ];
+
+      const yText = yDocs.secondaryDoc.getText('codemirror');
+      yDocs.secondaryDoc.transact(() => {
+        pendingDetectedDiff.forEach((detectedDiff) => {
+          if (isReplaceDiff(detectedDiff.data)) {
+
+            if (selectedText != null && selectedText.length !== 0) {
+              const lineInfo = getLineInfo(yText, lineRef.current);
+              if (lineInfo != null && lineInfo.text !== detectedDiff.data.diff.replace) {
+                yText.delete(lineInfo.startIndex, lineInfo.text.length);
+                insertTextAtLine(yText, lineRef.current, detectedDiff.data.diff.replace);
+              }
+
+              lineRef.current += 1;
+            }
+            else {
+              appendTextLastLine(yText, detectedDiff.data.diff.replace);
+            }
+          }
+          // if (isInsertDiff(detectedDiff.data)) {
+          //   yText.insert(positionRef.current, detectedDiff.data.diff.insert);
+          // }
+          // if (isDeleteDiff(detectedDiff.data)) {
+          //   yText.delete(positionRef.current, detectedDiff.data.diff.delete);
+          // }
+          // if (isRetainDiff(detectedDiff.data)) {
+          //   positionRef.current += detectedDiff.data.diff.retain;
+          // }
+        });
+      });
+
+      // 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) {
+        setSelectedText(undefined);
+        setDetectedDiff(undefined);
+        lineRef.current = 0;
+        // positionRef.current = 0;
+      }
+    }
+  }, [codeMirrorEditor, detectedDiff, selectedText, yDocs?.secondaryDoc]);
+
+
+  // Views
+  const headerIcon = useMemo(() => {
+    return <span className="material-symbols-outlined growi-ai-chat-icon me-3 fs-4">support_agent</span>;
+  }, []);
+
+  const headerText = useMemo(() => {
+    return <>{t('Editor Assistant')}</>;
+  }, [t]);
+
+  const placeHolder = useMemo(() => { return 'sidebar_ai_assistant.editor_assistant_placeholder' }, []);
+
+  const generateInitialView: GenerateInitialView = useCallback((onSubmit) => {
+    const selectAiAssistantHandler = (aiAssistant?: AiAssistantHasId) => {
+      setSelectedAiAssistant(aiAssistant);
+    };
+
+    const clickQuickMenuHandler = async(quickMenu: string) => {
+      await onSubmit({ input: quickMenu });
+    };
+
+    return (
+      <>
+        <div className="py-2">
+          <AiAssistantDropdown
+            selectedAiAssistant={selectedAiAssistant}
+            onSelect={selectAiAssistantHandler}
+          />
+        </div>
+        <QuickMenuList
+          onClick={clickQuickMenuHandler}
+        />
+      </>
+    );
+  }, [selectedAiAssistant]);
+
+
+  const generateMessageCard: GenerateMessageCard = useCallback((role, children, messageId, messageLogs, generatingAnswerMessage) => {
+    const isActionButtonShown = (() => {
+      if (!aiAssistantSidebarData?.isEditorAssistant) {
+        return false;
+      }
+
+      if (generatingAnswerMessage != null) {
+        return false;
+      }
+
+      const latestAssistantMessageLogId = messageLogs
+        .filter(message => !message.isUserMessage)
+        .slice(-1)[0];
+
+      if (messageId === latestAssistantMessageLogId?.id) {
+        return true;
+      }
+
+      return false;
+    })();
+
+
+    const accept = () => {
+      if (codeMirrorEditor?.view == null) {
+        return;
+      }
+
+      acceptAllChunks(codeMirrorEditor.view);
+      mutateIsEnableUnifiedMergeView(false);
+    };
+
+    const reject = () => {
+      mutateIsEnableUnifiedMergeView(false);
+    };
+
+    return (
+      <MessageCard
+        role={role}
+        showActionButtons={isActionButtonShown}
+        onAccept={accept}
+        onDiscard={reject}
+      >
+        {children}
+      </MessageCard>
+    );
+  }, [aiAssistantSidebarData?.isEditorAssistant, codeMirrorEditor?.view, mutateIsEnableUnifiedMergeView]);
+
+  return {
+    createThread,
+    postMessage,
+    processMessage,
+
+    // Views
+    generateInitialView,
+    generateMessageCard,
+    headerIcon,
+    headerText,
+    placeHolder,
+  };
+};
+
+export const useAiAssistantSidebarCloseEffect = (): void => {
+  const { data, close } = useAiAssistantSidebar();
+  const { data: editorMode } = useEditorMode();
+
+  useEffect(() => {
+    if (data?.isEditorAssistant && editorMode !== EditorMode.Editor) {
+      close();
+    }
+  }, [close, data?.isEditorAssistant, editorMode]);
+};

+ 193 - 0
apps/app/src/features/openai/client/services/knowledge-assistant.tsx

@@ -0,0 +1,193 @@
+import type { Dispatch, SetStateAction, RefObject } from 'react';
+import {
+  useCallback, useMemo, useState, useEffect,
+} from 'react';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { SseMessageSchema, type SseMessage } from '~/features/openai/interfaces/knowledge-assistant/sse-schemas';
+import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
+
+import type { MessageLog } from '../../interfaces/message';
+import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
+import { ThreadType } from '../../interfaces/thread-relation';
+import { AiAssistantChatInitialView } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView';
+import { MessageCard, type MessageCardRole } from '../components/AiAssistant/AiAssistantSidebar/MessageCard';
+import { useAiAssistantSidebar } from '../stores/ai-assistant';
+import { useSWRMUTxMessages } from '../stores/message';
+import { useSWRMUTxThreads } from '../stores/thread';
+
+interface CreateThread {
+  (aiAssistantId: string, initialUserMessage: string): Promise<IThreadRelationHasId>;
+}
+
+interface PostMessage {
+  (aiAssistantId: string, threadId: string, userMessage: string, summaryMode?: boolean): Promise<Response>;
+}
+
+interface ProcessMessage {
+  (data: unknown, handler: {
+    onMessage: (data: SseMessage) => void}
+  ): void;
+}
+
+interface GenerateMessageCard {
+  (role: MessageCardRole, children: string): JSX.Element;
+}
+
+type UseKnowledgeAssistant = () => {
+  createThread: CreateThread
+  postMessage: PostMessage
+  processMessage: ProcessMessage
+
+  // Views
+  initialView: JSX.Element
+  generateMessageCard: GenerateMessageCard,
+  headerIcon: JSX.Element
+  headerText: JSX.Element
+  placeHolder: string
+}
+
+export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
+  // Hooks
+  const { data: aiAssistantSidebarData } = useAiAssistantSidebar();
+  const { aiAssistantData } = aiAssistantSidebarData ?? {};
+  const { threadData } = aiAssistantSidebarData ?? {};
+  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData?._id);
+
+  // States
+  const [currentThreadTitle, setCurrentThreadId] = useState(threadData?.title);
+
+  // Functions
+  const createThread: CreateThread = useCallback(async(aiAssistantId, initialUserMessage) => {
+    const response = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
+      type: ThreadType.KNOWLEDGE,
+      aiAssistantId,
+      initialUserMessage,
+    });
+    const thread = response.data;
+
+    setCurrentThreadId(thread.title);
+
+    // No need to await because data is not used
+    mutateThreadData();
+
+    return thread;
+  }, [mutateThreadData]);
+
+  const postMessage: PostMessage = useCallback(async(aiAssistantId, threadId, userMessage, summaryMode) => {
+    const response = await fetch('/_api/v3/openai/message', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        aiAssistantId,
+        threadId,
+        userMessage,
+        summaryMode,
+      }),
+    });
+    return response;
+  }, []);
+
+  const processMessage: ProcessMessage = useCallback((data, handler) => {
+    handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
+      handler.onMessage(data);
+    });
+  }, []);
+
+  // Views
+  const headerIcon = useMemo(() => {
+    return <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>;
+  }, []);
+
+  const headerText = useMemo(() => {
+    return <>{currentThreadTitle ?? aiAssistantData?.name}</>;
+  }, [aiAssistantData?.name, currentThreadTitle]);
+
+  const placeHolder = useMemo(() => { return 'sidebar_ai_assistant.knowledge_assistant_placeholder' }, []);
+
+  const initialView = useMemo(() => {
+    if (aiAssistantSidebarData?.aiAssistantData == null) {
+      return <></>;
+    }
+
+    return (
+      <AiAssistantChatInitialView
+        description={aiAssistantSidebarData.aiAssistantData.description}
+        additionalInstruction={aiAssistantSidebarData.aiAssistantData.additionalInstruction}
+        pagePathPatterns={aiAssistantSidebarData.aiAssistantData.pagePathPatterns}
+      />
+    );
+  }, [aiAssistantSidebarData?.aiAssistantData]);
+
+  const generateMessageCard: GenerateMessageCard = useCallback((role, children) => {
+    return (
+      <MessageCard
+        role={role}
+      >
+        {children}
+      </MessageCard>
+    );
+  }, []);
+
+  return {
+    createThread,
+    postMessage,
+    processMessage,
+
+    // Views
+    initialView,
+    generateMessageCard,
+    headerIcon,
+    headerText,
+    placeHolder,
+  };
+};
+
+
+export const useFetchAndSetMessageDataEffect = (setMessageLogs: Dispatch<SetStateAction<MessageLog[]>>, threadId?: string): void => {
+  const { data: aiAssistantSidebarData } = useAiAssistantSidebar();
+  const { trigger: mutateMessageData } = useSWRMUTxMessages(aiAssistantSidebarData?.aiAssistantData?._id, threadId);
+
+  useEffect(() => {
+    const fetchAndSetMessageData = async() => {
+      const messageData = await mutateMessageData();
+      if (messageData != null) {
+        const normalizedMessageData = messageData.data
+          .reverse()
+          .filter(message => message.metadata?.shouldHideMessage !== 'true');
+
+        setMessageLogs(() => {
+          return normalizedMessageData.map((message, index) => (
+            {
+              id: index.toString(),
+              content: message.content[0].type === 'text' ? message.content[0].text.value : '',
+              isUserMessage: message.role === 'user',
+            }
+          ));
+        });
+      }
+    };
+
+    if (threadId != null) {
+      fetchAndSetMessageData();
+    }
+
+  }, [mutateMessageData, setMessageLogs, threadId]);
+};
+
+export const useAiAssistantSidebarCloseEffect = (sidebarRef: RefObject<HTMLDivElement>): void => {
+  const { data, close } = useAiAssistantSidebar();
+
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      if (data?.isOpened && sidebarRef.current && !sidebarRef.current.contains(event.target as Node) && !data.isEditorAssistant) {
+        close();
+      }
+    };
+
+    document.addEventListener('mousedown', handleClickOutside);
+    return () => {
+      document.removeEventListener('mousedown', handleClickOutside);
+    };
+  }, [close, data?.isEditorAssistant, data?.isOpened, sidebarRef]);
+};

+ 25 - 9
apps/app/src/features/openai/client/stores/ai-assistant.tsx

@@ -55,33 +55,49 @@ export const useSWRxAiAssistants = (): SWRResponse<AccessibleAiAssistantsHasId,
 };
 
 
-type AiAssistantChatSidebarStatus = {
+/*
+*  useAiAssistantSidebar
+*/
+type AiAssistantSidebarStatus = {
   isOpened: boolean,
+  isEditorAssistant?: boolean,
   aiAssistantData?: AiAssistantHasId,
   threadData?: IThreadRelationHasId,
 }
 
-type AiAssistantChatSidebarUtils = {
-  open(
+type AiAssistantSidebarUtils = {
+  openChat(
     aiAssistantData: AiAssistantHasId,
     threadData?: IThreadRelationHasId,
   ): void
+  openEditor(): void
   close(): void
 }
 
-export const useAiAssistantChatSidebar = (
-    status?: AiAssistantChatSidebarStatus,
-): SWRResponse<AiAssistantChatSidebarStatus, Error> & AiAssistantChatSidebarUtils => {
+export const useAiAssistantSidebar = (
+    status?: AiAssistantSidebarStatus,
+): SWRResponse<AiAssistantSidebarStatus, Error> & AiAssistantSidebarUtils => {
   const initialStatus = { isOpened: false };
-  const swrResponse = useSWRStatic<AiAssistantChatSidebarStatus, Error>('AiAssistantChatSidebar', status, { fallbackData: initialStatus });
+  const swrResponse = useSWRStatic<AiAssistantSidebarStatus, Error>('AiAssistantSidebar', status, { fallbackData: initialStatus });
 
   return {
     ...swrResponse,
-    open: useCallback(
+    openChat: useCallback(
       (aiAssistantData: AiAssistantHasId, threadData: IThreadRelationHasId) => {
         swrResponse.mutate({ isOpened: true, aiAssistantData, threadData });
       }, [swrResponse],
     ),
-    close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
+    openEditor: useCallback(
+      () => {
+        swrResponse.mutate({
+          isOpened: true, isEditorAssistant: true, aiAssistantData: undefined, threadData: undefined,
+        });
+      }, [swrResponse],
+    ),
+    close: useCallback(
+      () => swrResponse.mutate({
+        isOpened: false, isEditorAssistant: false, aiAssistantData: undefined, threadData: undefined,
+      }), [swrResponse],
+    ),
   };
 };

+ 2 - 2
apps/app/src/features/openai/client/stores/message.tsx

@@ -4,8 +4,8 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 
 import type { MessageWithCustomMetaData } from '../../interfaces/message';
 
-export const useSWRMUTxMessages = (aiAssistantId: string, threadId?: string): SWRMutationResponse<MessageWithCustomMetaData | null> => {
-  const key = threadId != null ? [`/openai/messages/${aiAssistantId}/${threadId}`] : null;
+export const useSWRMUTxMessages = (aiAssistantId?: string, threadId?: string): SWRMutationResponse<MessageWithCustomMetaData | null> => {
+  const key = aiAssistantId != null && threadId != null ? [`/openai/messages/${aiAssistantId}/${threadId}`] : null;
   return useSWRMutation(
     key,
     ([endpoint]) => apiv3Get(endpoint).then(response => response.data.messages),

+ 3 - 3
apps/app/src/features/openai/client/stores/thread.tsx

@@ -6,9 +6,9 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 
 import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
 
-const getKey = (aiAssistantId: string) => [`/openai/threads/${aiAssistantId}`];
+const getKey = (aiAssistantId?: string) => (aiAssistantId != null ? [`/openai/threads/${aiAssistantId}`] : null);
 
-export const useSWRxThreads = (aiAssistantId: string): SWRResponse<IThreadRelationHasId[], Error> => {
+export const useSWRxThreads = (aiAssistantId?: string): SWRResponse<IThreadRelationHasId[], Error> => {
   const key = getKey(aiAssistantId);
   return useSWRImmutable<IThreadRelationHasId[]>(
     key,
@@ -17,7 +17,7 @@ export const useSWRxThreads = (aiAssistantId: string): SWRResponse<IThreadRelati
 };
 
 
-export const useSWRMUTxThreads = (aiAssistantId: string): SWRMutationResponse<IThreadRelationHasId[], Error> => {
+export const useSWRMUTxThreads = (aiAssistantId?: string): SWRMutationResponse<IThreadRelationHasId[], Error> => {
   const key = getKey(aiAssistantId);
   return useSWRMutation(
     key,

+ 17 - 0
apps/app/src/features/openai/client/utils/get-share-scope-Icon.ts

@@ -0,0 +1,17 @@
+import type { AiAssistantAccessScope } from '../../interfaces/ai-assistant';
+import { AiAssistantShareScope } from '../../interfaces/ai-assistant';
+import { determineShareScope } from '../../utils/determine-share-scope';
+
+export const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAssistantAccessScope): string => {
+  const determinedSharedScope = determineShareScope(shareScope, accessScope);
+  switch (determinedSharedScope) {
+    case AiAssistantShareScope.OWNER:
+      return 'lock';
+    case AiAssistantShareScope.GROUPS:
+      return 'account_tree';
+    case AiAssistantShareScope.PUBLIC_ONLY:
+      return 'group';
+    case AiAssistantShareScope.SAME_AS_ACCESS_SCOPE:
+      return '';
+  }
+};

+ 32 - 0
apps/app/src/features/openai/interfaces/editor-assistant/llm-response-schemas.ts

@@ -0,0 +1,32 @@
+import { z } from 'zod';
+
+// -----------------------------------------------------------------------------
+// Type definitions
+// -----------------------------------------------------------------------------
+
+// Schema definitions
+export const LlmEditorAssistantMessageSchema = z.object({
+  message: z.string().describe('A friendly message explaining what changes were made or suggested'),
+});
+
+export const LlmEditorAssistantDiffSchema = z
+  .object({
+    replace: z.string().describe('The text that should replace the current content'),
+  });
+  // .object({
+  //   insert: z.string().describe('The text that should insert the content in the current position'),
+  // })
+  // .or(
+  //   z.object({
+  //     delete: z.number().int().describe('The number of characters that should be deleted from the current position'),
+  //   }),
+  // )
+  // .or(
+  //   z.object({
+  //     retain: z.number().int().describe('The number of characters that should be retained in the current position'),
+  //   }),
+  // );
+
+// Type definitions
+export type LlmEditorAssistantMessage = z.infer<typeof LlmEditorAssistantMessageSchema>;
+export type LlmEditorAssistantDiff = z.infer<typeof LlmEditorAssistantDiffSchema>;

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

@@ -0,0 +1,47 @@
+import { z } from 'zod';
+
+import { LlmEditorAssistantDiffSchema } from './llm-response-schemas';
+
+// -----------------------------------------------------------------------------
+// Type definitions
+// -----------------------------------------------------------------------------
+
+// Schema definitions
+export const SseMessageSchema = z.object({
+  appendedMessage: z.string().describe('The message that should be appended to the chat window'),
+});
+
+export const SseDetectedDiffSchema = z
+  .object({
+    diff: LlmEditorAssistantDiffSchema,
+  });
+
+export const SseFinalizedSchema = z
+  .object({
+    finalized: z.object({
+      message: z.string().describe('The final message that should be displayed in the chat window'),
+      replacements: z.array(LlmEditorAssistantDiffSchema),
+    }),
+  });
+
+// Type definitions
+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;
+// };
+
+export const isReplaceDiff = (diff: SseDetectedDiff): diff is { diff: { replace: string } } => {
+  return 'replace' in diff.diff;
+};

+ 16 - 0
apps/app/src/features/openai/interfaces/knowledge-assistant/sse-schemas.ts

@@ -0,0 +1,16 @@
+import { z } from 'zod';
+
+// Schema definitions
+export const SseMessageSchema = z.object({
+  content: z.array(z.object({
+    index: z.number(),
+    type: z.string(),
+    text: z.object({
+      value: z.string().describe('The message that should be appended to the chat window'),
+    }),
+  })),
+});
+
+
+// Type definitions
+export type SseMessage = z.infer<typeof SseMessageSchema>;

+ 6 - 0
apps/app/src/features/openai/interfaces/message.ts

@@ -11,3 +11,9 @@ export type MessageWithCustomMetaData = Omit<OpenAI.Beta.Threads.Messages.Messag
 };
 
 export type MessageListParams = OpenAI.Beta.Threads.Messages.MessageListParams;
+
+export type MessageLog = {
+  id: string,
+  content: string,
+  isUserMessage?: boolean,
+}

+ 9 - 0
apps/app/src/features/openai/interfaces/thread-relation.ts

@@ -2,11 +2,20 @@ import type { IUser, Ref, HasObjectId } from '@growi/core';
 
 import type { AiAssistant } from './ai-assistant';
 
+
+export const ThreadType = {
+  KNOWLEDGE: 'knowledge',
+  EDITOR: 'editor',
+} as const;
+
+export type ThreadType = typeof ThreadType[keyof typeof ThreadType];
+
 export interface IThreadRelation {
   userId: Ref<IUser>
   aiAssistant: Ref<AiAssistant>
   threadId: string;
   title?: string;
+  type: ThreadType;
   expiredAt: Date;
 }
 

+ 6 - 2
apps/app/src/features/openai/server/models/thread-relation.ts

@@ -3,7 +3,7 @@ import { type Model, type Document, Schema } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-import type { IThreadRelation } from '../../interfaces/thread-relation';
+import { type IThreadRelation, ThreadType } from '../../interfaces/thread-relation';
 
 const DAYS_UNTIL_EXPIRATION = 3;
 
@@ -28,7 +28,6 @@ const schema = new Schema<ThreadRelationDocument, ThreadRelationModel>({
   aiAssistant: {
     type: Schema.Types.ObjectId,
     ref: 'AiAssistant',
-    required: true,
   },
   threadId: {
     type: String,
@@ -38,6 +37,11 @@ const schema = new Schema<ThreadRelationDocument, ThreadRelationModel>({
   title: {
     type: String,
   },
+  type: {
+    type: String,
+    enum: Object.values(ThreadType),
+    required: true,
+  },
   expiredAt: {
     type: Date,
     default: generateExpirationDate,

+ 146 - 0
apps/app/src/features/openai/server/routes/edit/README.ja.md

@@ -0,0 +1,146 @@
+# Editor Assistant API 実装解説
+
+## 要求仕様
+
+Editor Assistant API は、OpenAI AssistantAPI を使用して、マークダウンエディタの編集をサポートする機能です。主な要件は以下の通りです:
+
+1. **ストリーミング処理**:
+   - OpenAI からの応答をストリーミングで受け取り、Server-Sent Events (SSE) でクライアントにリアルタイムに転送
+   - JSON データを適切なタイミングで解析し、クライアントに送信
+
+2. **データ形式**:
+   - SSE による応答は `SseMessageSchema`, `SseDetectedDiffSchema`, `SseFinalizedSchema` に準拠した JSON 形式
+   - `{ message: "..." }` と delta 形式の差分情報(`insert`, `delete`, `retain`)を含む
+
+3. **エラーハンドリング**:
+   - 不完全な JSON データの処理時のエラーを適切に処理
+   - リソースリークの防止
+
+4. **効率性**:
+   - メモリ使用量を最小限に抑える
+   - 不要な通信を避け、クライアントへの適切なタイミングでのデータ送信を実現
+   - メッセージの増分送信による通信量削減と、すでに処理済みの要素のスキップによる処理効率の向上
+
+## 重要なインプット
+
+### 実装時に参照したコード
+
+1. **jsonrepair ライブラリ**:
+   - 壊れた JSON や不完全な JSON を修復するライブラリ
+   - 特に部分的なストリーミング JSON の処理に有効
+
+2. **型定義**:
+   - `message-error.ts`: エラー型と定義
+   - `schema.ts`: エディタアシスタントのメッセージと差分の Zod スキーマ定義
+
+### 今後のリファクタリングに重要なインプット
+
+1. **OpenAI API の仕様変更**:
+   - AssistantAPI のレスポンス形式の変更に注意
+
+2. **jsonrepair のアップデート**:
+   - 新バージョンでの API 変更や最適化手法の変更を確認
+
+3. **パフォーマンス監視**:
+   - メモリ使用量と処理時間のモニタリング
+   - 大規模 JSON 処理時のボトルネック特定
+
+## 実装のポイント
+
+### 1. ストリーミング処理と不完全JSONの修復
+
+ストリーミング処理において、最大の課題は不完全なJSON文字列の処理です。OpenAI APIから部分的に届くJSONデータを即座に解析するために、以下の対策を実装しています:
+
+- **jsonrepair ライブラリの採用理由**:
+  - 通常、JSON文字列は完全な形でなければパースできません。これはストリーム処理において大きな制約となります。
+  - 全ての文字列を受け取るまで待たずに、途中経過をリアルタイムにユーザーに提示するため、jsonrepairを使用して部分的なJSON文字列を修復しています。
+  - これにより、メッセージと差分情報を受信次第、速やかにクライアントに届けることが可能になり、ユーザー体験が大幅に向上します。
+  
+  **具体例**:
+  ```javascript
+  // ストリームから受け取った不完全なJSONの例
+  const partialJson = '{"contents": [{"message": "テキストを修正し';
+  
+  // 通常のJSON.parseではエラー
+  // JSON.parse(partialJson); // SyntaxError: Unexpected end of JSON input
+  
+  // jsonrepairを使用した修復
+  const repairedJson = jsonrepair(partialJson);
+  // 結果: '{"contents": [{"message": "テキストを修正しています"}]}'
+  
+  // 修復されたJSONはパース可能
+  const parsedJson = JSON.parse(repairedJson);
+  // 結果: { contents: [{ message: 'テキストを修正しています' }] }
+  ```
+  
+  - このように、正常なJSONとして完結していない途中のデータでも、jsonrepairは欠けている部分を補完して有効なJSONに変換します。OpenAI APIからの応答では、完全なJSONが揃うまで待つことなく、部分的に受信したデータを即座に処理できるようになります。
+
+- **rawBufferの累積と継続的な解析**:
+  - 受信したテキストチャンクを`rawBuffer`に累積し、その都度jsonrepairでパース可能な形に修復しています。
+  - これは特にOpenAI APIの応答がJSON形式で指定されているにもかかわらず、ストリームではその一部だけが届く特性に対応するための実装です。
+
+### 2. 差分検出と適応的送信制御
+
+エディタアシスタントの核心部分は、OpenAI APIからのレスポンスから差分情報を適切に抽出し、効率的にクライアントに送信する機能です。以下のような工夫を行っています:
+
+- **メッセージと差分の処理の統合と最適化**:
+  - UI/UX要件に基づく設計として、メッセージと差分の処理を単一ループで効率的に実装しています。
+  - **メッセージ処理**:メッセージの「増分」(新しく追加された部分)のみをクライアントに送信します。これにより通信量を削減し、クライアント側の処理負荷を軽減します。
+  - **差分処理**:JSONノードとして確定した差分は即座に検出し通知します。ただし、確定していない(変更中の可能性がある)差分は送信を控えることでエディタの過剰な更新を防止します。
+
+- **処理効率の向上メカニズム**:
+  - `processedMessages` Mapを使って、各メッセージ要素の前回の内容を記録し、差分のみを計算します。
+  - `lastProcessedContentLength` を用いて、すでに処理済みの要素をスキップします。これにより大量のデータでも効率的に処理できます。
+  ```javascript
+  // 処理開始位置の最適化 - 確定済み要素のスキップ
+  const startProcessingIndex = Math.max(0, Math.min(this.lastProcessedContentLength, contents.length) - 1);
+  
+  // 単一ループでメッセージと差分を処理
+  for (let i = startProcessingIndex; i < contents.length; i++) {
+    // メッセージと差分の処理
+  }
+  ```
+
+- **OpenAIストリームの特性に対応した差分確定判定**:
+  - OpenAI APIからのJSONストリームは「前方から順に確定していく」特性があります。このAPIの特性を活用し、以下の判定ロジックを実装しています:
+  ```javascript
+  // 最終要素が変化した、またはこれが最終要素ではない場合 → 差分を確定とみなす
+  if (i < currentContentIndex || currentContentIndex > this.lastContentIndex) {
+    // 差分を確定して送信リストに追加
+  }
+  ```
+  - この条件判定は単なる技術的工夫ではなく、UXの向上を目的としています。確定していない差分を頻繁に送信すると、エディタが頻繁に更新されてユーザー体験が悪化するためです。
+
+- **重複防止メカニズム**:
+  - 差分の重複送信を避けるため、一意のキーを生成する`getDiffKey`メソッドを実装しています。
+  - Setデータ構造(`sentDiffKeys`)を使うことで、O(1)の時間複雑度で効率的に重複チェックを行います。
+  - この実装は、ストリームデータの累積的な性質(同じデータが何度も現れる可能性がある)に対応するために不可欠です。
+
+- **増分メッセージ計算の最適化**:
+  - メッセージ要素ごとに前回のメッセージとの差分を計算する`getAppendedContent`メソッドを実装しています。
+  - これにより、クライアントには新たに追加された部分のみを送信でき、通信量を大幅に削減できます。
+  ```javascript
+  private getAppendedContent(previousMessage: string, currentMessage: string): string {
+    // 前回のメッセージから増分部分のみを返す
+    return currentMessage.slice(previousMessage.length);
+  }
+  ```
+
+### 3. エラー耐性とリソース管理
+
+ストリーミング処理においてエラー耐性とリソース管理は特に重要です。以下の対策を講じています:
+
+- **エラーハンドリングの階層化**:
+  - JSONパースエラーはデバッグ用にログ出力するのみとし、処理を継続します。これはストリーミングの性質上、部分的なデータでパースエラーが発生するのは正常な動作だからです。
+  - 重大なエラーはクライアントに適切に通知し、リソースを解放します。
+
+- **リソース解放の徹底**:
+  - クライアント切断時やエラー発生時、処理完了時など、あらゆるシナリオでリソースを確実に解放するクリーンアップ処理を実装しています。
+  - `destroy`メソッドでメモリキャッシュをクリアし、イベントリスナーを解除することで、メモリリークを防止しています。
+
+- **非同期ストリーム処理の安全な終了**:
+  - ストリームの終了を適切に検出し、完全な結果を送信してから接続を終了する機構を設けています。
+  - エラー時でも可能な限り正常な形でレスポンスを返し、クライアント側での復旧を容易にします。
+
+このような設計と実装により、リアルタイム性と正確性を両立したエディタアシスタント機能を実現しています。ストリーミング処理の特性を活かしつつ、効率的なデータ処理と適応的な通知制御によって優れたユーザー体験を提供しています。
+

+ 325 - 0
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -0,0 +1,325 @@
+import { getIdStringForRef } from '@growi/core';
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler, Response } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { body } from 'express-validator';
+import { zodResponseFormat } from 'openai/helpers/zod';
+import type { MessageDelta } from 'openai/resources/beta/threads/messages.mjs';
+import { z } from 'zod';
+
+// Necessary imports
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+import { LlmEditorAssistantDiffSchema, LlmEditorAssistantMessageSchema } from '../../../interfaces/editor-assistant/llm-response-schemas';
+import type { SseDetectedDiff, SseFinalized, SseMessage } from '../../../interfaces/editor-assistant/sse-schemas';
+import { MessageErrorCode } from '../../../interfaces/message-error';
+import ThreadRelationModel from '../../models/thread-relation';
+import { getOrCreateEditorAssistant } from '../../services/assistant';
+import { openaiClient } from '../../services/client';
+import { LlmResponseStreamProcessor } from '../../services/editor-assistant';
+import { getStreamErrorCode } from '../../services/getStreamErrorCode';
+import { getOpenaiService } from '../../services/openai';
+import { replaceAnnotationWithPageLink } from '../../services/replace-annotation-with-page-link';
+import { certifyAiService } from '../middlewares/certify-ai-service';
+import { SseHelper } from '../utils/sse-helper';
+
+
+const logger = loggerFactory('growi:routes:apiv3:openai:message');
+
+// -----------------------------------------------------------------------------
+// Type definitions
+// -----------------------------------------------------------------------------
+
+const LlmEditorAssistantResponseSchema = z.object({
+  contents: z.array(z.union([LlmEditorAssistantMessageSchema, LlmEditorAssistantDiffSchema])),
+}).describe('The response format for the editor assistant');
+
+
+type ReqBody = {
+  userMessage: string,
+  markdown?: string,
+  threadId?: string,
+}
+
+type Req = Request<undefined, Response, ReqBody> & {
+  user: IUserHasId,
+}
+
+
+// -----------------------------------------------------------------------------
+// Endpoint handler factory
+// -----------------------------------------------------------------------------
+
+type PostMessageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+
+// -----------------------------------------------------------------------------
+// Instructions
+// -----------------------------------------------------------------------------
+/* eslint-disable max-len */
+const instructionWithMarkdown = `You are an Editor Assistant for GROWI, a markdown wiki system.
+    Your task is to help users edit their markdown content based on their requests.
+    Spaces and line breaks are also counted as individual characters.
+
+    RESPONSE FORMAT:
+    You must respond with a JSON object in the following format example:
+    {
+      "contents": [
+        { "message": "Your brief message about the upcoming change or proposal.\n\n" },
+        { "replace": "New text 1" },
+        { "message": "Additional explanation if needed" },
+        { "replace": "New text 2" },
+        ...more items if needed
+        { "message": "Your friendly message explaining what changes were made or suggested." }
+      ]
+    }
+
+    The array should contain:
+    - [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
+    - Objects with a "message" key for explanatory text to the user if needed.
+    - Edit markdown according to user instructions and include it line by line in the 'replace' object. Return original text for lines that do not need editing.
+    - [At the end of the list] A "message" object that contains your friendly message explaining that the operation was completed and what changes were made.
+
+    IMPORTANT:
+    - The text for lines that do not need correction must be returned exactly as in the original text.
+    - Include original text in the replace object even if it contains only spaces or line breaks
+
+    Always provide messages in the same language as the user's request.`;
+
+const instructionWithoutMarkdown = `You are an Editor Assistant for GROWI, a markdown wiki system.
+    Your task is to help users edit their markdown content based on their requests.
+
+    RESPONSE FORMAT:
+    You must respond with a JSON object in the following format example:
+    {
+      "contents": [
+        { "message": "Your brief message about the upcoming change or proposal.\n\n" },
+        { "replace": "New text 1" },
+        { "message": "Additional explanation if needed" },
+        { "replace": "New text 2" },
+        ...more items if needed
+        { "message": "Your friendly message explaining what changes were made or suggested." }
+      ]
+    }
+
+    The array should contain:
+    - [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
+    - Objects with a "message" key for explanatory text to the user if needed.
+    - Edit markdown according to user instructions and include it line by line in the 'replace' object.
+    - [At the end of the list] A "message" object that contains your friendly message explaining that the operation was completed and what changes were made.
+
+    Always provide messages in the same language as the user's request.`;
+/* eslint-disable max-len */
+
+/**
+ * Create endpoint handlers for editor assistant
+ */
+export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  // Validator setup
+  const validator: ValidationChain[] = [
+    body('userMessage')
+      .isString()
+      .withMessage('userMessage must be string')
+      .notEmpty()
+      .withMessage('userMessage must be set'),
+    body('markdown')
+      .optional()
+      .isString()
+      .withMessage('markdown must be string'),
+    body('threadId').optional().isString().withMessage('threadId must be string'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const {
+        userMessage, markdown, threadId,
+      } = req.body;
+
+      // Parameter check
+      if (threadId == null) {
+        return res.apiv3Err(new ErrorV3('threadId is not set', MessageErrorCode.THREAD_ID_IS_NOT_SET), 400);
+      }
+
+      // Service check
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      const threadRelation = await ThreadRelationModel.findOne({ threadId: { $eq: threadId } });
+      if (threadRelation == null) {
+        return res.apiv3Err(new ErrorV3('ThreadRelation not found'), 404);
+      }
+
+      // Check if usable
+      if (threadRelation.aiAssistant != null) {
+        const aiAssistantId = getIdStringForRef(threadRelation.aiAssistant);
+        const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+        if (!isAiAssistantUsable) {
+          return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+        }
+      }
+
+      // Initialize SSE helper and stream processor
+      const sseHelper = new SseHelper(res);
+      const streamProcessor = new LlmResponseStreamProcessor({
+        messageCallback: (appendedMessage) => {
+          sseHelper.writeData<SseMessage>({ appendedMessage });
+        },
+        diffDetectedCallback: (detected) => {
+          sseHelper.writeData<SseDetectedDiff>({ diff: detected });
+        },
+        dataFinalizedCallback: (message, replacements) => {
+          sseHelper.writeData<SseFinalized>({ finalized: { message: message ?? '', replacements } });
+        },
+      });
+
+      try {
+        // Set response headers
+        res.writeHead(200, {
+          'Content-Type': 'text/event-stream;charset=utf-8',
+          'Cache-Control': 'no-cache, no-transform',
+        });
+
+        let rawBuffer = '';
+
+        // Get assistant and process thread
+        const assistant = await getOrCreateEditorAssistant();
+        const thread = await openaiClient.beta.threads.retrieve(threadId);
+
+        // Create stream
+        const stream = openaiClient.beta.threads.runs.stream(thread.id, {
+          assistant_id: assistant.id,
+          additional_messages: [
+            {
+              role: 'assistant',
+              content: markdown != null
+                ? instructionWithMarkdown
+                : instructionWithoutMarkdown,
+            },
+            // {
+            //   role: 'assistant',
+            //   content: `You are an Editor Assistant for GROWI, a markdown wiki system.
+            //   Your task is to help users edit their markdown content based on their requests.
+
+            //   RESPONSE FORMAT:
+            //   You must respond with a JSON object in the following format example:
+            //   {
+            //     "contents": [
+            //       { "message": "Your brief message about the upcoming change or proposal.\n\n" },
+            //       { "retain": 10 },
+            //       { "insert": "New text 1" },
+            //       { "message": "Additional explanation if needed" },
+            //       { "retain": 100 },
+            //       { "delete": 15 },
+            //       { "insert": "New text 2" },
+            //       ...more items if needed
+            //       { "message": "Your friendly message explaining what changes were made or suggested." }
+            //     ]
+            //   }
+
+            //   The array should contain:
+            //   - [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
+            //   - Objects with a "message" key for explanatory text to the user if needed.
+            //   - Objects with "insert", "delete", and "retain" keys for replacements (Delta format by Quill Rich Text Editor)
+            //   - [At the end of the list] A "message" object that contains your friendly message explaining that the operation was completed and what changes were made.
+
+            //   If no changes are needed, include only message objects with explanations.
+            //   Always provide messages in the same language as the user's request.`,
+            // },
+            {
+              role: 'user',
+              content: `Current markdown content:\n\`\`\`markdown\n${markdown}\n\`\`\`\n\nUser request: ${userMessage}`,
+            },
+          ],
+          response_format: zodResponseFormat(LlmEditorAssistantResponseSchema, 'editor_assistant_response'),
+        });
+
+        // Message delta handler
+        const messageDeltaHandler = async(delta: MessageDelta) => {
+          const content = delta.content?.[0];
+
+          // Process annotations
+          if (content?.type === 'text' && content?.text?.annotations != null) {
+            await replaceAnnotationWithPageLink(content, req.user.lang);
+          }
+
+          // Process text
+          if (content?.type === 'text' && content.text?.value) {
+            const chunk = content.text.value;
+
+            // Process data with JSON processor
+            streamProcessor.process(rawBuffer, chunk);
+
+            rawBuffer += chunk;
+          }
+          else {
+            sseHelper.writeData(delta);
+          }
+        };
+
+        // Register event handlers
+        stream.on('messageDelta', messageDeltaHandler);
+
+        // Run error handler
+        stream.on('event', (delta) => {
+          if (delta.event === 'thread.run.failed') {
+            const errorMessage = delta.data.last_error?.message;
+            if (errorMessage == null) return;
+
+            logger.error(errorMessage);
+            sseHelper.writeError(errorMessage, getStreamErrorCode(errorMessage));
+          }
+        });
+
+        // Completion handler
+        stream.once('messageDone', () => {
+          // Process and send final result
+          streamProcessor.sendFinalResult(rawBuffer);
+
+          // Clean up stream
+          streamProcessor.destroy();
+          stream.off('messageDelta', messageDeltaHandler);
+          sseHelper.end();
+        });
+
+        // Error handler
+        stream.once('error', (err) => {
+          logger.error('Stream error:', err);
+
+          // Clean up
+          streamProcessor.destroy();
+          stream.off('messageDelta', messageDeltaHandler);
+          sseHelper.writeError('An error occurred while processing your request');
+          sseHelper.end();
+        });
+
+        // Clean up on client disconnect
+        req.on('close', () => {
+          streamProcessor.destroy();
+
+          if (stream) {
+            stream.off('messageDelta', () => {});
+            stream.off('event', () => {});
+          }
+
+          logger.debug('Connection closed by client');
+        });
+      }
+      catch (err) {
+        // Clean up and respond on error
+        logger.error('Error in edit handler:', err);
+        streamProcessor.destroy();
+        return res.status(500).send(err.message);
+      }
+    },
+  ];
+};

+ 4 - 0
apps/app/src/features/openai/server/routes/index.ts

@@ -39,6 +39,10 @@ export const factory = (crowi: Crowi): express.Router => {
       router.get('/messages/:aiAssistantId/:threadId', getMessagesFactory(crowi));
     });
 
+    import('./edit').then(({ postMessageToEditHandlersFactory }) => {
+      router.post('/edit', postMessageToEditHandlersFactory(crowi));
+    });
+
     import('./ai-assistant').then(({ createAiAssistantFactory }) => {
       router.post('/ai-assistant', createAiAssistantFactory(crowi));
     });

+ 9 - 13
apps/app/src/features/openai/server/routes/thread.ts

@@ -10,6 +10,7 @@ import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
+import { ThreadType } from '../../interfaces/thread-relation';
 import { getOpenaiService } from '../services/openai';
 
 import { certifyAiService } from './middlewares/certify-ai-service';
@@ -17,8 +18,9 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 const logger = loggerFactory('growi:routes:apiv3:openai:thread');
 
 type ReqBody = {
-  aiAssistantId: string,
-  initialUserMessage: string,
+  type: ThreadType,
+  aiAssistantId?: string,
+  initialUserMessage?: string,
 }
 
 type CreateThreadReq = Request<undefined, ApiV3Response, ReqBody> & { user: IUserHasId };
@@ -29,8 +31,9 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
   const validator: ValidationChain[] = [
-    body('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
-    body('initialUserMessage').isString().withMessage('initialUserMessage must be string'),
+    body('type').isIn(Object.values(ThreadType)).withMessage('type must be one of "editor" or "knowledge"'),
+    body('aiAssistantId').optional().isMongoId().withMessage('aiAssistantId must be string'),
+    body('initialUserMessage').optional().isString().withMessage('initialUserMessage must be string'),
   ];
 
   return [
@@ -42,19 +45,12 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
       }
 
-      const { aiAssistantId, initialUserMessage } = req.body;
+      const { type, aiAssistantId, initialUserMessage } = req.body;
 
       // express-validator ensures aiAssistantId is a string
 
       try {
-
-        const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, req.user);
-        if (!isAiAssistantUsable) {
-          return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
-        }
-
-        const thread = await openaiService.createThread(req.user._id, aiAssistantId, initialUserMessage);
-
+        const thread = await openaiService.createThread(req.user._id, type, aiAssistantId, initialUserMessage);
         return res.apiv3(thread);
       }
       catch (err) {

+ 56 - 0
apps/app/src/features/openai/server/routes/utils/sse-helper.ts

@@ -0,0 +1,56 @@
+import type { Response } from 'express';
+
+import type { StreamErrorCode } from '../../../interfaces/message-error';
+
+/**
+ * Interface to simplify SSE communication
+ */
+export interface ISseHelper {
+  /**
+   * Send data in SSE format
+   */
+  writeData<T extends object>(data: T): void;
+
+  /**
+   * Send error in SSE format
+   */
+  writeError(message: string, code?: StreamErrorCode): void;
+
+  /**
+   * End the response
+   */
+  end(): void;
+}
+
+/**
+ * SSE Helper Class
+ * Provides functionality to write data to response object in SSE format
+ */
+export class SseHelper implements ISseHelper {
+
+  constructor(private res: Response) {
+    this.res = res;
+  }
+
+  /**
+   * Send data in SSE format
+   */
+  writeData<T extends object>(data: T): void {
+    this.res.write(`data: ${JSON.stringify(data)}\n\n`);
+  }
+
+  /**
+   * Send error in SSE format
+   */
+  writeError(message: string, code?: StreamErrorCode): void {
+    this.res.write(`error: ${JSON.stringify({ code, message })}\n\n`);
+  }
+
+  /**
+   * End the response
+   */
+  end(): void {
+    this.res.end();
+  }
+
+}

+ 25 - 12
apps/app/src/features/openai/server/services/assistant/assistant.ts

@@ -8,27 +8,30 @@ import { openaiClient } from '../client';
 const AssistantType = {
   SEARCH: 'Search',
   CHAT: 'Chat',
+  EDIT: 'Edit',
 } as const;
 
 const AssistantDefaultModelMap: Record<AssistantType, OpenAI.Chat.ChatModel> = {
   [AssistantType.SEARCH]: 'gpt-4o-mini',
   [AssistantType.CHAT]: 'gpt-4o-mini',
-};
-
-const isValidChatModel = (model: string): model is OpenAI.Chat.ChatModel => {
-  return model.startsWith('gpt-');
+  [AssistantType.EDIT]: 'gpt-4o-mini',
 };
 
 const getAssistantModelByType = (type: AssistantType): OpenAI.Chat.ChatModel => {
-  const configValue = type === AssistantType.SEARCH
-    ? undefined // TODO: add the value for 'openai:assistantModel:search' to config-definition.ts
-    : configManager.getConfig('openai:assistantModel:chat');
-
-  if (typeof configValue === 'string' && isValidChatModel(configValue)) {
-    return configValue;
-  }
+  const configValue = (() => {
+    switch (type) {
+      case AssistantType.SEARCH:
+        // return configManager.getConfig('openai:assistantModel:search');
+        return undefined;
+      case AssistantType.CHAT:
+        return configManager.getConfig('openai:assistantModel:chat');
+      case AssistantType.EDIT:
+        // return configManager.getConfig('openai:assistantModel:edit');
+        return undefined;
+    }
+  })();
 
-  return AssistantDefaultModelMap[type];
+  return configValue ?? AssistantDefaultModelMap[type];
 };
 
 type AssistantType = typeof AssistantType[keyof typeof AssistantType];
@@ -103,3 +106,13 @@ export const getOrCreateChatAssistant = async(): Promise<OpenAI.Beta.Assistant>
   chatAssistant = await getOrCreateAssistant(AssistantType.CHAT);
   return chatAssistant;
 };
+
+let editorAssistant: OpenAI.Beta.Assistant | undefined;
+export const getOrCreateEditorAssistant = async(): Promise<OpenAI.Beta.Assistant> => {
+  if (editorAssistant != null) {
+    return editorAssistant;
+  }
+
+  editorAssistant = await getOrCreateAssistant(AssistantType.EDIT);
+  return editorAssistant;
+};

+ 9 - 7
apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts

@@ -23,14 +23,16 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator {
     // TODO: initialize openaiVectorStoreId property
   }
 
-  async createThread(vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread> {
-    return this.client.beta.threads.create({
-      tool_resources: {
-        file_search: {
-          vector_store_ids: [vectorStoreId],
+  async createThread(vectorStoreId?: string): Promise<OpenAI.Beta.Threads.Thread> {
+    return this.client.beta.threads.create(vectorStoreId != null
+      ? {
+        tool_resources: {
+          file_search: {
+            vector_store_ids: [vectorStoreId],
+          },
         },
-      },
-    });
+      }
+      : undefined);
   }
 
   async updateThread(threadId: string, vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread> {

+ 1 - 1
apps/app/src/features/openai/server/services/client-delegator/interfaces.ts

@@ -4,7 +4,7 @@ import type { Uploadable } from 'openai/uploads';
 import type { MessageListParams } from '../../../interfaces/message';
 
 export interface IOpenaiClientDelegator {
-  createThread(vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread>
+  createThread(vectorStoreId?: string): Promise<OpenAI.Beta.Threads.Thread>
   updateThread(threadId: string, vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread>
   retrieveThread(threadId: string): Promise<OpenAI.Beta.Threads.Thread>
   deleteThread(threadId: string): Promise<OpenAI.Beta.Threads.ThreadDeleted>

+ 9 - 7
apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts

@@ -24,14 +24,16 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator {
     this.client = new OpenAI({ apiKey });
   }
 
-  async createThread(vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread> {
-    return this.client.beta.threads.create({
-      tool_resources: {
-        file_search: {
-          vector_store_ids: [vectorStoreId],
+  async createThread(vectorStoreId?: string): Promise<OpenAI.Beta.Threads.Thread> {
+    return this.client.beta.threads.create(vectorStoreId != null
+      ? {
+        tool_resources: {
+          file_search: {
+            vector_store_ids: [vectorStoreId],
+          },
         },
-      },
-    });
+      }
+      : undefined);
   }
 
   async retrieveThread(threadId: string): Promise<OpenAI.Beta.Threads.Thread> {

+ 1 - 0
apps/app/src/features/openai/server/services/editor-assistant/index.ts

@@ -0,0 +1 @@
+export * from './llm-response-stream-processor';

+ 242 - 0
apps/app/src/features/openai/server/services/editor-assistant/llm-response-stream-processor.ts

@@ -0,0 +1,242 @@
+import { jsonrepair } from 'jsonrepair';
+import type { z } from 'zod';
+
+import loggerFactory from '~/utils/logger';
+
+import {
+  type LlmEditorAssistantMessage,
+  LlmEditorAssistantDiffSchema, type LlmEditorAssistantDiff,
+} from '../../../interfaces/editor-assistant/llm-response-schemas';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:edit:editor-stream-processor');
+
+/**
+ * Type guard: Check if item is a message type
+ */
+const isMessageItem = (item: unknown): item is LlmEditorAssistantMessage => {
+  return typeof item === 'object' && item !== null && 'message' in item;
+};
+
+/**
+ * Type guard: Check if item is a diff type
+ */
+const isDiffItem = (item: unknown): item is LlmEditorAssistantDiff => {
+  return typeof item === 'object' && item !== null
+    // && ('insert' in item || 'delete' in item || 'retain' in item);
+    && ('replace' in item);
+};
+
+type Options = {
+  messageCallback?: (appendedMessage: string) => void,
+  diffDetectedCallback?: (detected: LlmEditorAssistantDiff) => void,
+  dataFinalizedCallback?: (message: string | null, replacements: LlmEditorAssistantDiff[]) => void,
+}
+
+/**
+ * AI response stream processor for Editor Assisntant
+ * Extracts messages and diffs from JSON stream for editor
+ */
+export class LlmResponseStreamProcessor {
+
+  // Final response data
+  private message: string | null = null;
+
+  private replacements: LlmEditorAssistantDiff[] = [];
+
+  // Index of the last element in previous content
+  private lastContentIndex = -1;
+
+  // Last sent diff index
+  private lastSentDiffIndex = -1;
+
+  // Set of sent diff keys
+  private sentDiffKeys = new Set<string>();
+
+  // Map to store previous messages by index
+  private processedMessages: Map<number, string> = new Map();
+
+  // Last processed content length - to optimize processing
+  private lastProcessedContentLength = 0;
+
+  constructor(
+      private options?: Options,
+  ) {
+    this.options = options;
+  }
+
+  /**
+   * Process JSON data
+   * @param prevJsonString Previous JSON string
+   * @param chunk New chunk of JSON string
+   */
+  process(prevJsonString: string, chunk: string): void {
+    const jsonString = prevJsonString + chunk;
+
+    try {
+      const repairedJson = jsonrepair(jsonString);
+      const parsedJson = JSON.parse(repairedJson);
+
+      if (parsedJson?.contents && Array.isArray(parsedJson.contents)) {
+        const contents = parsedJson.contents;
+
+        // Index of the last element in current content
+        const currentContentIndex = contents.length - 1;
+
+        // Calculate processing start index - to avoid reprocessing known elements
+        const startProcessingIndex = Math.max(0, Math.min(this.lastProcessedContentLength, contents.length) - 1);
+
+        // Process both messages and diffs in a single loop
+        let diffUpdated = false;
+        let processedDiffIndex = -1;
+
+        // Unified loop for processing both messages and diffs
+        for (let i = startProcessingIndex; i < contents.length; i++) {
+          const item = contents[i];
+
+          // Process message items
+          if (isMessageItem(item)) {
+            const currentMessage = item.message;
+            const previousMessage = this.processedMessages.get(i);
+
+            if (previousMessage !== currentMessage) {
+              let appendedContent: string;
+
+              if (previousMessage == null) {
+                appendedContent = currentMessage;
+              }
+              else {
+                appendedContent = this.getAppendedContent(previousMessage, currentMessage);
+              }
+
+              this.processedMessages.set(i, currentMessage);
+              this.message = currentMessage;
+
+              if (appendedContent) {
+                this.options?.messageCallback?.(appendedContent);
+              }
+            }
+          }
+          // Process diff items
+          else if (isDiffItem(item)) {
+            const validDiff = LlmEditorAssistantDiffSchema.safeParse(item);
+            if (!validDiff.success) continue;
+
+            const diff = validDiff.data;
+            const key = this.getDiffKey(diff, i);
+
+            // Skip if already sent
+            if (this.sentDiffKeys.has(key)) continue;
+
+            // Consider the diff as finalized if:
+            // 1. This is not the last element OR
+            // 2. The last element has changed from previous parsing
+            if (i < currentContentIndex || currentContentIndex > this.lastContentIndex) {
+              this.replacements.push(diff);
+              this.sentDiffKeys.add(key);
+              diffUpdated = true;
+              processedDiffIndex = Math.max(processedDiffIndex, i);
+            }
+          }
+        }
+
+        // Update tracking variables for next iteration
+        this.lastContentIndex = currentContentIndex;
+        this.lastProcessedContentLength = contents.length;
+
+        // Send diff notification if new diffs were detected
+        if (diffUpdated && processedDiffIndex > this.lastSentDiffIndex) {
+          this.lastSentDiffIndex = processedDiffIndex;
+          this.options?.diffDetectedCallback?.(this.replacements[this.replacements.length - 1]);
+        }
+      }
+    }
+    catch (e) {
+      // Ignore parse errors (expected for incomplete JSON)
+      logger.debug('JSON parsing error (expected for partial data):', e);
+    }
+  }
+
+  /**
+   * Calculate the appended content between previous and current message
+   * @param previousMessage The previous complete message
+   * @param currentMessage The current complete message
+   * @returns The appended content (difference)
+   */
+  private getAppendedContent(previousMessage: string, currentMessage: string): string {
+    // If current message is shorter, return empty string (shouldn't happen in normal flow)
+    if (currentMessage.length <= previousMessage.length) {
+      return '';
+    }
+
+    // Return the appended part
+    return currentMessage.slice(previousMessage.length);
+  }
+
+  /**
+   * Generate unique key for a diff
+   */
+  private getDiffKey(diff: LlmEditorAssistantDiff, index: number): string {
+    // if ('insert' in diff) return `insert-${index}`;
+    // if ('delete' in diff) return `delete-${index}`;
+    // if ('retain' in diff) return `retain-${index}`;
+    if ('replace' in diff) return `replace-${index}`;
+    return '';
+  }
+
+  /**
+   * Send final result
+   */
+  sendFinalResult(rawBuffer: string): void {
+    try {
+      const repairedJson = jsonrepair(rawBuffer);
+      const parsedJson = JSON.parse(repairedJson);
+
+      // Get all diffs from the final data
+      if (parsedJson?.contents && Array.isArray(parsedJson.contents)) {
+        const contents = parsedJson.contents;
+
+        // Add any unsent diffs in a single loop
+        for (const item of contents) {
+          if (!isDiffItem(item)) continue;
+
+          const validDiff = LlmEditorAssistantDiffSchema.safeParse(item);
+          if (!validDiff.success) continue;
+
+          const diff = validDiff.data;
+          const key = this.getDiffKey(diff, contents.indexOf(item));
+
+          // Add any diffs that haven't been sent yet
+          if (!this.sentDiffKeys.has(key)) {
+            this.replacements.push(diff);
+            this.sentDiffKeys.add(key);
+          }
+        }
+      }
+
+      // Final notification
+      const fullMessage = Array.from(this.processedMessages.values()).join('');
+      this.options?.dataFinalizedCallback?.(fullMessage, this.replacements);
+    }
+    catch (e) {
+      logger.debug('Failed to parse final JSON response:', e);
+
+      // Send final notification even on error
+      const fullMessage = Array.from(this.processedMessages.values()).join('');
+      this.options?.dataFinalizedCallback?.(fullMessage, this.replacements);
+    }
+  }
+
+  /**
+   * Release resources
+   */
+  destroy(): void {
+    this.message = null;
+    this.processedMessages.clear();
+    this.replacements = [];
+    this.sentDiffKeys.clear();
+    this.lastContentIndex = -1;
+    this.lastSentDiffIndex = -1;
+    this.lastProcessedContentLength = 0;
+  }
+
+}

+ 5 - 0
apps/app/src/features/openai/server/services/normalize-data/normalize-thread-relation-expired-at/normalize-thread-relation-expired-at.integ.ts

@@ -2,10 +2,12 @@ import { faker } from '@faker-js/faker';
 import { addDays, subDays } from 'date-fns';
 import { Types } from 'mongoose';
 
+import { ThreadType } from '../../../../interfaces/thread-relation';
 import ThreadRelation from '../../../models/thread-relation';
 
 import { MAX_DAYS_UNTIL_EXPIRATION, normalizeExpiredAtForThreadRelations } from './normalize-thread-relation-expired-at';
 
+
 describe('normalizeExpiredAtForThreadRelations', () => {
 
   it('should update expiredAt to 3 days from now for expired thread relations', async() => {
@@ -17,6 +19,7 @@ describe('normalizeExpiredAtForThreadRelations', () => {
       threadId: 'test-thread',
       aiAssistant: new Types.ObjectId(),
       expiredAt: expiredDate,
+      type: ThreadType.KNOWLEDGE,
     });
     await threadRelation.save();
 
@@ -39,6 +42,7 @@ describe('normalizeExpiredAtForThreadRelations', () => {
       threadId: 'test-thread-2',
       aiAssistant: new Types.ObjectId(),
       expiredAt: nonExpiredDate,
+      type: ThreadType.KNOWLEDGE,
     });
     await threadRelation.save();
 
@@ -59,6 +63,7 @@ describe('normalizeExpiredAtForThreadRelations', () => {
       threadId: 'test-thread-3',
       aiAssistant: new Types.ObjectId(),
       expiredAt: nonExpiredDate,
+      type: ThreadType.KNOWLEDGE,
     });
     await threadRelation.save();
 

+ 8 - 7
apps/app/src/features/openai/server/services/openai.ts

@@ -34,6 +34,7 @@ import {
   type AccessibleAiAssistants, type AiAssistant, AiAssistantAccessScope, AiAssistantShareScope,
 } from '../../interfaces/ai-assistant';
 import type { MessageListParams } from '../../interfaces/message';
+import { ThreadType } from '../../interfaces/thread-relation';
 import { removeGlobPath } from '../../utils/remove-glob-path';
 import AiAssistantModel, { type AiAssistantDocument } from '../models/ai-assistant';
 import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html';
@@ -66,7 +67,7 @@ const convertPathPatternsToRegExp = (pagePathPatterns: string[]): Array<string |
 };
 
 export interface IOpenaiService {
-  createThread(userId: string, aiAssistantId: string, initialUserMessage: string): Promise<ThreadRelationDocument>;
+  createThread(userId: string, type: ThreadType, aiAssistantId?: string, initialUserMessage?: string): Promise<ThreadRelationDocument>;
   getThreadsByAiAssistantId(aiAssistantId: string): Promise<ThreadRelationDocument[]>
   deleteThread(threadRelationId: string): Promise<ThreadRelationDocument>;
   deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
@@ -118,9 +119,7 @@ class OpenaiService implements IOpenaiService {
     return threadTitle;
   }
 
-  async createThread(userId: string, aiAssistantId: string, initialUserMessage: string): Promise<ThreadRelationDocument> {
-    const vectorStoreRelation = await this.getVectorStoreRelationByAiAssistantId(aiAssistantId);
-
+  async createThread(userId: string, type: ThreadType, aiAssistantId?: string, initialUserMessage?: string): Promise<ThreadRelationDocument> {
     let threadTitle: string | null = null;
     if (initialUserMessage != null) {
       try {
@@ -132,9 +131,11 @@ class OpenaiService implements IOpenaiService {
     }
 
     try {
-      const thread = await this.client.createThread(vectorStoreRelation.vectorStoreId);
+      const vectorStoreRelation = aiAssistantId != null ? await this.getVectorStoreRelationByAiAssistantId(aiAssistantId) : null;
+      const thread = await this.client.createThread(vectorStoreRelation?.vectorStoreId);
       const threadRelation = await ThreadRelationModel.create({
         userId,
+        type,
         aiAssistant: aiAssistantId,
         threadId: thread.id,
         title: threadTitle,
@@ -159,8 +160,8 @@ class OpenaiService implements IOpenaiService {
     }
   }
 
-  async getThreadsByAiAssistantId(aiAssistantId: string): Promise<ThreadRelationDocument[]> {
-    const threadRelations = await ThreadRelationModel.find({ aiAssistant: aiAssistantId });
+  async getThreadsByAiAssistantId(aiAssistantId: string, type: ThreadType = ThreadType.KNOWLEDGE): Promise<ThreadRelationDocument[]> {
+    const threadRelations = await ThreadRelationModel.find({ aiAssistant: aiAssistantId, type });
     return threadRelations;
   }
 

+ 10 - 0
apps/app/src/features/openai/utils/handle-if-successfully-parsed.ts

@@ -0,0 +1,10 @@
+import type { z } from 'zod';
+
+export const handleIfSuccessfullyParsed = <T, >(data: T, zSchema: z.ZodSchema<T>,
+  callback: (data: T) => void,
+): void => {
+  const parsed = zSchema.safeParse(data);
+  if (parsed.success) {
+    callback(data);
+  }
+};

+ 1 - 3
apps/app/src/server/service/yjs/sync-ydoc.ts

@@ -1,4 +1,5 @@
 import { Origin, YDocStatus } from '@growi/core';
+import { type Delta } from '@growi/editor';
 import type { Document } from 'y-socket.io/dist/server';
 
 import loggerFactory from '~/utils/logger';
@@ -11,9 +12,6 @@ import type { MongodbPersistence } from './extended/mongodb-persistence';
 const logger = loggerFactory('growi:service:yjs:sync-ydoc');
 
 
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type Delta = Array<{insert?:Array<any>|string, delete?:number, retain?:number}>;
-
 type Context = {
   ydocStatus: YDocStatus,
 }

+ 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
  *********************************************************** */

+ 7 - 0
apps/app/src/stores/use-editing-clients.ts

@@ -0,0 +1,7 @@
+import { useSWRStatic } from '@growi/core/dist/swr';
+import type { EditingClient } from '@growi/editor';
+import type { SWRResponse } from 'swr';
+
+export const useEditingClients = (status?: EditingClient[]): SWRResponse<EditingClient[], Error> => {
+  return useSWRStatic<EditingClient[], Error>('editingUsers', status, { fallbackData: [] });
+};

+ 0 - 33
apps/app/src/stores/use-editing-users.ts

@@ -1,33 +0,0 @@
-import { useCallback } from 'react';
-
-import type { IUserHasId } from '@growi/core';
-import { useSWRStatic } from '@growi/core/dist/swr';
-import type { SWRResponse } from 'swr';
-
-type EditingUsersStatus = {
-  userList: IUserHasId[],
-}
-
-type EditingUsersStatusUtils = {
-  onEditorsUpdated(
-    userList: IUserHasId[],
-  ): void,
-}
-
-export const useEditingUsers = (status?: EditingUsersStatus): SWRResponse<EditingUsersStatus, Error> & EditingUsersStatusUtils => {
-  const initialData: EditingUsersStatus = {
-    userList: [],
-  };
-  const swrResponse = useSWRStatic<EditingUsersStatus, Error>('editingUsers', status, { fallbackData: initialData });
-
-  const { mutate } = swrResponse;
-
-  const onEditorsUpdated = useCallback((userList: IUserHasId[]): void => {
-    mutate({ userList });
-  }, [mutate]);
-
-  return {
-    ...swrResponse,
-    onEditorsUpdated,
-  };
-};

+ 4 - 0
packages/core-styles/scss/bootstrap/mixins/_button-outline-variant.scss

@@ -1,5 +1,9 @@
 @use 'sass:color';
 
+// Uncomment if you want to include this mixin with @use
+// $prefix: 'bs-' !default;
+// $btn-active-box-shadow: 0 !default;
+
 @mixin button-outline-variant-light(
   $color,
   $background: color.mix(#fff, $color, 90%),

+ 2 - 0
packages/editor/package.json

@@ -67,6 +67,8 @@
     "reactstrap": "^9.2.2",
     "string-width": "=4.2.2",
     "simplebar-react": "^2.3.6",
+    "socket.io": "^4.7.5",
+    "socket.io-client": "^4.7.5",
     "swr": "^2.3.2",
     "ts-deepmerge": "^6.2.0",
     "y-codemirror.next": "^0.3.5",

+ 0 - 2
packages/editor/src/@types/y-codemirror.next.d.ts

@@ -1,2 +0,0 @@
-// https://github.com/yjs/y-codemirror.next/issues/27
-declare module 'y-codemirror.next';

+ 5 - 1
packages/editor/src/client/components-internal/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -23,6 +23,8 @@ import { Toolbar } from './Toolbar';
 
 import style from './CodeMirrorEditor.module.scss';
 
+const moduleClass = style['codemirror-editor'];
+
 
 // Fix IME cursor position issue by EditContext
 // ref: https://github.com/weseek/growi/pull/9267
@@ -54,12 +56,14 @@ export type CodeMirrorEditorProps = {
 
 type Props = CodeMirrorEditorProps & {
   editorKey: string | GlobalCodeMirrorEditorKey,
+  className?: string,
   hideToolbar?: boolean,
 }
 
 export const CodeMirrorEditor = (props: Props): JSX.Element => {
   const {
     editorKey,
+    className,
     hideToolbar,
 
     cmProps,
@@ -217,7 +221,7 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
   }, [isUploading, isDragAccept, isDragReject, acceptedUploadFileType]);
 
   return (
-    <div className={`${style['codemirror-editor']} flex-expand-vert overflow-y-hidden`}>
+    <div className={`${className} ${moduleClass} flex-expand-vert overflow-y-hidden`}>
       <div {...getRootProps()} className={`dropzone  ${fileUploadState} flex-expand-vert`}>
         <input {...getInputProps()} />
         <FileDropzoneOverlay isEnabled={isDragActive} />

+ 33 - 9
packages/editor/src/client/components-internal/playground/Playground.tsx

@@ -3,6 +3,7 @@ import {
 } from 'react';
 
 import { AcceptedUploadFileType } from '@growi/core';
+import { GLOBAL_SOCKET_KEY, GLOBAL_SOCKET_NS, useSWRStatic } from '@growi/core/dist/swr';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 import { toast } from 'react-toastify';
 
@@ -22,17 +23,12 @@ export const Playground = (): JSX.Element => {
   const [editorTheme, setEditorTheme] = useState<EditorTheme>('defaultlight');
   const [editorKeymap, setEditorKeymap] = useState<KeyMapMode>('default');
   const [editorPaste, setEditorPaste] = useState<PasteMode>('both');
+  const [enableUnifiedMergeView, setUnifiedMergeViewEnabled] = useState(false);
   const [editorSettings, setEditorSettings] = useState<EditorSettings>();
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
-  const initialValue = '# header\n';
-
-  // initialize
-  useEffect(() => {
-    codeMirrorEditor?.initDoc(initialValue);
-    setMarkdownToPreview(initialValue);
-  }, [codeMirrorEditor, initialValue]);
+  const { mutate } = useSWRStatic(GLOBAL_SOCKET_KEY);
 
   // initial caret line
   useEffect(() => {
@@ -49,6 +45,26 @@ export const Playground = (): JSX.Element => {
     });
   }, [setEditorSettings, editorKeymap, editorTheme, editorPaste]);
 
+  // initialize global socket
+  useEffect(() => {
+    const setUpSocket = async() => {
+      const { io } = await import('socket.io-client');
+      const socket = io(GLOBAL_SOCKET_NS, {
+        transports: ['websocket'],
+      });
+
+      // eslint-disable-next-line no-console
+      socket.on('error', (err) => { console.error(err) });
+      // eslint-disable-next-line no-console
+      socket.on('connect_error', (err) => { console.error('Failed to connect with websocket.', err) });
+
+      mutate(socket);
+    };
+
+    setUpSocket();
+
+  }, [mutate]);
+
   // set handler to save with shortcut key
   const saveHandler = useCallback(() => {
     // eslint-disable-next-line no-console
@@ -79,7 +95,9 @@ export const Playground = (): JSX.Element => {
       <div className="flex-expand-horiz">
         <div className="flex-expand-vert">
           <CodeMirrorEditorMain
-            isEditorMode
+            enableCollaboration
+            enableUnifiedMergeView={enableUnifiedMergeView}
+            pageId="pageId-for-playground"
             onSave={saveHandler}
             onUpload={uploadHandler}
             indentSize={4}
@@ -90,7 +108,13 @@ export const Playground = (): JSX.Element => {
         </div>
         <div className="flex-expand-vert d-none d-lg-flex bg-light text-dark border-start border-dark-subtle p-3">
           <Preview markdown={markdownToPreview} />
-          <PlaygroundController setEditorTheme={setEditorTheme} setEditorKeymap={setEditorKeymap} setEditorPaste={setEditorPaste} />
+          <hr />
+          <PlaygroundController
+            setEditorTheme={setEditorTheme}
+            setEditorKeymap={setEditorKeymap}
+            setEditorPaste={setEditorPaste}
+            setUnifiedMergeView={setUnifiedMergeViewEnabled}
+          />
         </div>
       </div>
       <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '50px' }}>

+ 12 - 112
packages/editor/src/client/components-internal/playground/PlaygroundController.tsx

@@ -1,129 +1,29 @@
-import { useCallback, type JSX } from 'react';
-
-import { useForm } from 'react-hook-form';
-
 import type { EditorTheme, KeyMapMode, PasteMode } from '../../../consts';
-import {
-  GlobalCodeMirrorEditorKey,
-  AllEditorTheme, AllKeyMap,
-  AllPasteMode,
-} from '../../../consts';
-import { useCodeMirrorEditorIsolated } from '../../stores/codemirror-editor';
-
-export const InitEditorValueRow = (): JSX.Element => {
-
-  const { data } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
-
-  const initDoc = data?.initDoc;
-  const initEditorValue = useCallback(() => {
-    initDoc?.('# Header\n\n- foo\n-bar\n');
-  }, [initDoc]);
-
-  return (
-    <div className="row">
-      <div className="col">
-        <button
-          type="button"
-          className="btn btn-outline-secondary"
-          onClick={() => initEditorValue()}
-        >
-          Initialize editor value
-        </button>
-      </div>
-    </div>
-  );
-};
-
-type SetCaretLineRowFormData = {
-  lineNumber: number | string;
-};
-
-export const SetCaretLineRow = (): JSX.Element => {
 
-  const { data } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
-  const { register, handleSubmit } = useForm<SetCaretLineRowFormData>({
-    defaultValues: {
-      lineNumber: 1,
-    },
-  });
-
-  const setCaretLine = data?.setCaretLine;
-  const onSubmit = handleSubmit((submitData) => {
-    const lineNumber = Number(submitData.lineNumber) || 1;
-    setCaretLine?.(lineNumber);
-  });
-
-  return (
-    <form className="row mt-3" onSubmit={onSubmit}>
-      <div className="col">
-        <div className="input-group">
-          <input
-            {...register('lineNumber')}
-            type="number"
-            className="form-control"
-            placeholder="Input line number"
-            aria-label="line number"
-            aria-describedby="button-set-cursor"
-          />
-          <button type="submit" className="btn btn-outline-secondary" id="button-set-cursor">Set the cursor</button>
-        </div>
-      </div>
-    </form>
-
-  );
-};
-
-
-type SetParamRowProps = {
-    update: (value: any) => void,
-    items: string[],
-}
-
-const SetParamRow = (
-    props: SetParamRowProps,
-): JSX.Element => {
-  const { update, items } = props;
-  return (
-    <>
-      <div className="row mt-3">
-        <h2>default</h2>
-        <div className="col">
-          <div>
-            { items.map((item) => {
-              return (
-                <button
-                  type="button"
-                  className="btn btn-outline-secondary"
-                  onClick={() => {
-                    update(item);
-                  }}
-                >{item}
-                </button>
-              );
-            }) }
-          </div>
-        </div>
-      </div>
-    </>
-  );
-};
 
+import { InitEditorValueRow } from './controller/InitEditorValueRow';
+import { KeymapControl } from './controller/KeymapControl';
+import { PasteModeControl } from './controller/PasteModeControl';
+import { SetCaretLineRow } from './controller/SetCaretLineRow';
+import { ThemeControl } from './controller/ThemeControl';
+import { UnifiedMergeViewControl } from './controller/UnifiedMergeViewControl';
 
 type PlaygroundControllerProps = {
   setEditorTheme: (value: EditorTheme) => void
   setEditorKeymap: (value: KeyMapMode) => void
   setEditorPaste: (value: PasteMode) => void
+  setUnifiedMergeView: (value: boolean) => void
 };
 
 export const PlaygroundController = (props: PlaygroundControllerProps): JSX.Element => {
-  const { setEditorTheme, setEditorKeymap, setEditorPaste } = props;
   return (
-    <div className="container mt-5">
+    <div className="container">
       <InitEditorValueRow />
       <SetCaretLineRow />
-      <SetParamRow update={setEditorTheme} items={AllEditorTheme} />
-      <SetParamRow update={setEditorKeymap} items={AllKeyMap} />
-      <SetParamRow update={setEditorPaste} items={AllPasteMode} />
+      <UnifiedMergeViewControl onChange={bool => props.setUnifiedMergeView(bool)} />
+      <ThemeControl setEditorTheme={props.setEditorTheme} />
+      <KeymapControl setEditorKeymap={props.setEditorKeymap} />
+      <PasteModeControl setEditorPaste={props.setEditorPaste} />
     </div>
   );
 };

+ 27 - 0
packages/editor/src/client/components-internal/playground/controller/InitEditorValueRow.tsx

@@ -0,0 +1,27 @@
+import { useCallback } from 'react';
+
+import { GlobalCodeMirrorEditorKey } from '../../../../consts';
+import { useCodeMirrorEditorIsolated } from '../../../stores/codemirror-editor';
+
+export const InitEditorValueRow = (): JSX.Element => {
+  const { data } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+
+  const initDoc = data?.initDoc;
+  const initEditorValue = useCallback(() => {
+    initDoc?.('# Header\n\n- foo\n-bar\n');
+  }, [initDoc]);
+
+  return (
+    <div className="row">
+      <div className="col">
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={() => initEditorValue()}
+        >
+          Initialize editor value
+        </button>
+      </div>
+    </div>
+  );
+};

+ 19 - 0
packages/editor/src/client/components-internal/playground/controller/KeymapControl.tsx

@@ -0,0 +1,19 @@
+import type { KeyMapMode } from '../../../../consts';
+import { AllKeyMap } from '../../../../consts';
+
+import { OutlineSecondaryButtons } from './OutlineSecondaryButtons';
+
+type KeymapControlProps = {
+  setEditorKeymap: (value: KeyMapMode) => void;
+};
+
+export const KeymapControl = ({ setEditorKeymap }: KeymapControlProps): JSX.Element => {
+  return (
+    <div className="row mt-5">
+      <h2>Keymaps</h2>
+      <div className="col">
+        <OutlineSecondaryButtons<KeyMapMode> update={setEditorKeymap} items={AllKeyMap} />
+      </div>
+    </div>
+  );
+};

+ 24 - 0
packages/editor/src/client/components-internal/playground/controller/OutlineSecondaryButtons.tsx

@@ -0,0 +1,24 @@
+type OutlineSecondaryButtonsProps<V> = {
+  update: (value: V) => void,
+  items: V[],
+}
+
+export const OutlineSecondaryButtons = <V extends { toString: () => string }, >(
+  props: OutlineSecondaryButtonsProps<V>,
+): JSX.Element => {
+  const { update, items } = props;
+  return (
+    <div className="d-flex flex-wrap gap-1">
+      { items.map(item => (
+        <button
+          key={item.toString()}
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={() => update(item)}
+        >
+          {item.toString()}
+        </button>
+      )) }
+    </div>
+  );
+};

+ 19 - 0
packages/editor/src/client/components-internal/playground/controller/PasteModeControl.tsx

@@ -0,0 +1,19 @@
+import type { PasteMode } from '../../../../consts';
+import { AllPasteMode } from '../../../../consts';
+
+import { OutlineSecondaryButtons } from './OutlineSecondaryButtons';
+
+type PasteModeControlProps = {
+  setEditorPaste: (value: PasteMode) => void;
+};
+
+export const PasteModeControl = ({ setEditorPaste }: PasteModeControlProps): JSX.Element => {
+  return (
+    <div className="row mt-5">
+      <h2>Paste mode</h2>
+      <div className="col">
+        <OutlineSecondaryButtons<PasteMode> update={setEditorPaste} items={AllPasteMode} />
+      </div>
+    </div>
+  );
+};

+ 41 - 0
packages/editor/src/client/components-internal/playground/controller/SetCaretLineRow.tsx

@@ -0,0 +1,41 @@
+import { useForm } from 'react-hook-form';
+
+import { GlobalCodeMirrorEditorKey } from '../../../../consts';
+import { useCodeMirrorEditorIsolated } from '../../../stores/codemirror-editor';
+
+type SetCaretLineRowFormData = {
+  lineNumber: number | string;
+};
+
+export const SetCaretLineRow = (): JSX.Element => {
+  const { data } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+  const { register, handleSubmit } = useForm<SetCaretLineRowFormData>({
+    defaultValues: {
+      lineNumber: 1,
+    },
+  });
+
+  const setCaretLine = data?.setCaretLine;
+  const onSubmit = handleSubmit((submitData) => {
+    const lineNumber = Number(submitData.lineNumber) || 1;
+    setCaretLine?.(lineNumber);
+  });
+
+  return (
+    <form className="row mt-3" onSubmit={onSubmit}>
+      <div className="col">
+        <div className="input-group">
+          <input
+            {...register('lineNumber')}
+            type="number"
+            className="form-control"
+            placeholder="Input line number"
+            aria-label="line number"
+            aria-describedby="button-set-cursor"
+          />
+          <button type="submit" className="btn btn-outline-secondary" id="button-set-cursor">Set the cursor</button>
+        </div>
+      </div>
+    </form>
+  );
+};

+ 19 - 0
packages/editor/src/client/components-internal/playground/controller/ThemeControl.tsx

@@ -0,0 +1,19 @@
+import type { EditorTheme } from '../../../../consts';
+import { AllEditorTheme } from '../../../../consts';
+
+import { OutlineSecondaryButtons } from './OutlineSecondaryButtons';
+
+type ThemeControlProps = {
+  setEditorTheme: (value: EditorTheme) => void;
+};
+
+export const ThemeControl = ({ setEditorTheme }: ThemeControlProps): JSX.Element => {
+  return (
+    <div className="row mt-5">
+      <h2>Themes</h2>
+      <div className="col">
+        <OutlineSecondaryButtons<EditorTheme> update={setEditorTheme} items={AllEditorTheme} />
+      </div>
+    </div>
+  );
+};

+ 17 - 0
packages/editor/src/client/components-internal/playground/controller/UnifiedMergeViewControl.tsx

@@ -0,0 +1,17 @@
+type UnifiedMergeViewControlProps = {
+  onChange: (value: boolean) => void;
+};
+
+export const UnifiedMergeViewControl = ({ onChange }: UnifiedMergeViewControlProps): JSX.Element => {
+  return (
+    <div className="row mt-5">
+      <div className="col">
+        <div className="form-check form-switch">
+          <input className="form-check-input" type="checkbox" role="switch" id="flexSwitchCheckUnifiedMergeView" onChange={e => onChange(e.target.checked)} />
+          <label className="form-check-label" htmlFor="flexSwitchCheckUnifiedMergeView">Unified Merge View</label>
+        </div>
+
+      </div>
+    </div>
+  );
+};

+ 17 - 5
packages/editor/src/client/components/CodeMirrorEditorMain.tsx

@@ -7,8 +7,9 @@ import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 import deepmerge from 'ts-deepmerge';
 
 import { GlobalCodeMirrorEditorKey } from '../../consts';
+import type { EditingClient } from '../../interfaces';
 import { CodeMirrorEditor, type CodeMirrorEditorProps } from '../components-internal/CodeMirrorEditor';
-import { setDataLine } from '../services-internal';
+import { setDataLine, useUnifiedMergeView, codemirrorEditorClassForUnifiedMergeView } from '../services-internal';
 import { useCodeMirrorEditorIsolated } from '../stores/codemirror-editor';
 import { useCollaborativeEditorMode } from '../stores/use-collaborative-editor-mode';
 
@@ -24,19 +25,29 @@ type Props = CodeMirrorEditorProps & {
   user?: IUserHasId,
   pageId?: string,
   initialValue?: string,
-  isEditorMode: boolean,
-  onEditorsUpdated?: (userList: IUserHasId[]) => void,
+  enableCollaboration?: boolean,
+  enableUnifiedMergeView?: boolean,
+  onEditorsUpdated?: (clientList: EditingClient[]) => void,
 }
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
-    user, pageId, initialValue, isEditorMode, cmProps,
+    user, pageId,
+    enableCollaboration = false, enableUnifiedMergeView = false,
+    cmProps,
     onSave, onEditorsUpdated, ...otherProps
   } = props;
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
-  useCollaborativeEditorMode(isEditorMode, user, pageId, initialValue, onEditorsUpdated, codeMirrorEditor);
+  useCollaborativeEditorMode(enableCollaboration, codeMirrorEditor, {
+    user,
+    pageId,
+    onEditorsUpdated,
+    reviewMode: enableUnifiedMergeView,
+  });
+
+  useUnifiedMergeView(enableUnifiedMergeView, codeMirrorEditor, { pageId });
 
   // setup additional extensions
   useEffect(() => {
@@ -81,6 +92,7 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   return (
     <CodeMirrorEditor
       editorKey={GlobalCodeMirrorEditorKey.MAIN}
+      className={codemirrorEditorClassForUnifiedMergeView}
       onSave={onSave}
       cmProps={cmPropsOverride}
       {...otherProps}

+ 1 - 0
packages/editor/src/client/services-internal/index.ts

@@ -6,3 +6,4 @@ export * from './link-util';
 export * from './list-util';
 export * from './paste-util';
 export * from './table';
+export * from './unified-merge-view';

+ 98 - 0
packages/editor/src/client/services-internal/unified-merge-view/README.ja.md

@@ -0,0 +1,98 @@
+# useUnifiedMergeView 実装メモ
+
+## 背景
+
+- 現在のエディタは y-codemirror.next を使用した collaborative editor として実装されている
+- Socket.IO を介して同時多人数編集が可能
+- CodeMirror 6 の `@codemirror/merge` パッケージの Unified Merge View を用いた差分機能を実現するフックとして `useUnifiedMergeView` を実装する
+
+## 要件
+
+### 前提条件
+
+- Editor 1: Unified Merge View を有効化したエディタ(レビューモード)
+- Editor 2: 通常のエディタ(通常モード)
+- original: 編集開始時点のドキュメント
+- diff1: Editor 1 でのローカルな変更の差分
+- diff2: Editor 2 でのローカルな変更の差分
+
+### 期待される動作
+
+1. Editor 1(レビューモード)では:
+   - diff2 が発生した場合、yjs を通じて受け取る
+   - original + diff2 を基準として diff1 との差分を表示
+   - diff1 に対して Accept/Reject が可能
+   - Accept された時のみ diff1 が他のエディタに反映(送信)される
+
+2. Editor 2(通常モード)では:
+   - original + diff2 を表示
+   - Editor 1 で Accept された時のみ original + diff1 + diff2 となる
+
+3. collaborative editing 関連:
+   - y-codemirror.next による collaborative editing 機能は維持
+   - diff2(通常モードでの変更)は即座に他のエディタに反映
+
+## 技術的な制約・検討事項
+
+1. `@codemirror/merge` の実装:
+   - `unifiedMergeView` extension を使用
+   - `originalDocChangeEffect` で original document の更新が可能
+   - Accept/Reject 機能が標準で実装されている
+
+2. y-codemirror.next との統合:
+   - 標準では全ての変更が即座に他のエディタに反映される
+   - この機能を維持しながら、レビューモードでの変更(diff1)のみを一時的にバッファリングする必要がある
+
+## 実装方針
+
+1. レビューモードでの変更をバッファリング:
+   - use-secondary-ydocs.ts により、secondaryDoc に変更を保持、結果的にバッファリングする挙動になる
+   - リモートからの変更は通常通り処理
+
+2. Accept 時の処理:
+   - secondaryDoc にバッファリングされた変更を primaryDoc に適用することにより、他のエディタに反映される
+   - バッファをクリア
+
+3. Unified Merge View の設定:
+   - original + diff2 との差分を表示
+   - 標準の Accept/Reject 機能を利用
+
+## 実装のポイント
+
+### Accept による変更の二重適用問題
+
+1. 問題の概要
+   - Editor1 で Accept を実行すると、変更が二重に適用される症状が発生
+   - 原因: Accept による変更が YJS の同期機能を通じて Editor1 に戻ってきた際、再度 originalDoc に適用されてしまう
+
+2. 解決方法
+   - YJS の transaction に origin を付与して変更の出所を追跡
+   - Accept 時: `primaryDoc.transact(() => {...}, SYNC_BY_ACCEPT_CHUNK)`
+   - 同期時: `if (event.transaction.origin === SYNC_BY_ACCEPT_CHUNK) return`
+
+3. 変更の流れ
+   1. Editor1 で Accept が実行される
+   2. Accept で primaryDoc に同期する際に origin: 'accept' を指定
+   3. primaryDoc の変更が Editor1 に戻ってきても origin をチェックしスキップ
+   4. 結果として二重適用を防止
+
+### 個別の chunk の Accept 処理
+
+1. `@codemirror/merge` の仕組み:
+   - chunk の accept 時に `updateOriginalDoc` effect が発行される
+   - effect の value に accept された変更内容が ChangeSet として含まれる
+   - ChangeSet には変更範囲(fromA, toA)と新しい内容(inserted)が含まれる
+
+2. YJS への反映:
+   - ChangeSet の変更内容を primaryDoc の YText に適用する
+   - 処理は transact でラップし、「Accept による変更の二重適用問題」の通り origin を指定して二重適用を防止
+   - `iterChanges` で得られた位置情報をそのまま使用(絶対位置)
+   - delete と insert を順番に適用して変更を反映
+
+3. 変更の流れ:
+   1. Editor1 で chunk の Accept ボタンがクリックされる
+   2. `@codemirror/merge` が `updateOriginalDoc` effect を発行
+   3. effect から変更内容を取得し、YText の操作に変換
+   4. primaryDoc に変更を適用し、他のエディタに伝播
+
+この実装により、個々の chunk の Accept が正しく機能し、他の chunk には影響を与えません。

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

@@ -0,0 +1,4 @@
+import styles from './use-unified-merge-view.module.scss';
+
+export * from './use-unified-merge-view';
+export const codemirrorEditorClassForUnifiedMergeView = styles['codemirror-editor'];

+ 39 - 0
packages/editor/src/client/services-internal/unified-merge-view/use-customized-button-styles.ts

@@ -0,0 +1,39 @@
+import { useEffect } from 'react';
+
+import { EditorView } from '@codemirror/view';
+
+import type { UseCodeMirrorEditor } from '../../services';
+
+export const useCustomizedButtonStyles = (codeMirrorEditor?: UseCodeMirrorEditor): void => {
+
+  // Setup button styles
+  useEffect(() => {
+    if (codeMirrorEditor?.view == null) {
+      return;
+    }
+
+    const updateButtonStyles = () => {
+      const acceptButton = codeMirrorEditor.view?.dom.querySelector('button[name="accept"]');
+      acceptButton?.classList.add('btn', 'btn-sm', 'btn-success');
+
+      const rejectButton = codeMirrorEditor.view?.dom.querySelector('button[name="reject"]');
+      rejectButton?.classList.add('btn', 'btn-sm', 'btn-outline-secondary');
+      // Set button text
+      if (rejectButton != null) {
+        rejectButton.textContent = 'Discard';
+      }
+    };
+
+    // Initial setup
+    updateButtonStyles();
+
+    // Setup listener for future updates
+    const extension = EditorView.updateListener.of(() => {
+      updateButtonStyles();
+    });
+
+    const cleanupFunction = codeMirrorEditor?.appendExtensions([extension]);
+    return cleanupFunction;
+  }, [codeMirrorEditor]);
+
+};

+ 37 - 0
packages/editor/src/client/services-internal/unified-merge-view/use-unified-merge-view.module.scss

@@ -0,0 +1,37 @@
+// Change buttons layout for @codemirror/merge
+.codemirror-editor :global {
+  .cm-chunkButtons {
+    // reverse order
+    display: flex;
+    flex-direction: row-reverse;
+  }
+}
+
+// Change button size
+.codemirror-editor :global {
+  .cm-chunkButtons {
+    button {
+      --bs-btn-padding-y: .1rem;
+      --bs-btn-padding-x: .5rem;
+      --bs-btn-font-size: 1rem;
+    }
+  }
+}
+
+// Override button style with Bootstrap variables
+.codemirror-editor :global {
+  .cm-chunkButtons {
+    button {
+      color: var(--bs-btn-color) !important;
+      background: var(--bs-btn-bg) !important;
+      border: var(--bs-btn-border-width) solid var(--bs-btn-border-color) !important;
+      border-radius: var(--bs-btn-border-radius) !important;
+
+      &:hover {
+        color: var(--bs-btn-hover-color) !important;
+        background: var(--bs-btn-hover-bg) !important;
+        border-color: var(--bs-btn-hover-border-color) !important;
+      }
+    }
+  }
+}

+ 141 - 0
packages/editor/src/client/services-internal/unified-merge-view/use-unified-merge-view.ts

@@ -0,0 +1,141 @@
+import { useEffect } from 'react';
+
+import {
+  unifiedMergeView,
+  originalDocChangeEffect,
+  getOriginalDoc,
+  updateOriginalDoc,
+} from '@codemirror/merge';
+import type { StateEffect, Transaction } from '@codemirror/state';
+import {
+  ChangeSet,
+} from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
+import * as Y from 'yjs';
+
+import { deltaToChangeSpecs } from '../../../utils/delta-to-changespecs';
+import type { UseCodeMirrorEditor } from '../../services';
+import { useSecondaryYdocs } from '../../stores/use-secondary-ydocs';
+
+import { useCustomizedButtonStyles } from './use-customized-button-styles';
+
+
+// for avoiding apply update from primaryDoc to secondaryDoc twice
+const SYNC_BY_ACCEPT_CHUNK = 'synkByAcceptChunk';
+
+
+type Configuration = {
+  pageId?: string,
+}
+
+export const useUnifiedMergeView = (
+    isEnabled: boolean,
+    codeMirrorEditor?: UseCodeMirrorEditor,
+    configuration?: Configuration,
+): void => {
+
+  const { pageId } = configuration ?? {};
+
+  const { primaryDoc, secondaryDoc } = useSecondaryYdocs(isEnabled, {
+    pageId,
+    useSecondary: isEnabled,
+  }) ?? {};
+
+  useCustomizedButtonStyles(codeMirrorEditor);
+
+  // setup unifiedMergeView
+  useEffect(() => {
+    if (!isEnabled || primaryDoc == null || secondaryDoc == null || codeMirrorEditor == null) {
+      return;
+    }
+
+    const extension = isEnabled ? [
+      unifiedMergeView({
+        original: codeMirrorEditor.getDoc(),
+      }),
+    ] : [];
+
+    const cleanupFunction = codeMirrorEditor?.appendExtensions(extension);
+    return cleanupFunction;
+  }, [isEnabled, pageId, codeMirrorEditor, primaryDoc, secondaryDoc]);
+
+  // Setup sync from primaryDoc to secondaryDoc
+  useEffect(() => {
+    if (!isEnabled || primaryDoc == null || secondaryDoc == null || codeMirrorEditor == null) {
+      return;
+    }
+
+    const primaryYText = primaryDoc.getText('codemirror');
+
+    const sync = (event: Y.YTextEvent) => {
+      if (event.transaction.local) return;
+
+      // avoid apply update from primaryDoc to secondaryDoc twice
+      if (event.transaction.origin === SYNC_BY_ACCEPT_CHUNK) return;
+
+      if (codeMirrorEditor?.view?.state == null) {
+        return;
+      }
+
+      // sync from primaryDoc to secondaryDoc
+      Y.applyUpdate(secondaryDoc, Y.encodeStateAsUpdate(primaryDoc));
+
+      // sync from primaryDoc to original document
+      if (codeMirrorEditor?.view?.state != null) {
+        const changeSpecs = deltaToChangeSpecs(event.delta);
+        const originalDoc = getOriginalDoc(codeMirrorEditor.view.state);
+        const changeSet = ChangeSet.of(changeSpecs, originalDoc.length);
+        const effect = originalDocChangeEffect(codeMirrorEditor.view.state, changeSet);
+
+        // Dispatch in next tick to ensure state is updated
+        setTimeout(() => {
+          codeMirrorEditor.view?.dispatch({
+            effects: effect,
+          });
+        }, 0);
+      }
+    };
+
+    primaryYText.observe(sync);
+
+    // cleanup
+    return () => {
+      primaryYText.unobserve(sync);
+    };
+  }, [codeMirrorEditor, isEnabled, primaryDoc, secondaryDoc]);
+
+  // Setup sync from secondaryDoc to primaryDoc when accepting chunks
+  useEffect(() => {
+    if (!isEnabled || primaryDoc == null || secondaryDoc == null || codeMirrorEditor == null) {
+      return;
+    }
+
+    const extension = EditorView.updateListener.of((update) => {
+      // Find updateOriginalDoc effect which is dispatched when a chunk is accepted
+      const updateOrigEffect = update.transactions
+        .flatMap<StateEffect<Transaction>>(tr => tr.effects)
+        .find(e => e.is(updateOriginalDoc));
+
+      if (updateOrigEffect != null) {
+        const primaryYText = primaryDoc.getText('codemirror');
+
+        primaryDoc.transact(() => {
+          // fromA/toA positions are absolute document positions
+          updateOrigEffect.value.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
+            primaryYText.delete(fromA, toA - fromA);
+            if (inserted.length > 0) {
+              primaryYText.insert(fromA, inserted.toString());
+            }
+          });
+        }, SYNC_BY_ACCEPT_CHUNK);
+      }
+    });
+
+    const cleanup = codeMirrorEditor?.appendExtensions([extension]);
+
+    return () => {
+      cleanup?.();
+    };
+  }, [codeMirrorEditor, isEnabled, primaryDoc, secondaryDoc]);
+
+};

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

@@ -0,0 +1,60 @@
+import { useEffect } from 'react';
+
+import {
+  acceptChunk,
+  getChunks,
+} from '@codemirror/merge';
+import type { ViewUpdate } from '@codemirror/view';
+import { EditorView } from '@codemirror/view';
+
+import type { UseCodeMirrorEditor } from '..';
+
+
+export const acceptAllChunks = (view: EditorView): void => {
+  // Get all chunks from the editor state
+  const chunkData = getChunks(view.state);
+  if (chunkData == null || chunkData.chunks.length === 0) {
+    return;
+  }
+
+  for (const chunk of chunkData.chunks) {
+    // Use a position inside the chunk (middle point is safe)
+    const pos = chunk.fromB + Math.floor((chunk.endB - chunk.fromB) / 2);
+    acceptChunk(view, pos);
+  }
+};
+
+
+type OnSelected = (selectedText: string, selectedTextFirstLineNumber: number) => void
+
+const processSelectedText = (editorView: EditorView | ViewUpdate, onSelected?: OnSelected) => {
+  const selection = editorView.state.selection.main;
+  const selectedText = editorView.state.sliceDoc(selection.from, selection.to);
+  const selectedTextFirstLineNumber = editorView.state.doc.lineAt(selection.from).number - 1; // 0-based line number;
+  onSelected?.(selectedText, selectedTextFirstLineNumber);
+};
+
+export const useTextSelectionEffect = (codeMirrorEditor?: UseCodeMirrorEditor, onSelected?: OnSelected): void => {
+  useEffect(() => {
+    if (codeMirrorEditor == null) {
+      return;
+    }
+
+    // To handle cases where text is already selected in the editor at the time of first effect firing
+    if (codeMirrorEditor.view != null) {
+      processSelectedText(codeMirrorEditor.view, onSelected);
+    }
+
+    const extension = EditorView.updateListener.of((update) => {
+      if (update.selectionSet) {
+        processSelectedText(update, onSelected);
+      }
+    });
+
+    const cleanup = codeMirrorEditor?.appendExtensions([extension]);
+
+    return () => {
+      cleanup?.();
+    };
+  }, [codeMirrorEditor, onSelected]);
+};

+ 0 - 1
packages/editor/src/client/stores/codemirror-editor.ts

@@ -10,7 +10,6 @@ import { type UseCodeMirrorEditor, useCodeMirrorEditor } from '../services';
 
 const { isDeepEquals } = deepEquals;
 
-
 const isValid = (u: UseCodeMirrorEditor) => {
   return u.state != null && u.view != null;
 };

+ 98 - 92
packages/editor/src/client/stores/use-collaborative-editor-mode.ts

@@ -2,136 +2,142 @@ import { useEffect, useState } from 'react';
 
 import { keymap } from '@codemirror/view';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
-import { useGlobalSocket } from '@growi/core/dist/swr';
 import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next';
 import { SocketIOProvider } from 'y-socket.io';
 import * as Y from 'yjs';
 
 import { userColor } from '../../consts';
+import type { EditingClient } from '../../interfaces';
 import type { UseCodeMirrorEditor } from '../services';
 
-type UserLocalState = {
-  name: string;
-  user?: IUserHasId;
-  color: string;
-  colorLight: string;
+import { useSecondaryYdocs } from './use-secondary-ydocs';
+
+
+type Configuration = {
+  user?: IUserHasId,
+  pageId?: string,
+  reviewMode?: boolean,
+  onEditorsUpdated?: (clientList: EditingClient[]) => void,
 }
 
 export const useCollaborativeEditorMode = (
     isEnabled: boolean,
-    user?: IUserHasId,
-    pageId?: string,
-    initialValue?: string,
-    onEditorsUpdated?: (userList: IUserHasId[]) => void,
     codeMirrorEditor?: UseCodeMirrorEditor,
+    configuration?: Configuration,
 ): void => {
-  const [ydoc, setYdoc] = useState<Y.Doc | null>(null);
-  const [provider, setProvider] = useState<SocketIOProvider | null>(null);
-  const [cPageId, setCPageId] = useState(pageId);
-
-  const { data: socket } = useGlobalSocket();
-
-  // Cleanup Ydoc
-  useEffect(() => {
-    if (cPageId === pageId && isEnabled) {
-      return;
-    }
-
-    ydoc?.destroy();
-    setYdoc(null);
+  const {
+    user, pageId, onEditorsUpdated, reviewMode,
+  } = configuration ?? {};
 
-    // NOTICE: Destroying the provider leaves awareness in the other user's connection,
-    // so only awareness is destroyed here
-    provider?.awareness.destroy();
+  const { primaryDoc, activeDoc } = useSecondaryYdocs(isEnabled, {
+    pageId,
+    useSecondary: reviewMode,
+  }) ?? {};
 
-    setCPageId(pageId);
+  const [provider, setProvider] = useState<SocketIOProvider>();
 
-    // reset editors
-    onEditorsUpdated?.([]);
-  }, [cPageId, isEnabled, onEditorsUpdated, pageId, provider?.awareness, socket, ydoc]);
 
-  // Setup Ydoc
+  // reset editors
   useEffect(() => {
-    if (ydoc != null || !isEnabled) {
-      return;
-    }
-
-    // NOTICE: Old provider destroy at the time of ydoc setup,
-    // because the awareness destroying is not sync to other clients
-    provider?.destroy();
-    setProvider(null);
-
-    const _ydoc = new Y.Doc();
-    setYdoc(_ydoc);
-  }, [isEnabled, provider, ydoc]);
+    if (!isEnabled) return;
+    onEditorsUpdated?.([]);
+  }, [isEnabled, onEditorsUpdated]);
 
   // Setup provider
   useEffect(() => {
-    if (provider != null || pageId == null || ydoc == null || socket == null || onEditorsUpdated == null) {
-      return;
-    }
-
-    const socketIOProvider = new SocketIOProvider(
-      '/',
-      pageId,
-      ydoc,
-      {
-        autoConnect: true,
-        resyncInterval: 3000,
-      },
-    );
-
-    const userLocalState: UserLocalState = {
-      name: user?.name ? `${user.name}` : `Guest User ${Math.floor(Math.random() * 100)}`,
-      user,
-      color: userColor.color,
-      colorLight: userColor.light,
-    };
 
-    socketIOProvider.awareness.setLocalStateField('user', userLocalState);
+    let _provider: SocketIOProvider | undefined;
+    let providerSyncHandler: (isSync: boolean) => void;
+    let updateAwarenessHandler: (update: { added: number[]; updated: number[]; removed: number[]; }) => void;
 
-    socketIOProvider.on('sync', (isSync: boolean) => {
-      if (isSync) {
-        const userList: IUserHasId[] = Array.from(socketIOProvider.awareness.states.values(), value => value.user.user && value.user.user);
-        onEditorsUpdated(userList);
+    setProvider(() => {
+      if (!isEnabled || pageId == null || primaryDoc == null) {
+        return undefined;
       }
-    });
 
-    // update args type see: SocketIOProvider.Awareness.awarenessUpdate
-    socketIOProvider.awareness.on('update', (update: { added: unknown[]; removed: unknown[]; }) => {
-      const { added, removed } = update;
-      if (added.length > 0 || removed.length > 0) {
-        const userList: IUserHasId[] = Array.from(socketIOProvider.awareness.states.values(), value => value.user.user && value.user.user);
-        onEditorsUpdated(userList);
-      }
+      _provider = new SocketIOProvider(
+        '/',
+        pageId,
+        primaryDoc,
+        {
+          autoConnect: true,
+          resyncInterval: 3000,
+        },
+      );
+
+      const userLocalState: EditingClient = {
+        clientId: primaryDoc.clientID,
+        name: user?.name ? `${user.name}` : `Guest User ${Math.floor(Math.random() * 100)}`,
+        userId: user?._id,
+        color: userColor.color,
+        colorLight: userColor.light,
+      };
+
+      const { awareness } = _provider;
+      awareness.setLocalStateField('editors', userLocalState);
+
+      providerSyncHandler = (isSync: boolean) => {
+        if (isSync && onEditorsUpdated != null) {
+          const clientList: EditingClient[] = Array.from(awareness.getStates().values(), value => value.editors);
+          if (Array.isArray(clientList)) {
+            onEditorsUpdated(clientList);
+          }
+        }
+      };
+
+      _provider.on('sync', providerSyncHandler);
+
+      // update args type see: SocketIOProvider.Awareness.awarenessUpdate
+      updateAwarenessHandler = (update: { added: number[]; updated: number[]; removed: number[]; }) => {
+        // remove the states of disconnected clients
+        update.removed.forEach(clientId => awareness.states.delete(clientId));
+
+        // update editor list
+        if (onEditorsUpdated != null) {
+          const clientList: EditingClient[] = Array.from(awareness.states.values(), value => value.editors);
+          if (Array.isArray(clientList)) {
+            onEditorsUpdated(clientList);
+          }
+        }
+      };
+
+      awareness.on('update', updateAwarenessHandler);
+
+      return _provider;
     });
 
-    setProvider(socketIOProvider);
-  }, [initialValue, onEditorsUpdated, pageId, provider, socket, user, ydoc]);
+    return () => {
+      _provider?.awareness.setLocalState(null);
+      _provider?.awareness.off('update', updateAwarenessHandler);
+      _provider?.off('sync', providerSyncHandler);
+      _provider?.disconnect();
+      _provider?.destroy();
+    };
+  }, [isEnabled, primaryDoc, onEditorsUpdated, pageId, user]);
 
   // Setup Ydoc Extensions
   useEffect(() => {
-    if (ydoc == null || provider == null || codeMirrorEditor == null) {
+    if (!isEnabled || !primaryDoc || !activeDoc || !provider || !codeMirrorEditor) {
       return;
     }
 
-    const ytext = ydoc.getText('codemirror');
-    const undoManager = new Y.UndoManager(ytext);
+    const activeText = activeDoc.getText('codemirror');
+
+    const undoManager = new Y.UndoManager(activeText);
 
-    codeMirrorEditor.initDoc(ytext.toString());
+    // initialize document with activeDoc text
+    codeMirrorEditor.initDoc(activeText.toString());
 
-    const cleanupYUndoManagerKeymap = codeMirrorEditor.appendExtensions([
+    const extensions = [
       keymap.of(yUndoManagerKeymap),
-    ]);
-    const cleanupYCollab = codeMirrorEditor.appendExtensions([
-      yCollab(ytext, provider.awareness, { undoManager }),
-    ]);
+      yCollab(activeText, provider.awareness, { undoManager }),
+    ];
+
+    const cleanupFunctions = extensions.map(ext => codeMirrorEditor.appendExtensions([ext]));
 
     return () => {
-      cleanupYUndoManagerKeymap?.();
-      cleanupYCollab?.();
-      // clean up editor
+      cleanupFunctions.forEach(cleanup => cleanup?.());
       codeMirrorEditor.initDoc('');
     };
-  }, [codeMirrorEditor, provider, ydoc]);
+  }, [isEnabled, codeMirrorEditor, provider, primaryDoc, activeDoc, reviewMode]);
 };

+ 39 - 28
packages/editor/src/client/stores/use-editor-settings.ts

@@ -14,83 +14,94 @@ import {
   getEditorTheme, getKeymap, insertNewlineContinueMarkup, insertNewRowToMarkdownTable, isInTable,
 } from '../services-internal';
 
-
-export const useEditorSettings = (
+const useStyleActiveLine = (
     codeMirrorEditor?: UseCodeMirrorEditor,
-    editorSettings?: EditorSettings,
-    onSave?: () => void,
+    styleActiveLine?: boolean,
 ): void => {
-
   useEffect(() => {
-    if (editorSettings?.styleActiveLine == null) {
+    if (styleActiveLine == null) {
       return;
     }
-    const extensions = (editorSettings?.styleActiveLine) ? [[highlightActiveLine(), highlightActiveLineGutter()]] : [[]];
-
+    const extensions = styleActiveLine ? [[highlightActiveLine(), highlightActiveLineGutter()]] : [[]];
     const cleanupFunction = codeMirrorEditor?.appendExtensions?.(extensions);
     return cleanupFunction;
+  }, [codeMirrorEditor, styleActiveLine]);
+};
 
-  }, [codeMirrorEditor, editorSettings?.styleActiveLine]);
-
+const useEnterKeyHandler = (
+    codeMirrorEditor?: UseCodeMirrorEditor,
+    autoFormatMarkdownTable?: boolean,
+): void => {
   const onPressEnter: Command = useCallback((editor) => {
-    if (isInTable(editor) && editorSettings?.autoFormatMarkdownTable) {
+    if (isInTable(editor) && autoFormatMarkdownTable) {
       insertNewRowToMarkdownTable(editor);
       return true;
     }
     insertNewlineContinueMarkup(editor);
     return true;
-  }, [editorSettings?.autoFormatMarkdownTable]);
-
+  }, [autoFormatMarkdownTable]);
 
   useEffect(() => {
-
     const extension = keymap.of([
       { key: 'Enter', run: onPressEnter },
     ]);
-
     const cleanupFunction = codeMirrorEditor?.appendExtensions?.(extension);
     return cleanupFunction;
-
   }, [codeMirrorEditor, onPressEnter]);
+};
 
+const useThemeExtension = (
+    codeMirrorEditor?: UseCodeMirrorEditor,
+    theme?: EditorTheme,
+): void => {
   const [themeExtension, setThemeExtension] = useState<Extension | undefined>(undefined);
+
   useEffect(() => {
     const settingTheme = async(name?: EditorTheme) => {
       setThemeExtension(await getEditorTheme(name));
     };
-    settingTheme(editorSettings?.theme);
-  }, [codeMirrorEditor, editorSettings?.theme, setThemeExtension]);
+    settingTheme(theme);
+  }, [theme]);
 
   useEffect(() => {
     if (themeExtension == null) {
       return;
     }
-    // React CodeMirror has default theme which is default prec
-    // and extension have to be higher prec here than default theme.
     const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.high(themeExtension));
     return cleanupFunction;
   }, [codeMirrorEditor, themeExtension]);
+};
 
-
+const useKeymapExtension = (
+    codeMirrorEditor?: UseCodeMirrorEditor,
+    keymapMode?: KeyMapMode,
+    onSave?: () => void,
+): void => {
   const [keymapExtension, setKeymapExtension] = useState<Extension | undefined>(undefined);
+
   useEffect(() => {
     const settingKeyMap = async(name?: KeyMapMode) => {
       setKeymapExtension(await getKeymap(name, onSave));
     };
-    settingKeyMap(editorSettings?.keymapMode);
-
-  }, [codeMirrorEditor, editorSettings?.keymapMode, setKeymapExtension, onSave]);
+    settingKeyMap(keymapMode);
+  }, [keymapMode, onSave]);
 
   useEffect(() => {
     if (keymapExtension == null) {
       return;
     }
-
-    // Prevent these Keybind from overwriting the originally defined keymap.
     const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.low(keymapExtension));
     return cleanupFunction;
-
   }, [codeMirrorEditor, keymapExtension]);
+};
 
-
+export const useEditorSettings = (
+    codeMirrorEditor?: UseCodeMirrorEditor,
+    editorSettings?: EditorSettings,
+    onSave?: () => void,
+): void => {
+  useStyleActiveLine(codeMirrorEditor, editorSettings?.styleActiveLine);
+  useEnterKeyHandler(codeMirrorEditor, editorSettings?.autoFormatMarkdownTable);
+  useThemeExtension(codeMirrorEditor, editorSettings?.theme);
+  useKeymapExtension(codeMirrorEditor, editorSettings?.keymapMode, onSave);
 };

+ 68 - 0
packages/editor/src/client/stores/use-secondary-ydocs.ts

@@ -0,0 +1,68 @@
+import { useEffect } from 'react';
+
+import useSWRImmutable from 'swr/immutable';
+import * as Y from 'yjs';
+
+type Configuration = {
+  pageId?: string;
+  useSecondary?: boolean;
+}
+
+
+type StoredYDocs = {
+  primaryDoc: Y.Doc;
+  secondaryDoc: Y.Doc | undefined;
+}
+
+type YDocsState = StoredYDocs & {
+  activeDoc: Y.Doc,
+}
+
+export const useSecondaryYdocs = (isEnabled: boolean, configuration?: Configuration): YDocsState | null => {
+  const { pageId, useSecondary = false } = configuration ?? {};
+  const cacheKey = `swr-ydocs:${pageId}`;
+
+  const { data: docs, mutate } = useSWRImmutable<StoredYDocs>(
+    isEnabled && pageId ? cacheKey : null,
+    () => {
+      const primaryDoc = new Y.Doc();
+      return { primaryDoc, secondaryDoc: undefined };
+    },
+  );
+
+  useEffect(() => {
+    if (docs == null) return;
+
+    // create secondaryDoc if needed
+    if (useSecondary && docs.secondaryDoc == null) {
+      const secondaryDoc = new Y.Doc();
+      mutate({ ...docs, secondaryDoc }, false);
+
+      // apply primaryDoc state to secondaryDoc
+      Y.applyUpdate(secondaryDoc, Y.encodeStateAsUpdate(docs.primaryDoc));
+    }
+    // destroy secondaryDoc
+    else if (!useSecondary && docs.secondaryDoc != null) {
+      docs.secondaryDoc.destroy();
+      mutate({ ...docs, secondaryDoc: undefined }, false);
+    }
+
+    // cleanup
+    return () => {
+      if (!isEnabled) {
+        docs.primaryDoc.destroy();
+        docs.secondaryDoc?.destroy();
+      }
+    };
+  }, [docs, isEnabled, useSecondary, mutate]);
+
+  if (docs?.primaryDoc == null || (useSecondary && docs?.secondaryDoc == null)) {
+    return null;
+  }
+
+  return {
+    activeDoc: docs.secondaryDoc ?? docs.primaryDoc,
+    primaryDoc: docs.primaryDoc,
+    secondaryDoc: docs.secondaryDoc,
+  };
+};

+ 2 - 0
packages/editor/src/interfaces/delta.ts

@@ -0,0 +1,2 @@
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type Delta = Array<{insert?:string|object|Array<any>, delete?:number, retain?:number}>;

+ 7 - 0
packages/editor/src/interfaces/editing-client.ts

@@ -0,0 +1,7 @@
+export type EditingClient = {
+  clientId: number;
+  name: string;
+  userId?: string;
+  color: string;
+  colorLight: string;
+}

+ 2 - 0
packages/editor/src/interfaces/index.ts

@@ -1 +1,3 @@
+export * from './delta';
+export * from './editing-client';
 export * from './re-exports';

+ 8 - 1
packages/editor/src/main.scss

@@ -1,4 +1,11 @@
-@import 'bootstrap';
+@import '@growi/core-styles/scss/bootstrap/apply';
+
 @import 'react-toastify/scss/main';
 
 @import '@growi/core-styles/scss/helpers/flex-expand';
+
+:root {
+  --font-family-sans-serif: -apple-system, blinkmacsystemfont, 'Hiragino Kaku Gothic ProN', meiryo, sans-serif;
+  --font-family-serif: georgia, 'Times New Roman', times, serif;
+  --font-family-monospace: Menlo, Consolas, DejaVu Sans Mono, monospace;
+}

+ 33 - 0
packages/editor/src/utils/delta-to-changespecs.ts

@@ -0,0 +1,33 @@
+import { type ChangeSpec } from '@codemirror/state';
+
+import type { Delta } from '../interfaces';
+
+export const deltaToChangeSpecs = (delta: Delta): ChangeSpec[] => {
+  const changes: ChangeSpec[] = [];
+  let pos = 0;
+
+  for (const op of delta) {
+    if (op.retain != null) {
+      pos += op.retain;
+    }
+
+    if (op.delete != null) {
+      changes.push({
+        from: pos,
+        to: pos + op.delete,
+      });
+    }
+
+    if (op.insert != null) {
+      changes.push({
+        from: pos,
+        insert: typeof op.insert === 'string' ? op.insert : '',
+      });
+      if (typeof op.insert === 'string') {
+        pos += op.insert.length;
+      }
+    }
+  }
+
+  return changes;
+};

+ 29 - 1
packages/editor/vite.config.ts

@@ -1,11 +1,14 @@
 import path from 'path';
 
+
 import react from '@vitejs/plugin-react';
 import glob from 'glob';
 import { nodeExternals } from 'rollup-plugin-node-externals';
+import { Server } from 'socket.io';
+import type { Plugin } from 'vite';
 import { defineConfig } from 'vite';
 import dts from 'vite-plugin-dts';
-
+import { YSocketIO } from 'y-socket.io/dist/server';
 
 const excludeFiles = [
   '**/components/playground/*',
@@ -13,10 +16,35 @@ const excludeFiles = [
   '**/vite-env.d.ts',
 ];
 
+const devSocketIOPlugin = (): Plugin => ({
+  name: 'dev-socket-io',
+  apply: 'serve',
+  configureServer(server) {
+    if (!server.httpServer) return;
+
+    // setup socket.io
+    const io = new Server(server.httpServer);
+    io.on('connection', (socket) => {
+      // eslint-disable-next-line no-console
+      console.log('Client connected');
+
+      socket.on('disconnect', () => {
+        // eslint-disable-next-line no-console
+        console.log('Client disconnected');
+      });
+    });
+
+    // setup y-socket.io
+    const ysocketio = new YSocketIO(io);
+    ysocketio.initialize();
+  },
+});
+
 // https://vitejs.dev/config/
 export default defineConfig({
   plugins: [
     react(),
+    devSocketIOPlugin(),
     dts({
       entryRoot: 'src',
       exclude: [

+ 26 - 3
pnpm-lock.yaml

@@ -448,6 +448,9 @@ importers:
       js-yaml:
         specifier: ^4.1.0
         version: 4.1.0
+      jsonrepair:
+        specifier: ^3.12.0
+        version: 3.12.0
       katex:
         specifier: ^0.16.21
         version: 0.16.21
@@ -543,7 +546,7 @@ importers:
         version: 1.5.1
       openai:
         specifier: ^4.56.0
-        version: 4.56.0(encoding@0.1.13)(zod@3.23.8)
+        version: 4.56.0(encoding@0.1.13)(zod@3.24.2)
       openid-client:
         specifier: ^5.4.0
         version: 5.6.5
@@ -754,6 +757,9 @@ importers:
       yjs:
         specifier: ^13.6.18
         version: 13.6.19
+      zod:
+        specifier: ^3.24.2
+        version: 3.24.2
     devDependencies:
       '@emoji-mart/data':
         specifier: ^1.2.1
@@ -1342,6 +1348,12 @@ importers:
       simplebar-react:
         specifier: ^2.3.6
         version: 2.4.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+      socket.io:
+        specifier: ^4.7.5
+        version: 4.8.1
+      socket.io-client:
+        specifier: ^4.7.5
+        version: 4.8.1
       string-width:
         specifier: '=4.2.2'
         version: 4.2.2
@@ -9932,6 +9944,10 @@ packages:
     resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
     engines: {node: '>=0.10.0'}
 
+  jsonrepair@3.12.0:
+    resolution: {integrity: sha512-SWfjz8SuQ0wZjwsxtSJ3Zy8vvLg6aO/kxcp9TWNPGwJKgTZVfhNEQBMk/vPOpYCDFWRxD6QWuI6IHR1t615f0w==}
+    hasBin: true
+
   jsonwebtoken@9.0.2:
     resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
     engines: {node: '>=12', npm: '>=6'}
@@ -14613,6 +14629,9 @@ packages:
   zod@3.23.8:
     resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
 
+  zod@3.24.2:
+    resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
+
   zwitch@1.0.5:
     resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}
 
@@ -25426,6 +25445,8 @@ snapshots:
 
   jsonpointer@5.0.1: {}
 
+  jsonrepair@3.12.0: {}
+
   jsonwebtoken@9.0.2:
     dependencies:
       jws: 3.2.2
@@ -27229,7 +27250,7 @@ snapshots:
       is-docker: 2.2.1
       is-wsl: 2.2.0
 
-  openai@4.56.0(encoding@0.1.13)(zod@3.23.8):
+  openai@4.56.0(encoding@0.1.13)(zod@3.24.2):
     dependencies:
       '@types/node': 18.19.46
       '@types/node-fetch': 2.6.11
@@ -27239,7 +27260,7 @@ snapshots:
       formdata-node: 4.4.1
       node-fetch: 2.7.0(encoding@0.1.13)
     optionalDependencies:
-      zod: 3.23.8
+      zod: 3.24.2
     transitivePeerDependencies:
       - encoding
 
@@ -31084,6 +31105,8 @@ snapshots:
 
   zod@3.23.8: {}
 
+  zod@3.24.2: {}
+
   zwitch@1.0.5: {}
 
   zwitch@2.0.4: {}