Selaa lähdekoodia

Merge pull request #10422 from growilabs/support/156162-172790-openai-feature-client-dir-biome

support: Configure biome for OpenAI feature client dir
mergify[bot] 5 kuukautta sitten
vanhempi
sitoutus
e2c71f5ceb
54 muutettua tiedostoa jossa 3231 lisäystä ja 2127 poistoa
  1. 1 4
      apps/app/.eslintrc.js
  2. 40 26
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AccessScopeDropdown.tsx
  3. 20 14
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditInstruction.tsx
  4. 16 10
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx
  5. 58 42
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx
  6. 25 16
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx
  7. 134 51
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx
  8. 119 75
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementKeywordSearch.tsx
  9. 232 116
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx
  10. 10 9
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageSelectionMethod.tsx
  11. 143 92
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.tsx
  12. 18 9
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageSelectionMethodButtons.tsx
  13. 41 35
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectUserGroupModal.tsx
  14. 94 98
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePageList.tsx
  15. 27 22
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeSwitch.tsx
  16. 24 28
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx
  17. 9 9
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView.tsx
  18. 32 18
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown.tsx
  19. 419 274
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx
  20. 41 43
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.tsx
  21. 7 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/Header.tsx
  22. 4 3
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/NextLinkWrapper.tsx
  23. 16 13
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/QuickMenuList.tsx
  24. 19 13
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/ResizableTextArea.tsx
  25. 34 20
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/ThreadList.tsx
  26. 17 7
      apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.tsx
  27. 13 18
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistant.tsx
  28. 135 90
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantList.tsx
  29. 32 14
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx
  30. 17 14
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/DeleteAiAssistantModal.tsx
  31. 92 52
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/ThreadList.tsx
  32. 4 5
      apps/app/src/features/openai/client/components/AiIntegration/AiIntegration.tsx
  33. 12 5
      apps/app/src/features/openai/client/components/AiIntegration/AiIntegrationDisableMode.tsx
  34. 21 7
      apps/app/src/features/openai/client/services/ai-assistant.ts
  35. 117 100
      apps/app/src/features/openai/client/services/client-engine-integration.tsx
  36. 24 24
      apps/app/src/features/openai/client/services/editor-assistant/diff-application.ts
  37. 20 21
      apps/app/src/features/openai/client/services/editor-assistant/error-handling.ts
  38. 91 24
      apps/app/src/features/openai/client/services/editor-assistant/fuzzy-matching.spec.ts
  39. 85 48
      apps/app/src/features/openai/client/services/editor-assistant/fuzzy-matching.ts
  40. 17 14
      apps/app/src/features/openai/client/services/editor-assistant/get-page-body-for-context.spec.ts
  41. 19 7
      apps/app/src/features/openai/client/services/editor-assistant/get-page-body-for-context.ts
  42. 80 39
      apps/app/src/features/openai/client/services/editor-assistant/processor.ts
  43. 25 15
      apps/app/src/features/openai/client/services/editor-assistant/search-replace-engine.spec.ts
  44. 26 22
      apps/app/src/features/openai/client/services/editor-assistant/search-replace-engine.ts
  45. 42 27
      apps/app/src/features/openai/client/services/editor-assistant/text-normalization.ts
  46. 377 274
      apps/app/src/features/openai/client/services/editor-assistant/use-editor-assistant.tsx
  47. 206 154
      apps/app/src/features/openai/client/services/knowledge-assistant.tsx
  48. 6 2
      apps/app/src/features/openai/client/services/thread.ts
  49. 19 15
      apps/app/src/features/openai/client/services/use-selected-pages.tsx
  50. 108 64
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  51. 10 5
      apps/app/src/features/openai/client/stores/message.tsx
  52. 29 17
      apps/app/src/features/openai/client/stores/thread.tsx
  53. 4 1
      apps/app/src/features/openai/client/utils/get-share-scope-Icon.ts
  54. 0 1
      biome.json

+ 1 - 4
apps/app/.eslintrc.js

@@ -41,10 +41,7 @@ module.exports = {
     'src/features/page-bulk-export/**',
     'src/features/growi-plugin/**',
     'src/features/opentelemetry/**',
-    'src/features/openai/docs/**',
-    'src/features/openai/interfaces/**',
-    'src/features/openai/server/**',
-    'src/features/openai/utils/**',
+    'src/features/openai/**',
     'src/features/rate-limiter/**',
     'src/stores-universal/**',
     'src/interfaces/**',

+ 40 - 26
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AccessScopeDropdown.tsx

@@ -1,8 +1,12 @@
-import React, { useCallback } from 'react';
-
+import type React from 'react';
+import { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
-  UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem, Label,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  Label,
+  UncontrolledDropdown,
 } from 'reactstrap';
 
 import { useCurrentUser } from '~/stores-universal/context';
@@ -10,37 +14,40 @@ import { useCurrentUser } from '~/stores-universal/context';
 import { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant';
 
 type Props = {
-  isDisabled: boolean,
-  isDisabledGroups: boolean,
-  selectedAccessScope: AiAssistantAccessScope,
-  onSelect: (accessScope: AiAssistantAccessScope) => void,
-}
+  isDisabled: boolean;
+  isDisabledGroups: boolean;
+  selectedAccessScope: AiAssistantAccessScope;
+  onSelect: (accessScope: AiAssistantAccessScope) => void;
+};
 
 export const AccessScopeDropdown: React.FC<Props> = (props: Props) => {
-  const {
-    isDisabled,
-    isDisabledGroups,
-    selectedAccessScope,
-    onSelect,
-  } = props;
+  const { isDisabled, isDisabledGroups, selectedAccessScope, onSelect } = props;
 
   const { t } = useTranslation();
   const { data: currentUser } = useCurrentUser();
 
-  const getAccessScopeLabel = useCallback((accessScope: AiAssistantAccessScope) => {
-    const baseLabel = `modal_ai_assistant.access_scope.${accessScope}`;
-    return accessScope === AiAssistantAccessScope.OWNER
-      ? t(baseLabel, { username: currentUser?.username })
-      : t(baseLabel);
-  }, [currentUser?.username, t]);
+  const getAccessScopeLabel = useCallback(
+    (accessScope: AiAssistantAccessScope) => {
+      const baseLabel = `modal_ai_assistant.access_scope.${accessScope}`;
+      return accessScope === AiAssistantAccessScope.OWNER
+        ? t(baseLabel, { username: currentUser?.username })
+        : t(baseLabel);
+    },
+    [currentUser?.username, t],
+  );
 
-  const selectAccessScopeHandler = useCallback((accessScope: AiAssistantAccessScope) => {
-    onSelect(accessScope);
-  }, [onSelect]);
+  const selectAccessScopeHandler = useCallback(
+    (accessScope: AiAssistantAccessScope) => {
+      onSelect(accessScope);
+    },
+    [onSelect],
+  );
 
   return (
     <div className="mb-4">
-      <Label className="text-secondary mb-2">{t('modal_ai_assistant.page_access_permission')}</Label>
+      <Label className="text-secondary mb-2">
+        {t('modal_ai_assistant.page_access_permission')}
+      </Label>
       <UncontrolledDropdown>
         <DropdownToggle
           disabled={isDisabled}
@@ -50,9 +57,16 @@ export const AccessScopeDropdown: React.FC<Props> = (props: Props) => {
           {getAccessScopeLabel(selectedAccessScope)}
         </DropdownToggle>
         <DropdownMenu>
-          { [AiAssistantAccessScope.OWNER, AiAssistantAccessScope.GROUPS, AiAssistantAccessScope.PUBLIC_ONLY].map(accessScope => (
+          {[
+            AiAssistantAccessScope.OWNER,
+            AiAssistantAccessScope.GROUPS,
+            AiAssistantAccessScope.PUBLIC_ONLY,
+          ].map((accessScope) => (
             <DropdownItem
-              disabled={isDisabledGroups && accessScope === AiAssistantAccessScope.GROUPS}
+              disabled={
+                isDisabledGroups &&
+                accessScope === AiAssistantAccessScope.GROUPS
+              }
               onClick={() => selectAccessScopeHandler(accessScope)}
               key={accessScope}
             >

+ 20 - 14
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditInstruction.tsx

@@ -1,27 +1,29 @@
 import type { JSX } from 'react';
-
 import { useTranslation } from 'react-i18next';
 import {
-  ModalBody,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
   Input,
+  ModalBody,
   UncontrolledDropdown,
-  DropdownToggle,
-  DropdownMenu,
-  DropdownItem,
 } from 'reactstrap';
 
-import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
-
+import {
+  AiAssistantManagementModalPageMode,
+  useAiAssistantManagementModal,
+} from '../../../stores/ai-assistant';
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
 
-
 type Props = {
   instruction: string;
   onChange: (value: string) => void;
   onReset: () => void;
-}
+};
 
-export const AiAssistantManagementEditInstruction = (props: Props): JSX.Element => {
+export const AiAssistantManagementEditInstruction = (
+  props: Props,
+): JSX.Element => {
   const { instruction, onChange, onReset } = props;
   const { t } = useTranslation();
   const { changePageMode } = useAiAssistantManagementModal();
@@ -37,8 +39,10 @@ export const AiAssistantManagementEditInstruction = (props: Props): JSX.Element
       <ModalBody className="p-4">
         <p
           className="text-secondary py-1"
-          // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: t('modal_ai_assistant.instructions.description') }}
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+          dangerouslySetInnerHTML={{
+            __html: t('modal_ai_assistant.instructions.description'),
+          }}
         />
 
         <Input
@@ -47,7 +51,7 @@ export const AiAssistantManagementEditInstruction = (props: Props): JSX.Element
           className="mb-4"
           rows="8"
           value={instruction}
-          onChange={e => onChange(e.target.value)}
+          onChange={(e) => onChange(e.target.value)}
         />
 
         <div className="d-flex justify-content-end align-items-center">
@@ -61,7 +65,9 @@ export const AiAssistantManagementEditInstruction = (props: Props): JSX.Element
             </DropdownToggle>
             <DropdownMenu end>
               <DropdownItem onClick={onReset}>
-                <span className="material-symbols-outlined me-2 align-middle">undo</span>
+                <span className="material-symbols-outlined me-2 align-middle">
+                  undo
+                </span>
                 {t('modal_ai_assistant.instructions.reset_to_default')}
               </DropdownItem>
             </DropdownMenu>

+ 16 - 10
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx

@@ -1,5 +1,4 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { ModalBody } from 'reactstrap';
 import SimpleBar from 'simplebar-react';
@@ -7,7 +6,6 @@ import SimpleBar from 'simplebar-react';
 import { useLimitLearnablePageCountPerAssistant } from '~/stores-universal/context';
 
 import type { SelectablePage } from '../../../../interfaces/selectable-page';
-
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
 import { PageSelectionMethodButtons } from './PageSelectionMethodButtons';
 import { SelectablePageList } from './SelectablePageList';
@@ -15,17 +13,21 @@ import { SelectablePageList } from './SelectablePageList';
 type Props = {
   selectedPages: SelectablePage[];
   onRemove: (pageId: string) => void;
-}
+};
 
 export const AiAssistantManagementEditPages = (props: Props): JSX.Element => {
   const { t } = useTranslation();
-  const { data: limitLearnablePageCountPerAssistant } = useLimitLearnablePageCountPerAssistant();
+  const { data: limitLearnablePageCountPerAssistant } =
+    useLimitLearnablePageCountPerAssistant();
 
   const { selectedPages, onRemove } = props;
 
-  const removePageHandler = useCallback((page: SelectablePage) => {
-    onRemove(page.path);
-  }, [onRemove]);
+  const removePageHandler = useCallback(
+    (page: SelectablePage) => {
+      onRemove(page.path);
+    },
+    [onRemove],
+  );
 
   return (
     <>
@@ -35,8 +37,12 @@ export const AiAssistantManagementEditPages = (props: Props): JSX.Element => {
         <div className="px-4">
           <p
             className="text-secondary"
-            // eslint-disable-next-line react/no-danger
-            dangerouslySetInnerHTML={{ __html: t('modal_ai_assistant.edit_page_description', { limitLearnablePageCountPerAssistant }) }}
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+            dangerouslySetInnerHTML={{
+              __html: t('modal_ai_assistant.edit_page_description', {
+                limitLearnablePageCountPerAssistant,
+              }),
+            }}
           />
 
           <div className="mb-3">

+ 58 - 42
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx

@@ -1,13 +1,11 @@
-import React, {
-  useCallback, useState, useEffect, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import {
-  ModalBody, Input, Label,
-} from 'reactstrap';
+import { Input, Label, ModalBody } from 'reactstrap';
 
-import { AiAssistantShareScope, AiAssistantAccessScope } from '~/features/openai/interfaces/ai-assistant';
+import {
+  AiAssistantAccessScope,
+  AiAssistantShareScope,
+} from '~/features/openai/interfaces/ai-assistant';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import { useSWRxUserRelatedGroups } from '~/stores/user';
 
@@ -21,18 +19,18 @@ const ScopeType = {
   SHARE: 'Share',
 } as const;
 
-type ScopeType = typeof ScopeType[keyof typeof ScopeType];
+type ScopeType = (typeof ScopeType)[keyof typeof ScopeType];
 
 type Props = {
-  selectedShareScope: AiAssistantShareScope,
-  selectedAccessScope: AiAssistantAccessScope,
-  selectedUserGroupsForShareScope: PopulatedGrantedGroup[],
-  selectedUserGroupsForAccessScope: PopulatedGrantedGroup[],
-  onSelectShareScope: (scope: AiAssistantShareScope) => void,
-  onSelectAccessScope: (scope: AiAssistantAccessScope) => void,
-  onSelectShareScopeUserGroups: (userGroup: PopulatedGrantedGroup) => void,
-  onSelectAccessScopeUserGroups: (userGroup: PopulatedGrantedGroup) => void,
-}
+  selectedShareScope: AiAssistantShareScope;
+  selectedAccessScope: AiAssistantAccessScope;
+  selectedUserGroupsForShareScope: PopulatedGrantedGroup[];
+  selectedUserGroupsForAccessScope: PopulatedGrantedGroup[];
+  onSelectShareScope: (scope: AiAssistantShareScope) => void;
+  onSelectAccessScope: (scope: AiAssistantAccessScope) => void;
+  onSelectShareScopeUserGroups: (userGroup: PopulatedGrantedGroup) => void;
+  onSelectAccessScopeUserGroups: (userGroup: PopulatedGrantedGroup) => void;
+};
 
 export const AiAssistantManagementEditShare = (props: Props): JSX.Element => {
   const {
@@ -48,28 +46,35 @@ export const AiAssistantManagementEditShare = (props: Props): JSX.Element => {
 
   const { t } = useTranslation();
   const { data: userRelatedGroups } = useSWRxUserRelatedGroups();
-  const hasNoRelatedGroups = userRelatedGroups == null || userRelatedGroups.relatedGroups.length === 0;
+  const hasNoRelatedGroups =
+    userRelatedGroups == null || userRelatedGroups.relatedGroups.length === 0;
 
   const [isShared, setIsShared] = useState(false);
-  const [isSelectUserGroupModalOpen, setIsSelectUserGroupModalOpen] = useState(false);
-  const [selectedUserGroupType, setSelectedUserGroupType] = useState<ScopeType>(ScopeType.ACCESS);
+  const [isSelectUserGroupModalOpen, setIsSelectUserGroupModalOpen] =
+    useState(false);
+  const [selectedUserGroupType, setSelectedUserGroupType] = useState<ScopeType>(
+    ScopeType.ACCESS,
+  );
 
   useEffect(() => {
     setIsShared(() => {
       if (selectedShareScope !== AiAssistantShareScope.SAME_AS_ACCESS_SCOPE) {
         return true;
       }
-      return selectedShareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE && selectedAccessScope !== AiAssistantAccessScope.OWNER;
+      return (
+        selectedShareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE &&
+        selectedAccessScope !== AiAssistantAccessScope.OWNER
+      );
     });
-  }, [isShared, selectedAccessScope, selectedShareScope]);
+  }, [selectedAccessScope, selectedShareScope]);
 
   const changeShareToggleHandler = useCallback(() => {
     setIsShared((prev) => {
-      if (prev) { // if isShared === true
+      if (prev) {
+        // if isShared === true
         onSelectShareScope(AiAssistantShareScope.SAME_AS_ACCESS_SCOPE);
         onSelectAccessScope(AiAssistantAccessScope.OWNER);
-      }
-      else {
+      } else {
         onSelectShareScope(AiAssistantShareScope.PUBLIC_ONLY);
       }
       return !prev;
@@ -81,20 +86,28 @@ export const AiAssistantManagementEditShare = (props: Props): JSX.Element => {
     setIsSelectUserGroupModalOpen(true);
   }, []);
 
-  const selectShareScopeHandler = useCallback((shareScope: AiAssistantShareScope) => {
-    onSelectShareScope(shareScope);
-    if (shareScope === AiAssistantShareScope.GROUPS && !hasNoRelatedGroups) {
-      selectGroupScopeHandler(ScopeType.SHARE);
-    }
-  }, [hasNoRelatedGroups, onSelectShareScope, selectGroupScopeHandler]);
-
-  const selectAccessScopeHandler = useCallback((accessScope: AiAssistantAccessScope) => {
-    onSelectAccessScope(accessScope);
-    if (accessScope === AiAssistantAccessScope.GROUPS && !hasNoRelatedGroups) {
-      selectGroupScopeHandler(ScopeType.ACCESS);
-    }
-  }, [hasNoRelatedGroups, onSelectAccessScope, selectGroupScopeHandler]);
+  const selectShareScopeHandler = useCallback(
+    (shareScope: AiAssistantShareScope) => {
+      onSelectShareScope(shareScope);
+      if (shareScope === AiAssistantShareScope.GROUPS && !hasNoRelatedGroups) {
+        selectGroupScopeHandler(ScopeType.SHARE);
+      }
+    },
+    [hasNoRelatedGroups, onSelectShareScope, selectGroupScopeHandler],
+  );
 
+  const selectAccessScopeHandler = useCallback(
+    (accessScope: AiAssistantAccessScope) => {
+      onSelectAccessScope(accessScope);
+      if (
+        accessScope === AiAssistantAccessScope.GROUPS &&
+        !hasNoRelatedGroups
+      ) {
+        selectGroupScopeHandler(ScopeType.ACCESS);
+      }
+    },
+    [hasNoRelatedGroups, onSelectAccessScope, selectGroupScopeHandler],
+  );
 
   return (
     <>
@@ -133,12 +146,15 @@ export const AiAssistantManagementEditShare = (props: Props): JSX.Element => {
           isOpen={isSelectUserGroupModalOpen}
           userRelatedGroups={userRelatedGroups?.relatedGroups}
           closeModal={() => setIsSelectUserGroupModalOpen(false)}
-          selectedUserGroups={selectedUserGroupType === ScopeType.ACCESS ? selectedUserGroupsForAccessScope : selectedUserGroupsForShareScope}
+          selectedUserGroups={
+            selectedUserGroupType === ScopeType.ACCESS
+              ? selectedUserGroupsForAccessScope
+              : selectedUserGroupsForShareScope
+          }
           onSelect={(userGroup) => {
             if (selectedUserGroupType === ScopeType.ACCESS) {
               onSelectAccessScopeUserGroups(userGroup);
-            }
-            else {
+            } else {
               onSelectShareScopeUserGroups(userGroup);
             }
           }}

+ 25 - 16
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx

@@ -1,16 +1,18 @@
-import { type JSX } from 'react';
-
+import type { JSX } from 'react';
 import { useTranslation } from 'react-i18next';
 import { ModalHeader } from 'reactstrap';
 
-import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
+import {
+  AiAssistantManagementModalPageMode,
+  useAiAssistantManagementModal,
+} from '../../../stores/ai-assistant';
 
 type Props = {
   labelTranslationKey: string;
   hideBackButton?: boolean;
   backButtonColor?: 'primary' | 'secondary';
   backToPageMode?: AiAssistantManagementModalPageMode;
-}
+};
 
 export const AiAssistantManagementHeader = (props: Props): JSX.Element => {
   const {
@@ -26,23 +28,30 @@ export const AiAssistantManagementHeader = (props: Props): JSX.Element => {
   return (
     <ModalHeader
       tag="h4"
-      close={(
+      close={
         <button type="button" className="btn p-0" onClick={close}>
           <span className="material-symbols-outlined">close</span>
         </button>
-      )}
+      }
     >
       <div className="d-flex align-items-center">
-        { hideBackButton
-          ? (
-            <span className="growi-custom-icons growi-ai-assistant-icon me-3 fs-4">growi_ai</span>
-          )
-          : (
-            <button type="button" className="btn p-0 me-3" onClick={() => changePageMode(backToPageMode)}>
-              <span className={`material-symbols-outlined text-${backButtonColor}`}>chevron_left</span>
-            </button>
-          )
-        }
+        {hideBackButton ? (
+          <span className="growi-custom-icons growi-ai-assistant-icon me-3 fs-4">
+            growi_ai
+          </span>
+        ) : (
+          <button
+            type="button"
+            className="btn p-0 me-3"
+            onClick={() => changePageMode(backToPageMode)}
+          >
+            <span
+              className={`material-symbols-outlined text-${backButtonColor}`}
+            >
+              chevron_left
+            </span>
+          </button>
+        )}
         <span className="fw-bold">{t(labelTranslationKey)}</span>
       </div>
     </ModalHeader>

+ 134 - 51
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx

@@ -1,20 +1,30 @@
 import React, {
-  useCallback, useState, useMemo, useRef, useEffect, type JSX,
+  type JSX,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
 } from 'react';
-
 import { useTranslation } from 'react-i18next';
-import {
-  ModalBody, ModalFooter, Input,
-} from 'reactstrap';
+import { Input, ModalBody, ModalFooter } from 'reactstrap';
 
-import { AiAssistantShareScope, AiAssistantAccessScope } from '~/features/openai/interfaces/ai-assistant';
+import {
+  AiAssistantAccessScope,
+  AiAssistantShareScope,
+} from '~/features/openai/interfaces/ai-assistant';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
-import { useCurrentUser, useLimitLearnablePageCountPerAssistant } from '~/stores-universal/context';
+import {
+  useCurrentUser,
+  useLimitLearnablePageCountPerAssistant,
+} from '~/stores-universal/context';
 
 import type { SelectablePage } from '../../../../interfaces/selectable-page';
 import { determineShareScope } from '../../../../utils/determine-share-scope';
-import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
-
+import {
+  AiAssistantManagementModalPageMode,
+  useAiAssistantManagementModal,
+} from '../../../stores/ai-assistant';
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
 import { ShareScopeWarningModal } from './ShareScopeWarningModal';
 
@@ -24,15 +34,15 @@ type Props = {
   name: string;
   description: string;
   instruction: string;
-  shareScope: AiAssistantShareScope,
-  accessScope: AiAssistantAccessScope,
+  shareScope: AiAssistantShareScope;
+  accessScope: AiAssistantAccessScope;
   selectedPages: SelectablePage[];
-  selectedUserGroupsForAccessScope: PopulatedGrantedGroup[],
-  selectedUserGroupsForShareScope: PopulatedGrantedGroup[],
+  selectedUserGroupsForAccessScope: PopulatedGrantedGroup[];
+  selectedUserGroupsForShareScope: PopulatedGrantedGroup[];
   onNameChange: (value: string) => void;
   onDescriptionChange: (value: string) => void;
-  onUpsertAiAssistant: () => Promise<void>
-}
+  onUpsertAiAssistant: () => Promise<void>;
+};
 
 export const AiAssistantManagementHome = (props: Props): JSX.Element => {
   const {
@@ -53,10 +63,13 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
 
   const { t } = useTranslation();
   const { data: currentUser } = useCurrentUser();
-  const { data: limitLearnablePageCountPerAssistant } = useLimitLearnablePageCountPerAssistant();
-  const { close: closeAiAssistantManagementModal, changePageMode } = useAiAssistantManagementModal();
+  const { data: limitLearnablePageCountPerAssistant } =
+    useLimitLearnablePageCountPerAssistant();
+  const { close: closeAiAssistantManagementModal, changePageMode } =
+    useAiAssistantManagementModal();
 
-  const [isShareScopeWarningModalOpen, setIsShareScopeWarningModalOpen] = useState(false);
+  const [isShareScopeWarningModalOpen, setIsShareScopeWarningModalOpen] =
+    useState(false);
 
   const inputRef = useRef<HTMLInputElement>(null);
 
@@ -68,41 +81,71 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
     }, 0);
   }, [selectedPages]);
 
-  const getShareScopeLabel = useCallback((shareScope: AiAssistantShareScope) => {
-    const baseLabel = `modal_ai_assistant.share_scope.${shareScope}.label`;
-    return shareScope === AiAssistantShareScope.OWNER
-      ? t(baseLabel, { username: currentUser?.username })
-      : t(baseLabel);
-  }, [currentUser?.username, t]);
+  const getShareScopeLabel = useCallback(
+    (shareScope: AiAssistantShareScope) => {
+      const baseLabel = `modal_ai_assistant.share_scope.${shareScope}.label`;
+      return shareScope === AiAssistantShareScope.OWNER
+        ? t(baseLabel, { username: currentUser?.username })
+        : t(baseLabel);
+    },
+    [currentUser?.username, t],
+  );
 
-  const canUpsert = name !== '' && selectedPages.length !== 0 && (limitLearnablePageCountPerAssistant ?? 3000) >= totalSelectedPageCount;
+  const canUpsert =
+    name !== '' &&
+    selectedPages.length !== 0 &&
+    (limitLearnablePageCountPerAssistant ?? 3000) >= totalSelectedPageCount;
 
-  const upsertAiAssistantHandler = useCallback(async() => {
+  const upsertAiAssistantHandler = useCallback(async () => {
     const shouldWarning = () => {
       const isDifferentUserGroup = () => {
-        const selectedShareScopeUserGroupIds = selectedUserGroupsForShareScope.map(userGroup => userGroup.item._id);
-        const selectedAccessScopeUserGroupIds = selectedUserGroupsForAccessScope.map(userGroup => userGroup.item._id);
-        if (selectedShareScopeUserGroupIds.length !== selectedAccessScopeUserGroupIds.length) {
+        const selectedShareScopeUserGroupIds =
+          selectedUserGroupsForShareScope.map(
+            (userGroup) => userGroup.item._id,
+          );
+        const selectedAccessScopeUserGroupIds =
+          selectedUserGroupsForAccessScope.map(
+            (userGroup) => userGroup.item._id,
+          );
+        if (
+          selectedShareScopeUserGroupIds.length !==
+          selectedAccessScopeUserGroupIds.length
+        ) {
           return false;
         }
-        return selectedShareScopeUserGroupIds.every((val, index) => val === selectedAccessScopeUserGroupIds[index]);
+        return selectedShareScopeUserGroupIds.every(
+          (val, index) => val === selectedAccessScopeUserGroupIds[index],
+        );
       };
 
       const determinedShareScope = determineShareScope(shareScope, accessScope);
 
-      if (determinedShareScope === AiAssistantShareScope.PUBLIC_ONLY && accessScope !== AiAssistantAccessScope.PUBLIC_ONLY) {
+      if (
+        determinedShareScope === AiAssistantShareScope.PUBLIC_ONLY &&
+        accessScope !== AiAssistantAccessScope.PUBLIC_ONLY
+      ) {
         return true;
       }
 
-      if (determinedShareScope === AiAssistantShareScope.OWNER && accessScope !== AiAssistantAccessScope.OWNER) {
+      if (
+        determinedShareScope === AiAssistantShareScope.OWNER &&
+        accessScope !== AiAssistantAccessScope.OWNER
+      ) {
         return true;
       }
 
-      if (determinedShareScope === AiAssistantShareScope.GROUPS && accessScope === AiAssistantAccessScope.OWNER) {
+      if (
+        determinedShareScope === AiAssistantShareScope.GROUPS &&
+        accessScope === AiAssistantAccessScope.OWNER
+      ) {
         return true;
       }
 
-      if (determinedShareScope === AiAssistantShareScope.GROUPS && accessScope === AiAssistantAccessScope.GROUPS && !isDifferentUserGroup()) {
+      if (
+        determinedShareScope === AiAssistantShareScope.GROUPS &&
+        accessScope === AiAssistantAccessScope.GROUPS &&
+        !isDifferentUserGroup()
+      ) {
         return true;
       }
 
@@ -115,7 +158,13 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
     }
 
     await onUpsertAiAssistant();
-  }, [accessScope, onUpsertAiAssistant, selectedUserGroupsForAccessScope, selectedUserGroupsForShareScope, shareScope]);
+  }, [
+    accessScope,
+    onUpsertAiAssistant,
+    selectedUserGroupsForAccessScope,
+    selectedUserGroupsForShareScope,
+    shareScope,
+  ]);
 
   // Autofocus
   useEffect(() => {
@@ -129,7 +178,11 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
     <>
       <AiAssistantManagementHeader
         hideBackButton
-        labelTranslationKey={shouldEdit ? 'modal_ai_assistant.header.update_assistant' : 'modal_ai_assistant.header.add_new_assistant'}
+        labelTranslationKey={
+          shouldEdit
+            ? 'modal_ai_assistant.header.update_assistant'
+            : 'modal_ai_assistant.header.add_new_assistant'
+        }
       />
 
       <div className="px-4">
@@ -141,22 +194,26 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
               bsSize="lg"
               className="border-0 border-bottom border-2 px-0 rounded-0"
               value={name}
-              onChange={e => onNameChange(e.target.value)}
+              onChange={(e) => onNameChange(e.target.value)}
               innerRef={inputRef}
             />
           </div>
 
           <div className="mb-4">
             <div className="d-flex align-items-center mb-2">
-              <span className="text-secondary">{t('modal_ai_assistant.memo.title')}</span>
-              <span className="badge text-bg-secondary ms-2">{t('modal_ai_assistant.memo.optional')}</span>
+              <span className="text-secondary">
+                {t('modal_ai_assistant.memo.title')}
+              </span>
+              <span className="badge text-bg-secondary ms-2">
+                {t('modal_ai_assistant.memo.optional')}
+              </span>
             </div>
             <Input
               type="textarea"
               placeholder={t('modal_ai_assistant.memo.placeholder')}
               rows="4"
               value={description}
-              onChange={e => onDescriptionChange(e.target.value)}
+              onChange={(e) => onDescriptionChange(e.target.value)}
             />
             <small className="text-secondary d-block mt-2">
               {t('modal_ai_assistant.memo.description')}
@@ -166,39 +223,61 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
           <div>
             <button
               type="button"
-              onClick={() => { changePageMode(AiAssistantManagementModalPageMode.SHARE) }}
+              onClick={() => {
+                changePageMode(AiAssistantManagementModalPageMode.SHARE);
+              }}
               className="btn w-100 d-flex justify-content-between align-items-center py-3 mb-2 border-0"
             >
-              <span className="fw-normal">{t('modal_ai_assistant.page_mode_title.share')}</span>
+              <span className="fw-normal">
+                {t('modal_ai_assistant.page_mode_title.share')}
+              </span>
               <div className="d-flex align-items-center text-secondary">
                 <span>{getShareScopeLabel(shareScope)}</span>
-                <span className="material-symbols-outlined ms-2 align-middle">chevron_right</span>
+                <span className="material-symbols-outlined ms-2 align-middle">
+                  chevron_right
+                </span>
               </div>
             </button>
 
             <button
               type="button"
-              onClick={() => { changePageMode(AiAssistantManagementModalPageMode.PAGES) }}
+              onClick={() => {
+                changePageMode(AiAssistantManagementModalPageMode.PAGES);
+              }}
               className="btn w-100 d-flex justify-content-between align-items-center py-3 mb-2 border-0"
             >
-              <span className="fw-normal">{t('modal_ai_assistant.page_mode_title.pages')}</span>
+              <span className="fw-normal">
+                {t('modal_ai_assistant.page_mode_title.pages')}
+              </span>
               <div className="d-flex align-items-center text-secondary">
-                <span>{t('modal_ai_assistant.page_count', { count: totalSelectedPageCount })}</span>
-                <span className="material-symbols-outlined ms-2 align-middle">chevron_right</span>
+                <span>
+                  {t('modal_ai_assistant.page_count', {
+                    count: totalSelectedPageCount,
+                  })}
+                </span>
+                <span className="material-symbols-outlined ms-2 align-middle">
+                  chevron_right
+                </span>
               </div>
             </button>
 
             <button
               type="button"
-              onClick={() => { changePageMode(AiAssistantManagementModalPageMode.INSTRUCTION) }}
+              onClick={() => {
+                changePageMode(AiAssistantManagementModalPageMode.INSTRUCTION);
+              }}
               className="btn w-100 d-flex justify-content-between align-items-center py-3 mb-2 border-0"
             >
-              <span className="fw-normal">{t('modal_ai_assistant.page_mode_title.instruction')}</span>
+              <span className="fw-normal">
+                {t('modal_ai_assistant.page_mode_title.instruction')}
+              </span>
               <div className="d-flex align-items-center text-secondary">
                 <span className="text-truncate" style={{ maxWidth: '280px' }}>
                   {instruction}
                 </span>
-                <span className="material-symbols-outlined ms-2 align-middle">chevron_right</span>
+                <span className="material-symbols-outlined ms-2 align-middle">
+                  chevron_right
+                </span>
               </div>
             </button>
           </div>
@@ -219,7 +298,11 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
             className="btn btn-primary"
             onClick={upsertAiAssistantHandler}
           >
-            {t(shouldEdit ? 'modal_ai_assistant.submit_button.update_assistant' : 'modal_ai_assistant.submit_button.create_assistant')}
+            {t(
+              shouldEdit
+                ? 'modal_ai_assistant.submit_button.update_assistant'
+                : 'modal_ai_assistant.submit_button.create_assistant',
+            )}
           </button>
         </ModalFooter>
       </div>

+ 119 - 75
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementKeywordSearch.tsx

@@ -1,14 +1,16 @@
 import React, {
-  useRef, useMemo, useCallback, useState, useEffect, type KeyboardEvent,
+  type KeyboardEvent,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
 } from 'react';
-
 import type { IPageHasId } from '@growi/core';
 import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
-import { type TypeaheadRef, Typeahead } from 'react-bootstrap-typeahead';
+import { Typeahead, type TypeaheadRef } from 'react-bootstrap-typeahead';
 import { useTranslation } from 'react-i18next';
-import {
-  ModalBody,
-} from 'reactstrap';
+import { ModalBody } from 'reactstrap';
 import SimpleBar from 'simplebar-react';
 
 import { useSWRxSearch } from '~/stores/search';
@@ -16,9 +18,9 @@ import { useSWRxSearch } from '~/stores/search';
 import type { SelectablePage } from '../../../../interfaces/selectable-page';
 import { useSelectedPages } from '../../../services/use-selected-pages';
 import {
-  useAiAssistantManagementModal, AiAssistantManagementModalPageMode,
+  AiAssistantManagementModalPageMode,
+  useAiAssistantManagementModal,
 } from '../../../stores/ai-assistant';
-
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
 import { SelectablePageList } from './SelectablePageList';
 
@@ -27,40 +29,46 @@ import styles from './AiAssistantManagementKeywordSearch.module.scss';
 const moduleClass = styles['grw-ai-assistant-keyword-search'] ?? '';
 
 type SelectedSearchKeyword = {
-  id: string
-  label: string
-}
+  id: string;
+  label: string;
+};
 
-const isSelectedSearchKeyword = (value: unknown): value is SelectedSearchKeyword => {
+const isSelectedSearchKeyword = (
+  value: unknown,
+): value is SelectedSearchKeyword => {
   return (value as SelectedSearchKeyword).label != null;
 };
 
-
 type Props = {
-  isActivePane: boolean
-  baseSelectedPages: SelectablePage[],
+  isActivePane: boolean;
+  baseSelectedPages: SelectablePage[];
   updateBaseSelectedPages: (pages: SelectablePage[]) => void;
-}
+};
 
 export const AiAssistantKeywordSearch = (props: Props): JSX.Element => {
   const { isActivePane, baseSelectedPages, updateBaseSelectedPages } = props;
 
-  const [selectedSearchKeywords, setSelectedSearchKeywords] = useState<Array<SelectedSearchKeyword>>([]);
-  const {
-    selectedPages, selectedPagesArray, addPage, removePage,
-  } = useSelectedPages(baseSelectedPages);
+  const [selectedSearchKeywords, setSelectedSearchKeywords] = useState<
+    Array<SelectedSearchKeyword>
+  >([]);
+  const { selectedPages, selectedPagesArray, addPage, removePage } =
+    useSelectedPages(baseSelectedPages);
 
   const joinedSelectedSearchKeywords = useMemo(() => {
-    return selectedSearchKeywords.map(item => item.label).join(' ');
+    return selectedSearchKeywords.map((item) => item.label).join(' ');
   }, [selectedSearchKeywords]);
 
   const { t } = useTranslation();
-  const { data: searchResult } = useSWRxSearch(joinedSelectedSearchKeywords, null, {
-    limit: 10,
-    offset: 0,
-    includeUserPages: true,
-    includeTrashPages: false,
-  });
+  const { data: searchResult } = useSWRxSearch(
+    joinedSelectedSearchKeywords,
+    null,
+    {
+      limit: 10,
+      offset: 0,
+      includeUserPages: true,
+      includeTrashPages: false,
+    },
+  );
 
   // Search results will include subordinate pages by default
   const pagesWithGlobPath = useMemo((): IPageHasId[] | undefined => {
@@ -68,7 +76,7 @@ export const AiAssistantKeywordSearch = (props: Props): JSX.Element => {
       return;
     }
 
-    const pages = searchResult.data.map(item => item.data);
+    const pages = searchResult.data.map((item) => item.data);
     return pages.map((page) => {
       const newPage = { ...page };
       if (newPage.path === '/') {
@@ -83,59 +91,79 @@ export const AiAssistantKeywordSearch = (props: Props): JSX.Element => {
   }, [searchResult]);
 
   const shownSearchResult = useMemo(() => {
-    return selectedSearchKeywords.length > 0 && searchResult != null && searchResult.data.length > 0;
+    return (
+      selectedSearchKeywords.length > 0 &&
+      searchResult != null &&
+      searchResult.data.length > 0
+    );
   }, [searchResult, selectedSearchKeywords.length]);
 
-
-  const { data: aiAssistantManagementModalData, changePageMode } = useAiAssistantManagementModal();
-  const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
+  const { data: aiAssistantManagementModalData, changePageMode } =
+    useAiAssistantManagementModal();
+  const isNewAiAssistant =
+    aiAssistantManagementModalData?.aiAssistantData == null;
 
   const typeaheadRef = useRef<TypeaheadRef>(null);
 
-  const changeHandler = useCallback((selected: Array<SelectedSearchKeyword>) => {
-    setSelectedSearchKeywords(selected);
-  }, []);
+  const changeHandler = useCallback(
+    (selected: Array<SelectedSearchKeyword>) => {
+      setSelectedSearchKeywords(selected);
+    },
+    [],
+  );
 
-  const keyDownHandler = useCallback((event: KeyboardEvent<HTMLElement>) => {
-    if (event.code !== 'Space') {
-      return;
-    }
+  const keyDownHandler = useCallback(
+    (event: KeyboardEvent<HTMLElement>) => {
+      if (event.code !== 'Space') {
+        return;
+      }
 
-    if (selectedSearchKeywords.length >= 5) {
-      return;
-    }
+      if (selectedSearchKeywords.length >= 5) {
+        return;
+      }
 
-    event.preventDefault();
+      event.preventDefault();
 
-    // fix: https://redmine.weseek.co.jp/issues/140689
-    // "event.isComposing" is not supported
-    const isComposing = event.nativeEvent.isComposing;
-    if (isComposing) {
-      return;
-    }
+      // fix: https://redmine.weseek.co.jp/issues/140689
+      // "event.isComposing" is not supported
+      const isComposing = event.nativeEvent.isComposing;
+      if (isComposing) {
+        return;
+      }
 
-    const initialItem = typeaheadRef?.current?.state?.initialItem;
-    const handleMenuItemSelect = typeaheadRef?.current?._handleMenuItemSelect;
-    if (initialItem == null || handleMenuItemSelect == null) {
-      return;
-    }
+      const initialItem = typeaheadRef?.current?.state?.initialItem;
+      const handleMenuItemSelect = typeaheadRef?.current?._handleMenuItemSelect;
+      if (initialItem == null || handleMenuItemSelect == null) {
+        return;
+      }
 
-    if (!isSelectedSearchKeyword(initialItem)) {
-      return;
-    }
+      if (!isSelectedSearchKeyword(initialItem)) {
+        return;
+      }
 
-    const allLabels = selectedSearchKeywords.map(item => item.label);
-    if (allLabels.includes(initialItem.label)) {
-      return;
-    }
+      const allLabels = selectedSearchKeywords.map((item) => item.label);
+      if (allLabels.includes(initialItem.label)) {
+        return;
+      }
 
-    handleMenuItemSelect(initialItem, event);
-  }, [selectedSearchKeywords]);
+      handleMenuItemSelect(initialItem, event);
+    },
+    [selectedSearchKeywords],
+  );
 
   const nextButtonClickHandler = useCallback(() => {
     updateBaseSelectedPages(Array.from(selectedPages.values()));
-    changePageMode(isNewAiAssistant ? AiAssistantManagementModalPageMode.HOME : AiAssistantManagementModalPageMode.PAGES);
-  }, [changePageMode, isNewAiAssistant, selectedPages, updateBaseSelectedPages]);
+    changePageMode(
+      isNewAiAssistant
+        ? AiAssistantManagementModalPageMode.HOME
+        : AiAssistantManagementModalPageMode.PAGES,
+    );
+  }, [
+    changePageMode,
+    isNewAiAssistant,
+    selectedPages,
+    updateBaseSelectedPages,
+  ]);
 
   // Autofocus
   useEffect(() => {
@@ -148,8 +176,16 @@ export const AiAssistantKeywordSearch = (props: Props): JSX.Element => {
     <div className={moduleClass}>
       <AiAssistantManagementHeader
         backButtonColor="secondary"
-        backToPageMode={baseSelectedPages.length === 0 ? AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD : AiAssistantManagementModalPageMode.PAGES}
-        labelTranslationKey={isNewAiAssistant ? 'modal_ai_assistant.header.add_new_assistant' : 'modal_ai_assistant.header.update_assistant'}
+        backToPageMode={
+          baseSelectedPages.length === 0
+            ? AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD
+            : AiAssistantManagementModalPageMode.PAGES
+        }
+        labelTranslationKey={
+          isNewAiAssistant
+            ? 'modal_ai_assistant.header.add_new_assistant'
+            : 'modal_ai_assistant.header.update_assistant'
+        }
       />
 
       <ModalBody className="px-4">
@@ -170,24 +206,30 @@ export const AiAssistantKeywordSearch = (props: Props): JSX.Element => {
             onKeyDown={keyDownHandler}
           />
 
-          <label htmlFor="ai-assistant-keyword-search" className="form-text text-muted mt-2">
+          <label
+            htmlFor="ai-assistant-keyword-search"
+            className="form-text text-muted mt-2"
+          >
             {t('modal_ai_assistant.max_items_space_separated_hint')}
           </label>
         </div>
 
-        { shownSearchResult && (
+        {shownSearchResult && (
           <>
             <h4 className="text-center fw-bold mb-3 mt-4">
               {t('modal_ai_assistant.select_assistant_reference_pages')}
             </h4>
             <div className="px-4">
-              <SimpleBar className="page-list-container" style={{ maxHeight: '300px' }}>
+              <SimpleBar
+                className="page-list-container"
+                style={{ maxHeight: '300px' }}
+              >
                 <SelectablePageList
                   isEditable
                   pages={pagesWithGlobPath ?? []}
                   method="add"
                   onClickMethodButton={addPage}
-                  disablePagePaths={selectedPagesArray.map(page => page.path)}
+                  disablePagePaths={selectedPagesArray.map((page) => page.path)}
                 />
               </SimpleBar>
             </div>
@@ -199,17 +241,19 @@ export const AiAssistantKeywordSearch = (props: Props): JSX.Element => {
         </h4>
 
         <div className="px-4">
-          <SimpleBar className="page-list-container" style={{ maxHeight: '300px' }}>
+          <SimpleBar
+            className="page-list-container"
+            style={{ maxHeight: '300px' }}
+          >
             <SelectablePageList
               pages={selectedPagesArray}
               method="remove"
               onClickMethodButton={removePage}
             />
           </SimpleBar>
-          <label className="form-text text-muted mt-2">
+          <span className="form-text text-muted mt-2">
             {t('modal_ai_assistant.can_add_later')}
-          </label>
-
+          </span>
         </div>
 
         <div className="d-flex justify-content-center mt-4">

+ 232 - 116
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx

@@ -1,18 +1,16 @@
-import React, {
-  useCallback, useState, useEffect, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import type { IPageHasId } from '@growi/core';
-import {
-  type IGrantedGroup, isPopulated,
-} from '@growi/core';
+import { type IGrantedGroup, isPopulated } from '@growi/core';
 import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
 import { useTranslation } from 'react-i18next';
 import { Modal, TabContent, TabPane } from 'reactstrap';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { UpsertAiAssistantData } from '~/features/openai/interfaces/ai-assistant';
-import { AiAssistantAccessScope, AiAssistantShareScope } from '~/features/openai/interfaces/ai-assistant';
+import {
+  AiAssistantAccessScope,
+  AiAssistantShareScope,
+} from '~/features/openai/interfaces/ai-assistant';
 import type { IPagePathWithDescendantCount } from '~/interfaces/page';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import { useSWRxPagePathsWithDescendantCount } from '~/stores/page';
@@ -20,14 +18,16 @@ import loggerFactory from '~/utils/logger';
 
 import type { SelectablePage } from '../../../../interfaces/selectable-page';
 import { removeGlobPath } from '../../../../utils/remove-glob-path';
-import { createAiAssistant, updateAiAssistant } from '../../../services/ai-assistant';
 import {
-  useSWRxAiAssistants,
-  useAiAssistantSidebar,
-  useAiAssistantManagementModal,
+  createAiAssistant,
+  updateAiAssistant,
+} from '../../../services/ai-assistant';
+import {
   AiAssistantManagementModalPageMode,
+  useAiAssistantManagementModal,
+  useAiAssistantSidebar,
+  useSWRxAiAssistants,
 } from '../../../stores/ai-assistant';
-
 import { AiAssistantManagementEditInstruction } from './AiAssistantManagementEditInstruction';
 import { AiAssistantManagementEditPages } from './AiAssistantManagementEditPages';
 import { AiAssistantManagementEditShare } from './AiAssistantManagementEditShare';
@@ -40,26 +40,41 @@ import styles from './AiAssistantManagementModal.module.scss';
 
 const moduleClass = styles['grw-ai-assistant-management'] ?? '';
 
-const logger = loggerFactory('growi:openai:client:components:AiAssistantManagementModal');
+const logger = loggerFactory(
+  'growi:openai:client:components:AiAssistantManagementModal',
+);
 
 // PopulatedGrantedGroup[] -> IGrantedGroup[]
-const convertToGrantedGroups = (selectedGroups: PopulatedGrantedGroup[]): IGrantedGroup[] => {
-  return selectedGroups.map(group => ({
+const convertToGrantedGroups = (
+  selectedGroups: PopulatedGrantedGroup[],
+): IGrantedGroup[] => {
+  return selectedGroups.map((group) => ({
     type: group.type,
     item: group.item._id,
   }));
 };
 
 // IGrantedGroup[] -> PopulatedGrantedGroup[]
-const convertToPopulatedGrantedGroups = (selectedGroups: IGrantedGroup[]): PopulatedGrantedGroup[] => {
-  const populatedGrantedGroups = selectedGroups.filter(group => isPopulated(group.item)) as PopulatedGrantedGroup[];
+const convertToPopulatedGrantedGroups = (
+  selectedGroups: IGrantedGroup[],
+): PopulatedGrantedGroup[] => {
+  const populatedGrantedGroups = selectedGroups.filter((group) =>
+    isPopulated(group.item),
+  ) as PopulatedGrantedGroup[];
   return populatedGrantedGroups;
 };
 
-const convertToSelectedPages = (pagePathPatterns: string[], pagePathsWithDescendantCount: IPagePathWithDescendantCount[]): SelectablePage[] => {
+const convertToSelectedPages = (
+  pagePathPatterns: string[],
+  pagePathsWithDescendantCount: IPagePathWithDescendantCount[],
+): SelectablePage[] => {
   return pagePathPatterns.map((pagePathPattern) => {
-    const pathWithoutGlob = isGlobPatternPath(pagePathPattern) ? pagePathPattern.slice(0, -2) : pagePathPattern;
-    const page = pagePathsWithDescendantCount.find(p => p.path === pathWithoutGlob);
+    const pathWithoutGlob = isGlobPatternPath(pagePathPattern)
+      ? pagePathPattern.slice(0, -2)
+      : pagePathPattern;
+    const page = pagePathsWithDescendantCount.find(
+      (p) => p.path === pathWithoutGlob,
+    );
     return {
       ...page,
       path: pagePathPattern,
@@ -71,30 +86,45 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   // Hooks
   const { t } = useTranslation();
   const { mutate: mutateAiAssistants } = useSWRxAiAssistants();
-  const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal();
-  const { data: aiAssistantSidebarData, refreshAiAssistantData } = useAiAssistantSidebar();
-  const { data: pagePathsWithDescendantCount } = useSWRxPagePathsWithDescendantCount(
-    removeGlobPath(aiAssistantManagementModalData?.aiAssistantData?.pagePathPatterns) ?? null,
-    undefined,
-    true,
-    true,
-  );
+  const {
+    data: aiAssistantManagementModalData,
+    close: closeAiAssistantManagementModal,
+  } = useAiAssistantManagementModal();
+  const { data: aiAssistantSidebarData, refreshAiAssistantData } =
+    useAiAssistantSidebar();
+  const { data: pagePathsWithDescendantCount } =
+    useSWRxPagePathsWithDescendantCount(
+      removeGlobPath(
+        aiAssistantManagementModalData?.aiAssistantData?.pagePathPatterns,
+      ) ?? null,
+      undefined,
+      true,
+      true,
+    );
 
   const aiAssistant = aiAssistantManagementModalData?.aiAssistantData;
   const shouldEdit = aiAssistant != null;
-  const pageMode = aiAssistantManagementModalData?.pageMode ?? AiAssistantManagementModalPageMode.HOME;
-
+  const pageMode =
+    aiAssistantManagementModalData?.pageMode ??
+    AiAssistantManagementModalPageMode.HOME;
 
   // States
   const [name, setName] = useState<string>('');
   const [description, setDescription] = useState<string>('');
-  const [selectedShareScope, setSelectedShareScope] = useState<AiAssistantShareScope>(AiAssistantShareScope.SAME_AS_ACCESS_SCOPE);
-  const [selectedAccessScope, setSelectedAccessScope] = useState<AiAssistantAccessScope>(AiAssistantAccessScope.OWNER);
-  const [selectedUserGroupsForAccessScope, setSelectedUserGroupsForAccessScope] = useState<PopulatedGrantedGroup[]>([]);
-  const [selectedUserGroupsForShareScope, setSelectedUserGroupsForShareScope] = useState<PopulatedGrantedGroup[]>([]);
+  const [selectedShareScope, setSelectedShareScope] =
+    useState<AiAssistantShareScope>(AiAssistantShareScope.SAME_AS_ACCESS_SCOPE);
+  const [selectedAccessScope, setSelectedAccessScope] =
+    useState<AiAssistantAccessScope>(AiAssistantAccessScope.OWNER);
+  const [
+    selectedUserGroupsForAccessScope,
+    setSelectedUserGroupsForAccessScope,
+  ] = useState<PopulatedGrantedGroup[]>([]);
+  const [selectedUserGroupsForShareScope, setSelectedUserGroupsForShareScope] =
+    useState<PopulatedGrantedGroup[]>([]);
   const [selectedPages, setSelectedPages] = useState<SelectablePage[]>([]);
-  const [instruction, setInstruction] = useState<string>(t('modal_ai_assistant.default_instruction'));
-
+  const [instruction, setInstruction] = useState<string>(
+    t('modal_ai_assistant.default_instruction'),
+  );
 
   // Effects
   useEffect(() => {
@@ -104,30 +134,50 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       setInstruction(aiAssistant.additionalInstruction);
       setSelectedShareScope(aiAssistant.shareScope);
       setSelectedAccessScope(aiAssistant.accessScope);
-      setSelectedUserGroupsForShareScope(convertToPopulatedGrantedGroups(aiAssistant.grantedGroupsForShareScope ?? []));
-      setSelectedUserGroupsForAccessScope(convertToPopulatedGrantedGroups(aiAssistant.grantedGroupsForAccessScope ?? []));
+      setSelectedUserGroupsForShareScope(
+        convertToPopulatedGrantedGroups(
+          aiAssistant.grantedGroupsForShareScope ?? [],
+        ),
+      );
+      setSelectedUserGroupsForAccessScope(
+        convertToPopulatedGrantedGroups(
+          aiAssistant.grantedGroupsForAccessScope ?? [],
+        ),
+      );
     }
-  // eslint-disable-next-line max-len
-  }, [aiAssistant?.accessScope, aiAssistant?.additionalInstruction, aiAssistant?.description, aiAssistant?.grantedGroupsForAccessScope, aiAssistant?.grantedGroupsForShareScope, aiAssistant?.name, aiAssistant?.pagePathPatterns, aiAssistant?.shareScope, shouldEdit]);
+    // eslint-disable-next-line max-len
+  }, [
+    aiAssistant?.accessScope,
+    aiAssistant?.additionalInstruction,
+    aiAssistant?.description,
+    aiAssistant?.grantedGroupsForAccessScope,
+    aiAssistant?.grantedGroupsForShareScope,
+    aiAssistant?.name,
+    aiAssistant?.shareScope,
+    shouldEdit,
+  ]);
 
   useEffect(() => {
     if (shouldEdit && pagePathsWithDescendantCount != null) {
-      setSelectedPages(convertToSelectedPages(aiAssistant.pagePathPatterns, pagePathsWithDescendantCount));
+      setSelectedPages(
+        convertToSelectedPages(
+          aiAssistant.pagePathPatterns,
+          pagePathsWithDescendantCount,
+        ),
+      );
     }
   }, [aiAssistant?.pagePathPatterns, pagePathsWithDescendantCount, shouldEdit]);
 
-
   /*
-  *  For AiAssistantManagementKeywordSearch & AiAssistantManagementPageTreeSelection methods
-  */
+   *  For AiAssistantManagementKeywordSearch & AiAssistantManagementPageTreeSelection methods
+   */
   const selectPageHandler = useCallback((pages: IPageHasId[]) => {
     setSelectedPages(pages);
   }, []);
 
-
   /*
-  *  For AiAssistantManagementHome methods
-  */
+   *  For AiAssistantManagementHome methods
+   */
   const changeNameHandler = useCallback((value: string) => {
     setName(value);
   }, []);
@@ -136,18 +186,21 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
     setDescription(value);
   }, []);
 
-  const upsertAiAssistantHandler = useCallback(async() => {
+  const upsertAiAssistantHandler = useCallback(async () => {
     try {
-      const pagePathPatterns = selectedPages
-        .map(selectedPage => selectedPage.path);
+      const pagePathPatterns = selectedPages.map(
+        (selectedPage) => selectedPage.path,
+      );
 
-      const grantedGroupsForShareScope = selectedShareScope === AiAssistantShareScope.GROUPS
-        ? convertToGrantedGroups(selectedUserGroupsForShareScope)
-        : undefined;
+      const grantedGroupsForShareScope =
+        selectedShareScope === AiAssistantShareScope.GROUPS
+          ? convertToGrantedGroups(selectedUserGroupsForShareScope)
+          : undefined;
 
-      const grantedGroupsForAccessScope = selectedAccessScope === AiAssistantAccessScope.GROUPS
-        ? convertToGrantedGroups(selectedUserGroupsForAccessScope)
-        : undefined;
+      const grantedGroupsForAccessScope =
+        selectedAccessScope === AiAssistantAccessScope.GROUPS
+          ? convertToGrantedGroups(selectedUserGroupsForAccessScope)
+          : undefined;
 
       const reqBody: UpsertAiAssistantData = {
         name,
@@ -161,77 +214,131 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       };
 
       if (shouldEdit) {
-        const updatedAiAssistant = await updateAiAssistant(aiAssistant._id, reqBody);
-        if (aiAssistantSidebarData?.aiAssistantData?._id === updatedAiAssistant._id) {
+        const updatedAiAssistant = await updateAiAssistant(
+          aiAssistant._id,
+          reqBody,
+        );
+        if (
+          aiAssistantSidebarData?.aiAssistantData?._id ===
+          updatedAiAssistant._id
+        ) {
           refreshAiAssistantData(updatedAiAssistant);
         }
-      }
-      else {
+      } else {
         await createAiAssistant(reqBody);
       }
 
-      toastSuccess(shouldEdit ? t('modal_ai_assistant.toaster.update_success') : t('modal_ai_assistant.toaster.create_success'));
+      toastSuccess(
+        shouldEdit
+          ? t('modal_ai_assistant.toaster.update_success')
+          : t('modal_ai_assistant.toaster.create_success'),
+      );
       mutateAiAssistants();
       closeAiAssistantManagementModal();
-    }
-    catch (err) {
-      toastError(shouldEdit ? t('modal_ai_assistant.toaster.update_failed') : t('modal_ai_assistant.toaster.create_failed'));
+    } catch (err) {
+      toastError(
+        shouldEdit
+          ? t('modal_ai_assistant.toaster.update_failed')
+          : t('modal_ai_assistant.toaster.create_failed'),
+      );
       logger.error(err);
     }
   }, [
-    selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope,
-    selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, t, mutateAiAssistants,
-    closeAiAssistantManagementModal, aiAssistant?._id, aiAssistantSidebarData?.aiAssistantData?._id, refreshAiAssistantData,
+    selectedPages,
+    selectedShareScope,
+    selectedUserGroupsForShareScope,
+    selectedAccessScope,
+    selectedUserGroupsForAccessScope,
+    name,
+    description,
+    instruction,
+    shouldEdit,
+    t,
+    mutateAiAssistants,
+    closeAiAssistantManagementModal,
+    aiAssistant?._id,
+    aiAssistantSidebarData?.aiAssistantData?._id,
+    refreshAiAssistantData,
   ]);
 
-
   /*
-  *  For AiAssistantManagementEditShare methods
-  */
-  const selectShareScopeHandler = useCallback((shareScope: AiAssistantShareScope) => {
-    setSelectedShareScope(shareScope);
-  }, []);
-
-  const selectAccessScopeHandler = useCallback((accessScope: AiAssistantAccessScope) => {
-    setSelectedAccessScope(accessScope);
-  }, []);
+   *  For AiAssistantManagementEditShare methods
+   */
+  const selectShareScopeHandler = useCallback(
+    (shareScope: AiAssistantShareScope) => {
+      setSelectedShareScope(shareScope);
+    },
+    [],
+  );
 
-  const selectShareScopeUserGroups = useCallback((targetUserGroup: PopulatedGrantedGroup) => {
-    const selectedUserGroupIds = selectedUserGroupsForShareScope.map(userGroup => userGroup.item._id);
-    if (selectedUserGroupIds.includes(targetUserGroup.item._id)) {
-      // if selected, remove it
-      setSelectedUserGroupsForShareScope(selectedUserGroupsForShareScope.filter(userGroup => userGroup.item._id !== targetUserGroup.item._id));
-    }
-    else {
-      // if not selected, add it
-      setSelectedUserGroupsForShareScope([...selectedUserGroupsForShareScope, targetUserGroup]);
-    }
-  }, [selectedUserGroupsForShareScope]);
+  const selectAccessScopeHandler = useCallback(
+    (accessScope: AiAssistantAccessScope) => {
+      setSelectedAccessScope(accessScope);
+    },
+    [],
+  );
 
-  const selectAccessScopeUserGroups = useCallback((targetUserGroup: PopulatedGrantedGroup) => {
-    const selectedUserGroupIds = selectedUserGroupsForAccessScope.map(userGroup => userGroup.item._id);
-    if (selectedUserGroupIds.includes(targetUserGroup.item._id)) {
-      // if selected, remove it
-      setSelectedUserGroupsForAccessScope(selectedUserGroupsForAccessScope.filter(userGroup => userGroup.item._id !== targetUserGroup.item._id));
-    }
-    else {
-      // if not selected, add it
-      setSelectedUserGroupsForAccessScope([...selectedUserGroupsForAccessScope, targetUserGroup]);
-    }
-  }, [selectedUserGroupsForAccessScope]);
+  const selectShareScopeUserGroups = useCallback(
+    (targetUserGroup: PopulatedGrantedGroup) => {
+      const selectedUserGroupIds = selectedUserGroupsForShareScope.map(
+        (userGroup) => userGroup.item._id,
+      );
+      if (selectedUserGroupIds.includes(targetUserGroup.item._id)) {
+        // if selected, remove it
+        setSelectedUserGroupsForShareScope(
+          selectedUserGroupsForShareScope.filter(
+            (userGroup) => userGroup.item._id !== targetUserGroup.item._id,
+          ),
+        );
+      } else {
+        // if not selected, add it
+        setSelectedUserGroupsForShareScope([
+          ...selectedUserGroupsForShareScope,
+          targetUserGroup,
+        ]);
+      }
+    },
+    [selectedUserGroupsForShareScope],
+  );
 
+  const selectAccessScopeUserGroups = useCallback(
+    (targetUserGroup: PopulatedGrantedGroup) => {
+      const selectedUserGroupIds = selectedUserGroupsForAccessScope.map(
+        (userGroup) => userGroup.item._id,
+      );
+      if (selectedUserGroupIds.includes(targetUserGroup.item._id)) {
+        // if selected, remove it
+        setSelectedUserGroupsForAccessScope(
+          selectedUserGroupsForAccessScope.filter(
+            (userGroup) => userGroup.item._id !== targetUserGroup.item._id,
+          ),
+        );
+      } else {
+        // if not selected, add it
+        setSelectedUserGroupsForAccessScope([
+          ...selectedUserGroupsForAccessScope,
+          targetUserGroup,
+        ]);
+      }
+    },
+    [selectedUserGroupsForAccessScope],
+  );
 
   /*
-  *  For AiAssistantManagementEditPages methods
-  */
-  const removePageHandler = useCallback((pagePath: string) => {
-    setSelectedPages(selectedPages.filter(selectedPage => selectedPage.path !== pagePath));
-  }, [selectedPages]);
-
+   *  For AiAssistantManagementEditPages methods
+   */
+  const removePageHandler = useCallback(
+    (pagePath: string) => {
+      setSelectedPages(
+        selectedPages.filter((selectedPage) => selectedPage.path !== pagePath),
+      );
+    },
+    [selectedPages],
+  );
 
   /*
-  *  For AiAssistantManagementEditInstruction methods
-  */
+   *  For AiAssistantManagementEditInstruction methods
+   */
   const changeInstructionHandler = useCallback((value: string) => {
     setInstruction(value);
   }, []);
@@ -243,13 +350,17 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   return (
     <>
       <TabContent activeTab={pageMode}>
-        <TabPane tabId={AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD}>
+        <TabPane
+          tabId={AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD}
+        >
           <AiAssistantManagementPageSelectionMethod />
         </TabPane>
 
         <TabPane tabId={AiAssistantManagementModalPageMode.KEYWORD_SEARCH}>
           <AiAssistantKeywordSearch
-            isActivePane={pageMode === AiAssistantManagementModalPageMode.KEYWORD_SEARCH}
+            isActivePane={
+              pageMode === AiAssistantManagementModalPageMode.KEYWORD_SEARCH
+            }
             baseSelectedPages={selectedPages}
             updateBaseSelectedPages={selectPageHandler}
           />
@@ -312,17 +423,22 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   );
 };
 
-
 export const AiAssistantManagementModal = (): JSX.Element => {
-  const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal();
+  const {
+    data: aiAssistantManagementModalData,
+    close: closeAiAssistantManagementModal,
+  } = useAiAssistantManagementModal();
 
   const isOpened = aiAssistantManagementModalData?.isOpened ?? false;
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeAiAssistantManagementModal} className={moduleClass}>
-      { isOpened && (
-        <AiAssistantManagementModalSubstance />
-      ) }
+    <Modal
+      size="lg"
+      isOpen={isOpened}
+      toggle={closeAiAssistantManagementModal}
+      className={moduleClass}
+    >
+      {isOpened && <AiAssistantManagementModalSubstance />}
     </Modal>
   );
 };

+ 10 - 9
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageSelectionMethod.tsx

@@ -1,25 +1,27 @@
 import React from 'react';
-
 import { useTranslation } from 'react-i18next';
-import {
-  ModalBody,
-} from 'reactstrap';
+import { ModalBody } from 'reactstrap';
 
 import { useAiAssistantManagementModal } from '../../../stores/ai-assistant';
-
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
 import { PageSelectionMethodButtons } from './PageSelectionMethodButtons';
 
 export const AiAssistantManagementPageSelectionMethod = (): JSX.Element => {
   const { t } = useTranslation();
-  const { data: aiAssistantManagementModalData } = useAiAssistantManagementModal();
-  const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
+  const { data: aiAssistantManagementModalData } =
+    useAiAssistantManagementModal();
+  const isNewAiAssistant =
+    aiAssistantManagementModalData?.aiAssistantData == null;
 
   return (
     <>
       <AiAssistantManagementHeader
         hideBackButton={isNewAiAssistant}
-        labelTranslationKey={isNewAiAssistant ? 'modal_ai_assistant.header.add_new_assistant' : 'modal_ai_assistant.header.update_assistant'}
+        labelTranslationKey={
+          isNewAiAssistant
+            ? 'modal_ai_assistant.header.add_new_assistant'
+            : 'modal_ai_assistant.header.update_assistant'
+        }
       />
 
       <ModalBody className="px-4">
@@ -28,7 +30,6 @@ export const AiAssistantManagementPageSelectionMethod = (): JSX.Element => {
         </h4>
 
         <PageSelectionMethodButtons />
-
       </ModalBody>
     </>
   );

+ 143 - 92
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.tsx

@@ -1,11 +1,6 @@
-import React, {
-  Suspense, useCallback, memo,
-} from 'react';
-
+import React, { memo, Suspense, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
-import {
-  ModalBody,
-} from 'reactstrap';
+import { ModalBody } from 'reactstrap';
 import SimpleBar from 'simplebar-react';
 
 import { ItemsTree } from '~/client/components/ItemsTree';
@@ -15,115 +10,166 @@ import { TreeItemLayout } from '~/client/components/TreeItem';
 import type { IPageForItem } from '~/interfaces/page';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
 
-import { type SelectablePage, isSelectablePage } from '../../../../interfaces/selectable-page';
+import {
+  isSelectablePage,
+  type SelectablePage,
+} from '../../../../interfaces/selectable-page';
 import { useSelectedPages } from '../../../services/use-selected-pages';
-import { AiAssistantManagementModalPageMode, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
-
+import {
+  AiAssistantManagementModalPageMode,
+  useAiAssistantManagementModal,
+} from '../../../stores/ai-assistant';
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
 import { SelectablePageList } from './SelectablePageList';
 
 import styles from './AiAssistantManagementPageTreeSelection.module.scss';
 
-const moduleClass = styles['grw-ai-assistant-management-page-tree-selection'] ?? '';
-
-const SelectablePageTree = memo((props: { onClickAddPageButton: (page: SelectablePage) => void }) => {
-  const { onClickAddPageButton } = props;
-
-  const { data: isGuestUser } = useIsGuestUser();
-  const { data: isReadOnlyUser } = useIsReadOnlyUser();
-
-  const pageTreeItemClickHandler = useCallback((page: IPageForItem) => {
-    if (!isSelectablePage(page)) {
-      return;
-    }
-
-    onClickAddPageButton(page);
-  }, [onClickAddPageButton]);
-
-  const PageTreeItem = (props: TreeItemProps) => {
-    const { itemNode } = props;
-    const { page } = itemNode;
-
-    const SelectPageButton = () => {
-      return (
-        <button
-          type="button"
-          className="border-0 rounded btn p-0"
-          onClick={(e) => {
-            e.stopPropagation();
-            pageTreeItemClickHandler(page);
-          }}
-        >
-          <span className="material-symbols-outlined p-0 me-2 text-primary">add_circle</span>
-        </button>
-      );
-    };
+const moduleClass =
+  styles['grw-ai-assistant-management-page-tree-selection'] ?? '';
 
-    return (
-      <TreeItemLayout
-        {...props}
-        itemClass={PageTreeItem}
-        className="text-muted"
-        customHoveredEndComponents={[SelectPageButton]}
-      />
+const SelectablePageTree = memo(
+  (props: { onClickAddPageButton: (page: SelectablePage) => void }) => {
+    const { onClickAddPageButton } = props;
+
+    const { data: isGuestUser } = useIsGuestUser();
+    const { data: isReadOnlyUser } = useIsReadOnlyUser();
+
+    const pageTreeItemClickHandler = useCallback(
+      (page: IPageForItem) => {
+        if (!isSelectablePage(page)) {
+          return;
+        }
+
+        onClickAddPageButton(page);
+      },
+      [onClickAddPageButton],
     );
-  };
 
-  return (
-    <div className="page-tree-item">
-      <ItemsTree
-        targetPath="/"
-        isEnableActions={!isGuestUser}
-        isReadOnlyUser={!!isReadOnlyUser}
-        CustomTreeItem={PageTreeItem}
-      />
-    </div>
-  );
-});
+    const SelectPageButton = useCallback(
+      ({ page }: { page: IPageForItem }) => {
+        return (
+          <button
+            type="button"
+            className="border-0 rounded btn p-0"
+            onClick={(e) => {
+              e.stopPropagation();
+              pageTreeItemClickHandler(page);
+            }}
+          >
+            <span className="material-symbols-outlined p-0 me-2 text-primary">
+              add_circle
+            </span>
+          </button>
+        );
+      },
+      [pageTreeItemClickHandler],
+    );
+
+    const PageTreeItem = useCallback(
+      (props: TreeItemProps) => {
+        const { itemNode } = props;
+        const { page } = itemNode;
+
+        return (
+          <TreeItemLayout
+            {...props}
+            itemClass={PageTreeItem}
+            className="text-muted"
+            customHoveredEndComponents={[
+              () => <SelectPageButton page={page} />,
+            ]}
+          />
+        );
+      },
+      [SelectPageButton],
+    );
+
+    return (
+      <div className="page-tree-item">
+        <ItemsTree
+          targetPath="/"
+          isEnableActions={!isGuestUser}
+          isReadOnlyUser={!!isReadOnlyUser}
+          CustomTreeItem={PageTreeItem}
+        />
+      </div>
+    );
+  },
+);
 
 type Props = {
-  baseSelectedPages: SelectablePage[],
+  baseSelectedPages: SelectablePage[];
   updateBaseSelectedPages: (pages: SelectablePage[]) => void;
-}
+};
 
-export const AiAssistantManagementPageTreeSelection = (props: Props): JSX.Element => {
+export const AiAssistantManagementPageTreeSelection = (
+  props: Props,
+): JSX.Element => {
   const { baseSelectedPages, updateBaseSelectedPages } = props;
 
   const { t } = useTranslation();
-  const { data: aiAssistantManagementModalData, changePageMode } = useAiAssistantManagementModal();
-  const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
+  const { data: aiAssistantManagementModalData, changePageMode } =
+    useAiAssistantManagementModal();
+  const isNewAiAssistant =
+    aiAssistantManagementModalData?.aiAssistantData == null;
 
   const {
-    selectedPages, selectedPagesRef, selectedPagesArray, addPage, removePage,
+    selectedPages,
+    selectedPagesRef,
+    selectedPagesArray,
+    addPage,
+    removePage,
   } = useSelectedPages(baseSelectedPages);
 
-
-  const addPageButtonClickHandler = useCallback((page: SelectablePage) => {
-    const pagePathWithGlob = `${page.path}/*`;
-    if (selectedPagesRef.current == null || selectedPagesRef.current.has(pagePathWithGlob)) {
-      return;
-    }
-
-    const clonedPage = { ...page };
-    clonedPage.path = pagePathWithGlob;
-
-    addPage(clonedPage);
-  }, [
-    addPage,
-    selectedPagesRef, // Prevent flickering (use ref to avoid method recreation)
-  ]);
+  const addPageButtonClickHandler = useCallback(
+    (page: SelectablePage) => {
+      const pagePathWithGlob = `${page.path}/*`;
+      if (
+        selectedPagesRef.current == null ||
+        selectedPagesRef.current.has(pagePathWithGlob)
+      ) {
+        return;
+      }
+
+      const clonedPage = { ...page };
+      clonedPage.path = pagePathWithGlob;
+
+      addPage(clonedPage);
+    },
+    [
+      addPage,
+      selectedPagesRef, // Prevent flickering (use ref to avoid method recreation)
+    ],
+  );
 
   const nextButtonClickHandler = useCallback(() => {
     updateBaseSelectedPages(Array.from(selectedPages.values()));
-    changePageMode(isNewAiAssistant ? AiAssistantManagementModalPageMode.HOME : AiAssistantManagementModalPageMode.PAGES);
-  }, [changePageMode, isNewAiAssistant, selectedPages, updateBaseSelectedPages]);
+    changePageMode(
+      isNewAiAssistant
+        ? AiAssistantManagementModalPageMode.HOME
+        : AiAssistantManagementModalPageMode.PAGES,
+    );
+  }, [
+    changePageMode,
+    isNewAiAssistant,
+    selectedPages,
+    updateBaseSelectedPages,
+  ]);
 
   return (
     <div className={moduleClass}>
       <AiAssistantManagementHeader
         backButtonColor="secondary"
-        backToPageMode={baseSelectedPages.length === 0 ? AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD : AiAssistantManagementModalPageMode.PAGES}
-        labelTranslationKey={isNewAiAssistant ? 'modal_ai_assistant.header.add_new_assistant' : 'modal_ai_assistant.header.update_assistant'}
+        backToPageMode={
+          baseSelectedPages.length === 0
+            ? AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD
+            : AiAssistantManagementModalPageMode.PAGES
+        }
+        labelTranslationKey={
+          isNewAiAssistant
+            ? 'modal_ai_assistant.header.add_new_assistant'
+            : 'modal_ai_assistant.header.update_assistant'
+        }
       />
 
       <ModalBody className="px-4">
@@ -133,7 +179,9 @@ export const AiAssistantManagementPageTreeSelection = (props: Props): JSX.Elemen
 
         <Suspense fallback={<ItemsTreeContentSkeleton />}>
           <div className="px-4">
-            <SelectablePageTree onClickAddPageButton={addPageButtonClickHandler} />
+            <SelectablePageTree
+              onClickAddPageButton={addPageButtonClickHandler}
+            />
           </div>
         </Suspense>
 
@@ -142,7 +190,10 @@ export const AiAssistantManagementPageTreeSelection = (props: Props): JSX.Elemen
         </h4>
 
         <div className="px-4">
-          <SimpleBar className="page-list-container" style={{ maxHeight: '300px' }}>
+          <SimpleBar
+            className="page-list-container"
+            style={{ maxHeight: '300px' }}
+          >
             <SelectablePageList
               method="remove"
               methodButtonPosition="right"
@@ -150,9 +201,9 @@ export const AiAssistantManagementPageTreeSelection = (props: Props): JSX.Elemen
               onClickMethodButton={removePage}
             />
           </SimpleBar>
-          <label className="form-text text-muted mt-2">
+          <span className="form-text text-muted mt-2">
             {t('modal_ai_assistant.can_add_later')}
-          </label>
+          </span>
         </div>
 
         <div className="d-flex justify-content-center mt-4">

+ 18 - 9
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageSelectionMethodButtons.tsx

@@ -1,14 +1,20 @@
 import React from 'react';
-
 import { useTranslation } from 'react-i18next';
 
-import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
+import {
+  AiAssistantManagementModalPageMode,
+  useAiAssistantManagementModal,
+} from '../../../stores/ai-assistant';
 
 import styles from './PageSelectionMethodButtons.module.scss';
 
 const moduleClass = styles['page-selection-method-buttons'] ?? '';
 
-const SelectionButton = (props: { icon: string, label: string, onClick: () => void }): JSX.Element => {
+const SelectionButton = (props: {
+  icon: string;
+  label: string;
+  onClick: () => void;
+}): JSX.Element => {
   const { icon, label, onClick } = props;
 
   return (
@@ -17,9 +23,7 @@ const SelectionButton = (props: { icon: string, label: string, onClick: () => vo
       className="btn text-center py-4 w-100 page-selection-method-btn"
       onClick={onClick}
     >
-      <span
-        className="material-symbols-outlined d-block mb-3 fs-1"
-      >
+      <span className="material-symbols-outlined d-block mb-3 fs-1">
         {icon}
       </span>
       <div>{label}</div>
@@ -27,7 +31,6 @@ const SelectionButton = (props: { icon: string, label: string, onClick: () => vo
   );
 };
 
-
 export const PageSelectionMethodButtons = (): JSX.Element => {
   const { t } = useTranslation();
   const { changePageMode } = useAiAssistantManagementModal();
@@ -39,14 +42,20 @@ export const PageSelectionMethodButtons = (): JSX.Element => {
           <SelectionButton
             icon="manage_search"
             label={t('modal_ai_assistant.search_by_keyword')}
-            onClick={() => changePageMode(AiAssistantManagementModalPageMode.KEYWORD_SEARCH)}
+            onClick={() =>
+              changePageMode(AiAssistantManagementModalPageMode.KEYWORD_SEARCH)
+            }
           />
         </div>
         <div className="col">
           <SelectionButton
             icon="account_tree"
             label={t('modal_ai_assistant.select_from_page_tree')}
-            onClick={() => changePageMode(AiAssistantManagementModalPageMode.PAGE_TREE_SELECTION)}
+            onClick={() =>
+              changePageMode(
+                AiAssistantManagementModalPageMode.PAGE_TREE_SELECTION,
+              )
+            }
           />
         </div>
       </div>

+ 41 - 35
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectUserGroupModal.tsx

@@ -1,51 +1,58 @@
-import React, { useCallback } from 'react';
-
+import type React from 'react';
+import { useCallback } from 'react';
 import { GroupType } from '@growi/core';
 import { useTranslation } from 'react-i18next';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 
 type Props = {
-  isOpen: boolean,
-  userRelatedGroups?: PopulatedGrantedGroup[],
-  selectedUserGroups: PopulatedGrantedGroup[],
-  closeModal: () => void,
-  onSelect: (userGroup: PopulatedGrantedGroup) => void,
-}
+  isOpen: boolean;
+  userRelatedGroups?: PopulatedGrantedGroup[];
+  selectedUserGroups: PopulatedGrantedGroup[];
+  closeModal: () => void;
+  onSelect: (userGroup: PopulatedGrantedGroup) => void;
+};
 
 const SelectUserGroupModalSubstance: React.FC<Props> = (props: Props) => {
-  const {
-    userRelatedGroups,
-    selectedUserGroups,
-    onSelect,
-    closeModal,
-  } = props;
+  const { userRelatedGroups, selectedUserGroups, onSelect, closeModal } = props;
 
   const { t } = useTranslation();
 
-  const checked = useCallback((targetUserGroup: PopulatedGrantedGroup) => {
-    const selectedUserGroupIds = selectedUserGroups.map(userGroup => userGroup.item._id);
-    return selectedUserGroupIds.includes(targetUserGroup.item._id);
-  }, [selectedUserGroups]);
+  const checked = useCallback(
+    (targetUserGroup: PopulatedGrantedGroup) => {
+      const selectedUserGroupIds = selectedUserGroups.map(
+        (userGroup) => userGroup.item._id,
+      );
+      return selectedUserGroupIds.includes(targetUserGroup.item._id);
+    },
+    [selectedUserGroups],
+  );
 
   return (
     <ModalBody className="d-flex flex-column">
-      {userRelatedGroups != null && userRelatedGroups.map(userGroup => (
-        <button
-          className="btn btn-outline-primary d-flex justify-content-start mb-3 mx-4 align-items-center p-3"
-          type="button"
-          key={userGroup.item._id}
-          onClick={() => onSelect(userGroup)}
-        >
-          <input type="checkbox" checked={checked(userGroup)} onChange={() => {}} />
-          <p className="ms-3 mb-0">{userGroup.item.name}</p>
-          {userGroup.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{userGroup.item.provider}</span>}
-          {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
-        </button>
-      ))}
+      {userRelatedGroups != null &&
+        userRelatedGroups.map((userGroup) => (
+          <button
+            className="btn btn-outline-primary d-flex justify-content-start mb-3 mx-4 align-items-center p-3"
+            type="button"
+            key={userGroup.item._id}
+            onClick={() => onSelect(userGroup)}
+          >
+            <input
+              type="checkbox"
+              checked={checked(userGroup)}
+              onChange={() => {}}
+            />
+            <p className="ms-3 mb-0">{userGroup.item.name}</p>
+            {userGroup.type === GroupType.externalUserGroup && (
+              <span className="ms-2 badge badge-pill badge-info">
+                {userGroup.item.provider}
+              </span>
+            )}
+            {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
+          </button>
+        ))}
       <button
         type="button"
         className="btn btn-primary mt-2 mx-auto"
@@ -53,7 +60,6 @@ const SelectUserGroupModalSubstance: React.FC<Props> = (props: Props) => {
       >
         {t('Done')}
       </button>
-
     </ModalBody>
   );
 };

+ 94 - 98
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePageList.tsx

@@ -1,13 +1,11 @@
-import React, {
-  useMemo, memo, useState, useCallback, useRef, useEffect,
-} from 'react';
-
+import type React from 'react';
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'react-i18next';
 import AutosizeInput from 'react-input-autosize';
 
-import { type SelectablePage } from '../../../../interfaces/selectable-page';
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
 import { isCreatablePagePathPattern } from '../../../../utils/is-creatable-page-path-pattern';
 
 import styles from './SelectablePageList.module.scss';
@@ -17,17 +15,12 @@ const moduleClass = styles['selectable-page-list'] ?? '';
 type MethodButtonProps = {
   page: SelectablePage;
   disablePagePaths: string[];
-  method: 'add' | 'remove' | 'delete'
+  method: 'add' | 'remove' | 'delete';
   onClickMethodButton: (page: SelectablePage) => void;
-}
+};
 
 const MethodButton = memo((props: MethodButtonProps) => {
-  const {
-    page,
-    disablePagePaths,
-    method,
-    onClickMethodButton,
-  } = props;
+  const { page, disablePagePaths, method, onClickMethodButton } = props;
 
   const iconName = useMemo(() => {
     switch (method) {
@@ -65,20 +58,17 @@ const MethodButton = memo((props: MethodButtonProps) => {
         onClickMethodButton(page);
       }}
     >
-      <span className="material-symbols-outlined">
-        {iconName}
-      </span>
+      <span className="material-symbols-outlined">{iconName}</span>
     </button>
   );
 });
 
-
 type EditablePagePathProps = {
   isEditable?: boolean;
   page: SelectablePage;
   disablePagePaths: string[];
   methodButtonPosition?: 'left' | 'right';
-}
+};
 
 const EditablePagePath = memo((props: EditablePagePathProps): JSX.Element => {
   const {
@@ -91,40 +81,49 @@ const EditablePagePath = memo((props: EditablePagePathProps): JSX.Element => {
   const [editingPagePath, setEditingPagePath] = useState<string | null>(null);
   const [inputValue, setInputValue] = useState('');
 
-  const inputRef = useRef<HTMLInputElement & AutosizeInput | null>(null);
+  const inputRef = useRef<(HTMLInputElement & AutosizeInput) | null>(null);
   const editingContainerRef = useRef<HTMLDivElement>(null);
   const [editingContainerRect] = useRect(editingContainerRef);
 
   const isEditing = isEditable && editingPagePath === page.path;
 
-  const handlePagePathClick = useCallback((page: SelectablePage) => {
-    if (!isEditable || disablePagePaths.includes(page.path)) {
-      return;
-    }
-    setEditingPagePath(page.path);
-    setInputValue(page.path);
-  }, [disablePagePaths, isEditable]);
+  const handlePagePathClick = useCallback(
+    (page: SelectablePage) => {
+      if (!isEditable || disablePagePaths.includes(page.path)) {
+        return;
+      }
+      setEditingPagePath(page.path);
+      setInputValue(page.path);
+    },
+    [disablePagePaths, isEditable],
+  );
 
   const handleInputBlur = useCallback(() => {
     setEditingPagePath(null);
   }, []);
 
-  const handleInputKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
-    if (e.key === 'Enter') {
+  const handleInputKeyDown = useCallback(
+    (e: React.KeyboardEvent<HTMLInputElement>) => {
+      if (e.key === 'Enter') {
+        // Validate page path
+        const pagePathWithSlash = pathUtils.addHeadingSlash(inputValue);
+        if (
+          inputValue === '' ||
+          disablePagePaths.includes(pagePathWithSlash) ||
+          !isCreatablePagePathPattern(pagePathWithSlash)
+        ) {
+          handleInputBlur();
+          return;
+        }
+
+        // Update page path
+        page.path = pagePathWithSlash;
 
-      // Validate page path
-      const pagePathWithSlash = pathUtils.addHeadingSlash(inputValue);
-      if (inputValue === '' || disablePagePaths.includes(pagePathWithSlash) || !isCreatablePagePathPattern(pagePathWithSlash)) {
         handleInputBlur();
-        return;
       }
-
-      // Update page path
-      page.path = pagePathWithSlash;
-
-      handleInputBlur();
-    }
-  }, [disablePagePaths, handleInputBlur, inputValue, page]);
+    },
+    [disablePagePaths, handleInputBlur, inputValue, page],
+  );
 
   // Autofocus
   useEffect(() => {
@@ -139,44 +138,44 @@ const EditablePagePath = memo((props: EditablePagePathProps): JSX.Element => {
       className={`flex-grow-1 ${methodButtonPosition === 'left' ? 'me-2' : 'mx-2'}`}
       style={{ minWidth: 0 }}
     >
-      {isEditing
-        ? (
-          <AutosizeInput
-            id="page-path-input"
-            inputClassName="page-path-input"
-            type="text"
-            ref={inputRef}
-            value={inputValue}
-            onBlur={handleInputBlur}
-            onChange={e => setInputValue(e.target.value)}
-            onKeyDown={handleInputKeyDown}
-            inputStyle={{ maxWidth: (editingContainerRect?.width ?? 0) - 10 }}
-          />
-        )
-        : (
-          <span
-            className={`page-path ${isEditable && !disablePagePaths.includes(page.path) ? 'page-path-editable' : ''}`}
-            onClick={() => handlePagePathClick(page)}
-            title={page.path}
-          >
-            {page.path}
-          </span>
-        )}
+      {isEditing ? (
+        <AutosizeInput
+          id="page-path-input"
+          inputClassName="page-path-input"
+          type="text"
+          ref={inputRef}
+          value={inputValue}
+          onBlur={handleInputBlur}
+          onChange={(e) => setInputValue(e.target.value)}
+          onKeyDown={handleInputKeyDown}
+          inputStyle={{ maxWidth: (editingContainerRect?.width ?? 0) - 10 }}
+        />
+      ) : (
+        <button
+          type="button"
+          className={`btn btn-link p-0 page-path ${isEditable && !disablePagePaths.includes(page.path) ? 'page-path-editable' : ''}`}
+          onClick={() => handlePagePathClick(page)}
+          title={page.path}
+        >
+          {page.path}
+        </button>
+      )}
     </div>
   );
 });
 
-
 type SelectablePageListProps = {
-  pages: SelectablePage[],
-  method: 'add' | 'remove' | 'delete'
-  methodButtonPosition?: 'left' | 'right',
-  disablePagePaths?: string[],
-  isEditable?: boolean,
-  onClickMethodButton: (page: SelectablePage) => void,
-}
-
-export const SelectablePageList = (props: SelectablePageListProps): JSX.Element => {
+  pages: SelectablePage[];
+  method: 'add' | 'remove' | 'delete';
+  methodButtonPosition?: 'left' | 'right';
+  disablePagePaths?: string[];
+  isEditable?: boolean;
+  onClickMethodButton: (page: SelectablePage) => void;
+};
+
+export const SelectablePageList = (
+  props: SelectablePageListProps,
+): JSX.Element => {
   const {
     pages,
     method,
@@ -192,7 +191,9 @@ export const SelectablePageList = (props: SelectablePageListProps): JSX.Element
     return (
       <div className={moduleClass}>
         <div className="border-0 text-center page-list-item rounded py-3">
-          <p className="text-muted mb-0">{t('modal_ai_assistant.no_pages_selected')}</p>
+          <p className="text-muted mb-0">
+            {t('modal_ai_assistant.no_pages_selected')}
+          </p>
         </div>
       </div>
     );
@@ -206,17 +207,14 @@ export const SelectablePageList = (props: SelectablePageListProps): JSX.Element
             key={page.path}
             className="list-group-item border-0 page-list-item d-flex align-items-center p-1 mb-2 rounded"
           >
-
-            {methodButtonPosition === 'left'
-              && (
-                <MethodButton
-                  page={page}
-                  method={method}
-                  disablePagePaths={disablePagePaths}
-                  onClickMethodButton={onClickMethodButton}
-                />
-              )
-            }
+            {methodButtonPosition === 'left' && (
+              <MethodButton
+                page={page}
+                method={method}
+                disablePagePaths={disablePagePaths}
+                onClickMethodButton={onClickMethodButton}
+              />
+            )}
 
             <EditablePagePath
               page={page}
@@ -225,22 +223,20 @@ export const SelectablePageList = (props: SelectablePageListProps): JSX.Element
               methodButtonPosition={methodButtonPosition}
             />
 
-            <span className={`badge bg-body-secondary rounded-pill ${methodButtonPosition === 'left' ? 'me-2' : ''}`}>
-              <span className="text-body-tertiary">
-                {page.descendantCount}
-              </span>
+            <span
+              className={`badge bg-body-secondary rounded-pill ${methodButtonPosition === 'left' ? 'me-2' : ''}`}
+            >
+              <span className="text-body-tertiary">{page.descendantCount}</span>
             </span>
 
-            {methodButtonPosition === 'right'
-              && (
-                <MethodButton
-                  page={page}
-                  method={method}
-                  disablePagePaths={disablePagePaths}
-                  onClickMethodButton={onClickMethodButton}
-                />
-              )
-            }
+            {methodButtonPosition === 'right' && (
+              <MethodButton
+                page={page}
+                method={method}
+                disablePagePaths={disablePagePaths}
+                onClickMethodButton={onClickMethodButton}
+              />
+            )}
           </div>
         );
       })}

+ 27 - 22
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeSwitch.tsx

@@ -1,48 +1,53 @@
-import React from 'react';
-
+import type React from 'react';
 import { useTranslation } from 'react-i18next';
-import {
-  Input, Label, FormGroup,
-} from 'reactstrap';
+import { FormGroup, Input, Label } from 'reactstrap';
 
 import { AiAssistantShareScope } from '../../../../interfaces/ai-assistant';
 
 type Props = {
-  isDisabled: boolean,
-  isDisabledGroups: boolean,
-  selectedShareScope: AiAssistantShareScope,
-  onSelect: (shareScope: AiAssistantShareScope) => void,
-}
+  isDisabled: boolean;
+  isDisabledGroups: boolean;
+  selectedShareScope: AiAssistantShareScope;
+  onSelect: (shareScope: AiAssistantShareScope) => void;
+};
 
 export const ShareScopeSwitch: React.FC<Props> = (props: Props) => {
-  const {
-    isDisabled,
-    isDisabledGroups,
-    selectedShareScope,
-    onSelect,
-  } = props;
+  const { isDisabled, isDisabledGroups, selectedShareScope, onSelect } = props;
 
   const { t } = useTranslation();
 
   return (
     <div className="mb-4">
-      <Label className="text-secondary mb-3">{t('modal_ai_assistant.share_scope.title')}</Label>
+      <Label className="text-secondary mb-3">
+        {t('modal_ai_assistant.share_scope.title')}
+      </Label>
       <div className="d-flex flex-column gap-3">
-
-        {[AiAssistantShareScope.PUBLIC_ONLY, AiAssistantShareScope.GROUPS, AiAssistantShareScope.SAME_AS_ACCESS_SCOPE].map(shareScope => (
+        {[
+          AiAssistantShareScope.PUBLIC_ONLY,
+          AiAssistantShareScope.GROUPS,
+          AiAssistantShareScope.SAME_AS_ACCESS_SCOPE,
+        ].map((shareScope) => (
           <FormGroup check key={shareScope}>
             <Input
               type="radio"
               name="shareScope"
               id="shareGroup"
               className="form-check-input"
-              disabled={isDisabled || (isDisabledGroups && shareScope === AiAssistantShareScope.GROUPS)}
+              disabled={
+                isDisabled ||
+                (isDisabledGroups &&
+                  shareScope === AiAssistantShareScope.GROUPS)
+              }
               onChange={() => onSelect(shareScope)}
               checked={selectedShareScope === shareScope}
             />
             <Label check for="shareGroup" className="d-flex flex-column">
-              <span>{t(`modal_ai_assistant.share_scope.${shareScope}.label`)}</span>
-              <small className="text-secondary">{t(`modal_ai_assistant.share_scope.${shareScope}.desc`)}</small>
+              <span>
+                {t(`modal_ai_assistant.share_scope.${shareScope}.label`)}
+              </span>
+              <small className="text-secondary">
+                {t(`modal_ai_assistant.share_scope.${shareScope}.desc`)}
+              </small>
             </Label>
           </FormGroup>
         ))}

+ 24 - 28
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx

@@ -1,26 +1,18 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import type { SelectablePage } from '../../../../interfaces/selectable-page';
 
 type Props = {
-  isOpen: boolean,
-  selectedPages: SelectablePage[],
-  closeModal: () => void,
-  onSubmit: () => Promise<void>,
-}
+  isOpen: boolean;
+  selectedPages: SelectablePage[];
+  closeModal: () => void;
+  onSubmit: () => Promise<void>;
+};
 
 export const ShareScopeWarningModal = (props: Props): JSX.Element => {
-  const {
-    isOpen,
-    selectedPages,
-    closeModal,
-    onSubmit,
-  } = props;
+  const { isOpen, selectedPages, closeModal, onSubmit } = props;
 
   const { t } = useTranslation();
 
@@ -33,30 +25,34 @@ export const ShareScopeWarningModal = (props: Props): JSX.Element => {
     <Modal size="lg" isOpen={isOpen} toggle={closeModal}>
       <ModalHeader toggle={closeModal}>
         <div className="d-flex align-items-center">
-          <span className="material-symbols-outlined text-warning me-2 fs-4">warning</span>
-          <span className="text-warning fw-bold">{t('share_scope_warning_modal.header_title')}</span>
+          <span className="material-symbols-outlined text-warning me-2 fs-4">
+            warning
+          </span>
+          <span className="text-warning fw-bold">
+            {t('share_scope_warning_modal.header_title')}
+          </span>
         </div>
       </ModalHeader>
 
       <ModalBody className="py-4 px-4">
         <p
           className="mb-4"
-          // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: t('share_scope_warning_modal.warning_message') }}
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+          dangerouslySetInnerHTML={{
+            __html: t('share_scope_warning_modal.warning_message'),
+          }}
         />
 
         <div className="mb-4">
-          <p className="mb-2 text-secondary">{t('share_scope_warning_modal.selected_pages_label')}</p>
-          {selectedPages.map(selectedPage => (
-            <code key={selectedPage.path}>
-              {selectedPage.path}
-            </code>
+          <p className="mb-2 text-secondary">
+            {t('share_scope_warning_modal.selected_pages_label')}
+          </p>
+          {selectedPages.map((selectedPage) => (
+            <code key={selectedPage.path}>{selectedPage.path}</code>
           ))}
         </div>
 
-        <p>
-          {t('share_scope_warning_modal.confirmation_message')}
-        </p>
+        <p>{t('share_scope_warning_modal.confirmation_message')}</p>
       </ModalBody>
 
       <ModalFooter>

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

@@ -2,23 +2,23 @@ import Link from 'next/link';
 import { useTranslation } from 'react-i18next';
 
 import { removeGlobPath } from '../../../../utils/remove-glob-path';
-
 import { ThreadList } from './ThreadList';
 
 type Props = {
-  description: string,
-  pagePathPatterns: string[],
-}
+  description: string;
+  pagePathPatterns: string[];
+};
 
-export const AiAssistantChatInitialView: React.FC<Props> = ({ description, pagePathPatterns }: Props): JSX.Element => {
+export const AiAssistantChatInitialView: React.FC<Props> = ({
+  description,
+  pagePathPatterns,
+}: Props): JSX.Element => {
   const { t } = useTranslation();
 
   return (
     <>
       {description.length !== 0 && (
-        <p className="text-body-secondary mb-0">
-          {description}
-        </p>
+        <p className="text-body-secondary mb-0">{description}</p>
       )}
 
       <div>
@@ -26,7 +26,7 @@ export const AiAssistantChatInitialView: React.FC<Props> = ({ description, pageP
           {t('sidebar_ai_assistant.reference_pages_label')}
         </p>
         <div className="d-flex flex-column gap-1">
-          { pagePathPatterns.map(pagePathPattern => (
+          {pagePathPatterns.map((pagePathPattern) => (
             <Link
               key={pagePathPattern}
               href={removeGlobPath([pagePathPattern])[0]}

+ 32 - 18
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown.tsx

@@ -1,12 +1,10 @@
-
-import React, { useMemo, useCallback } from 'react';
-
+import React, { useCallback, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
-  UncontrolledDropdown,
-  DropdownToggle,
-  DropdownMenu,
   DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  UncontrolledDropdown,
 } from 'reactstrap';
 
 import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
@@ -15,10 +13,13 @@ import { getShareScopeIcon } from '../../../utils/get-share-scope-Icon';
 
 type Props = {
   selectedAiAssistant?: AiAssistantHasId;
-  onSelect(aiAssistant?: AiAssistantHasId): void
-}
+  onSelect(aiAssistant?: AiAssistantHasId): void;
+};
 
-export const AiAssistantDropdown = ({ selectedAiAssistant, onSelect }: Props): JSX.Element => {
+export const AiAssistantDropdown = ({
+  selectedAiAssistant,
+  onSelect,
+}: Props): JSX.Element => {
   const { t } = useTranslation();
   const { data: aiAssistantData } = useSWRxAiAssistants();
 
@@ -26,7 +27,10 @@ export const AiAssistantDropdown = ({ selectedAiAssistant, onSelect }: Props): J
     if (aiAssistantData == null) {
       return [];
     }
-    return [...aiAssistantData.myAiAssistants, ...aiAssistantData.teamAiAssistants];
+    return [
+      ...aiAssistantData.myAiAssistants,
+      ...aiAssistantData.teamAiAssistants,
+    ];
   }, [aiAssistantData]);
 
   const getAiAssistantLabel = useCallback((aiAssistant: AiAssistantHasId) => {
@@ -40,17 +44,27 @@ export const AiAssistantDropdown = ({ selectedAiAssistant, onSelect }: Props): J
     );
   }, []);
 
-  const selectAiAssistantHandler = useCallback((aiAssistant?: AiAssistantHasId) => {
-    onSelect(aiAssistant);
-  }, [onSelect]);
+  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
+        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) => {

+ 419 - 274
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx

@@ -1,40 +1,51 @@
-import type { KeyboardEvent, JSX } from 'react';
+import type { JSX, KeyboardEvent } from 'react';
 import {
-  type FC, memo, useEffect, useState, useCallback, useMemo,
+  type FC,
+  memo,
+  useCallback,
+  useEffect,
+  useMemo,
+  useState,
 } from 'react';
-
 import { Controller } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import { Collapse } from 'reactstrap';
 import SimpleBar from 'simplebar-react';
 
 import { toastError } from '~/client/util/toastr';
-import { useGrowiCloudUri, useIsEnableUnifiedMergeView } from '~/stores-universal/context';
+import {
+  useGrowiCloudUri,
+  useIsEnableUnifiedMergeView,
+} from '~/stores-universal/context';
 import loggerFactory from '~/utils/logger';
 
 import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
 import type { MessageLog } from '../../../../interfaces/message';
-import { MessageErrorCode, StreamErrorCode } from '../../../../interfaces/message-error';
+import {
+  MessageErrorCode,
+  StreamErrorCode,
+} from '../../../../interfaces/message-error';
 import type { IThreadRelationHasId } from '../../../../interfaces/thread-relation';
 import {
-  useEditorAssistant,
-  isEditorAssistantFormData,
   type FormData as FormDataForEditorAssistant,
+  isEditorAssistantFormData,
+  useEditorAssistant,
 } from '../../../services/editor-assistant';
 import {
-  useKnowledgeAssistant,
-  useFetchAndSetMessageDataEffect,
   type FormData as FormDataForKnowledgeAssistant,
+  useFetchAndSetMessageDataEffect,
+  useKnowledgeAssistant,
 } from '../../../services/knowledge-assistant';
 import { useAiAssistantSidebar } from '../../../stores/ai-assistant';
 import { useSWRxThreads } from '../../../stores/thread';
-
 import { MessageCard } from './MessageCard/MessageCard';
 import { ResizableTextarea } from './ResizableTextArea';
 
 import styles from './AiAssistantSidebar.module.scss';
 
-const logger = loggerFactory('growi:openai:client:components:AiAssistantSidebar');
+const logger = loggerFactory(
+  'growi:openai:client:components:AiAssistantSidebar',
+);
 
 const moduleClass = styles['grw-ai-assistant-sidebar'] ?? '';
 
@@ -47,9 +58,11 @@ type AiAssistantSidebarSubstanceProps = {
   onCloseButtonClicked?: () => void;
   onNewThreadCreated?: (thread: IThreadRelationHasId) => void;
   onMessageReceived?: () => void;
-}
+};
 
-const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> = (props: AiAssistantSidebarSubstanceProps) => {
+const AiAssistantSidebarSubstance: React.FC<
+  AiAssistantSidebarSubstanceProps
+> = (props: AiAssistantSidebarSubstanceProps) => {
   const {
     isEditorAssistant,
     aiAssistantData,
@@ -61,9 +74,11 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
 
   // States
   const [messageLogs, setMessageLogs] = useState<MessageLog[]>([]);
-  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<MessageLog>();
+  const [generatingAnswerMessage, setGeneratingAnswerMessage] =
+    useState<MessageLog>();
   const [errorMessage, setErrorMessage] = useState<string | undefined>();
-  const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
+  const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] =
+    useState<boolean>(false);
 
   // Hooks
   const { t } = useTranslation();
@@ -78,7 +93,8 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
 
     // Views
     initialView: initialViewForKnowledgeAssistant,
-    generateModeSwitchesDropdown: generateModeSwitchesDropdownForKnowledgeAssistant,
+    generateModeSwitchesDropdown:
+      generateModeSwitchesDropdownForKnowledgeAssistant,
     headerIcon: headerIconForKnowledgeAssistant,
     headerText: headerTextForKnowledgeAssistant,
     placeHolder: placeHolderForKnowledgeAssistant,
@@ -103,7 +119,9 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     placeHolder: placeHolderForEditorAssistant,
   } = useEditorAssistant();
 
-  const form = isEditorAssistant ? formForEditorAssistant : formForKnowledgeAssistant;
+  const form = isEditorAssistant
+    ? formForEditorAssistant
+    : formForKnowledgeAssistant;
 
   // Effects
   useFetchAndSetMessageDataEffect(setMessageLogs, threadData?.threadId);
@@ -115,239 +133,304 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     }
 
     resetFormForKnowledgeAssistant();
-  }, [isEditorAssistant, resetFormEditorAssistant, resetFormForKnowledgeAssistant]);
+  }, [
+    isEditorAssistant,
+    resetFormEditorAssistant,
+    resetFormForKnowledgeAssistant,
+  ]);
+
+  const createThread = useCallback(
+    async (initialUserMessage: string) => {
+      if (isEditorAssistant) {
+        const thread = await createThreadForEditorAssistant();
+        return thread;
+      }
 
-  const createThread = useCallback(async(initialUserMessage: string) => {
-    if (isEditorAssistant) {
-      const thread = await createThreadForEditorAssistant();
+      if (aiAssistantData == null) {
+        return;
+      }
+      const thread = await createThreadForKnowledgeAssistant(
+        aiAssistantData._id,
+        initialUserMessage,
+      );
       return thread;
-    }
-
-    if (aiAssistantData == null) {
-      return;
-    }
-    const thread = await createThreadForKnowledgeAssistant(aiAssistantData._id, initialUserMessage);
-    return thread;
-  }, [aiAssistantData, createThreadForEditorAssistant, createThreadForKnowledgeAssistant, isEditorAssistant]);
+    },
+    [
+      aiAssistantData,
+      createThreadForEditorAssistant,
+      createThreadForKnowledgeAssistant,
+      isEditorAssistant,
+    ],
+  );
 
-  const postMessage = useCallback(async(threadId: string, formData: FormData) => {
-    if (threadId == null) {
-      throw new Error('threadId is not set');
-    }
+  const postMessage = useCallback(
+    async (threadId: string, formData: FormData) => {
+      if (threadId == null) {
+        throw new Error('threadId is not set');
+      }
 
-    if (isEditorAssistant) {
-      if (isEditorAssistantFormData(formData)) {
-        const response = await postMessageForEditorAssistant({
+      if (isEditorAssistant) {
+        if (isEditorAssistantFormData(formData)) {
+          const response = await postMessageForEditorAssistant({
+            threadId,
+            formData,
+          });
+          return response;
+        }
+        return;
+      }
+      if (aiAssistantData?._id != null) {
+        const response = await postMessageForKnowledgeAssistant({
+          aiAssistantId: aiAssistantData._id,
           threadId,
           formData,
         });
         return response;
       }
-      return;
-    }
-    if (aiAssistantData?._id != null) {
-      const response = await postMessageForKnowledgeAssistant({
-        aiAssistantId: aiAssistantData._id,
-        threadId,
-        formData,
-      });
-      return response;
-    }
-  }, [aiAssistantData?._id, isEditorAssistant, postMessageForEditorAssistant, postMessageForKnowledgeAssistant]);
+    },
+    [
+      aiAssistantData?._id,
+      isEditorAssistant,
+      postMessageForEditorAssistant,
+      postMessageForKnowledgeAssistant,
+    ],
+  );
 
   const isGenerating = generatingAnswerMessage != null;
-  const submitSubstance = useCallback(async(data: FormData) => {
-    // do nothing when the assistant is generating an answer
-    if (isGenerating) {
-      return;
-    }
-
-    // do nothing when the input is empty
-    if (data.input.trim().length === 0) {
-      return;
-    }
-
-    const { length: logLength } = messageLogs;
-
-    // add user message to the logs
-    const newUserMessage = { id: logLength.toString(), content: data.input, isUserMessage: true };
-    setMessageLogs(msgs => [...msgs, newUserMessage]);
+  const submitSubstance = useCallback(
+    async (data: FormData) => {
+      // do nothing when the assistant is generating an answer
+      if (isGenerating) {
+        return;
+      }
 
-    resetForm();
+      // do nothing when the input is empty
+      if (data.input.trim().length === 0) {
+        return;
+      }
 
-    setErrorMessage(undefined);
+      const { length: logLength } = messageLogs;
 
-    // add an empty assistant message
-    const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
-    setGeneratingAnswerMessage(newAnswerMessage);
+      // add user message to the logs
+      const newUserMessage = {
+        id: logLength.toString(),
+        content: data.input,
+        isUserMessage: true,
+      };
+      setMessageLogs((msgs) => [...msgs, newUserMessage]);
 
-    // create thread
-    let threadId = threadData?.threadId;
-    if (threadId == null) {
-      try {
-        const newThread = await createThread(newUserMessage.content);
-        if (newThread == null) {
-          return;
-        }
+      resetForm();
 
-        threadId = newThread.threadId;
+      setErrorMessage(undefined);
 
-        onNewThreadCreated?.(newThread);
-      }
-      catch (err) {
-        logger.error(err.toString());
-        toastError(t('sidebar_ai_assistant.failed_to_create_or_retrieve_thread'));
-      }
-    }
+      // add an empty assistant message
+      const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
+      setGeneratingAnswerMessage(newAnswerMessage);
 
-    // post message
-    try {
+      // create thread
+      let threadId = threadData?.threadId;
       if (threadId == null) {
-        return;
-      }
-
-      const response = await postMessage(threadId, data);
-      if (response == null) {
-        return;
-      }
+        try {
+          const newThread = await createThread(newUserMessage.content);
+          if (newThread == null) {
+            return;
+          }
 
-      if (!response.ok) {
-        const resJson = await response.json();
-        if ('errors' in resJson) {
-          // eslint-disable-next-line @typescript-eslint/no-unused-vars
-          const errors = resJson.errors.map(({ message }) => message).join(', ');
-          form.setError('input', { type: 'manual', message: `[${response.status}] ${errors}` });
+          threadId = newThread.threadId;
 
-          const hasThreadIdNotSetError = resJson.errors.some(err => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET);
-          if (hasThreadIdNotSetError) {
-            toastError(t('sidebar_ai_assistant.failed_to_create_or_retrieve_thread'));
-          }
+          onNewThreadCreated?.(newThread);
+        } catch (err) {
+          logger.error(err.toString());
+          toastError(
+            t('sidebar_ai_assistant.failed_to_create_or_retrieve_thread'),
+          );
         }
-        setGeneratingAnswerMessage(undefined);
-        return;
       }
 
-      const reader = response.body?.getReader();
-      const decoder = new TextDecoder('utf-8');
-
-      const read = async() => {
-        if (reader == null) return;
+      // post message
+      try {
+        if (threadId == null) {
+          return;
+        }
 
-        const { done, value } = await reader.read();
+        const response = await postMessage(threadId, data);
+        if (response == null) {
+          return;
+        }
 
-        // add assistant message to the logs
-        if (done) {
-          setGeneratingAnswerMessage((generatingAnswerMessage) => {
-            if (generatingAnswerMessage == null) return;
-            setMessageLogs(msgs => [...msgs, generatingAnswerMessage]);
-            return undefined;
-          });
+        if (!response.ok) {
+          const resJson = await response.json();
+          if ('errors' in resJson) {
+            // eslint-disable-next-line @typescript-eslint/no-unused-vars
+            const errors = resJson.errors
+              .map(({ message }) => message)
+              .join(', ');
+            form.setError('input', {
+              type: 'manual',
+              message: `[${response.status}] ${errors}`,
+            });
 
-          // refresh thread data
-          onMessageReceived?.();
+            const hasThreadIdNotSetError = resJson.errors.some(
+              (err) => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET,
+            );
+            if (hasThreadIdNotSetError) {
+              toastError(
+                t('sidebar_ai_assistant.failed_to_create_or_retrieve_thread'),
+              );
+            }
+          }
+          setGeneratingAnswerMessage(undefined);
           return;
         }
 
-        const chunk = decoder.decode(value);
-
-        let isPreMessageGenerated = false;
-        let isMainMessageGenerationStarted = false;
-        const preMessages: string[] = [];
-        const mainMessages: string[] = [];
-        const lines = chunk.split('\n\n');
-        lines.forEach((line) => {
-          const trimmedLine = line.trim();
-          if (trimmedLine.startsWith('data:')) {
-            const data = JSON.parse(line.replace('data: ', ''));
-
-            processMessageForKnowledgeAssistant(data, {
-              onPreMessage: (data) => {
-                // When main message is sent while pre-message is being transmitted
-                if (isMainMessageGenerationStarted) {
-                  preMessages.length = 0;
-                  return;
-                }
-                if (data.finished) {
-                  isPreMessageGenerated = true;
-                  return;
-                }
-                if (data.text == null) {
-                  return;
-                }
-                preMessages.push(data.text);
-              },
-              onMessage: (data) => {
-                if (!isMainMessageGenerationStarted) {
-                  isMainMessageGenerationStarted = true;
-                }
+        const reader = response.body?.getReader();
+        const decoder = new TextDecoder('utf-8');
 
-                // When main message is sent while pre-message is being transmitted
-                if (!isPreMessageGenerated) {
-                  preMessages.length = 0;
-                }
-                mainMessages.push(data.content[0].text.value);
-              },
-            });
+        const read = async () => {
+          if (reader == null) return;
+
+          const { done, value } = await reader.read();
 
-            processMessageForEditorAssistant(data, {
-              onMessage: (data) => {
-                mainMessages.push(data.appendedMessage);
-              },
-              onDetectedDiff: (data) => {
-                logger.debug('sse diff', { data });
-              },
-              onFinalized: (data) => {
-                logger.debug('sse finalized', { data });
-              },
+          // add assistant message to the logs
+          if (done) {
+            setGeneratingAnswerMessage((generatingAnswerMessage) => {
+              if (generatingAnswerMessage == null) return;
+              setMessageLogs((msgs) => [...msgs, generatingAnswerMessage]);
+              return undefined;
             });
+
+            // refresh thread data
+            onMessageReceived?.();
+            return;
           }
-          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_ai_assistant.budget_exceeded_for_growi_cloud' : 'sidebar_ai_assistant.budget_exceeded');
+          const chunk = decoder.decode(value);
+
+          let isPreMessageGenerated = false;
+          let isMainMessageGenerationStarted = false;
+          const preMessages: string[] = [];
+          const mainMessages: string[] = [];
+          const lines = chunk.split('\n\n');
+          lines.forEach((line) => {
+            const trimmedLine = line.trim();
+            if (trimmedLine.startsWith('data:')) {
+              const data = JSON.parse(line.replace('data: ', ''));
+
+              processMessageForKnowledgeAssistant(data, {
+                onPreMessage: (data) => {
+                  // When main message is sent while pre-message is being transmitted
+                  if (isMainMessageGenerationStarted) {
+                    preMessages.length = 0;
+                    return;
+                  }
+                  if (data.finished) {
+                    isPreMessageGenerated = true;
+                    return;
+                  }
+                  if (data.text == null) {
+                    return;
+                  }
+                  preMessages.push(data.text);
+                },
+                onMessage: (data) => {
+                  if (!isMainMessageGenerationStarted) {
+                    isMainMessageGenerationStarted = true;
+                  }
+
+                  // When main message is sent while pre-message is being transmitted
+                  if (!isPreMessageGenerated) {
+                    preMessages.length = 0;
+                  }
+                  mainMessages.push(data.content[0].text.value);
+                },
+              });
+
+              processMessageForEditorAssistant(data, {
+                onMessage: (data) => {
+                  mainMessages.push(data.appendedMessage);
+                },
+                onDetectedDiff: (data) => {
+                  logger.debug('sse diff', { data });
+                },
+                onFinalized: (data) => {
+                  logger.debug('sse finalized', { data });
+                },
+              });
+            } 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_ai_assistant.budget_exceeded_for_growi_cloud'
+                    : 'sidebar_ai_assistant.budget_exceeded',
+                );
+              }
             }
-          }
-        });
+          });
 
-        // append text values to the assistant message
-        setGeneratingAnswerMessage((prevMessage) => {
-          if (prevMessage == null) return;
-          return {
-            ...prevMessage,
-            content: prevMessage.content + preMessages.join('') + mainMessages.join(''),
-          };
-        });
+          // append text values to the assistant message
+          setGeneratingAnswerMessage((prevMessage) => {
+            if (prevMessage == null) return;
+            return {
+              ...prevMessage,
+              content:
+                prevMessage.content +
+                preMessages.join('') +
+                mainMessages.join(''),
+            };
+          });
 
+          read();
+        };
         read();
-      };
-      read();
-    }
-    catch (err) {
-      logger.error(err.toString());
-      form.setError('input', { type: 'manual', message: err.toString() });
-    }
+      } catch (err) {
+        logger.error(err.toString());
+        form.setError('input', { type: 'manual', message: err.toString() });
+      }
 
-  // eslint-disable-next-line max-len
-  }, [isGenerating, messageLogs, resetForm, threadData?.threadId, createThread, onNewThreadCreated, t, postMessage, form, onMessageReceived, processMessageForKnowledgeAssistant, processMessageForEditorAssistant, growiCloudUri]);
+      // eslint-disable-next-line max-len
+    },
+    [
+      isGenerating,
+      messageLogs,
+      resetForm,
+      threadData?.threadId,
+      createThread,
+      onNewThreadCreated,
+      t,
+      postMessage,
+      form,
+      onMessageReceived,
+      processMessageForKnowledgeAssistant,
+      processMessageForEditorAssistant,
+      growiCloudUri,
+    ],
+  );
 
-  const submit = useCallback((data: FormData) => {
-    if (isEditorAssistant) {
-      const markdownType = (() => {
-        if (isEditorAssistantFormData(data) && data.markdownType != null) {
-          return data.markdownType;
-        }
+  const submit = useCallback(
+    (data: FormData) => {
+      if (isEditorAssistant) {
+        const markdownType = (() => {
+          if (isEditorAssistantFormData(data) && data.markdownType != null) {
+            return data.markdownType;
+          }
 
-        return isTextSelected ? 'selected' : 'none';
-      })();
+          return isTextSelected ? 'selected' : 'none';
+        })();
 
-      return submitSubstance({ ...data, markdownType });
-    }
+        return submitSubstance({ ...data, markdownType });
+      }
 
-    return submitSubstance(data);
-  }, [isEditorAssistant, isTextSelected, submitSubstance]);
+      return submitSubstance(data);
+    },
+    [isEditorAssistant, isTextSelected, submitSubstance],
+  );
 
   const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
     // Do nothing while composing
@@ -366,22 +449,38 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     return isEditorAssistant
       ? headerIconForEditorAssistant
       : headerIconForKnowledgeAssistant;
-  }, [headerIconForEditorAssistant, headerIconForKnowledgeAssistant, isEditorAssistant]);
+  }, [
+    headerIconForEditorAssistant,
+    headerIconForKnowledgeAssistant,
+    isEditorAssistant,
+  ]);
 
   const headerText = useMemo(() => {
     return isEditorAssistant
       ? headerTextForEditorAssistant
       : headerTextForKnowledgeAssistant;
-  }, [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]);
+    return t(
+      isEditorAssistant
+        ? placeHolderForEditorAssistant
+        : placeHolderForKnowledgeAssistant,
+    );
+  }, [
+    form.formState.isSubmitting,
+    isEditorAssistant,
+    placeHolderForEditorAssistant,
+    placeHolderForKnowledgeAssistant,
+    t,
+  ]);
 
   const initialView = useMemo(() => {
     if (isEditorAssistant) {
@@ -389,7 +488,12 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     }
 
     return initialViewForKnowledgeAssistant;
-  }, [generateInitialViewForEditorAssistant, initialViewForKnowledgeAssistant, isEditorAssistant, submit]);
+  }, [
+    generateInitialViewForEditorAssistant,
+    initialViewForKnowledgeAssistant,
+    isEditorAssistant,
+    submit,
+  ]);
 
   const messageCardAdditionalItemForGeneratingMessage = useMemo(() => {
     if (isEditorAssistant) {
@@ -399,17 +503,28 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     return <></>;
   }, [generatingEditorTextLabel, isEditorAssistant]);
 
-
-  const messageCardAdditionalItemForGeneratedMessage = useCallback((messageId?: string) => {
-    if (isEditorAssistant) {
-      if (messageId == null || messageLogs == null) {
-        return <></>;
+  const messageCardAdditionalItemForGeneratedMessage = useCallback(
+    (messageId?: string) => {
+      if (isEditorAssistant) {
+        if (messageId == null || messageLogs == null) {
+          return <></>;
+        }
+        return generateActionButtons(
+          messageId,
+          messageLogs,
+          generatingAnswerMessage,
+        );
       }
-      return generateActionButtons(messageId, messageLogs, generatingAnswerMessage);
-    }
 
-    return undefined;
-  }, [generateActionButtons, generatingAnswerMessage, isEditorAssistant, messageLogs]);
+      return undefined;
+    },
+    [
+      generateActionButtons,
+      generatingAnswerMessage,
+      isEditorAssistant,
+      messageLogs,
+    ],
+  );
 
   return (
     <>
@@ -429,55 +544,61 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
         </div>
 
         <div className="flex-grow-1 overflow-hidden">
-          <SimpleBar
-            className="h-100"
-            autoHide
-          >
+          <SimpleBar className="h-100" autoHide>
             {!isEditorAssistant && threadTitleViewForKnowledgeAssistant}
             <div className="p-4">
               <div className="d-flex flex-column gap-4 flex-grow-1">
-                { threadData != null
-                  ? (
-                    <div className="vstack gap-4 pb-2">
-                      { messageLogs.map(message => (
-                        <>
-                          <MessageCard
-                            role={message.isUserMessage ? 'user' : 'assistant'}
-                            additionalItem={messageCardAdditionalItemForGeneratedMessage(message.id)}
-                          >
-                            {message.content}
-                          </MessageCard>
-                        </>
-                      )) }
-                      { generatingAnswerMessage != null && (
+                {threadData != null ? (
+                  <div className="vstack gap-4 pb-2">
+                    {messageLogs.map((message) => (
+                      <>
                         <MessageCard
-                          role="assistant"
-                          additionalItem={messageCardAdditionalItemForGeneratingMessage}
+                          sender={message.isUserMessage ? 'user' : 'assistant'}
+                          additionalItem={messageCardAdditionalItemForGeneratedMessage(
+                            message.id,
+                          )}
                         >
-                          {generatingAnswerMessage.content}
+                          {message.content}
                         </MessageCard>
-                      )}
-                      { isEditorAssistant && partialContentWarnLabel }
-                      { 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_ai_assistant.caution_against_hallucination')}
-                          </span>
-                        </div>
-                      )}
-                    </div>
-                  )
-                  : (
-                    <>{ initialView }</>
-                  )
-                }
+                      </>
+                    ))}
+                    {generatingAnswerMessage != null && (
+                      <MessageCard
+                        sender="assistant"
+                        additionalItem={
+                          messageCardAdditionalItemForGeneratingMessage
+                        }
+                      >
+                        {generatingAnswerMessage.content}
+                      </MessageCard>
+                    )}
+                    {isEditorAssistant && partialContentWarnLabel}
+                    {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_ai_assistant.caution_against_hallucination',
+                          )}
+                        </span>
+                      </div>
+                    )}
+                  </div>
+                ) : (
+                  <>{initialView}</>
+                )}
               </div>
             </div>
           </SimpleBar>
         </div>
 
         <div className="input-form-area position-sticky bg-body z-2 p-3">
-          <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-1">
+          <form
+            onSubmit={form.handleSubmit(submit)}
+            className="flex-fill vstack gap-1"
+          >
             <Controller
               name="input"
               control={form.control}
@@ -495,8 +616,9 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
               )}
             />
             <div className="flex-fill hstack gap-2 justify-content-between m-0">
-              { !isEditorAssistant && generateModeSwitchesDropdownForKnowledgeAssistant(isGenerating) }
-              { isEditorAssistant && <div /> }
+              {!isEditorAssistant &&
+                generateModeSwitchesDropdownForKnowledgeAssistant(isGenerating)}
+              {isEditorAssistant && <div />}
               <button
                 type="submit"
                 className="btn btn-submit no-border"
@@ -510,20 +632,32 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
           {form.formState.errors.input != null && (
             <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_ai_assistant.error_message') }</span>
+                <span className="material-symbols-outlined text-danger me-2">
+                  error
+                </span>
+                <span className="text-danger">
+                  {errorMessage != null
+                    ? t(errorMessage)
+                    : t('sidebar_ai_assistant.error_message')}
+                </span>
               </div>
 
               <button
                 type="button"
                 className="btn btn-link text-body-secondary p-0"
                 aria-expanded={isErrorDetailCollapsed}
-                onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
+                onClick={() =>
+                  setIsErrorDetailCollapsed(!isErrorDetailCollapsed)
+                }
               >
-                <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
+                <span
+                  className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}
+                >
                   chevron_right
                 </span>
-                <span className="small">{t('sidebar_ai_assistant.show_error_detail')}</span>
+                <span className="small">
+                  {t('sidebar_ai_assistant.show_error_detail')}
+                </span>
               </button>
 
               <Collapse isOpen={isErrorDetailCollapsed}>
@@ -543,21 +677,30 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
   );
 };
 
-
 export const AiAssistantSidebar: FC = memo((): JSX.Element => {
-  const { data: aiAssistantSidebarData, close: closeAiAssistantSidebar, refreshThreadData } = useAiAssistantSidebar();
-  const { mutate: mutateIsEnableUnifiedMergeView } = useIsEnableUnifiedMergeView();
+  const {
+    data: aiAssistantSidebarData,
+    close: closeAiAssistantSidebar,
+    refreshThreadData,
+  } = useAiAssistantSidebar();
+  const { mutate: mutateIsEnableUnifiedMergeView } =
+    useIsEnableUnifiedMergeView();
 
   const aiAssistantData = aiAssistantSidebarData?.aiAssistantData;
   const threadData = aiAssistantSidebarData?.threadData;
   const isOpened = aiAssistantSidebarData?.isOpened;
   const isEditorAssistant = aiAssistantSidebarData?.isEditorAssistant ?? false;
 
-  const { data: threads, mutate: mutateThreads } = useSWRxThreads(aiAssistantData?._id);
+  const { data: threads, mutate: mutateThreads } = useSWRxThreads(
+    aiAssistantData?._id,
+  );
 
-  const newThreadCreatedHandler = useCallback((thread: IThreadRelationHasId): void => {
-    refreshThreadData(thread);
-  }, [refreshThreadData]);
+  const newThreadCreatedHandler = useCallback(
+    (thread: IThreadRelationHasId): void => {
+      refreshThreadData(thread);
+    },
+    [refreshThreadData],
+  );
 
   useEffect(() => {
     if (!aiAssistantSidebarData?.isOpened) {
@@ -571,7 +714,9 @@ export const AiAssistantSidebar: FC = memo((): JSX.Element => {
       return;
     }
 
-    const currentThread = threads.find(t => t.threadId === threadData?.threadId);
+    const currentThread = threads.find(
+      (t) => t.threadId === threadData?.threadId,
+    );
     if (currentThread != null) {
       refreshThreadData(currentThread);
     }

+ 41 - 43
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.tsx

@@ -1,5 +1,4 @@
-import { type JSX } from 'react';
-
+import type { JSX } from 'react';
 import { useTranslation } from 'react-i18next';
 import ReactMarkdown from 'react-markdown';
 
@@ -10,41 +9,44 @@ 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 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 AssistantMessageCard = ({
   children,
   additionalItem,
 }: {
-  children: string,
-  additionalItem?: JSX.Element,
+  children: string;
+  additionalItem?: JSX.Element;
 }): JSX.Element => {
   const { t } = useTranslation();
 
   return (
-    <div className={`card border-0 ${moduleClass} ${assistantMessageCardModuleClass}`}>
+    <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>
+          <span className="growi-custom-icons grw-ai-icon rounded-pill">
+            growi_ai
+          </span>
         </div>
         <div>
-          { children.length > 0
-            ? (
-              <>
-                <ReactMarkdown components={{
+          {children.length > 0 ? (
+            <>
+              <ReactMarkdown
+                components={{
                   a: NextLinkWrapper,
                   h1: ({ children }) => <Header level={1}>{children}</Header>,
                   h2: ({ children }) => <Header level={2}>{children}</Header>,
@@ -53,43 +55,39 @@ const AssistantMessageCard = ({
                   h5: ({ children }) => <Header level={5}>{children}</Header>,
                   h6: ({ children }) => <Header level={6}>{children}</Header>,
                 }}
-                >{children}
-                </ReactMarkdown>
-                { additionalItem }
-              </>
-            )
-            : (
-              <span className="text-thinking">
-                {t('sidebar_ai_assistant.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
-              </span>
-            )
-          }
+              >
+                {children}
+              </ReactMarkdown>
+              {additionalItem}
+            </>
+          ) : (
+            <span className="text-thinking">
+              {t('sidebar_ai_assistant.progress_label')}{' '}
+              <span className="material-symbols-outlined">more_horiz</span>
+            </span>
+          )}
         </div>
       </div>
     </div>
   );
 };
 
-
-type MessageCardRole = 'user' | 'assistant';
+type MessageSender = 'user' | 'assistant';
 
 type Props = {
-  role: MessageCardRole,
-  children: string,
-  additionalItem?: JSX.Element,
-}
+  sender: MessageSender;
+  children: string;
+  additionalItem?: JSX.Element;
+};
 
 export const MessageCard = (props: Props): JSX.Element => {
-  const {
-    role, children, additionalItem,
-  } = props;
-
-  return role === 'user'
-    ? <UserMessageCard>{children}</UserMessageCard>
-    : (
-      <AssistantMessageCard
-        additionalItem={additionalItem}
-      >{children}
-      </AssistantMessageCard>
-    );
+  const { sender, children, additionalItem } = props;
+
+  return sender === 'user' ? (
+    <UserMessageCard>{children}</UserMessageCard>
+  ) : (
+    <AssistantMessageCard additionalItem={additionalItem}>
+      {children}
+    </AssistantMessageCard>
+  );
 };

+ 7 - 1
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/Header.tsx

@@ -9,7 +9,13 @@ const fontSizes: Record<Level, string> = {
   6: '0.625rem',
 };
 
-export const Header = ({ children, level }: { children: React.ReactNode, level: Level}): JSX.Element => {
+export const Header = ({
+  children,
+  level,
+}: {
+  children: React.ReactNode;
+  level: Level;
+}): JSX.Element => {
   const Tag = `h${level}` as keyof JSX.IntrinsicElements;
 
   return (

+ 4 - 3
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/NextLinkWrapper.tsx

@@ -1,10 +1,11 @@
-import React from 'react';
-
+import type React from 'react';
 import type { LinkProps } from 'next/link';
 
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 
-export const NextLinkWrapper = (props: LinkProps & {children: React.ReactNode, href: string}): JSX.Element => {
+export const NextLinkWrapper = (
+  props: LinkProps & { children: React.ReactNode; href: string },
+): JSX.Element => {
   return (
     <NextLink href={props.href} className="link-primary">
       {props.children}

+ 16 - 13
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/QuickMenuList.tsx

@@ -1,27 +1,26 @@
 import { useCallback } from 'react';
-
 import { useTranslation } from 'react-i18next';
 
 type Props = {
-  onClick: (presetPrompt: string) => void
-}
+  onClick: (presetPrompt: string) => void;
+};
 
-const presetMenus = [
-  'summarize',
-  'correct',
-];
+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]);
+  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 => (
+        {presetMenus.map((presetMenu) => (
           <button
             type="button"
             key={presetMenu}
@@ -29,8 +28,12 @@ export const QuickMenuList: React.FC<Props> = ({ onClick }: Props) => {
             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>
+              <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>
         ))}

+ 19 - 13
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/ResizableTextArea.tsx

@@ -1,24 +1,30 @@
 import type {
-  ChangeEventHandler, DetailedHTMLProps, TextareaHTMLAttributes, JSX,
+  ChangeEventHandler,
+  DetailedHTMLProps,
+  JSX,
+  TextareaHTMLAttributes,
 } from 'react';
 import { useCallback } from 'react';
 
-type Props = DetailedHTMLProps<TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>;
+type Props = DetailedHTMLProps<
+  TextareaHTMLAttributes<HTMLTextAreaElement>,
+  HTMLTextAreaElement
+>;
 
 export const ResizableTextarea = (props: Props): JSX.Element => {
-
   const { onChange: _onChange, ...rest } = props;
 
-  const onChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback((e) => {
-    _onChange?.(e);
-
-    // auto resize
-    // refs: https://zenn.dev/soma3134/articles/1e2fb0eab75b2d
-    e.target.style.height = 'auto';
-    e.target.style.height = `${e.target.scrollHeight + 4}px`;
-  }, [_onChange]);
+  const onChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
+    (e) => {
+      _onChange?.(e);
 
-  return (
-    <textarea onChange={onChange} {...rest} />
+      // auto resize
+      // refs: https://zenn.dev/soma3134/articles/1e2fb0eab75b2d
+      e.target.style.height = 'auto';
+      e.target.style.height = `${e.target.scrollHeight + 4}px`;
+    },
+    [_onChange],
   );
+
+  return <textarea onChange={onChange} {...rest} />;
 };

+ 34 - 20
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/ThreadList.tsx

@@ -1,5 +1,5 @@
-import React, { useCallback } from 'react';
-
+import type React from 'react';
+import { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import type { IThreadRelationHasId } from '~/features/openai/interfaces/thread-relation';
@@ -11,20 +11,24 @@ import styles from './ThreadList.module.scss';
 
 const moduleClass = styles['thread-list'] ?? '';
 
-
 export const ThreadList: React.FC = () => {
   const { t } = useTranslation();
   const { openChat, data: aiAssistantSidebarData } = useAiAssistantSidebar();
-  const { data: threads } = useSWRxThreads(aiAssistantSidebarData?.aiAssistantData?._id);
+  const { data: threads } = useSWRxThreads(
+    aiAssistantSidebarData?.aiAssistantData?._id,
+  );
 
-  const openChatHandler = useCallback((threadData: IThreadRelationHasId) => {
-    const aiAssistantData = aiAssistantSidebarData?.aiAssistantData;
-    if (aiAssistantData == null) {
-      return;
-    }
+  const openChatHandler = useCallback(
+    (threadData: IThreadRelationHasId) => {
+      const aiAssistantData = aiAssistantSidebarData?.aiAssistantData;
+      if (aiAssistantData == null) {
+        return;
+      }
 
-    openChat(aiAssistantData, threadData);
-  }, [aiAssistantSidebarData?.aiAssistantData, openChat]);
+      openChat(aiAssistantData, threadData);
+    },
+    [aiAssistantSidebarData?.aiAssistantData, openChat],
+  );
 
   if (threads == null || threads.length === 0) {
     return (
@@ -37,18 +41,28 @@ export const ThreadList: React.FC = () => {
   return (
     <>
       <ul className={`list-group ${moduleClass}`}>
-        {threads.map(thread => (
+        {threads.map((thread) => (
           <li
-            onClick={() => { openChatHandler(thread) }}
             key={thread._id}
-            role="button"
-            tabIndex={0}
-            className="d-flex align-items-center list-group-item list-group-item-action border-0 rounded-1 bg-body-tertiary mb-2"
+            className="list-group-item border-0 rounded-1 bg-body-tertiary mb-2"
           >
-            <div className="text-body-secondary">
-              <span className="material-symbols-outlined fs-5 me-2">chat</span>
-              <span className="flex-grow-1">{thread.title}</span>
-            </div>
+            <button
+              type="button"
+              className="btn btn-link d-flex align-items-center list-group-item-action border-0 rounded-1 p-0"
+              onClick={() => {
+                openChatHandler(thread);
+              }}
+              onMouseDown={(e) => {
+                e.preventDefault();
+              }}
+            >
+              <div className="text-body-secondary">
+                <span className="material-symbols-outlined fs-5 me-2">
+                  chat
+                </span>
+                <span className="flex-grow-1">{thread.title}</span>
+              </div>
+            </button>
           </li>
         ))}
       </ul>

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

@@ -1,12 +1,14 @@
-import React, { useCallback, useMemo, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { NotAvailable } from '~/client/components/NotAvailable';
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
 import { useIsAiEnabled } from '~/stores-universal/context';
 
-import { useAiAssistantSidebar, useSWRxAiAssistants } from '../../stores/ai-assistant';
+import {
+  useAiAssistantSidebar,
+  useSWRxAiAssistants,
+} from '../../stores/ai-assistant';
 
 import styles from './OpenDefaultAiAssistantButton.module.scss';
 
@@ -20,8 +22,11 @@ const OpenDefaultAiAssistantButtonSubstance = (): JSX.Element => {
       return null;
     }
 
-    const allAiAssistants = [...aiAssistantData.myAiAssistants, ...aiAssistantData.teamAiAssistants];
-    return allAiAssistants.find(aiAssistant => aiAssistant.isDefault);
+    const allAiAssistants = [
+      ...aiAssistantData.myAiAssistants,
+      ...aiAssistantData.teamAiAssistants,
+    ];
+    return allAiAssistants.find((aiAssistant) => aiAssistant.isDefault);
   }, [aiAssistantData]);
 
   const openDefaultAiAssistantButtonClickHandler = useCallback(() => {
@@ -34,13 +39,18 @@ const OpenDefaultAiAssistantButtonSubstance = (): JSX.Element => {
 
   return (
     <NotAvailableForGuest>
-      <NotAvailable isDisabled={defaultAiAssistant == null} title={t('default_ai_assistant.not_set')}>
+      <NotAvailable
+        isDisabled={defaultAiAssistant == null}
+        title={t('default_ai_assistant.not_set')}
+      >
         <button
           type="button"
           className={`btn btn-search ${styles['btn-open-default-ai-assistant']}`}
           onClick={openDefaultAiAssistantButtonClickHandler}
         >
-          <span className="growi-custom-icons fs-4 align-middle lh-1">ai_assistant</span>
+          <span className="growi-custom-icons fs-4 align-middle lh-1">
+            ai_assistant
+          </span>
         </button>
       </NotAvailable>
     </NotAvailableForGuest>

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

@@ -1,12 +1,14 @@
-import React, { Suspense, type JSX } from 'react';
-
+import React, { type JSX, Suspense } from 'react';
 import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 
 import ItemsTreeContentSkeleton from '~/client/components/ItemsTree/ItemsTreeContentSkeleton';
 import { useIsGuestUser } from '~/stores-universal/context';
 
-const AiAssistantContent = dynamic(() => import('./AiAssistantSubstance').then(mod => mod.AiAssistantContent), { ssr: false });
+const AiAssistantContent = dynamic(
+  () => import('./AiAssistantSubstance').then((mod) => mod.AiAssistantContent),
+  { ssr: false },
+);
 
 export const AiAssistant = (): JSX.Element => {
   const { t } = useTranslation();
@@ -15,23 +17,16 @@ export const AiAssistant = (): JSX.Element => {
   return (
     <div className="px-3">
       <div className="grw-sidebar-content-header py-4 d-flex">
-        <h3 className="fs-6 fw-bold mb-0">
-          {t('Knowledge Assistant')}
-        </h3>
+        <h3 className="fs-6 fw-bold mb-0">{t('Knowledge Assistant')}</h3>
       </div>
 
-      { isGuestUser
-        ? (
-          <h4 className="fs-6">
-            { t('Not available for guest') }
-          </h4>
-        )
-        : (
-          <Suspense fallback={<ItemsTreeContentSkeleton />}>
-            <AiAssistantContent />
-          </Suspense>
-        )
-      }
+      {isGuestUser ? (
+        <h4 className="fs-6">{t('Not available for guest')}</h4>
+      ) : (
+        <Suspense fallback={<ItemsTreeContentSkeleton />}>
+          <AiAssistantContent />
+        </Suspense>
+      )}
     </div>
   );
 };

+ 135 - 90
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantList.tsx

@@ -1,5 +1,5 @@
-import React, { useCallback, useState } from 'react';
-
+import type React from 'react';
+import { useCallback, useState } from 'react';
 import type { IUserHasId } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 import { useTranslation } from 'react-i18next';
@@ -9,19 +9,27 @@ import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useCurrentUser } from '~/stores-universal/context';
 import loggerFactory from '~/utils/logger';
 
-import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+import {
+  type AiAssistantHasId,
+  AiAssistantShareScope,
+} from '../../../../interfaces/ai-assistant';
 import { determineShareScope } from '../../../../utils/determine-share-scope';
-import { deleteAiAssistant, setDefaultAiAssistant } from '../../../services/ai-assistant';
-import { useAiAssistantSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+import {
+  deleteAiAssistant,
+  setDefaultAiAssistant,
+} from '../../../services/ai-assistant';
+import {
+  useAiAssistantManagementModal,
+  useAiAssistantSidebar,
+} from '../../../stores/ai-assistant';
 import { getShareScopeIcon } from '../../../utils/get-share-scope-Icon';
-
 import { DeleteAiAssistantModal } from './DeleteAiAssistantModal';
 
 const logger = loggerFactory('growi:openai:client:components:AiAssistantList');
 
 /*
-*  AiAssistantItem
-*/
+ *  AiAssistantItem
+ */
 type AiAssistantItemProps = {
   currentUser?: IUserHasId | null;
   aiAssistant: AiAssistantHasId;
@@ -39,99 +47,123 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
   onDeleteClick,
   onUpdated,
 }) => {
-
   const { t } = useTranslation();
 
-  const openManagementModalHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
-    onEditClick(aiAssistantData);
-  }, [onEditClick]);
-
-  const openChatHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
-    onItemClick(aiAssistantData);
-  }, [onItemClick]);
+  const openManagementModalHandler = useCallback(
+    (aiAssistantData: AiAssistantHasId) => {
+      onEditClick(aiAssistantData);
+    },
+    [onEditClick],
+  );
 
+  const openChatHandler = useCallback(
+    (aiAssistantData: AiAssistantHasId) => {
+      onItemClick(aiAssistantData);
+    },
+    [onItemClick],
+  );
 
-  const setDefaultAiAssistantHandler = useCallback(async() => {
+  const setDefaultAiAssistantHandler = useCallback(async () => {
     try {
       await setDefaultAiAssistant(aiAssistant._id, !aiAssistant.isDefault);
       onUpdated?.();
-      toastSuccess(t('ai_assistant_substance.toaster.ai_assistant_set_default_success'));
-    }
-    catch (err) {
+      toastSuccess(
+        t('ai_assistant_substance.toaster.ai_assistant_set_default_success'),
+      );
+    } catch (err) {
       logger.error(err);
-      toastError(t('ai_assistant_substance.toaster.ai_assistant_set_default_failed'));
+      toastError(
+        t('ai_assistant_substance.toaster.ai_assistant_set_default_failed'),
+      );
     }
   }, [aiAssistant._id, aiAssistant.isDefault, onUpdated, t]);
 
-  const isOperable = currentUser?._id != null && getIdStringForRef(aiAssistant.owner) === currentUser._id;
-  const isPublicAiAssistantOperable = currentUser?.admin
-    && determineShareScope(aiAssistant.shareScope, aiAssistant.accessScope) === AiAssistantShareScope.PUBLIC_ONLY;
+  const isOperable =
+    currentUser?._id != null &&
+    getIdStringForRef(aiAssistant.owner) === currentUser._id;
+  const isPublicAiAssistantOperable =
+    currentUser?.admin &&
+    determineShareScope(aiAssistant.shareScope, aiAssistant.accessScope) ===
+      AiAssistantShareScope.PUBLIC_ONLY;
 
   return (
     <>
-      <li
-        onClick={(e) => {
-          e.stopPropagation();
-          openChatHandler(aiAssistant);
-        }}
-        role="button"
-        className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1"
-      >
-        <div className="d-flex justify-content-center">
-          <span className="material-symbols-outlined fs-5">{getShareScopeIcon(aiAssistant.shareScope, aiAssistant.accessScope)}</span>
-        </div>
-
-        <div className="grw-item-title ps-1">
-          <p className="text-truncate m-auto">{aiAssistant.name}</p>
-        </div>
-
-        <div className="grw-btn-actions opacity-0 d-flex justify-content-center">
-          {isPublicAiAssistantOperable && (
-            <button
-              type="button"
-              className="btn btn-link text-secondary p-0"
-              onClick={(e) => {
-                e.stopPropagation();
-                setDefaultAiAssistantHandler();
-              }}
-            >
-              <span className={`material-symbols-outlined fs-5 ${aiAssistant.isDefault ? 'fill' : ''}`}>star</span>
-            </button>
-          )}
-          {isOperable && (
-            <>
-              <button
-                type="button"
-                className="btn btn-link text-secondary p-0"
-                onClick={(e) => {
-                  e.stopPropagation();
-                  openManagementModalHandler(aiAssistant);
-                }}
-              >
-                <span className="material-symbols-outlined fs-5">edit</span>
-              </button>
+      <li className="list-group-item border-0 p-0">
+        <button
+          type="button"
+          className="btn btn-link list-group-item-action border-0 d-flex align-items-center rounded-1"
+          onClick={(e) => {
+            e.stopPropagation();
+            openChatHandler(aiAssistant);
+          }}
+          onMouseDown={(e) => {
+            e.preventDefault();
+          }}
+        >
+          <div className="d-flex justify-content-center">
+            <span className="material-symbols-outlined fs-5">
+              {getShareScopeIcon(
+                aiAssistant.shareScope,
+                aiAssistant.accessScope,
+              )}
+            </span>
+          </div>
+
+          <div className="grw-item-title ps-1">
+            <p className="text-truncate m-auto">{aiAssistant.name}</p>
+          </div>
+
+          <div className="grw-btn-actions opacity-0 d-flex justify-content-center">
+            {isPublicAiAssistantOperable && (
               <button
                 type="button"
                 className="btn btn-link text-secondary p-0"
                 onClick={(e) => {
                   e.stopPropagation();
-                  onDeleteClick(aiAssistant);
+                  setDefaultAiAssistantHandler();
                 }}
               >
-                <span className="material-symbols-outlined fs-5">delete</span>
+                <span
+                  className={`material-symbols-outlined fs-5 ${aiAssistant.isDefault ? 'fill' : ''}`}
+                >
+                  star
+                </span>
               </button>
-            </>
-          )}
-        </div>
+            )}
+            {isOperable && (
+              <>
+                <button
+                  type="button"
+                  className="btn btn-link text-secondary p-0"
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    openManagementModalHandler(aiAssistant);
+                  }}
+                >
+                  <span className="material-symbols-outlined fs-5">edit</span>
+                </button>
+                <button
+                  type="button"
+                  className="btn btn-link text-secondary p-0"
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    onDeleteClick(aiAssistant);
+                  }}
+                >
+                  <span className="material-symbols-outlined fs-5">delete</span>
+                </button>
+              </>
+            )}
+          </div>
+        </button>
       </li>
     </>
   );
 };
 
-
 /*
-*  AiAssistantList
-*/
+ *  AiAssistantList
+ */
 type AiAssistantListProps = {
   isTeamAssistant?: boolean;
   aiAssistants: AiAssistantHasId[];
@@ -141,16 +173,22 @@ type AiAssistantListProps = {
 };
 
 export const AiAssistantList: React.FC<AiAssistantListProps> = ({
-  isTeamAssistant, aiAssistants, onUpdated, onDeleted, onCollapsed,
+  isTeamAssistant,
+  aiAssistants,
+  onUpdated,
+  onDeleted,
+  onCollapsed,
 }) => {
   const { t } = useTranslation();
   const { openChat } = useAiAssistantSidebar();
   const { data: currentUser } = useCurrentUser();
-  const { open: openAiAssistantManagementModal } = useAiAssistantManagementModal();
+  const { open: openAiAssistantManagementModal } =
+    useAiAssistantManagementModal();
 
   const [isCollapsed, setIsCollapsed] = useState(false);
 
-  const [aiAssistantToBeDeleted, setAiAssistantToBeDeleted] = useState<AiAssistantHasId | null>(null);
+  const [aiAssistantToBeDeleted, setAiAssistantToBeDeleted] =
+    useState<AiAssistantHasId | null>(null);
   const [isDeleteModalShown, setIsDeleteModalShown] = useState(false);
   const [errorMessageOnDelete, setErrorMessageOnDelete] = useState('');
 
@@ -174,24 +212,30 @@ export const AiAssistantList: React.FC<AiAssistantListProps> = ({
     setErrorMessageOnDelete('');
   }, []);
 
-  const onDeleteAiAssistantAfterOperation = useCallback((aiAssistantId: string) => {
-    onCancelDeleteAiAssistant();
-    onDeleted?.(aiAssistantId);
-  }, [onCancelDeleteAiAssistant, onDeleted]);
+  const onDeleteAiAssistantAfterOperation = useCallback(
+    (aiAssistantId: string) => {
+      onCancelDeleteAiAssistant();
+      onDeleted?.(aiAssistantId);
+    },
+    [onCancelDeleteAiAssistant, onDeleted],
+  );
 
-  const onDeleteAiAssistant = useCallback(async() => {
+  const onDeleteAiAssistant = useCallback(async () => {
     if (aiAssistantToBeDeleted == null) return;
 
     try {
       await deleteAiAssistant(aiAssistantToBeDeleted._id);
       onDeleteAiAssistantAfterOperation(aiAssistantToBeDeleted._id);
-      toastSuccess(t('ai_assistant_substance.toaster.ai_assistant_deleted_success'));
-    }
-    catch (err) {
+      toastSuccess(
+        t('ai_assistant_substance.toaster.ai_assistant_deleted_success'),
+      );
+    } catch (err) {
       const message = err instanceof Error ? err.message : String(err);
       setErrorMessageOnDelete(message);
       logger.error(err);
-      toastError(t('ai_assistant_substance.toaster.ai_assistant_deleted_failed'));
+      toastError(
+        t('ai_assistant_substance.toaster.ai_assistant_deleted_failed'),
+      );
     }
   }, [aiAssistantToBeDeleted, onDeleteAiAssistantAfterOperation, t]);
 
@@ -205,17 +249,18 @@ export const AiAssistantList: React.FC<AiAssistantListProps> = ({
         disabled={aiAssistants.length === 0}
       >
         <h3 className="grw-ai-assistant-substance-header fw-bold mb-0 me-1">
-          {t(`ai_assistant_substance.${isTeamAssistant ? 'team' : 'my'}_assistants`)}
+          {t(
+            `ai_assistant_substance.${isTeamAssistant ? 'team' : 'my'}_assistants`,
+          )}
         </h3>
-        <span
-          className="material-symbols-outlined"
-        >{`keyboard_arrow_${isCollapsed ? 'down' : 'right'}`}
+        <span className="material-symbols-outlined">
+          {`keyboard_arrow_${isCollapsed ? 'down' : 'right'}`}
         </span>
       </button>
 
       <Collapse isOpen={isCollapsed}>
         <ul className="list-group">
-          {aiAssistants.map(assistant => (
+          {aiAssistants.map((assistant) => (
             <AiAssistantItem
               key={assistant._id}
               currentUser={currentUser}

+ 32 - 14
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx

@@ -1,10 +1,12 @@
 import React, { type JSX, useCallback } from 'react';
-
 import { useTranslation } from 'react-i18next';
 
-import { useAiAssistantManagementModal, useSWRxAiAssistants, useAiAssistantSidebar } from '../../../stores/ai-assistant';
+import {
+  useAiAssistantManagementModal,
+  useAiAssistantSidebar,
+  useSWRxAiAssistants,
+} from '../../../stores/ai-assistant';
 import { useSWRINFxRecentThreads } from '../../../stores/thread';
-
 import { AiAssistantList } from './AiAssistantList';
 import { ThreadList } from './ThreadList';
 
@@ -15,19 +17,33 @@ const moduleClass = styles['grw-ai-assistant-substance'] ?? '';
 export const AiAssistantContent = (): JSX.Element => {
   const { t } = useTranslation();
   const { open } = useAiAssistantManagementModal();
-  const { data: aiAssistantSidebarData, close: closeAiAssistantSidebar } = useAiAssistantSidebar();
+  const { data: aiAssistantSidebarData, close: closeAiAssistantSidebar } =
+    useAiAssistantSidebar();
   const { mutate: mutateRecentThreads } = useSWRINFxRecentThreads();
-  const { data: aiAssistants, mutate: mutateAiAssistants } = useSWRxAiAssistants();
+  const { data: aiAssistants, mutate: mutateAiAssistants } =
+    useSWRxAiAssistants();
 
-  const deleteAiAssistantHandler = useCallback(async(aiAssistantId: string) => {
-    await mutateAiAssistants();
-    await mutateRecentThreads();
+  const deleteAiAssistantHandler = useCallback(
+    async (aiAssistantId: string) => {
+      await mutateAiAssistants();
+      await mutateRecentThreads();
 
-    // If the sidebar is opened for the assistant being deleted, close it
-    if (aiAssistantSidebarData?.isOpened && aiAssistantSidebarData?.aiAssistantData?._id === aiAssistantId) {
-      closeAiAssistantSidebar();
-    }
-  }, [aiAssistantSidebarData?.aiAssistantData?._id, aiAssistantSidebarData?.isOpened, closeAiAssistantSidebar, mutateAiAssistants, mutateRecentThreads]);
+      // If the sidebar is opened for the assistant being deleted, close it
+      if (
+        aiAssistantSidebarData?.isOpened &&
+        aiAssistantSidebarData?.aiAssistantData?._id === aiAssistantId
+      ) {
+        closeAiAssistantSidebar();
+      }
+    },
+    [
+      aiAssistantSidebarData?.aiAssistantData?._id,
+      aiAssistantSidebarData?.isOpened,
+      closeAiAssistantSidebar,
+      mutateAiAssistants,
+      mutateRecentThreads,
+    ],
+  );
 
   return (
     <div className={moduleClass}>
@@ -37,7 +53,9 @@ export const AiAssistantContent = (): JSX.Element => {
         onClick={() => open()}
       >
         <span className="material-symbols-outlined fs-5 me-2">add</span>
-        <span className="fw-normal">{t('ai_assistant_substance.add_assistant')}</span>
+        <span className="fw-normal">
+          {t('ai_assistant_substance.add_assistant')}
+        </span>
       </button>
 
       <div className="d-flex flex-column gap-4">

+ 17 - 14
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/DeleteAiAssistantModal.tsx

@@ -1,9 +1,6 @@
-import React from 'react';
-
+import type React from 'react';
 import { useTranslation } from 'next-i18next';
-import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
 
@@ -16,7 +13,11 @@ export type DeleteAiAssistantModalProps = {
 };
 
 export const DeleteAiAssistantModal: React.FC<DeleteAiAssistantModalProps> = ({
-  isShown, aiAssistant, errorMessage, onCancel, onConfirm,
+  isShown,
+  aiAssistant,
+  errorMessage,
+  onCancel,
+  onConfirm,
 }) => {
   const { t } = useTranslation();
 
@@ -27,7 +28,9 @@ export const DeleteAiAssistantModal: React.FC<DeleteAiAssistantModalProps> = ({
     return (
       <>
         <span className="material-symbols-outlined me-1">delete_forever</span>
-        <span className="fw-bold">{t('ai_assistant_substance.delete_modal.title')}</span>
+        <span className="fw-bold">
+          {t('ai_assistant_substance.delete_modal.title')}
+        </span>
       </>
     );
   };
@@ -36,7 +39,11 @@ export const DeleteAiAssistantModal: React.FC<DeleteAiAssistantModalProps> = ({
     if (!isShown || aiAssistant == null) {
       return null;
     }
-    return <p className="fw-bold mb-0">{t('ai_assistant_substance.delete_modal.confirm_message')}</p>;
+    return (
+      <p className="fw-bold mb-0">
+        {t('ai_assistant_substance.delete_modal.confirm_message')}
+      </p>
+    );
   };
 
   const footerContent = () => {
@@ -61,12 +68,8 @@ export const DeleteAiAssistantModal: React.FC<DeleteAiAssistantModalProps> = ({
       <ModalHeader tag="h5" toggle={onCancel} className="text-danger px-4">
         {headerContent()}
       </ModalHeader>
-      <ModalBody className="px-4">
-        {bodyContent()}
-      </ModalBody>
-      <ModalFooter className="px-4 gap-2">
-        {footerContent()}
-      </ModalFooter>
+      <ModalBody className="px-4">{bodyContent()}</ModalBody>
+      <ModalFooter className="px-4 gap-2">{footerContent()}</ModalFooter>
     </Modal>
   );
 };

+ 92 - 52
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/ThreadList.tsx

@@ -1,11 +1,14 @@
-import React, { useCallback } from 'react';
-
+import type React from 'react';
+import { useCallback } from 'react';
 import { getIdStringForRef } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 
 import InfiniteScroll from '~/client/components/InfiniteScroll';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { useSWRMUTxThreads, useSWRINFxRecentThreads } from '~/features/openai/client/stores/thread';
+import {
+  useSWRINFxRecentThreads,
+  useSWRMUTxThreads,
+} from '~/features/openai/client/stores/thread';
 import loggerFactory from '~/utils/logger';
 
 import { deleteThread } from '../../../services/thread';
@@ -17,68 +20,105 @@ export const ThreadList: React.FC = () => {
   const swrInifiniteThreads = useSWRINFxRecentThreads();
   const { t } = useTranslation();
   const { data, mutate: mutateRecentThreads } = swrInifiniteThreads;
-  const { openChat, data: aiAssistantSidebarData, close: closeAiAssistantSidebar } = useAiAssistantSidebar();
-  const { trigger: mutateAssistantThreadData } = useSWRMUTxThreads(aiAssistantSidebarData?.aiAssistantData?._id);
+  const {
+    openChat,
+    data: aiAssistantSidebarData,
+    close: closeAiAssistantSidebar,
+  } = useAiAssistantSidebar();
+  const { trigger: mutateAssistantThreadData } = useSWRMUTxThreads(
+    aiAssistantSidebarData?.aiAssistantData?._id,
+  );
 
   const isEmpty = data?.[0]?.paginateResult.totalDocs === 0;
-  const isReachingEnd = isEmpty || (data != null && (data[data.length - 1].paginateResult.hasNextPage === false));
+  const isReachingEnd =
+    isEmpty ||
+    (data != null &&
+      data[data.length - 1].paginateResult.hasNextPage === false);
 
-  const deleteThreadHandler = useCallback(async(aiAssistantId: string, threadRelationId: string) => {
-    try {
-      await deleteThread({ aiAssistantId, threadRelationId });
-      toastSuccess(t('ai_assistant_substance.toaster.thread_deleted_success'));
+  const deleteThreadHandler = useCallback(
+    async (aiAssistantId: string, threadRelationId: string) => {
+      try {
+        await deleteThread({ aiAssistantId, threadRelationId });
+        toastSuccess(
+          t('ai_assistant_substance.toaster.thread_deleted_success'),
+        );
 
-      await Promise.all([mutateAssistantThreadData(), mutateRecentThreads()]);
+        await Promise.all([mutateAssistantThreadData(), mutateRecentThreads()]);
 
-      // Close if the thread to be deleted is open in right sidebar
-      if (aiAssistantSidebarData?.isOpened && aiAssistantSidebarData?.threadData?._id === threadRelationId) {
-        closeAiAssistantSidebar();
+        // Close if the thread to be deleted is open in right sidebar
+        if (
+          aiAssistantSidebarData?.isOpened &&
+          aiAssistantSidebarData?.threadData?._id === threadRelationId
+        ) {
+          closeAiAssistantSidebar();
+        }
+      } catch (err) {
+        logger.error(err);
+        toastError(t('ai_assistant_substance.toaster.thread_deleted_failed'));
       }
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(t('ai_assistant_substance.toaster.thread_deleted_failed'));
-    }
-  }, [aiAssistantSidebarData?.isOpened, aiAssistantSidebarData?.threadData?._id, closeAiAssistantSidebar, mutateAssistantThreadData, mutateRecentThreads, t]);
+    },
+    [
+      aiAssistantSidebarData?.isOpened,
+      aiAssistantSidebarData?.threadData?._id,
+      closeAiAssistantSidebar,
+      mutateAssistantThreadData,
+      mutateRecentThreads,
+      t,
+    ],
+  );
 
   return (
     <>
       <ul className="list-group">
-        <InfiniteScroll swrInifiniteResponse={swrInifiniteThreads} isReachingEnd={isReachingEnd}>
-          { data != null && data.map(thread => thread.paginateResult.docs).flat()
-            .map(thread => (
-              <li
-                key={thread._id}
-                role="button"
-                className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1"
-                onClick={(e) => {
-                  e.stopPropagation();
-                  openChat(thread.aiAssistant, thread);
-                }}
-              >
-                <div>
-                  <span className="material-symbols-outlined fs-5">chat</span>
-                </div>
+        <InfiniteScroll
+          swrInifiniteResponse={swrInifiniteThreads}
+          isReachingEnd={isReachingEnd}
+        >
+          {data
+            ?.flatMap((thread) => thread.paginateResult.docs)
+            .map((thread) => (
+              <li key={thread._id} className="list-group-item border-0 p-0">
+                <button
+                  type="button"
+                  className="btn btn-link list-group-item-action border-0 d-flex align-items-center rounded-1"
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    openChat(thread.aiAssistant, thread);
+                  }}
+                  onMouseDown={(e) => {
+                    e.preventDefault();
+                  }}
+                >
+                  <div>
+                    <span className="material-symbols-outlined fs-5">chat</span>
+                  </div>
 
-                <div className="grw-item-title ps-1">
-                  <p className="text-truncate m-auto">{thread.title ?? 'Untitled thread'}</p>
-                </div>
+                  <div className="grw-item-title ps-1">
+                    <p className="text-truncate m-auto">
+                      {thread.title ?? 'Untitled thread'}
+                    </p>
+                  </div>
 
-                <div className="grw-btn-actions opacity-0 d-flex justify-content-center ">
-                  <button
-                    type="button"
-                    className="btn btn-link text-secondary p-0"
-                    onClick={(e) => {
-                      e.stopPropagation();
-                      deleteThreadHandler(getIdStringForRef(thread.aiAssistant), thread._id);
-                    }}
-                  >
-                    <span className="material-symbols-outlined fs-5">delete</span>
-                  </button>
-                </div>
+                  <div className="grw-btn-actions opacity-0 d-flex justify-content-center">
+                    <button
+                      type="button"
+                      className="btn btn-link text-secondary p-0"
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        deleteThreadHandler(
+                          getIdStringForRef(thread.aiAssistant),
+                          thread._id,
+                        );
+                      }}
+                    >
+                      <span className="material-symbols-outlined fs-5">
+                        delete
+                      </span>
+                    </button>
+                  </div>
+                </button>
               </li>
-            ))
-          }
+            ))}
         </InfiniteScroll>
       </ul>
     </>

+ 4 - 5
apps/app/src/features/openai/client/components/AiIntegration/AiIntegration.tsx

@@ -1,17 +1,16 @@
 import type { JSX } from 'react';
-
 import { useTranslation } from 'react-i18next';
 
-
 export const AiIntegration = (): JSX.Element => {
   const { t } = useTranslation('admin');
 
   return (
     <div data-testid="admin-ai-integration">
-      <h2 className="admin-setting-header">{ t('ai_integration.ai_search_management') }</h2>
+      <h2 className="admin-setting-header">
+        {t('ai_integration.ai_search_management')}
+      </h2>
 
-      <div className="row">
-      </div>
+      <div className="row"></div>
     </div>
   );
 };

+ 12 - 5
apps/app/src/features/openai/client/components/AiIntegration/AiIntegrationDisableMode.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import React from 'react';
-
 import { useTranslation } from 'react-i18next';
 
 import { useGrowiDocumentationUrl } from '~/stores-universal/context';
@@ -16,11 +15,19 @@ export const AiIntegrationDisableMode: FC = () => {
           <div className="col-md-6 mt-5">
             <div className="text-center">
               {/* error icon large */}
-              <h1><span className="material-symbols-outlined">error</span></h1>
-              <h1 className="text-center">{t('ai_integration.ai_integration')}</h1>
+              <h1>
+                <span className="material-symbols-outlined">error</span>
+              </h1>
+              <h1 className="text-center">
+                {t('ai_integration.ai_integration')}
+              </h1>
               <h3
-                // eslint-disable-next-line react/no-danger
-                dangerouslySetInnerHTML={{ __html: t('ai_integration.disable_mode_explanation', { documentationUrl }) }}
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+                dangerouslySetInnerHTML={{
+                  __html: t('ai_integration.disable_mode_explanation', {
+                    documentationUrl,
+                  }),
+                }}
               />
             </div>
           </div>

+ 21 - 7
apps/app/src/features/openai/client/services/ai-assistant.ts

@@ -1,20 +1,34 @@
-import { apiv3Post, apiv3Put, apiv3Delete } from '~/client/util/apiv3-client';
+import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 
-import type { UpsertAiAssistantData, AiAssistantHasId } from '../../interfaces/ai-assistant';
+import type {
+  AiAssistantHasId,
+  UpsertAiAssistantData,
+} from '../../interfaces/ai-assistant';
 
-export const createAiAssistant = async(body: UpsertAiAssistantData): Promise<void> => {
+export const createAiAssistant = async (
+  body: UpsertAiAssistantData,
+): Promise<void> => {
   await apiv3Post('/openai/ai-assistant', body);
 };
 
-export const updateAiAssistant = async(id: string, body: UpsertAiAssistantData): Promise<AiAssistantHasId> => {
-  const res = await apiv3Put<{updatedAiAssistant: AiAssistantHasId}>(`/openai/ai-assistant/${id}`, body);
+export const updateAiAssistant = async (
+  id: string,
+  body: UpsertAiAssistantData,
+): Promise<AiAssistantHasId> => {
+  const res = await apiv3Put<{ updatedAiAssistant: AiAssistantHasId }>(
+    `/openai/ai-assistant/${id}`,
+    body,
+  );
   return res.data.updatedAiAssistant;
 };
 
-export const setDefaultAiAssistant = async(id: string, isDefault: boolean): Promise<void> => {
+export const setDefaultAiAssistant = async (
+  id: string,
+  isDefault: boolean,
+): Promise<void> => {
   await apiv3Put(`/openai/ai-assistant/${id}/set-default`, { isDefault });
 };
 
-export const deleteAiAssistant = async(id: string): Promise<void> => {
+export const deleteAiAssistant = async (id: string): Promise<void> => {
   await apiv3Delete(`/openai/ai-assistant/${id}`);
 };

+ 117 - 100
apps/app/src/features/openai/client/services/client-engine-integration.tsx

@@ -3,15 +3,11 @@
  * Provides seamless integration between existing SSE processing and new client-side engine
  */
 
-import {
-  useCallback, useRef, useMemo,
-} from 'react';
-
+import { useCallback, useMemo, useRef } from 'react';
 import type { Text as YText } from 'yjs';
 
 import type { SseDetectedDiff } from '../../interfaces/editor-assistant/sse-schemas';
 import type { ProcessingResult } from '../interfaces/types';
-
 import { ClientSearchReplaceProcessor } from './editor-assistant/processor';
 
 // -----------------------------------------------------------------------------
@@ -59,24 +55,33 @@ export interface ProcessingProgress {
 // Client Engine Integration Hook
 // -----------------------------------------------------------------------------
 
-export function useClientEngineIntegration(config: Partial<ClientEngineConfig> = {}): {
+export function useClientEngineIntegration(
+  config: Partial<ClientEngineConfig> = {},
+): {
   processHybrid: (
     content: string,
     detectedDiffs: SseDetectedDiff[],
     serverProcessingFn: () => Promise<void>,
-  ) => Promise<{ success: boolean; method: 'client' | 'server'; result?: ProcessingResult }>;
+  ) => Promise<{
+    success: boolean;
+    method: 'client' | 'server';
+    result?: ProcessingResult;
+  }>;
   applyToYText: (yText: YText, processedContent: string) => boolean;
   isClientProcessingEnabled: boolean;
 } {
   // Configuration with defaults
-  const finalConfig: ClientEngineConfig = useMemo(() => ({
-    enableClientProcessing: true,
-    enableServerFallback: true,
-    enablePerformanceMetrics: true,
-    maxProcessingTime: 10000,
-    batchSize: 5,
-    ...config,
-  }), [config]);
+  const finalConfig: ClientEngineConfig = useMemo(
+    () => ({
+      enableClientProcessing: true,
+      enableServerFallback: true,
+      enablePerformanceMetrics: true,
+      maxProcessingTime: 10000,
+      batchSize: 5,
+      ...config,
+    }),
+    [config],
+  );
 
   // Client processor instance
   const clientProcessor = useRef<ClientSearchReplaceProcessor>();
@@ -93,102 +98,111 @@ export function useClientEngineIntegration(config: Partial<ClientEngineConfig> =
   /**
    * Apply processed content to YText (CodeMirror integration)
    */
-  const applyToYText = useCallback((
-      yText: YText,
-      processedContent: string,
-  ): boolean => {
-    try {
-      const currentContent = yText.toString();
-
-      if (currentContent === processedContent) {
-        // No changes needed
+  const applyToYText = useCallback(
+    (yText: YText, processedContent: string): boolean => {
+      try {
+        const currentContent = yText.toString();
+
+        if (currentContent === processedContent) {
+          // No changes needed
+          return true;
+        }
+
+        // Apply changes in a transaction
+        yText.doc?.transact(() => {
+          // Clear existing content
+          yText.delete(0, yText.length);
+          // Insert new content
+          yText.insert(0, processedContent);
+        });
+
         return true;
+      } catch (error) {
+        return false;
       }
-
-      // Apply changes in a transaction
-      yText.doc?.transact(() => {
-        // Clear existing content
-        yText.delete(0, yText.length);
-        // Insert new content
-        yText.insert(0, processedContent);
-      });
-
-      return true;
-    }
-    catch (error) {
-      return false;
-    }
-  }, []);
+    },
+    [],
+  );
 
   /**
    * Hybrid processing: try client first, fallback to server
    */
-  const processHybrid = useCallback(async(
+  const processHybrid = useCallback(
+    async (
       content: string,
       detectedDiffs: SseDetectedDiff[],
       serverProcessingFn: () => Promise<void>,
-  ): Promise<{ success: boolean; method: 'client' | 'server'; result?: ProcessingResult }> => {
-    if (!finalConfig.enableClientProcessing || !clientProcessor.current) {
-      // Client processing disabled, use server only
-      await serverProcessingFn();
-      return { success: true, method: 'server' };
-    }
-
-    try {
-      // Convert SseDetectedDiff to LlmEditorAssistantDiff format
-      const diffs = detectedDiffs
-        .map(d => d.diff)
-        .filter((diff): diff is NonNullable<typeof diff> => diff != null);
-
-      // Validate required fields for client processing
-      for (const diff of diffs) {
-        if (!diff.startLine || !diff.search) {
-          throw new Error('Missing required fields for client processing');
-        }
-      }
-
-      // Process with client engine
-      const diffResult = await clientProcessor.current.processMultipleDiffs(content, diffs, {
-        enableProgressCallbacks: true,
-        batchSize: finalConfig.batchSize,
-        maxProcessingTime: finalConfig.maxProcessingTime,
-      });
-
-      // Convert DiffApplicationResult to ProcessingResult
-      const processingTime = performance.now();
-      const result: ProcessingResult = {
-        success: diffResult.success,
-        error: diffResult.failedParts?.[0],
-        matches: [],
-        appliedCount: diffResult.appliedCount,
-        skippedCount: Math.max(0, diffs.length - diffResult.appliedCount),
-        modifiedText: diffResult.content || content,
-        processingTime,
-      };
-
-      if (result.success) {
-        return { success: true, method: 'client', result };
-      }
-
-      // Client processing failed, fallback to server if enabled
-      if (finalConfig.enableServerFallback) {
+    ): Promise<{
+      success: boolean;
+      method: 'client' | 'server';
+      result?: ProcessingResult;
+    }> => {
+      if (!finalConfig.enableClientProcessing || !clientProcessor.current) {
+        // Client processing disabled, use server only
         await serverProcessingFn();
         return { success: true, method: 'server' };
       }
 
-      // No fallback, return client error
-      return { success: false, method: 'client', result };
-    }
-    catch (error) {
-      // Fallback to server on error
-      if (finalConfig.enableServerFallback) {
-        await serverProcessingFn();
-        return { success: true, method: 'server' };
-      }
+      try {
+        // Convert SseDetectedDiff to LlmEditorAssistantDiff format
+        const diffs = detectedDiffs
+          .map((d) => d.diff)
+          .filter((diff): diff is NonNullable<typeof diff> => diff != null);
+
+        // Validate required fields for client processing
+        for (const diff of diffs) {
+          if (!diff.startLine || !diff.search) {
+            throw new Error('Missing required fields for client processing');
+          }
+        }
+
+        // Process with client engine
+        const diffResult = await clientProcessor.current.processMultipleDiffs(
+          content,
+          diffs,
+          {
+            enableProgressCallbacks: true,
+            batchSize: finalConfig.batchSize,
+            maxProcessingTime: finalConfig.maxProcessingTime,
+          },
+        );
+
+        // Convert DiffApplicationResult to ProcessingResult
+        const processingTime = performance.now();
+        const result: ProcessingResult = {
+          success: diffResult.success,
+          error: diffResult.failedParts?.[0],
+          matches: [],
+          appliedCount: diffResult.appliedCount,
+          skippedCount: Math.max(0, diffs.length - diffResult.appliedCount),
+          modifiedText: diffResult.content || content,
+          processingTime,
+        };
+
+        if (result.success) {
+          return { success: true, method: 'client', result };
+        }
 
-      return { success: false, method: 'client' };
-    }
-  }, [finalConfig]);
+        // Client processing failed, fallback to server if enabled
+        if (finalConfig.enableServerFallback) {
+          await serverProcessingFn();
+          return { success: true, method: 'server' };
+        }
+
+        // No fallback, return client error
+        return { success: false, method: 'client', result };
+      } catch (error) {
+        // Fallback to server on error
+        if (finalConfig.enableServerFallback) {
+          await serverProcessingFn();
+          return { success: true, method: 'server' };
+        }
+
+        return { success: false, method: 'client' };
+      }
+    },
+    [finalConfig],
+  );
 
   return {
     // Processing functions
@@ -209,9 +223,12 @@ export function useClientEngineIntegration(config: Partial<ClientEngineConfig> =
  */
 export function shouldUseClientProcessing(): boolean {
   // This could be controlled by environment variables, user settings, etc.
-  return (process.env.NODE_ENV === 'development')
-    || (typeof window !== 'undefined'
-        && (window as { __GROWI_CLIENT_PROCESSING_ENABLED__?: boolean }).__GROWI_CLIENT_PROCESSING_ENABLED__ === true);
+  return (
+    process.env.NODE_ENV === 'development' ||
+    (typeof window !== 'undefined' &&
+      (window as { __GROWI_CLIENT_PROCESSING_ENABLED__?: boolean })
+        .__GROWI_CLIENT_PROCESSING_ENABLED__ === true)
+  );
 }
 
 export default useClientEngineIntegration;

+ 24 - 24
apps/app/src/features/openai/client/services/editor-assistant/diff-application.ts

@@ -5,8 +5,11 @@
  */
 
 import type { LlmEditorAssistantDiff } from '../../../interfaces/editor-assistant/llm-response-schemas';
-import type { SingleDiffResult, ProcessorConfig, SearchContext } from '../../interfaces/types';
-
+import type {
+  ProcessorConfig,
+  SearchContext,
+  SingleDiffResult,
+} from '../../interfaces/types';
 import { ClientErrorHandler } from './error-handling';
 import { ClientFuzzyMatcher } from './fuzzy-matching';
 
@@ -15,7 +18,6 @@ import { ClientFuzzyMatcher } from './fuzzy-matching';
 // -----------------------------------------------------------------------------
 
 export class ClientDiffApplicationEngine {
-
   private fuzzyMatcher: ClientFuzzyMatcher;
 
   private errorHandler: ClientErrorHandler;
@@ -23,8 +25,8 @@ export class ClientDiffApplicationEngine {
   private config: Required<ProcessorConfig>;
 
   constructor(
-      config: Partial<ProcessorConfig> = {},
-      errorHandler?: ClientErrorHandler,
+    config: Partial<ProcessorConfig> = {},
+    errorHandler?: ClientErrorHandler,
   ) {
     // Set defaults optimized for browser environment
     this.config = {
@@ -44,9 +46,9 @@ export class ClientDiffApplicationEngine {
    * Apply a single diff to content with browser-optimized processing
    */
   applySingleDiff(
-      content: string,
-      diff: LlmEditorAssistantDiff,
-      lineDelta = 0,
+    content: string,
+    diff: LlmEditorAssistantDiff,
+    lineDelta = 0,
   ): SingleDiffResult {
     try {
       // Validate search content
@@ -92,9 +94,7 @@ export class ClientDiffApplicationEngine {
         updatedLines: replacementResult.lines,
         lineDelta: replacementResult.lineDelta,
       };
-
-    }
-    catch (error) {
+    } catch (error) {
       return {
         success: false,
         error: this.errorHandler.createContentError(
@@ -105,13 +105,12 @@ export class ClientDiffApplicationEngine {
     }
   }
 
-
   /**
    * Apply multiple diffs in sequence with proper delta tracking
    */
   applyMultipleDiffs(
-      content: string,
-      diffs: LlmEditorAssistantDiff[],
+    content: string,
+    diffs: LlmEditorAssistantDiff[],
   ): {
     success: boolean;
     finalContent?: string;
@@ -136,8 +135,7 @@ export class ClientDiffApplicationEngine {
         currentContent = result.updatedLines.join('\n');
         totalLineDelta += result.lineDelta || 0;
         appliedCount++;
-      }
-      else {
+      } else {
         errors.push(result);
       }
     }
@@ -159,8 +157,8 @@ export class ClientDiffApplicationEngine {
    * Create search context with line adjustments
    */
   private createSearchContext(
-      diff: LlmEditorAssistantDiff,
-      lineDelta: number,
+    diff: LlmEditorAssistantDiff,
+    lineDelta: number,
   ): SearchContext {
     return {
       startLine: diff.startLine ? diff.startLine + lineDelta : undefined,
@@ -173,9 +171,9 @@ export class ClientDiffApplicationEngine {
    * Apply replacement with indentation preservation
    */
   private applyReplacement(
-      lines: string[],
-      matchResult: { index: number; content: string },
-      replaceText: string,
+    lines: string[],
+    matchResult: { index: number; content: string },
+    replaceText: string,
   ): { lines: string[]; lineDelta: number } {
     const startLineIndex = matchResult.index;
     const originalLines = matchResult.content.split('\n');
@@ -206,7 +204,10 @@ export class ClientDiffApplicationEngine {
   /**
    * Preserve indentation pattern from original content
    */
-  private preserveIndentation(originalLine: string, replaceText: string): string {
+  private preserveIndentation(
+    originalLine: string,
+    replaceText: string,
+  ): string {
     // Extract indentation from the original line
     const indentMatch = originalLine.match(/^(\s*)/);
     const originalIndent = indentMatch ? indentMatch[1] : '';
@@ -236,7 +237,7 @@ export class ClientDiffApplicationEngine {
    * Sort diffs for optimal application order (bottom to top)
    */
   private sortDiffsForApplication(
-      diffs: LlmEditorAssistantDiff[],
+    diffs: LlmEditorAssistantDiff[],
   ): LlmEditorAssistantDiff[] {
     return [...diffs].sort((a, b) => {
       // If both have line numbers, sort by line number (descending)
@@ -293,5 +294,4 @@ export class ClientDiffApplicationEngine {
       issues,
     };
   }
-
 }

+ 20 - 21
apps/app/src/features/openai/client/services/editor-assistant/error-handling.ts

@@ -22,7 +22,7 @@ export const CLIENT_SUGGESTIONS = {
     'Check for exact whitespace and formatting',
     'Try a smaller, more specific search pattern',
     'Verify line endings match your content',
-    'Use the browser\'s search function to locate content first',
+    "Use the browser's search function to locate content first",
   ],
   EMPTY_SEARCH: [
     'Provide valid search content',
@@ -45,7 +45,6 @@ export const CLIENT_SUGGESTIONS = {
 // -----------------------------------------------------------------------------
 
 export class ClientErrorHandler {
-
   private readonly enableConsoleLogging: boolean;
 
   private readonly enableUserFeedback: boolean;
@@ -59,9 +58,9 @@ export class ClientErrorHandler {
    * Create a detailed error for search content not found
    */
   createSearchNotFoundError(
-      searchContent: string,
-      matchResult?: MatchResult,
-      startLine?: number,
+    searchContent: string,
+    matchResult?: MatchResult,
+    startLine?: number,
   ): DiffError {
     const lineRange = startLine ? ` (starting at line ${startLine})` : '';
     const similarityInfo = matchResult?.similarity
@@ -77,7 +76,9 @@ export class ClientErrorHandler {
         bestMatch: matchResult?.content,
         similarity: matchResult?.similarity,
         suggestions: [...CLIENT_SUGGESTIONS.SEARCH_NOT_FOUND],
-        lineRange: startLine ? `starting at line ${startLine}` : 'entire document',
+        lineRange: startLine
+          ? `starting at line ${startLine}`
+          : 'entire document',
       },
     };
 
@@ -105,10 +106,7 @@ export class ClientErrorHandler {
   /**
    * Create an error for content/parsing issues
    */
-  createContentError(
-      originalError: Error,
-      context?: string,
-  ): DiffError {
+  createContentError(originalError: Error, context?: string): DiffError {
     const error: DiffError = {
       type: 'CONTENT_ERROR',
       message: `${CLIENT_ERROR_MESSAGES.CONTENT_ERROR}: ${originalError.message}`,
@@ -128,10 +126,7 @@ export class ClientErrorHandler {
   /**
    * Create an error for browser timeout
    */
-  createTimeoutError(
-      searchContent: string,
-      timeoutMs: number,
-  ): DiffError {
+  createTimeoutError(searchContent: string, timeoutMs: number): DiffError {
     const error: DiffError = {
       type: 'CONTENT_ERROR', // Using CONTENT_ERROR as base type
       message: `${CLIENT_ERROR_MESSAGES.TIMEOUT_ERROR} (${timeoutMs}ms)`,
@@ -155,7 +150,10 @@ export class ClientErrorHandler {
   /**
    * Generate a suggested correct format based on the best match
    */
-  private generateCorrectFormat(searchContent: string, bestMatch: string): string {
+  private generateCorrectFormat(
+    searchContent: string,
+    bestMatch: string,
+  ): string {
     // Simple diff-like format for user guidance
     const searchLines = searchContent.split('\n');
     const matchLines = bestMatch.split('\n');
@@ -171,9 +169,9 @@ export class ClientErrorHandler {
    * Log error to console (if enabled) with contextual information
    */
   private logError(
-      error: DiffError,
-      context: string,
-      originalError?: Error,
+    error: DiffError,
+    context: string,
+    originalError?: Error,
   ): void {
     if (!this.enableConsoleLogging) {
       return;
@@ -202,7 +200,8 @@ export class ClientErrorHandler {
    * Format error for user display
    */
   formatErrorForUser(error: DiffError): string {
-    const suggestions = error.details.suggestions?.slice(0, 3).join('\n• ') || '';
+    const suggestions =
+      error.details.suggestions?.slice(0, 3).join('\n• ') || '';
 
     return `❌ ${error.message}\n\n💡 Suggestions:\n• ${suggestions}`;
   }
@@ -225,9 +224,9 @@ export class ClientErrorHandler {
       .map((error, index) => `${index + 1}. ${error.message}`)
       .join('\n');
 
-    const moreErrors = errors.length > 5 ? `\n... and ${errors.length - 5} more issues` : '';
+    const moreErrors =
+      errors.length > 5 ? `\n... and ${errors.length - 5} more issues` : '';
 
     return summary + errorList + moreErrors;
   }
-
 }

+ 91 - 24
apps/app/src/features/openai/client/services/editor-assistant/fuzzy-matching.spec.ts

@@ -1,11 +1,10 @@
 import type { SearchContext } from '../../interfaces/types';
-
 import {
   ClientFuzzyMatcher,
   calculateSimilarity,
-  splitLines,
   joinLines,
   measurePerformance,
+  splitLines,
 } from './fuzzy-matching';
 
 // Test utilities
@@ -66,17 +65,26 @@ describe('fuzzy-matching', () => {
     });
 
     test('should return low similarity for very different strings', () => {
-      const similarity = calculateSimilarity('hello world', 'completely different');
+      const similarity = calculateSimilarity(
+        'hello world',
+        'completely different',
+      );
       expect(similarity).toBeLessThan(0.3);
     });
 
     test('should handle length-based early filtering', () => {
-      const similarity = calculateSimilarity('a', 'very long string that is much longer');
+      const similarity = calculateSimilarity(
+        'a',
+        'very long string that is much longer',
+      );
       expect(similarity).equals(0); // fixed to zero for early filtering for performance
     });
 
     test('should handle unicode characters', () => {
-      const similarity = calculateSimilarity('こんにちは世界', 'こんにちは世界');
+      const similarity = calculateSimilarity(
+        'こんにちは世界',
+        'こんにちは世界',
+      );
       expect(similarity).toBe(1.0);
     });
   });
@@ -154,15 +162,23 @@ describe('fuzzy-matching', () => {
       });
 
       test('should throw error for invalid threshold', () => {
-        expect(() => matcher.setThreshold(-0.1)).toThrow('Threshold must be between 0.0 and 1.0');
-        expect(() => matcher.setThreshold(1.1)).toThrow('Threshold must be between 0.0 and 1.0');
+        expect(() => matcher.setThreshold(-0.1)).toThrow(
+          'Threshold must be between 0.0 and 1.0',
+        );
+        expect(() => matcher.setThreshold(1.1)).toThrow(
+          'Threshold must be between 0.0 and 1.0',
+        );
       });
     });
 
     describe('tryExactLineMatch', () => {
       test('should match exact content at specified line', () => {
         const content = createTestContent();
-        const result = matcher.tryExactLineMatch(content, 'console.log("hello world");', 2);
+        const result = matcher.tryExactLineMatch(
+          content,
+          'console.log("hello world");',
+          2,
+        );
 
         expect(result.success).toBe(true);
         expect(result.similarity).toBe(1.0);
@@ -181,7 +197,11 @@ describe('fuzzy-matching', () => {
       test('should fail for line number beyond content', () => {
         const content = createTestContent();
         const lines = content.split('\n');
-        const result = matcher.tryExactLineMatch(content, 'test', lines.length + 1);
+        const result = matcher.tryExactLineMatch(
+          content,
+          'test',
+          lines.length + 1,
+        );
 
         expect(result.success).toBe(false);
         expect(result.error).toBe('Invalid line number');
@@ -207,7 +227,11 @@ describe('fuzzy-matching', () => {
 
       test('should handle fuzzy matching below threshold', () => {
         const content = createTestContent();
-        const result = matcher.tryExactLineMatch(content, 'completely different text', 2);
+        const result = matcher.tryExactLineMatch(
+          content,
+          'completely different text',
+          2,
+        );
 
         expect(result.success).toBe(false);
         expect(result.error).toBe('Similarity below threshold');
@@ -217,7 +241,12 @@ describe('fuzzy-matching', () => {
     describe('performBufferedSearch', () => {
       test('should find match within buffer range', () => {
         const content = createTestContent();
-        const result = matcher.performBufferedSearch(content, 'console.log("hello world");', 2, 5);
+        const result = matcher.performBufferedSearch(
+          content,
+          'console.log("hello world");',
+          2,
+          5,
+        );
 
         expect(result.success).toBe(true);
         expect(result.similarity).toBe(1.0);
@@ -228,7 +257,12 @@ describe('fuzzy-matching', () => {
 console.log("test2");
 console.log("hello world");
 console.log("test3");`;
-        const result = matcher.performBufferedSearch(content, 'console.log("hello world");', 2, 2);
+        const result = matcher.performBufferedSearch(
+          content,
+          'console.log("hello world");',
+          2,
+          2,
+        );
 
         expect(result.success).toBe(true);
         expect(result.similarity).toBe(1.0);
@@ -236,7 +270,12 @@ console.log("test3");`;
 
       test('should return no match when nothing similar found', () => {
         const content = createTestContent();
-        const result = matcher.performBufferedSearch(content, 'nonexistent function call', 2, 5);
+        const result = matcher.performBufferedSearch(
+          content,
+          'nonexistent function call',
+          2,
+          5,
+        );
 
         expect(result.success).toBe(false);
         expect(result.error).toBe('No match found');
@@ -273,7 +312,10 @@ return null;`;
 
       test('should return no match when threshold not met', () => {
         const content = createTestContent();
-        const result = matcher.performFullSearch(content, 'completely unrelated content here');
+        const result = matcher.performFullSearch(
+          content,
+          'completely unrelated content here',
+        );
 
         expect(result.success).toBe(false);
         expect(result.error).toBe('No match found');
@@ -281,7 +323,10 @@ return null;`;
 
       test('should handle early exit for exact matches', () => {
         const largeContent = createLargeContent(500);
-        const result = matcher.performFullSearch(largeContent, 'Line 10: This is line number 10 with some content');
+        const result = matcher.performFullSearch(
+          largeContent,
+          'Line 10: This is line number 10 with some content',
+        );
 
         expect(result.success).toBe(true);
         expect(result.similarity).toBe(1.0);
@@ -308,7 +353,11 @@ return null;`;
       test('should use exact line match when preferredStartLine is provided', () => {
         const content = createTestContent();
         const context: SearchContext = { preferredStartLine: 2 };
-        const result = matcher.findBestMatch(content, 'console.log("hello world");', context);
+        const result = matcher.findBestMatch(
+          content,
+          'console.log("hello world");',
+          context,
+        );
 
         expect(result.success).toBe(true);
         expect(result.similarity).toBe(1.0);
@@ -317,8 +366,15 @@ return null;`;
 
       test('should fall back to buffered search when exact line match fails', () => {
         const content = createTestContent();
-        const context: SearchContext = { preferredStartLine: 1, bufferLines: 10 };
-        const result = matcher.findBestMatch(content, 'console.log("hello world");', context);
+        const context: SearchContext = {
+          preferredStartLine: 1,
+          bufferLines: 10,
+        };
+        const result = matcher.findBestMatch(
+          content,
+          'console.log("hello world");',
+          context,
+        );
 
         expect(result.success).toBe(true);
         expect(result.similarity).toBe(1.0);
@@ -350,7 +406,10 @@ return null;`;
         const largeContent = createLargeContent(1000);
 
         // This might timeout, but should not crash
-        const result = timeoutMatcher.findBestMatch(largeContent, 'some search text that might not exist');
+        const result = timeoutMatcher.findBestMatch(
+          largeContent,
+          'some search text that might not exist',
+        );
 
         // Should either succeed or fail gracefully
         expect(typeof result.success).toBe('boolean');
@@ -361,7 +420,10 @@ return null;`;
 
       test('should provide search time information', () => {
         const content = createTestContent();
-        const result = matcher.findBestMatch(content, 'console.log("hello world");');
+        const result = matcher.findBestMatch(
+          content,
+          'console.log("hello world");',
+        );
 
         expect(result.searchTime).toBeGreaterThanOrEqual(0);
         expect(typeof result.searchTime).toBe('number');
@@ -401,7 +463,10 @@ return null;`;
   const message = "こんにちは世界 🌍";
   console.log(message);
 }`;
-        const result = matcher.findBestMatch(content, 'const message = "こんにちは世界 🌍";');
+        const result = matcher.findBestMatch(
+          content,
+          'const message = "こんにちは世界 🌍";',
+        );
 
         expect(result.success).toBe(true);
         expect(result.similarity).toBe(1.0);
@@ -415,8 +480,7 @@ return null;`;
         expect(result.similarity).equal(0); // fixed to zero for early filtering for performance
         if (result.similarity >= 0.85) {
           expect(result.success).toBe(true);
-        }
-        else {
+        } else {
           expect(result.success).toBe(false);
         }
       });
@@ -439,7 +503,10 @@ return null;`;
         const largeContent = createLargeContent(2000);
         const startTime = performance.now();
 
-        const result = matcher.findBestMatch(largeContent, 'Line 1500: This is line number 1500 with some content');
+        const result = matcher.findBestMatch(
+          largeContent,
+          'Line 1500: This is line number 1500 with some content',
+        );
 
         const duration = performance.now() - startTime;
 

+ 85 - 48
apps/app/src/features/openai/client/services/editor-assistant/fuzzy-matching.ts

@@ -7,7 +7,6 @@
 import { distance } from 'fastest-levenshtein';
 
 import type { MatchResult, SearchContext } from '../../interfaces/types';
-
 import { normalizeForBrowserFuzzyMatch } from './text-normalization';
 
 // -----------------------------------------------------------------------------
@@ -30,7 +29,9 @@ export function calculateSimilarity(original: string, search: string): number {
   }
 
   // Length-based early filtering for performance
-  const lengthRatio = Math.min(original.length, search.length) / Math.max(original.length, search.length);
+  const lengthRatio =
+    Math.min(original.length, search.length) /
+    Math.max(original.length, search.length);
   if (lengthRatio < 0.3) {
     return 0; // Too different in length
   }
@@ -49,7 +50,10 @@ export function calculateSimilarity(original: string, search: string): number {
 
   // Calculate similarity ratio (0 to 1, where 1 is an exact match)
   // This matches roo-code's calculation method
-  const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length);
+  const maxLength = Math.max(
+    normalizedOriginal.length,
+    normalizedSearch.length,
+  );
   return 1 - dist / maxLength;
 }
 
@@ -58,7 +62,6 @@ export function calculateSimilarity(original: string, search: string): number {
 // -----------------------------------------------------------------------------
 
 export class ClientFuzzyMatcher {
-
   private threshold: number;
 
   private readonly maxSearchTime: number; // Browser performance limit
@@ -72,9 +75,9 @@ export class ClientFuzzyMatcher {
    * Try exact line match at the specified line
    */
   tryExactLineMatch(
-      content: string,
-      searchText: string,
-      startLine: number,
+    content: string,
+    searchText: string,
+    startLine: number,
   ): MatchResult {
     const lines = content.split('\n');
 
@@ -87,7 +90,11 @@ export class ClientFuzzyMatcher {
     const endLine = Math.min(startLine + searchLines.length - 1, lines.length);
 
     if (endLine - startLine + 1 !== searchLines.length) {
-      return { success: false, similarity: 0, error: 'Not enough lines for search' };
+      return {
+        success: false,
+        similarity: 0,
+        error: 'Not enough lines for search',
+      };
     }
 
     // Extract content from specified lines
@@ -95,7 +102,9 @@ export class ClientFuzzyMatcher {
 
     // Check for exact match first
     if (targetContent === searchText) {
-      const startIndex = lines.slice(0, startLine - 1).join('\n').length + (startLine > 1 ? 1 : 0);
+      const startIndex =
+        lines.slice(0, startLine - 1).join('\n').length +
+        (startLine > 1 ? 1 : 0);
       const endIndex = startIndex + searchText.length;
 
       return {
@@ -113,7 +122,9 @@ export class ClientFuzzyMatcher {
     // Check fuzzy match
     const similarity = calculateSimilarity(targetContent, searchText);
     if (similarity >= this.threshold) {
-      const startIndex = lines.slice(0, startLine - 1).join('\n').length + (startLine > 1 ? 1 : 0);
+      const startIndex =
+        lines.slice(0, startLine - 1).join('\n').length +
+        (startLine > 1 ? 1 : 0);
       const endIndex = startIndex + targetContent.length;
 
       return {
@@ -135,10 +146,10 @@ export class ClientFuzzyMatcher {
    * Perform buffered search around the preferred line
    */
   performBufferedSearch(
-      content: string,
-      searchText: string,
-      preferredStartLine: number,
-      bufferLines = 40,
+    content: string,
+    searchText: string,
+    preferredStartLine: number,
+    bufferLines = 40,
   ): MatchResult {
     const lines = content.split('\n');
     const searchLines = searchText.split('\n');
@@ -147,10 +158,18 @@ export class ClientFuzzyMatcher {
     const startBound = Math.max(1, preferredStartLine - bufferLines);
     const endBound = Math.min(lines.length, preferredStartLine + bufferLines);
 
-    let bestMatch: MatchResult = { success: false, similarity: 0, error: 'No match found' };
+    let bestMatch: MatchResult = {
+      success: false,
+      similarity: 0,
+      error: 'No match found',
+    };
 
     // Search within the buffer area
-    for (let currentLine = startBound; currentLine <= endBound - searchLines.length + 1; currentLine++) {
+    for (
+      let currentLine = startBound;
+      currentLine <= endBound - searchLines.length + 1;
+      currentLine++
+    ) {
       const match = this.tryExactLineMatch(content, searchText, currentLine);
 
       if (match.success && match.similarity > bestMatch.similarity) {
@@ -169,17 +188,22 @@ export class ClientFuzzyMatcher {
   /**
    * Perform full search across entire content
    */
-  performFullSearch(
-      content: string,
-      searchText: string,
-  ): MatchResult {
+  performFullSearch(content: string, searchText: string): MatchResult {
     const lines = content.split('\n');
     const searchLines = searchText.split('\n');
 
-    let bestMatch: MatchResult = { success: false, similarity: 0, error: 'No match found' };
+    let bestMatch: MatchResult = {
+      success: false,
+      similarity: 0,
+      error: 'No match found',
+    };
 
     // Search entire content
-    for (let currentLine = 1; currentLine <= lines.length - searchLines.length + 1; currentLine++) {
+    for (
+      let currentLine = 1;
+      currentLine <= lines.length - searchLines.length + 1;
+      currentLine++
+    ) {
       const match = this.tryExactLineMatch(content, searchText, currentLine);
 
       if (match.success && match.similarity > bestMatch.similarity) {
@@ -200,9 +224,9 @@ export class ClientFuzzyMatcher {
    * Optimized for browser environment with timeout protection
    */
   findBestMatch(
-      content: string,
-      searchText: string,
-      context: SearchContext = {},
+    content: string,
+    searchText: string,
+    context: SearchContext = {},
   ): MatchResult {
     const startTime = performance.now();
 
@@ -221,35 +245,39 @@ export class ClientFuzzyMatcher {
 
     // 指定行から優先検索
     if (context.preferredStartLine) {
-      const exactMatch = this.tryExactLineMatch(content, searchText, context.preferredStartLine);
+      const exactMatch = this.tryExactLineMatch(
+        content,
+        searchText,
+        context.preferredStartLine,
+      );
       if (exactMatch.success) {
         return exactMatch;
       }
 
       // 指定行周辺でfuzzy検索
-      return this.performBufferedSearch(content, searchText, context.preferredStartLine, context.bufferLines || 40);
+      return this.performBufferedSearch(
+        content,
+        searchText,
+        context.preferredStartLine,
+        context.bufferLines || 40,
+      );
     }
 
     // Calculate search bounds with buffer
     const bounds = this.calculateSearchBounds(lines.length, context);
 
     // Middle-out search with browser timeout protection
-    return this.performMiddleOutSearch(
-      lines,
-      searchLines,
-      bounds,
-      startTime,
-    );
+    return this.performMiddleOutSearch(lines, searchLines, bounds, startTime);
   }
 
   /**
    * Middle-out search algorithm optimized for browser performance
    */
   private performMiddleOutSearch(
-      lines: string[],
-      searchLines: string[],
-      bounds: { startIndex: number; endIndex: number },
-      startTime: number,
+    lines: string[],
+    searchLines: string[],
+    bounds: { startIndex: number; endIndex: number },
+    startTime: number,
   ): MatchResult {
     const { startIndex, endIndex } = bounds;
     const searchLength = searchLines.length;
@@ -281,7 +309,12 @@ export class ClientFuzzyMatcher {
 
       // Search left side
       if (leftIndex >= startIndex) {
-        const result = this.checkMatch(lines, leftIndex, searchLength, searchChunk);
+        const result = this.checkMatch(
+          lines,
+          leftIndex,
+          searchLength,
+          searchChunk,
+        );
         if (result.score > bestScore) {
           bestScore = result.score;
           bestMatchIndex = leftIndex;
@@ -297,7 +330,12 @@ export class ClientFuzzyMatcher {
 
       // Search right side
       if (rightIndex <= actualEndIndex) {
-        const result = this.checkMatch(lines, rightIndex, searchLength, searchChunk);
+        const result = this.checkMatch(
+          lines,
+          rightIndex,
+          searchLength,
+          searchChunk,
+        );
         if (result.score > bestScore) {
           bestScore = result.score;
           bestMatchIndex = rightIndex;
@@ -325,10 +363,10 @@ export class ClientFuzzyMatcher {
    * Check similarity at a specific position with performance optimization
    */
   private checkMatch(
-      lines: string[],
-      startIndex: number,
-      length: number,
-      searchChunk: string,
+    lines: string[],
+    startIndex: number,
+    length: number,
+    searchChunk: string,
   ): { score: number; content: string } {
     const chunk = lines.slice(startIndex, startIndex + length).join('\n');
     const similarity = calculateSimilarity(chunk, searchChunk);
@@ -343,8 +381,8 @@ export class ClientFuzzyMatcher {
    * Calculate search bounds considering buffer lines and browser limitations
    */
   private calculateSearchBounds(
-      totalLines: number,
-      context: SearchContext,
+    totalLines: number,
+    context: SearchContext,
   ): { startIndex: number; endIndex: number } {
     const bufferLines = context.bufferLines ?? 40; // Default browser-optimized buffer
 
@@ -418,7 +456,6 @@ export class ClientFuzzyMatcher {
   getMaxSearchTime(): number {
     return this.maxSearchTime;
   }
-
 }
 
 // -----------------------------------------------------------------------------
@@ -445,8 +482,8 @@ export function joinLines(lines: string[], originalContent?: string): string {
  * Browser performance measurement helper
  */
 export function measurePerformance<T>(
-    operation: () => T,
-    label = 'Fuzzy matching operation',
+  operation: () => T,
+  label = 'Fuzzy matching operation',
 ): { result: T; duration: number } {
   const start = performance.now();
   const result = operation();

+ 17 - 14
apps/app/src/features/openai/client/services/editor-assistant/get-page-body-for-context.spec.ts

@@ -1,13 +1,7 @@
 import { Text } from '@codemirror/state';
 import type { UseCodeMirrorEditor } from '@growi/editor/dist/client/services/use-codemirror-editor';
-import {
-  describe,
-  it,
-  expect,
-  vi,
-  beforeEach,
-} from 'vitest';
-import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { type DeepMockProxy, mockDeep } from 'vitest-mock-extended';
 
 import { getPageBodyForContext } from './get-page-body-for-context';
 
@@ -119,7 +113,9 @@ describe('getPageBodyForContext', () => {
 
       // Mock view with cursor at position 1000
       if (mockEditor.view?.state?.selection?.main) {
-        Object.defineProperty(mockEditor.view.state.selection.main, 'head', { value: cursorPos });
+        Object.defineProperty(mockEditor.view.state.selection.main, 'head', {
+          value: cursorPos,
+        });
       }
 
       const result = getPageBodyForContext(mockEditor, 200, 300);
@@ -145,7 +141,9 @@ describe('getPageBodyForContext', () => {
 
       // Mock view with cursor at position 950
       if (mockEditor.view?.state?.selection?.main) {
-        Object.defineProperty(mockEditor.view.state.selection.main, 'head', { value: cursorPos });
+        Object.defineProperty(mockEditor.view.state.selection.main, 'head', {
+          value: cursorPos,
+        });
       }
 
       const result = getPageBodyForContext(mockEditor, 100, 200);
@@ -174,7 +172,9 @@ describe('getPageBodyForContext', () => {
 
       // Mock view with cursor at position 1000
       if (mockEditor.view?.state?.selection?.main) {
-        Object.defineProperty(mockEditor.view.state.selection.main, 'head', { value: cursorPos });
+        Object.defineProperty(mockEditor.view.state.selection.main, 'head', {
+          value: cursorPos,
+        });
       }
 
       const result = getPageBodyForContext(mockEditor, 100, 200);
@@ -203,7 +203,9 @@ describe('getPageBodyForContext', () => {
 
       // Mock view with cursor at position 0
       if (mockEditor.view?.state?.selection?.main) {
-        Object.defineProperty(mockEditor.view.state.selection.main, 'head', { value: cursorPos });
+        Object.defineProperty(mockEditor.view.state.selection.main, 'head', {
+          value: cursorPos,
+        });
       }
 
       const result = getPageBodyForContext(mockEditor, 100, 200);
@@ -232,7 +234,9 @@ describe('getPageBodyForContext', () => {
 
       // Mock view with cursor at position 995
       if (mockEditor.view?.state?.selection?.main) {
-        Object.defineProperty(mockEditor.view.state.selection.main, 'head', { value: cursorPos });
+        Object.defineProperty(mockEditor.view.state.selection.main, 'head', {
+          value: cursorPos,
+        });
       }
 
       const result = getPageBodyForContext(mockEditor, 50, 500); // Total: 550 < 1000
@@ -251,6 +255,5 @@ describe('getPageBodyForContext', () => {
       });
       expect(result?.content).toHaveLength(550); // 1000 - 450 = 550
     });
-
   });
 });

+ 19 - 7
apps/app/src/features/openai/client/services/editor-assistant/get-page-body-for-context.ts

@@ -16,9 +16,9 @@ export type PageBodyContextResult = {
  * @returns Page body context result with metadata, or undefined if editor is not available
  */
 export const getPageBodyForContext = (
-    codeMirrorEditor: UseCodeMirrorEditor | undefined,
-    maxLengthBeforeCursor: number,
-    maxLengthAfterCursor: number,
+  codeMirrorEditor: UseCodeMirrorEditor | undefined,
+  maxLengthBeforeCursor: number,
+  maxLengthAfterCursor: number,
 ): PageBodyContextResult | undefined => {
   const doc = codeMirrorEditor?.getDoc();
   const length = doc?.length ?? 0;
@@ -38,16 +38,28 @@ export const getPageBodyForContext = (
     const availableAfterCursor = length - cursorPos;
 
     // Calculate actual chars to take before and after cursor
-    const charsBeforeCursor = Math.min(maxLengthBeforeCursor, availableBeforeCursor);
-    const charsAfterCursor = Math.min(maxLengthAfterCursor, availableAfterCursor);
+    const charsBeforeCursor = Math.min(
+      maxLengthBeforeCursor,
+      availableBeforeCursor,
+    );
+    const charsAfterCursor = Math.min(
+      maxLengthAfterCursor,
+      availableAfterCursor,
+    );
 
     // Calculate shortfalls and redistribute
     const shortfallBefore = maxLengthBeforeCursor - charsBeforeCursor;
     const shortfallAfter = maxLengthAfterCursor - charsAfterCursor;
 
     // Redistribute shortfalls
-    const finalCharsAfterCursor = Math.min(charsAfterCursor + shortfallBefore, availableAfterCursor);
-    const finalCharsBeforeCursor = Math.min(charsBeforeCursor + shortfallAfter, availableBeforeCursor);
+    const finalCharsAfterCursor = Math.min(
+      charsAfterCursor + shortfallBefore,
+      availableAfterCursor,
+    );
+    const finalCharsBeforeCursor = Math.min(
+      charsBeforeCursor + shortfallAfter,
+      availableBeforeCursor,
+    );
 
     // Calculate start and end positions
     const startPos = Math.max(cursorPos - finalCharsBeforeCursor, 0);

+ 80 - 39
apps/app/src/features/openai/client/services/editor-assistant/processor.ts

@@ -5,11 +5,15 @@
  */
 
 import type { LlmEditorAssistantDiff } from '../../../interfaces/editor-assistant/llm-response-schemas';
-import type { DiffApplicationResult, ProcessorConfig, DiffError } from '../../interfaces/types';
-
+import type {
+  DiffApplicationResult,
+  DiffError,
+  ProcessorConfig,
+} from '../../interfaces/types';
 import { ClientDiffApplicationEngine } from './diff-application';
 import { ClientErrorHandler } from './error-handling';
 import { ClientFuzzyMatcher } from './fuzzy-matching';
+
 // Note: measureNormalization import removed as it's not used in this file
 
 // Types for batch processing results
@@ -26,7 +30,13 @@ interface BatchProcessingResult {
 
 export interface ProcessingStatus {
   /** Current processing step */
-  step: 'initializing' | 'parsing' | 'applying' | 'validating' | 'completed' | 'error';
+  step:
+    | 'initializing'
+    | 'parsing'
+    | 'applying'
+    | 'validating'
+    | 'completed'
+    | 'error';
   /** Progress percentage (0-100) */
   progress: number;
   /** Current operation description */
@@ -61,7 +71,6 @@ export interface ProcessingOptions {
 // -----------------------------------------------------------------------------
 
 export class ClientSearchReplaceProcessor {
-
   private fuzzyMatcher: ClientFuzzyMatcher;
 
   private diffEngine: ClientDiffApplicationEngine;
@@ -73,8 +82,8 @@ export class ClientSearchReplaceProcessor {
   private currentStatus: ProcessingStatus | null = null;
 
   constructor(
-      config: Partial<ProcessorConfig> = {},
-      errorHandler?: ClientErrorHandler,
+    config: Partial<ProcessorConfig> = {},
+    errorHandler?: ClientErrorHandler,
   ) {
     // Browser-optimized defaults
     this.config = {
@@ -87,7 +96,10 @@ export class ClientSearchReplaceProcessor {
     };
 
     this.fuzzyMatcher = new ClientFuzzyMatcher(this.config.fuzzyThreshold);
-    this.diffEngine = new ClientDiffApplicationEngine(this.config, errorHandler);
+    this.diffEngine = new ClientDiffApplicationEngine(
+      this.config,
+      errorHandler,
+    );
     this.errorHandler = errorHandler ?? new ClientErrorHandler();
   }
 
@@ -95,9 +107,9 @@ export class ClientSearchReplaceProcessor {
    * Process multiple diffs with real-time progress and browser optimization
    */
   async processMultipleDiffs(
-      content: string,
-      diffs: LlmEditorAssistantDiff[],
-      options: ProcessingOptions = {},
+    content: string,
+    diffs: LlmEditorAssistantDiff[],
+    options: ProcessingOptions = {},
   ): Promise<DiffApplicationResult> {
     const {
       enableProgressCallbacks = true,
@@ -135,7 +147,9 @@ export class ClientSearchReplaceProcessor {
 
       if (diffs.length > this.config.maxDiffBlocks) {
         const error = this.errorHandler.createContentError(
-          new Error(`Too many diffs: ${diffs.length} > ${this.config.maxDiffBlocks}`),
+          new Error(
+            `Too many diffs: ${diffs.length} > ${this.config.maxDiffBlocks}`,
+          ),
           'Diff count validation',
         );
         return {
@@ -159,8 +173,7 @@ export class ClientSearchReplaceProcessor {
         const validation = this.diffEngine.validateDiff(diff);
         if (validation.valid) {
           validDiffs.push(diff);
-        }
-        else {
+        } else {
           validationErrors.push(
             this.errorHandler.createContentError(
               new Error(validation.issues.join(', ')),
@@ -179,7 +192,11 @@ export class ClientSearchReplaceProcessor {
       }
 
       // Update status
-      this.updateStatus('applying', 20, `Applying ${validDiffs.length} diffs...`);
+      this.updateStatus(
+        'applying',
+        20,
+        `Applying ${validDiffs.length} diffs...`,
+      );
       if (enableProgressCallbacks && onProgress && this.currentStatus) {
         onProgress(this.currentStatus);
       }
@@ -204,25 +221,36 @@ export class ClientSearchReplaceProcessor {
         success: results.errors.length === 0,
         appliedCount: results.appliedCount,
         content: results.finalContent,
-        failedParts: [...validationErrors, ...results.errors.map(e => e.error).filter((error): error is DiffError => error !== undefined)],
+        failedParts: [
+          ...validationErrors,
+          ...results.errors
+            .map((e) => e.error)
+            .filter((error): error is DiffError => error !== undefined),
+        ],
       };
 
       // Performance monitoring
       if (enablePerformanceMonitoring) {
         const totalTime = performance.now() - startTime;
-        this.logPerformanceMetrics(totalTime, diffs.length, results.appliedCount);
+        this.logPerformanceMetrics(
+          totalTime,
+          diffs.length,
+          results.appliedCount,
+        );
       }
 
       // Update status
-      this.updateStatus('completed', 100, `Completed: ${results.appliedCount}/${diffs.length} diffs applied`);
+      this.updateStatus(
+        'completed',
+        100,
+        `Completed: ${results.appliedCount}/${diffs.length} diffs applied`,
+      );
       if (enableProgressCallbacks && onProgress && this.currentStatus) {
         onProgress(this.currentStatus);
       }
 
       return finalResult;
-
-    }
-    catch (error) {
+    } catch (error) {
       const processingError = this.errorHandler.createContentError(
         error as Error,
         'Main processing error',
@@ -249,11 +277,11 @@ export class ClientSearchReplaceProcessor {
    * Process diffs in batches to prevent browser blocking
    */
   private async processDiffsInBatches(
-      content: string,
-      diffs: LlmEditorAssistantDiff[],
-      batchSize: number,
-      maxProcessingTime: number,
-      onProgress?: (status: ProcessingStatus) => void,
+    content: string,
+    diffs: LlmEditorAssistantDiff[],
+    batchSize: number,
+    maxProcessingTime: number,
+    onProgress?: (status: ProcessingStatus) => void,
   ): Promise<BatchProcessingResult> {
     let currentContent = content;
     let totalApplied = 0;
@@ -279,16 +307,24 @@ export class ClientSearchReplaceProcessor {
 
       // Update progress
       const progress = Math.floor((processedCount / diffs.length) * 70) + 20; // 20-90% range
-      this.updateStatus('applying', progress, `Processing batch ${batchIndex + 1}...`, processedCount);
+      this.updateStatus(
+        'applying',
+        progress,
+        `Processing batch ${batchIndex + 1}...`,
+        processedCount,
+      );
       if (onProgress && this.currentStatus) {
         onProgress(this.currentStatus);
       }
 
       // Process batch
-      const batchResult = this.diffEngine.applyMultipleDiffs(currentContent, batch);
+      const batchResult = this.diffEngine.applyMultipleDiffs(
+        currentContent,
+        batch,
+      );
 
-      allResults.push(...batchResult.results.map(r => ({ error: r.error })));
-      allErrors.push(...batchResult.errors.map(e => ({ error: e.error })));
+      allResults.push(...batchResult.results.map((r) => ({ error: r.error })));
+      allErrors.push(...batchResult.errors.map((e) => ({ error: e.error })));
       totalApplied += batchResult.appliedCount;
 
       if (batchResult.finalContent) {
@@ -327,10 +363,10 @@ export class ClientSearchReplaceProcessor {
    * Update processing status
    */
   private updateStatus(
-      step: ProcessingStatus['step'],
-      progress: number,
-      description: string,
-      processedCount?: number,
+    step: ProcessingStatus['step'],
+    progress: number,
+    description: string,
+    processedCount?: number,
   ): void {
     if (!this.currentStatus) return;
 
@@ -354,9 +390,9 @@ export class ClientSearchReplaceProcessor {
    * Log performance metrics for optimization
    */
   private logPerformanceMetrics(
-      totalTime: number,
-      totalDiffs: number,
-      appliedDiffs: number,
+    totalTime: number,
+    totalDiffs: number,
+    appliedDiffs: number,
   ): void {
     const metrics = {
       totalTime: Math.round(totalTime),
@@ -366,11 +402,17 @@ export class ClientSearchReplaceProcessor {
     };
 
     // eslint-disable-next-line no-console
-    console.info('[ClientSearchReplaceProcessor] Performance metrics:', metrics);
+    console.info(
+      '[ClientSearchReplaceProcessor] Performance metrics:',
+      metrics,
+    );
 
     if (totalTime > 5000) {
       // eslint-disable-next-line no-console
-      console.warn('[ClientSearchReplaceProcessor] Slow processing detected:', metrics);
+      console.warn(
+        '[ClientSearchReplaceProcessor] Slow processing detected:',
+        metrics,
+      );
     }
   }
 
@@ -409,5 +451,4 @@ export class ClientSearchReplaceProcessor {
       this.updateStatus('error', 0, 'Processing cancelled by user');
     }
   }
-
 }

+ 25 - 15
apps/app/src/features/openai/client/services/editor-assistant/search-replace-engine.spec.ts

@@ -1,10 +1,10 @@
-import { type Text as YText, Doc as YDoc } from 'yjs';
+import { Doc as YDoc, type Text as YText } from 'yjs';
 
 import {
-  performSearchReplace,
-  performExactSearchReplace,
-  getLineFromIndex,
   getContextAroundLine,
+  getLineFromIndex,
+  performExactSearchReplace,
+  performSearchReplace,
 } from './search-replace-engine';
 
 // Test utilities
@@ -107,11 +107,7 @@ describe('search-replace-engine', () => {
       const content = createTestContent();
       const ytext = createYTextFromString(content);
 
-      const success = performExactSearchReplace(
-        ytext,
-        '',
-        'replacement',
-      );
+      const success = performExactSearchReplace(ytext, '', 'replacement');
 
       expect(success).toBe(true); // Empty string is found at index 0
       expect(ytext.toString()).toContain('replacement');
@@ -183,8 +179,7 @@ console.log("different");`;
       // May pass or fail depending on similarity threshold
       if (success) {
         expect(ytext.toString()).toContain('console.log("world");');
-      }
-      else {
+      } else {
         expect(ytext.toString()).toBe(content); // Unchanged if fuzzy match fails
       }
     });
@@ -286,7 +281,9 @@ line5`;
       const contextSmall = getContextAroundLine(content, 5, 1);
       const contextLarge = getContextAroundLine(content, 5, 3);
 
-      expect(contextLarge.split('\n').length).toBeGreaterThan(contextSmall.split('\n').length);
+      expect(contextLarge.split('\n').length).toBeGreaterThan(
+        contextSmall.split('\n').length,
+      );
     });
   });
 
@@ -319,7 +316,12 @@ line5`;
       const largeContent = `${'line\n'.repeat(1000)}target line\n${'line\n'.repeat(1000)}`;
       const ytext = createYTextFromString(largeContent);
 
-      const success = performSearchReplace(ytext, 'target line', 'found target', 1001);
+      const success = performSearchReplace(
+        ytext,
+        'target line',
+        'found target',
+        1001,
+      );
       if (success) {
         expect(ytext.toString()).toContain('found target');
       }
@@ -329,7 +331,11 @@ line5`;
       const content = 'Hello 👋 World\nこんにちは世界\nLine 3';
       const ytext = createYTextFromString(content);
 
-      const success = performExactSearchReplace(ytext, 'こんにちは世界', 'Hello World');
+      const success = performExactSearchReplace(
+        ytext,
+        'こんにちは世界',
+        'Hello World',
+      );
       expect(success).toBe(true);
       expect(ytext.toString()).toContain('Hello World');
       expect(ytext.toString()).not.toContain('こんにちは世界');
@@ -339,7 +345,11 @@ line5`;
       const content = 'function test() { return /regex/g; }';
       const ytext = createYTextFromString(content);
 
-      const success = performExactSearchReplace(ytext, '/regex/g', '/newregex/g');
+      const success = performExactSearchReplace(
+        ytext,
+        '/regex/g',
+        '/newregex/g',
+      );
       expect(success).toBe(true);
       expect(ytext.toString()).toContain('/newregex/g');
     });

+ 26 - 22
apps/app/src/features/openai/client/services/editor-assistant/search-replace-engine.ts

@@ -1,5 +1,4 @@
-
-import { type Text as YText } from 'yjs';
+import type { Text as YText } from 'yjs';
 
 import { ClientFuzzyMatcher } from './fuzzy-matching';
 
@@ -7,23 +6,19 @@ import { ClientFuzzyMatcher } from './fuzzy-matching';
  * Perform search and replace operation on YText with fuzzy matching
  */
 export function performSearchReplace(
-    yText: YText,
-    searchText: string,
-    replaceText: string,
-    startLine: number,
+  yText: YText,
+  searchText: string,
+  replaceText: string,
+  startLine: number,
 ): boolean {
   const content = yText.toString();
 
   // 1. Start search from the specified line
   const fuzzyMatcher = new ClientFuzzyMatcher();
-  const result = fuzzyMatcher.findBestMatch(
-    content,
-    searchText,
-    {
-      preferredStartLine: startLine,
-      bufferLines: 20, // Search within a range of 20 lines before and after
-    },
-  );
+  const result = fuzzyMatcher.findBestMatch(content, searchText, {
+    preferredStartLine: startLine,
+    bufferLines: 20, // Search within a range of 20 lines before and after
+  });
 
   if (result.success && result.matchedRange) {
     // 2. Replace the found location precisely
@@ -40,10 +35,10 @@ export function performSearchReplace(
  * Exact search without fuzzy matching for testing purposes
  */
 export function performExactSearchReplace(
-    yText: YText,
-    searchText: string,
-    replaceText: string,
-    startLine?: number,
+  yText: YText,
+  searchText: string,
+  replaceText: string,
+  startLine?: number,
 ): boolean {
   const content = yText.toString();
   const lines = content.split('\n');
@@ -78,7 +73,10 @@ export function performExactSearchReplace(
 /**
  * Helper function to get line information from content
  */
-export function getLineFromIndex(content: string, index: number): { lineNumber: number, columnNumber: number } {
+export function getLineFromIndex(
+  content: string,
+  index: number,
+): { lineNumber: number; columnNumber: number } {
   const lines = content.substring(0, index).split('\n');
   const lineNumber = lines.length;
   const columnNumber = lines[lines.length - 1].length;
@@ -89,14 +87,19 @@ export function getLineFromIndex(content: string, index: number): { lineNumber:
 /**
  * Helper function to get content around a specific line for debugging
  */
-export function getContextAroundLine(content: string, lineNumber: number, contextLines = 3): string {
+export function getContextAroundLine(
+  content: string,
+  lineNumber: number,
+  contextLines = 3,
+): string {
   const lines = content.split('\n');
 
   // Handle edge cases for line numbers beyond content
   if (lineNumber > lines.length) {
     // Return the last few lines if requested line is beyond content
     const startLine = Math.max(0, lines.length - contextLines);
-    return lines.slice(startLine)
+    return lines
+      .slice(startLine)
       .map((line, index) => {
         const actualLineNumber = startLine + index + 1;
         return `  ${actualLineNumber}: ${line}`;
@@ -107,7 +110,8 @@ export function getContextAroundLine(content: string, lineNumber: number, contex
   const startLine = Math.max(0, lineNumber - contextLines - 1);
   const endLine = Math.min(lines.length, lineNumber + contextLines);
 
-  return lines.slice(startLine, endLine)
+  return lines
+    .slice(startLine, endLine)
     .map((line, index) => {
       const actualLineNumber = startLine + index + 1;
       const marker = actualLineNumber === lineNumber ? '→' : ' ';

+ 42 - 27
apps/app/src/features/openai/client/services/editor-assistant/text-normalization.ts

@@ -85,13 +85,18 @@ export function normalizeForBrowserFuzzyMatch(text: string): string {
   // Fast typographic character replacement
   normalized = normalized.replace(TYPOGRAPHIC_REGEX, (match) => {
     switch (match) {
-      case '\u2026': return '...';
+      case '\u2026':
+        return '...';
       case '\u2014':
-      case '\u2013': return '-';
+      case '\u2013':
+        return '-';
       case '\u00A0':
-      case '\u2009': return ' ';
-      case '\u200B': return '';
-      default: return match;
+      case '\u2009':
+        return ' ';
+      case '\u200B':
+        return '';
+      default:
+        return match;
     }
   });
 
@@ -109,8 +114,8 @@ export function normalizeForBrowserFuzzyMatch(text: string): string {
  * General client-side string normalization with configurable options
  */
 export function clientNormalizeString(
-    str: string,
-    options: ClientNormalizeOptions = GENERAL_OPTIONS,
+  str: string,
+  options: ClientNormalizeOptions = GENERAL_OPTIONS,
 ): string {
   if (!str) return str;
 
@@ -126,7 +131,11 @@ export function clientNormalizeString(
   // Apply typographic character normalization
   if (options.typographicChars) {
     normalized = normalized.replace(TYPOGRAPHIC_REGEX, (match) => {
-      return CLIENT_NORMALIZATION_MAPS.TYPOGRAPHIC[match as keyof typeof CLIENT_NORMALIZATION_MAPS.TYPOGRAPHIC] || match;
+      return (
+        CLIENT_NORMALIZATION_MAPS.TYPOGRAPHIC[
+          match as keyof typeof CLIENT_NORMALIZATION_MAPS.TYPOGRAPHIC
+        ] || match
+      );
     });
   }
 
@@ -160,14 +169,16 @@ export function clientNormalizeString(
 export function quickNormalizeForFuzzyMatch(text: string): string {
   if (!text) return '';
 
-  return text
-    // Smart quotes (fastest replacement)
-    .replace(/[""]/g, '"')
-    .replace(/['']/g, "'")
-    // Basic whitespace normalization
-    .replace(/\s+/g, ' ')
-    .trim()
-    .toLowerCase();
+  return (
+    text
+      // Smart quotes (fastest replacement)
+      .replace(/[""]/g, '"')
+      .replace(/['']/g, "'")
+      // Basic whitespace normalization
+      .replace(/\s+/g, ' ')
+      .trim()
+      .toLowerCase()
+  );
 }
 
 // -----------------------------------------------------------------------------
@@ -178,11 +189,14 @@ export function quickNormalizeForFuzzyMatch(text: string): string {
  * Check if two strings are equal after client-side normalization
  */
 export function clientNormalizedEquals(
-    str1: string,
-    str2: string,
-    options?: ClientNormalizeOptions,
+  str1: string,
+  str2: string,
+  options?: ClientNormalizeOptions,
 ): boolean {
-  return clientNormalizeString(str1, options) === clientNormalizeString(str2, options);
+  return (
+    clientNormalizeString(str1, options) ===
+    clientNormalizeString(str2, options)
+  );
 }
 
 /**
@@ -209,9 +223,9 @@ export function prepareSimilarityText(text: string): string {
  * Performance-measured normalization with browser optimization
  */
 export function measureNormalization<T>(
-    text: string,
-    normalizer: (text: string) => T,
-    label = 'Text normalization',
+  text: string,
+  normalizer: (text: string) => T,
+  label = 'Text normalization',
 ): { result: T; duration: number } {
   const start = performance.now();
   const result = normalizer(text);
@@ -220,7 +234,9 @@ export function measureNormalization<T>(
   // Log slow normalizations for optimization
   if (duration > 10) {
     // eslint-disable-next-line no-console
-    console.warn(`${label} took ${duration.toFixed(2)}ms for ${text.length} characters`);
+    console.warn(
+      `${label} took ${duration.toFixed(2)}ms for ${text.length} characters`,
+    );
   }
 
   return { result, duration };
@@ -237,7 +253,7 @@ export function checkUnicodeSupport(): {
   nfc: boolean;
   smartQuotes: boolean;
   typographic: boolean;
-  } {
+} {
   try {
     const testString = 'Test\u201C\u2019\u2026';
     const normalized = testString.normalize('NFC');
@@ -247,8 +263,7 @@ export function checkUnicodeSupport(): {
       smartQuotes: testString.includes('\u201C'),
       typographic: testString.includes('\u2026'),
     };
-  }
-  catch (error) {
+  } catch (error) {
     return {
       nfc: false,
       smartQuotes: false,

+ 377 - 274
apps/app/src/features/openai/client/services/editor-assistant/use-editor-assistant.tsx

@@ -1,30 +1,28 @@
-import {
-  useCallback, useEffect, useState, useRef, useMemo,
-} from 'react';
-
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import {
-  acceptAllChunks, useTextSelectionEffect,
+  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 { useForm, type UseFormReturn } from 'react-hook-form';
+import { type UseFormReturn, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
-import { type Text as YText } from 'yjs';
+import type { Text as YText } from 'yjs';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { useIsEnableUnifiedMergeView } from '~/stores-universal/context';
 import { useCurrentPageId } from '~/stores/page';
+import { useIsEnableUnifiedMergeView } from '~/stores-universal/context';
 
 import type { AiAssistantHasId } from '../../../interfaces/ai-assistant';
 import {
-  SseMessageSchema,
+  type EditRequestBody,
+  type SseDetectedDiff,
   SseDetectedDiffSchema,
+  type SseFinalized,
   SseFinalizedSchema,
   type SseMessage,
-  type SseDetectedDiff,
-  type SseFinalized,
-  type EditRequestBody,
+  SseMessageSchema,
 } from '../../../interfaces/editor-assistant/sse-schemas';
 import type { MessageLog } from '../../../interfaces/message';
 import type { IThreadRelationHasId } from '../../../interfaces/thread-relation';
@@ -33,68 +31,73 @@ import { handleIfSuccessfullyParsed } from '../../../utils/handle-if-successfull
 import { AiAssistantDropdown } from '../../components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown';
 import { QuickMenuList } from '../../components/AiAssistant/AiAssistantSidebar/QuickMenuList';
 import { useAiAssistantSidebar } from '../../stores/ai-assistant';
-import { useClientEngineIntegration, shouldUseClientProcessing } from '../client-engine-integration';
-
+import {
+  shouldUseClientProcessing,
+  useClientEngineIntegration,
+} from '../client-engine-integration';
 import { getPageBodyForContext } from './get-page-body-for-context';
 import { performSearchReplace } from './search-replace-engine';
 
-interface CreateThread {
-  (): Promise<IThreadRelationHasId>;
-}
+type CreateThread = () => Promise<IThreadRelationHasId>;
 
 type PostMessageArgs = {
   threadId: string;
   formData: FormData;
-}
+};
 
-interface PostMessage {
-  (args: PostMessageArgs): Promise<Response>;
-}
-interface ProcessMessage {
-  (data: unknown, handler: {
+type PostMessage = (args: PostMessageArgs) => Promise<Response>;
+type 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 GenerateActionButtons {
-  (messageId: string, messageLogs: MessageLog[], generatingAnswerMessage?: MessageLog): JSX.Element;
-}
+  },
+) => void;
+
+type GenerateInitialView = (
+  onSubmit: (data: FormData) => Promise<void>,
+) => JSX.Element;
+type GenerateActionButtons = (
+  messageId: string,
+  messageLogs: MessageLog[],
+  generatingAnswerMessage?: MessageLog,
+) => JSX.Element;
 export interface FormData {
-  input: string,
-  markdownType?: 'full' | 'selected' | 'none'
+  input: string;
+  markdownType?: 'full' | 'selected' | 'none';
 }
 
 type DetectedDiff = Array<{
-  data: SseDetectedDiff,
-  applied: boolean,
-  id: string,
-}>
+  data: SseDetectedDiff;
+  applied: boolean;
+  id: string;
+}>;
 
 type UseEditorAssistant = () => {
-  createThread: CreateThread,
-  postMessage: PostMessage,
-  processMessage: ProcessMessage,
-  form: UseFormReturn<FormData>
-  resetForm: () => void
-  isTextSelected: boolean,
-  isGeneratingEditorText: boolean,
+  createThread: CreateThread;
+  postMessage: PostMessage;
+  processMessage: ProcessMessage;
+  form: UseFormReturn<FormData>;
+  resetForm: () => void;
+  isTextSelected: boolean;
+  isGeneratingEditorText: boolean;
 
   // Views
-  generateInitialView: GenerateInitialView,
-  generatingEditorTextLabel?: JSX.Element,
-  partialContentWarnLabel?: JSX.Element,
-  generateActionButtons: GenerateActionButtons,
-  headerIcon: JSX.Element,
-  headerText: JSX.Element,
-  placeHolder: string,
-}
+  generateInitialView: GenerateInitialView;
+  generatingEditorTextLabel?: JSX.Element;
+  partialContentWarnLabel?: JSX.Element;
+  generateActionButtons: GenerateActionButtons;
+  headerIcon: JSX.Element;
+  headerText: JSX.Element;
+  placeHolder: string;
+};
 
-const insertTextAtLine = (yText: YText, lineNumber: number, textToInsert: string): void => {
+const insertTextAtLine = (
+  yText: YText,
+  lineNumber: number,
+  textToInsert: string,
+): void => {
   // Get the entire text content
   const content = yText.toString();
 
@@ -126,23 +129,36 @@ export const useEditorAssistant: UseEditorAssistant = () => {
 
   // States
   const [detectedDiff, setDetectedDiff] = useState<DetectedDiff>();
-  const [selectedAiAssistant, setSelectedAiAssistant] = useState<AiAssistantHasId>();
+  const [selectedAiAssistant, setSelectedAiAssistant] =
+    useState<AiAssistantHasId>();
   const [selectedText, setSelectedText] = useState<string>();
   const [selectedTextIndex, setSelectedTextIndex] = useState<number>();
-  const [isGeneratingEditorText, setIsGeneratingEditorText] = useState<boolean>(false);
+  const [isGeneratingEditorText, setIsGeneratingEditorText] =
+    useState<boolean>(false);
   const [partialContentInfo, setPartialContentInfo] = useState<{
     startIndex: number;
     endIndex: number;
   } | null>(null);
 
-  const isTextSelected = useMemo(() => selectedText != null && selectedText.length !== 0, [selectedText]);
+  const isTextSelected = useMemo(
+    () => selectedText != null && selectedText.length !== 0,
+    [selectedText],
+  );
 
   // 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: 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();
   const clientEngine = useClientEngineIntegration({
     enableClientProcessing: shouldUseClientProcessing(),
@@ -161,7 +177,7 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     form.reset({ input: '' });
   }, [form]);
 
-  const createThread: CreateThread = useCallback(async() => {
+  const createThread: CreateThread = useCallback(async () => {
     const response = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
       type: ThreadType.EDITOR,
       aiAssistantId: selectedAiAssistant?._id,
@@ -169,166 +185,231 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     return response.data;
   }, [selectedAiAssistant?._id]);
 
-  const postMessage: PostMessage = useCallback(async({ threadId, formData }) => {
-    // Clear partial content info on new request
-    setPartialContentInfo(null);
-
-    // Disable UnifiedMergeView when a Form is submitted with UnifiedMergeView enabled
-    mutateIsEnableUnifiedMergeView(false);
+  const postMessage: PostMessage = useCallback(
+    async ({ threadId, formData }) => {
+      // Clear partial content info on new request
+      setPartialContentInfo(null);
 
-    const pageBodyContext = getPageBodyForContext(codeMirrorEditor, 2000, 8000);
+      // Disable UnifiedMergeView when a Form is submitted with UnifiedMergeView enabled
+      mutateIsEnableUnifiedMergeView(false);
 
-    if (!pageBodyContext) {
-      throw new Error('Unable to get page body context');
-    }
+      const pageBodyContext = getPageBodyForContext(
+        codeMirrorEditor,
+        2000,
+        8000,
+      );
 
-    // Store partial content info if applicable
-    if (pageBodyContext.isPartial && pageBodyContext.startIndex != null && pageBodyContext.endIndex != null) {
-      setPartialContentInfo({
-        startIndex: pageBodyContext.startIndex,
-        endIndex: pageBodyContext.endIndex,
-      });
-    }
+      if (!pageBodyContext) {
+        throw new Error('Unable to get page body context');
+      }
 
-    const requestBody = {
-      threadId,
-      aiAssistantId: selectedAiAssistant?._id,
-      userMessage: formData.input,
-      pageBody: pageBodyContext.content,
-      ...(pageBodyContext.isPartial && {
-        isPageBodyPartial: pageBodyContext.isPartial,
-        partialPageBodyStartIndex: pageBodyContext.startIndex,
-      }),
-      ...(selectedText != null && selectedText.length > 0 && {
-        selectedText,
-        selectedPosition: selectedTextIndex,
-      }),
-    } satisfies EditRequestBody;
-
-    const response = await fetch('/_api/v3/openai/edit', {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify(requestBody),
-    });
+      // Store partial content info if applicable
+      if (
+        pageBodyContext.isPartial &&
+        pageBodyContext.startIndex != null &&
+        pageBodyContext.endIndex != null
+      ) {
+        setPartialContentInfo({
+          startIndex: pageBodyContext.startIndex,
+          endIndex: pageBodyContext.endIndex,
+        });
+      }
 
-    return response;
-  }, [codeMirrorEditor, mutateIsEnableUnifiedMergeView, selectedAiAssistant?._id, selectedText, selectedTextIndex]);
+      const requestBody = {
+        threadId,
+        aiAssistantId: selectedAiAssistant?._id,
+        userMessage: formData.input,
+        pageBody: pageBodyContext.content,
+        ...(pageBodyContext.isPartial && {
+          isPageBodyPartial: pageBodyContext.isPartial,
+          partialPageBodyStartIndex: pageBodyContext.startIndex,
+        }),
+        ...(selectedText != null &&
+          selectedText.length > 0 && {
+            selectedText,
+            selectedPosition: selectedTextIndex,
+          }),
+      } satisfies EditRequestBody;
+
+      const response = await fetch('/_api/v3/openai/edit', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(requestBody),
+      });
 
+      return response;
+    },
+    [
+      codeMirrorEditor,
+      mutateIsEnableUnifiedMergeView,
+      selectedAiAssistant?._id,
+      selectedText,
+      selectedTextIndex,
+    ],
+  );
 
   // Enhanced processMessage with client engine support (保持)
-  const processMessage = useCallback(async(data: unknown, handler: {
-    onMessage: (data: SseMessage) => void;
-    onDetectedDiff: (data: SseDetectedDiff) => void;
-    onFinalized: (data: SseFinalized) => void;
-  }) => {
-    // Reset timer whenever data is received
-    const handleDataReceived = () => {
-    // Clear existing timer
-      if (timerRef.current != null) {
-        clearTimeout(timerRef.current);
-      }
+  const processMessage = useCallback(
+    async (
+      data: unknown,
+      handler: {
+        onMessage: (data: SseMessage) => void;
+        onDetectedDiff: (data: SseDetectedDiff) => void;
+        onFinalized: (data: SseFinalized) => void;
+      },
+    ) => {
+      // Reset timer whenever data is received
+      const handleDataReceived = () => {
+        // Clear existing timer
+        if (timerRef.current != null) {
+          clearTimeout(timerRef.current);
+        }
 
-      // Hide spinner since data is flowing
-      if (isGeneratingEditorText) {
-        setIsGeneratingEditorText(false);
-      }
+        // Hide spinner since data is flowing
+        if (isGeneratingEditorText) {
+          setIsGeneratingEditorText(false);
+        }
 
-      // Set new timer
-      timerRef.current = setTimeout(() => {
-        setIsGeneratingEditorText(true);
-      }, 500);
-    };
+        // Set new timer
+        timerRef.current = setTimeout(() => {
+          setIsGeneratingEditorText(true);
+        }, 500);
+      };
 
-    handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
-      handleDataReceived();
-      handler.onMessage(data);
-    });
+      handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
+        handleDataReceived();
+        handler.onMessage(data);
+      });
 
-    handleIfSuccessfullyParsed(data, SseDetectedDiffSchema, async(diffData: SseDetectedDiff) => {
-      handleDataReceived();
-      mutateIsEnableUnifiedMergeView(true);
-
-      // Check if client engine processing is enabled
-      if (clientEngine.isClientProcessingEnabled && yDocs?.secondaryDoc != null) {
-        try {
-          // Get current content
-          const yText = yDocs.secondaryDoc.getText('codemirror');
-          const currentContent = yText.toString();
-
-          // Process with client engine
-          const result = await clientEngine.processHybrid(
-            currentContent,
-            [diffData],
-            async() => {
-              // Fallback to original server-side processing
-              setDetectedDiff((prev) => {
-                const newData = { data: diffData, applied: false, id: crypto.randomUUID() };
-                if (prev == null) {
-                  return [newData];
+      handleIfSuccessfullyParsed(
+        data,
+        SseDetectedDiffSchema,
+        async (diffData: SseDetectedDiff) => {
+          handleDataReceived();
+          mutateIsEnableUnifiedMergeView(true);
+
+          // Check if client engine processing is enabled
+          if (
+            clientEngine.isClientProcessingEnabled &&
+            yDocs?.secondaryDoc != null
+          ) {
+            try {
+              // Get current content
+              const yText = yDocs.secondaryDoc.getText('codemirror');
+              const currentContent = yText.toString();
+
+              // Process with client engine
+              const result = await clientEngine.processHybrid(
+                currentContent,
+                [diffData],
+                async () => {
+                  // Fallback to original server-side processing
+                  setDetectedDiff((prev) => {
+                    const newData = {
+                      data: diffData,
+                      applied: false,
+                      id: crypto.randomUUID(),
+                    };
+                    if (prev == null) {
+                      return [newData];
+                    }
+                    return [...prev, newData];
+                  });
+                },
+              );
+
+              // Apply result if client processing succeeded
+              if (
+                result.success &&
+                result.method === 'client' &&
+                result.result?.modifiedText
+              ) {
+                const applied = clientEngine.applyToYText(
+                  yText,
+                  result.result.modifiedText,
+                );
+                if (applied) {
+                  handler.onDetectedDiff(diffData);
+                  return;
                 }
-                return [...prev, newData];
-              });
-            },
-          );
-
-          // Apply result if client processing succeeded
-          if (result.success && result.method === 'client' && result.result?.modifiedText) {
-            const applied = clientEngine.applyToYText(yText, result.result.modifiedText);
-            if (applied) {
-              handler.onDetectedDiff(diffData);
-              return;
+              }
+            } catch (error) {
+              // Fall through to server-side processing
             }
           }
-        }
-        catch (error) {
-          // Fall through to server-side processing
-        }
-      }
-
-      // Original server-side processing (fallback or default)
-      setDetectedDiff((prev) => {
-        const newData = { data: diffData, applied: false, id: crypto.randomUUID() };
-        if (prev == null) {
-          return [newData];
-        }
-        return [...prev, newData];
-      });
-      handler.onDetectedDiff(diffData);
-    });
-
-    handleIfSuccessfullyParsed(data, SseFinalizedSchema, (data: SseFinalized) => {
-      handler.onFinalized(data);
-    });
-  }, [isGeneratingEditorText, mutateIsEnableUnifiedMergeView, clientEngine, yDocs]);
-
-  const selectTextHandler = useCallback(({ selectedText, selectedTextIndex, selectedTextFirstLineNumber }) => {
-    setSelectedText(selectedText);
-    setSelectedTextIndex(selectedTextIndex);
-    lineRef.current = selectedTextFirstLineNumber;
-  }, []);
 
+          // Original server-side processing (fallback or default)
+          setDetectedDiff((prev) => {
+            const newData = {
+              data: diffData,
+              applied: false,
+              id: crypto.randomUUID(),
+            };
+            if (prev == null) {
+              return [newData];
+            }
+            return [...prev, newData];
+          });
+          handler.onDetectedDiff(diffData);
+        },
+      );
+
+      handleIfSuccessfullyParsed(
+        data,
+        SseFinalizedSchema,
+        (data: SseFinalized) => {
+          handler.onFinalized(data);
+        },
+      );
+    },
+    [
+      isGeneratingEditorText,
+      mutateIsEnableUnifiedMergeView,
+      clientEngine,
+      yDocs,
+    ],
+  );
+
+  const selectTextHandler = useCallback(
+    ({ selectedText, selectedTextIndex, selectedTextFirstLineNumber }) => {
+      setSelectedText(selectedText);
+      setSelectedTextIndex(selectedTextIndex);
+      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) {
+    const pendingDetectedDiff: DetectedDiff | undefined = detectedDiff?.filter(
+      (diff) => diff.applied === false,
+    );
+    if (
+      yDocs?.secondaryDoc != null &&
+      pendingDetectedDiff != null &&
+      pendingDetectedDiff.length > 0
+    ) {
       const yText = yDocs.secondaryDoc.getText('codemirror');
       yDocs.secondaryDoc.transact(() => {
         pendingDetectedDiff.forEach((detectedDiff) => {
           if (detectedDiff.data.diff) {
             const { search, replace, startLine } = detectedDiff.data.diff;
             // New search and replace processing
-            const success = performSearchReplace(yText, search, replace, startLine);
+            const success = performSearchReplace(
+              yText,
+              search,
+              replace,
+              startLine,
+            );
 
             if (!success) {
               // Fallback: existing behavior
               if (isTextSelected) {
                 insertTextAtLine(yText, lineRef.current, replace);
                 lineRef.current += 1;
-              }
-              else {
+              } else {
                 appendTextLastLine(yText, replace);
               }
             }
@@ -339,7 +420,9 @@ export const useEditorAssistant: UseEditorAssistant = () => {
       // Mark items as applied after applying to secondaryDoc
       setDetectedDiff((prev) => {
         if (!prev) return prev;
-        const pendingDetectedDiffIds = pendingDetectedDiff.map(diff => diff.id);
+        const pendingDetectedDiffIds = pendingDetectedDiff.map(
+          (diff) => diff.id,
+        );
         return prev.map((diff) => {
           if (pendingDetectedDiffIds.includes(diff.id)) {
             return { ...diff, applied: true };
@@ -348,11 +431,14 @@ export const useEditorAssistant: UseEditorAssistant = () => {
         });
       });
     }
-  }, [codeMirrorEditor, detectedDiff, isTextSelected, selectedText, yDocs?.secondaryDoc]);
+  }, [detectedDiff, isTextSelected, yDocs?.secondaryDoc]);
 
   // Set detectedDiff to undefined after applying all detectedDiff to secondaryDoc
   useEffect(() => {
-    if (detectedDiff?.filter(detectedDiff => detectedDiff.applied === false).length === 0) {
+    if (
+      detectedDiff?.filter((detectedDiff) => detectedDiff.applied === false)
+        .length === 0
+    ) {
       setSelectedText(undefined);
       setSelectedTextIndex(undefined);
       setDetectedDiff(undefined);
@@ -370,107 +456,121 @@ export const useEditorAssistant: UseEditorAssistant = () => {
   }, []);
   // Views
   const headerIcon = useMemo(() => {
-    return <span className="material-symbols-outlined growi-ai-chat-icon me-3 fs-4">support_agent</span>;
+    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 placeHolder = useMemo(() => {
+    return 'sidebar_ai_assistant.editor_assistant_placeholder';
+  }, []);
 
-  const generateInitialView: GenerateInitialView = useCallback((onSubmit) => {
-    const selectAiAssistantHandler = (aiAssistant?: AiAssistantHasId) => {
-      setSelectedAiAssistant(aiAssistant);
-    };
+  const generateInitialView: GenerateInitialView = useCallback(
+    (onSubmit) => {
+      const selectAiAssistantHandler = (aiAssistant?: AiAssistantHasId) => {
+        setSelectedAiAssistant(aiAssistant);
+      };
+
+      const clickQuickMenuHandler = async (quickMenu: string) => {
+        await onSubmit({ input: quickMenu, markdownType: 'full' });
+      };
+
+      return (
+        <>
+          <div className="py-2">
+            <AiAssistantDropdown
+              selectedAiAssistant={selectedAiAssistant}
+              onSelect={selectAiAssistantHandler}
+            />
+          </div>
+          <QuickMenuList onClick={clickQuickMenuHandler} />
+        </>
+      );
+    },
+    [selectedAiAssistant],
+  );
+
+  const generateActionButtons: GenerateActionButtons = useCallback(
+    (messageId, messageLogs, generatingAnswerMessage) => {
+      const isActionButtonShown = (() => {
+        if (!aiAssistantSidebarData?.isEditorAssistant) {
+          return false;
+        }
 
-    const clickQuickMenuHandler = async(quickMenu: string) => {
-      await onSubmit({ input: quickMenu, markdownType: 'full' });
-    };
+        if (!isEnableUnifiedMergeView) {
+          return false;
+        }
 
-    return (
-      <>
-        <div className="py-2">
-          <AiAssistantDropdown
-            selectedAiAssistant={selectedAiAssistant}
-            onSelect={selectAiAssistantHandler}
-          />
-        </div>
-        <QuickMenuList
-          onClick={clickQuickMenuHandler}
-        />
-      </>
-    );
-  }, [selectedAiAssistant]);
+        if (generatingAnswerMessage != null) {
+          return false;
+        }
 
-  const generateActionButtons: GenerateActionButtons = useCallback((messageId, messageLogs, generatingAnswerMessage) => {
-    const isActionButtonShown = (() => {
-      if (!aiAssistantSidebarData?.isEditorAssistant) {
-        return false;
-      }
+        const latestAssistantMessageLogId = messageLogs
+          .filter((message) => !message.isUserMessage)
+          .slice(-1)[0];
 
-      if (!isEnableUnifiedMergeView) {
-        return false;
-      }
+        if (messageId === latestAssistantMessageLogId?.id) {
+          return true;
+        }
 
-      if (generatingAnswerMessage != null) {
         return false;
-      }
+      })();
 
-      const latestAssistantMessageLogId = messageLogs
-        .filter(message => !message.isUserMessage)
-        .slice(-1)[0];
+      const accept = () => {
+        if (codeMirrorEditor?.view == null) {
+          return;
+        }
 
-      if (messageId === latestAssistantMessageLogId?.id) {
-        return true;
-      }
+        acceptAllChunks(codeMirrorEditor.view);
+        mutateIsEnableUnifiedMergeView(false);
+      };
 
-      return false;
-    })();
+      const reject = () => {
+        mutateIsEnableUnifiedMergeView(false);
+      };
 
-    const accept = () => {
-      if (codeMirrorEditor?.view == null) {
-        return;
+      if (!isActionButtonShown) {
+        return <></>;
       }
 
-      acceptAllChunks(codeMirrorEditor.view);
-      mutateIsEnableUnifiedMergeView(false);
-    };
-
-    const reject = () => {
-      mutateIsEnableUnifiedMergeView(false);
-    };
-
-    if (!isActionButtonShown) {
-      return <></>;
-    }
-
-    return (
-      <div className="d-flex mt-2 justify-content-start">
-        <button
-          type="button"
-          className="btn btn-outline-secondary me-2"
-          onClick={reject}
-        >
-          {t('sidebar_ai_assistant.discard')}
-        </button>
-        <button
-          type="button"
-          className="btn btn-success"
-          onClick={accept}
-        >
-          {t('sidebar_ai_assistant.accept')}
-        </button>
-      </div>
-    );
-  }, [aiAssistantSidebarData?.isEditorAssistant, codeMirrorEditor?.view, isEnableUnifiedMergeView, mutateIsEnableUnifiedMergeView, t]);
+      return (
+        <div className="d-flex mt-2 justify-content-start">
+          <button
+            type="button"
+            className="btn btn-outline-secondary me-2"
+            onClick={reject}
+          >
+            {t('sidebar_ai_assistant.discard')}
+          </button>
+          <button type="button" className="btn btn-success" onClick={accept}>
+            {t('sidebar_ai_assistant.accept')}
+          </button>
+        </div>
+      );
+    },
+    [
+      aiAssistantSidebarData?.isEditorAssistant,
+      codeMirrorEditor?.view,
+      isEnableUnifiedMergeView,
+      mutateIsEnableUnifiedMergeView,
+      t,
+    ],
+  );
 
   const generatingEditorTextLabel = useMemo(() => {
     return (
       <>
         {isGeneratingEditorText && (
           <span className="text-thinking">
-            {t('sidebar_ai_assistant.text_generation_by_editor_assistant_label')}
+            {t(
+              'sidebar_ai_assistant.text_generation_by_editor_assistant_label',
+            )}
           </span>
         )}
       </>
@@ -491,8 +591,7 @@ export const useEditorAssistant: UseEditorAssistant = () => {
       try {
         // return line number if possible
         return doc.lineAt(index).number;
-      }
-      catch {
+      } catch {
         // Fallback: return character index and switch to character mode
         isLineMode = false;
         return index + 1;
@@ -508,9 +607,7 @@ export const useEditorAssistant: UseEditorAssistant = () => {
 
     return (
       <div className="alert alert-warning py-2 px-3 mb-3" role="alert">
-        <small>
-          {t(translationKey, { startPosition, endPosition })}
-        </small>
+        <small>{t(translationKey, { startPosition, endPosition })}</small>
       </div>
     );
   }, [partialContentInfo, t, codeMirrorEditor]);
@@ -536,6 +633,12 @@ export const useEditorAssistant: UseEditorAssistant = () => {
 };
 
 // type guard
-export const isEditorAssistantFormData = (formData: unknown): formData is FormData => {
-  return typeof formData === 'object' && formData != null && 'markdownType' in formData;
+export const isEditorAssistantFormData = (
+  formData: unknown,
+): formData is FormData => {
+  return (
+    typeof formData === 'object' &&
+    formData != null &&
+    'markdownType' in formData
+  );
 };

+ 206 - 154
apps/app/src/features/openai/client/services/knowledge-assistant.tsx

@@ -1,31 +1,39 @@
 import type { Dispatch, SetStateAction } from 'react';
-import {
-  useCallback, useMemo, useState, useEffect,
-} from 'react';
-
-import { useForm, type UseFormReturn } from 'react-hook-form';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { type UseFormReturn, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import {
-  UncontrolledTooltip, Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  UncontrolledTooltip,
 } from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import {
-  SseMessageSchema, type SseMessage, SsePreMessageSchema, type SsePreMessage,
+  type SseMessage,
+  SseMessageSchema,
+  type SsePreMessage,
+  SsePreMessageSchema,
 } from '~/features/openai/interfaces/knowledge-assistant/sse-schemas';
 import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
 
-import type { MessageLog, MessageWithCustomMetaData } from '../../interfaces/message';
+import type {
+  MessageLog,
+  MessageWithCustomMetaData,
+} from '../../interfaces/message';
 import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
 import { ThreadType } from '../../interfaces/thread-relation';
 import { AiAssistantChatInitialView } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView';
 import { useAiAssistantSidebar } from '../stores/ai-assistant';
 import { useSWRMUTxMessages } from '../stores/message';
-import { useSWRMUTxThreads, useSWRINFxRecentThreads } from '../stores/thread';
+import { useSWRINFxRecentThreads, useSWRMUTxThreads } from '../stores/thread';
 
-interface CreateThread {
-  (aiAssistantId: string, initialUserMessage: string): Promise<IThreadRelationHasId>;
-}
+type CreateThread = (
+  aiAssistantId: string,
+  initialUserMessage: string,
+) => Promise<IThreadRelationHasId>;
 
 type PostMessageArgs = {
   aiAssistantId: string;
@@ -33,47 +41,44 @@ type PostMessageArgs = {
   formData: FormData;
 };
 
-interface PostMessage {
-  (args: PostMessageArgs): Promise<Response>;
-}
+type PostMessage = (args: PostMessageArgs) => Promise<Response>;
 
-interface ProcessMessage {
-  (data: unknown, handler: {
-    onMessage: (data: SseMessage) => void
-    onPreMessage: (data: SsePreMessage) => void
-  }
-  ): void;
-}
+type ProcessMessage = (
+  data: unknown,
+  handler: {
+    onMessage: (data: SseMessage) => void;
+    onPreMessage: (data: SsePreMessage) => void;
+  },
+) => void;
 
 export interface FormData {
-  input: string
-  summaryMode?: boolean
-  extendedThinkingMode?: boolean
+  input: string;
+  summaryMode?: boolean;
+  extendedThinkingMode?: boolean;
 }
 
-interface GenerateModeSwitchesDropdown {
-  (isGenerating: boolean): JSX.Element
-}
+type GenerateModeSwitchesDropdown = (isGenerating: boolean) => JSX.Element;
 
 type UseKnowledgeAssistant = () => {
-  createThread: CreateThread
-  postMessage: PostMessage
-  processMessage: ProcessMessage
-  form: UseFormReturn<FormData>
-  resetForm: () => void
-  threadTitleView: JSX.Element
+  createThread: CreateThread;
+  postMessage: PostMessage;
+  processMessage: ProcessMessage;
+  form: UseFormReturn<FormData>;
+  resetForm: () => void;
+  threadTitleView: JSX.Element;
 
   // Views
-  initialView: JSX.Element
-  generateModeSwitchesDropdown: GenerateModeSwitchesDropdown
-  headerIcon: JSX.Element
-  headerText: JSX.Element
-  placeHolder: string
-}
+  initialView: JSX.Element;
+  generateModeSwitchesDropdown: GenerateModeSwitchesDropdown;
+  headerIcon: JSX.Element;
+  headerText: JSX.Element;
+  placeHolder: string;
+};
 
 export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
   // Hooks
-  const { data: aiAssistantSidebarData, refreshThreadData } = useAiAssistantSidebar();
+  const { data: aiAssistantSidebarData, refreshThreadData } =
+    useAiAssistantSidebar();
   const { aiAssistantData } = aiAssistantSidebarData ?? {};
   const { mutate: mutateRecentThreads } = useSWRINFxRecentThreads();
   const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData?._id);
@@ -97,58 +102,74 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
     form.reset({ input: '', summaryMode, extendedThinkingMode });
   }, [form]);
 
-  const createThread: CreateThread = useCallback(async(aiAssistantId, initialUserMessage) => {
-    const response = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
-      type: ThreadType.KNOWLEDGE,
-      aiAssistantId,
-      initialUserMessage,
-    });
-    const thread = response.data;
-
-    // No need to await because data is not used
-    mutateThreadData();
-
-    return thread;
-  }, [mutateThreadData]);
-
-  const postMessage: PostMessage = useCallback(async({ aiAssistantId, threadId, formData }) => {
-    const response = await fetch('/_api/v3/openai/message', {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
+  const createThread: CreateThread = useCallback(
+    async (aiAssistantId, initialUserMessage) => {
+      const response = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
+        type: ThreadType.KNOWLEDGE,
         aiAssistantId,
-        threadId,
-        userMessage: formData.input,
-        summaryMode: form.getValues('summaryMode'),
-        extendedThinkingMode: form.getValues('extendedThinkingMode'),
-      }),
-    });
+        initialUserMessage,
+      });
+      const thread = response.data;
 
-    mutateRecentThreads();
+      // No need to await because data is not used
+      mutateThreadData();
 
-    return response;
-  }, [form, mutateRecentThreads]);
+      return thread;
+    },
+    [mutateThreadData],
+  );
+
+  const postMessage: PostMessage = useCallback(
+    async ({ aiAssistantId, threadId, formData }) => {
+      const response = await fetch('/_api/v3/openai/message', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          aiAssistantId,
+          threadId,
+          userMessage: formData.input,
+          summaryMode: form.getValues('summaryMode'),
+          extendedThinkingMode: form.getValues('extendedThinkingMode'),
+        }),
+      });
+
+      mutateRecentThreads();
+
+      return response;
+    },
+    [form, mutateRecentThreads],
+  );
 
   const processMessage: ProcessMessage = useCallback((data, handler) => {
     handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
       handler.onMessage(data);
     });
 
-    handleIfSuccessfullyParsed(data, SsePreMessageSchema, (data: SsePreMessage) => {
-      handler.onPreMessage(data);
-    });
+    handleIfSuccessfullyParsed(
+      data,
+      SsePreMessageSchema,
+      (data: SsePreMessage) => {
+        handler.onPreMessage(data);
+      },
+    );
   }, []);
 
   // Views
   const headerIcon = useMemo(() => {
-    return <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>;
+    return (
+      <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">
+        ai_assistant
+      </span>
+    );
   }, []);
 
   const headerText = useMemo(() => {
     return <>{aiAssistantData?.name}</>;
   }, [aiAssistantData?.name]);
 
-  const placeHolder = useMemo(() => { return 'sidebar_ai_assistant.knowledge_assistant_placeholder' }, []);
+  const placeHolder = useMemo(() => {
+    return 'sidebar_ai_assistant.knowledge_assistant_placeholder';
+  }, []);
 
   const initialView = useMemo(() => {
     if (aiAssistantSidebarData?.aiAssistantData == null) {
@@ -158,7 +179,9 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
     return (
       <AiAssistantChatInitialView
         description={aiAssistantSidebarData.aiAssistantData.description}
-        pagePathPatterns={aiAssistantSidebarData.aiAssistantData.pagePathPatterns}
+        pagePathPatterns={
+          aiAssistantSidebarData.aiAssistantData.pagePathPatterns
+        }
       />
     );
   }, [aiAssistantSidebarData?.aiAssistantData]);
@@ -166,74 +189,93 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
   const toggleDropdown = useCallback(() => {
-    setDropdownOpen(prevState => !prevState);
+    setDropdownOpen((prevState) => !prevState);
   }, []);
 
-  const generateModeSwitchesDropdown: GenerateModeSwitchesDropdown = useCallback((isGenerating) => {
-    return (
-      <Dropdown isOpen={dropdownOpen} toggle={toggleDropdown} direction="up">
-        <DropdownToggle size="sm" outline className="border-0">
-          <span className="material-symbols-outlined">tune</span>
-        </DropdownToggle>
-        <DropdownMenu>
-          <DropdownItem tag="div" toggle={false}>
-            <div className="form-check form-switch">
-              <input
-                id="swSummaryMode"
-                type="checkbox"
-                role="switch"
-                className="form-check-input"
-                {...form.register('summaryMode')}
-                disabled={form.formState.isSubmitting || isGenerating}
-              />
-              <label className="form-check-label" htmlFor="swSummaryMode">
-                {t('sidebar_ai_assistant.summary_mode_label')}
-              </label>
-              <a
-                id="tooltipForHelpOfSummaryMode"
-                role="button"
-                className="ms-1"
-              >
-                <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
-              </a>
-              <UncontrolledTooltip
-                target="tooltipForHelpOfSummaryMode"
-              >
-                {t('sidebar_ai_assistant.summary_mode_help')}
-              </UncontrolledTooltip>
-            </div>
-          </DropdownItem>
-          <DropdownItem tag="div" toggle={false}>
-            <div className="form-check form-switch">
-              <input
-                id="swExtendedThinkingMode"
-                type="checkbox"
-                role="switch"
-                className="form-check-input"
-                {...form.register('extendedThinkingMode')}
-                disabled={form.formState.isSubmitting || isGenerating}
-              />
-              <label className="form-check-label" htmlFor="swExtendedThinkingMode">
-                {t('sidebar_ai_assistant.extended_thinking_mode_label')}
-              </label>
-              <a
-                id="tooltipForHelpOfExtendedThinkingMode"
-                role="button"
-                className="ms-1"
-              >
-                <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
-              </a>
-              <UncontrolledTooltip
-                target="tooltipForHelpOfExtendedThinkingMode"
-              >
-                {t('sidebar_ai_assistant.extended_thinking_mode_help')}
-              </UncontrolledTooltip>
-            </div>
-          </DropdownItem>
-        </DropdownMenu>
-      </Dropdown>
+  const generateModeSwitchesDropdown: GenerateModeSwitchesDropdown =
+    useCallback(
+      (isGenerating) => {
+        return (
+          <Dropdown
+            isOpen={dropdownOpen}
+            toggle={toggleDropdown}
+            direction="up"
+          >
+            <DropdownToggle size="sm" outline className="border-0">
+              <span className="material-symbols-outlined">tune</span>
+            </DropdownToggle>
+            <DropdownMenu>
+              <DropdownItem tag="div" toggle={false}>
+                <div className="form-check form-switch">
+                  <input
+                    id="swSummaryMode"
+                    type="checkbox"
+                    role="switch"
+                    aria-checked={form.watch('summaryMode')}
+                    className="form-check-input"
+                    {...form.register('summaryMode')}
+                    disabled={form.formState.isSubmitting || isGenerating}
+                  />
+                  <label className="form-check-label" htmlFor="swSummaryMode">
+                    {t('sidebar_ai_assistant.summary_mode_label')}
+                  </label>
+                  <button
+                    type="button"
+                    id="tooltipForHelpOfSummaryMode"
+                    className="btn btn-link p-0 ms-1"
+                  >
+                    <span
+                      className="material-symbols-outlined fs-6"
+                      style={{ lineHeight: 'unset' }}
+                    >
+                      help
+                    </span>
+                  </button>
+                  <UncontrolledTooltip target="tooltipForHelpOfSummaryMode">
+                    {t('sidebar_ai_assistant.summary_mode_help')}
+                  </UncontrolledTooltip>
+                </div>
+              </DropdownItem>
+              <DropdownItem tag="div" toggle={false}>
+                <div className="form-check form-switch">
+                  <input
+                    id="swExtendedThinkingMode"
+                    type="checkbox"
+                    role="switch"
+                    aria-checked={form.watch('extendedThinkingMode')}
+                    className="form-check-input"
+                    {...form.register('extendedThinkingMode')}
+                    disabled={form.formState.isSubmitting || isGenerating}
+                  />
+                  <label
+                    className="form-check-label"
+                    htmlFor="swExtendedThinkingMode"
+                  >
+                    {t('sidebar_ai_assistant.extended_thinking_mode_label')}
+                  </label>
+                  <button
+                    type="button"
+                    id="tooltipForHelpOfExtendedThinkingMode"
+                    className="btn btn-link p-0 ms-1"
+                  >
+                    <span
+                      className="material-symbols-outlined fs-6"
+                      style={{ lineHeight: 'unset' }}
+                    >
+                      help
+                    </span>
+                  </button>
+                  <UncontrolledTooltip target="tooltipForHelpOfExtendedThinkingMode">
+                    {t('sidebar_ai_assistant.extended_thinking_mode_help')}
+                  </UncontrolledTooltip>
+                </div>
+              </DropdownItem>
+            </DropdownMenu>
+          </Dropdown>
+        );
+      },
+      [dropdownOpen, toggleDropdown, form, t],
     );
-  }, [dropdownOpen, toggleDropdown, form, t]);
 
   const threadTitleView = useMemo(() => {
     const { threadData } = aiAssistantSidebarData ?? {};
@@ -276,10 +318,9 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
   };
 };
 
-
 // Helper function to transform API message data to MessageLog[]
 const transformApiMessagesToLogs = (
-    apiMessageData: MessageWithCustomMetaData | null | undefined,
+  apiMessageData: MessageWithCustomMetaData | null | undefined,
 ): MessageLog[] => {
   if (apiMessageData?.data == null || !Array.isArray(apiMessageData.data)) {
     return [];
@@ -291,11 +332,16 @@ const transformApiMessagesToLogs = (
   return apiMessageData.data
     .slice() // Create a shallow copy before reversing
     .reverse()
-    .filter((message: ApiMessageItem) => message.metadata?.shouldHideMessage !== 'true')
+    .filter(
+      (message: ApiMessageItem) =>
+        message.metadata?.shouldHideMessage !== 'true',
+    )
     .map((message: ApiMessageItem): MessageLog => {
       // Extract the first text content block, if any
       let messageTextContent = '';
-      const textContentBlock = message.content?.find(contentBlock => contentBlock.type === 'text');
+      const textContentBlock = message.content?.find(
+        (contentBlock) => contentBlock.type === 'text',
+      );
       if (textContentBlock != null && textContentBlock.type === 'text') {
         messageTextContent = textContentBlock.text.value;
       }
@@ -309,8 +355,8 @@ const transformApiMessagesToLogs = (
 };
 
 export const useFetchAndSetMessageDataEffect = (
-    setMessageLogs: Dispatch<SetStateAction<MessageLog[]>>,
-    threadId?: string,
+  setMessageLogs: Dispatch<SetStateAction<MessageLog[]>>,
+  threadId?: string,
 ): void => {
   const { data: aiAssistantSidebarData } = useAiAssistantSidebar();
   const { trigger: mutateMessageData } = useSWRMUTxMessages(
@@ -328,29 +374,35 @@ export const useFetchAndSetMessageDataEffect = (
       return; // Early return if no threadId
     }
 
-    const fetchAndSetLogs = async() => {
+    const fetchAndSetLogs = async () => {
       try {
         // Assuming mutateMessageData() returns a Promise<MessageWithCustomMetaData | null | undefined>
-        const rawApiMessageData: MessageWithCustomMetaData | null | undefined = await mutateMessageData();
+        const rawApiMessageData: MessageWithCustomMetaData | null | undefined =
+          await mutateMessageData();
         const fetchedLogs = transformApiMessagesToLogs(rawApiMessageData);
 
         setMessageLogs((currentLogs) => {
           // Preserve current logs if they represent a single, user-submitted message
           // AND the newly fetched logs are empty (common for new threads).
-          const shouldPreserveCurrentMessage = currentLogs.length === 1
-            && currentLogs[0].isUserMessage
-            && fetchedLogs.length === 0;
+          const shouldPreserveCurrentMessage =
+            currentLogs.length === 1 &&
+            currentLogs[0].isUserMessage &&
+            fetchedLogs.length === 0;
 
           // Update with fetched logs, or preserve current if applicable
           return shouldPreserveCurrentMessage ? currentLogs : fetchedLogs;
         });
-      }
-      catch (error) {
+      } catch (error) {
         // console.error('Failed to fetch or process message data:', error); // Optional: for debugging
         setMessageLogs([]); // Clear logs on error to avoid inconsistent state
       }
     };
 
     fetchAndSetLogs();
-  }, [threadId, mutateMessageData, setMessageLogs, aiAssistantSidebarData?.isEditorAssistant]); // Dependencies
+  }, [
+    threadId,
+    mutateMessageData,
+    setMessageLogs,
+    aiAssistantSidebarData?.isEditorAssistant,
+  ]); // Dependencies
 };

+ 6 - 2
apps/app/src/features/openai/client/services/thread.ts

@@ -2,6 +2,10 @@ import { apiv3Delete } from '~/client/util/apiv3-client';
 
 import type { IApiv3DeleteThreadParams } from '../../interfaces/thread-relation';
 
-export const deleteThread = async(params: IApiv3DeleteThreadParams): Promise<void> => {
-  await apiv3Delete(`/openai/thread/${params.aiAssistantId}/${params.threadRelationId}`);
+export const deleteThread = async (
+  params: IApiv3DeleteThreadParams,
+): Promise<void> => {
+  await apiv3Delete(
+    `/openai/thread/${params.aiAssistantId}/${params.threadRelationId}`,
+  );
 };

+ 19 - 15
apps/app/src/features/openai/client/services/use-selected-pages.tsx

@@ -1,22 +1,24 @@
-import {
-  useState, useCallback, useEffect, useMemo, useRef,
-} from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 
 import type { SelectablePage } from '../../interfaces/selectable-page';
 import { useAiAssistantManagementModal } from '../stores/ai-assistant';
 
-
 type UseSelectedPages = {
-  selectedPages: Map<string, SelectablePage>,
-  selectedPagesRef: React.RefObject<Map<string, SelectablePage>>,
-  selectedPagesArray: SelectablePage[],
-  addPage: (page: SelectablePage) => void,
-  removePage: (page: SelectablePage) => void,
-}
+  selectedPages: Map<string, SelectablePage>;
+  selectedPagesRef: React.RefObject<Map<string, SelectablePage>>;
+  selectedPagesArray: SelectablePage[];
+  addPage: (page: SelectablePage) => void;
+  removePage: (page: SelectablePage) => void;
+};
 
-export const useSelectedPages = (initialPages?: SelectablePage[]): UseSelectedPages => {
-  const [selectedPages, setSelectedPages] = useState<Map<string, SelectablePage>>(new Map());
-  const { data: aiAssistantManagementModalData } = useAiAssistantManagementModal();
+export const useSelectedPages = (
+  initialPages?: SelectablePage[],
+): UseSelectedPages => {
+  const [selectedPages, setSelectedPages] = useState<
+    Map<string, SelectablePage>
+  >(new Map());
+  const { data: aiAssistantManagementModalData } =
+    useAiAssistantManagementModal();
 
   const selectedPagesRef = useRef(selectedPages);
 
@@ -30,7 +32,10 @@ export const useSelectedPages = (initialPages?: SelectablePage[]): UseSelectedPa
 
   useEffect(() => {
     // Initialize each time PageMode is changed
-    if (initialPages != null && aiAssistantManagementModalData?.pageMode != null) {
+    if (
+      initialPages != null &&
+      aiAssistantManagementModalData?.pageMode != null
+    ) {
       const initialMap = new Map<string, SelectablePage>();
       initialPages.forEach((page) => {
         if (page.path != null) {
@@ -61,7 +66,6 @@ export const useSelectedPages = (initialPages?: SelectablePage[]): UseSelectedPa
     });
   }, []);
 
-
   return {
     selectedPages,
     selectedPagesRef,

+ 108 - 64
apps/app/src/features/openai/client/stores/ai-assistant.tsx

@@ -1,18 +1,19 @@
 import { useCallback } from 'react';
-
 import { useSWRStatic } from '@growi/core/dist/swr';
-import { type SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 
-import { type AccessibleAiAssistantsHasId, type AiAssistantHasId } from '../../interfaces/ai-assistant';
+import type {
+  AccessibleAiAssistantsHasId,
+  AiAssistantHasId,
+} from '../../interfaces/ai-assistant';
 import type { IThreadRelationHasId } from '../../interfaces/thread-relation'; // IThreadHasId を削除
 
-
 /*
-*  useAiAssistantManagementModal
-*/
+ *  useAiAssistantManagementModal
+ */
 export const AiAssistantManagementModalPageMode = {
   HOME: 'home',
   SHARE: 'share',
@@ -23,112 +24,155 @@ export const AiAssistantManagementModalPageMode = {
   PAGE_TREE_SELECTION: 'page-tree-selection',
 } as const;
 
-export type AiAssistantManagementModalPageMode = typeof AiAssistantManagementModalPageMode[keyof typeof AiAssistantManagementModalPageMode];
+export type AiAssistantManagementModalPageMode =
+  (typeof AiAssistantManagementModalPageMode)[keyof typeof AiAssistantManagementModalPageMode];
 
 type AiAssistantManagementModalStatus = {
-  isOpened: boolean,
-  pageMode?: AiAssistantManagementModalPageMode,
+  isOpened: boolean;
+  pageMode?: AiAssistantManagementModalPageMode;
   aiAssistantData?: AiAssistantHasId;
-}
+};
 
 type AiAssistantManagementModalUtils = {
-  open(aiAssistantData?: AiAssistantHasId): void
-  close(): void
-  changePageMode(pageType: AiAssistantManagementModalPageMode): void
-}
+  open(aiAssistantData?: AiAssistantHasId): void;
+  close(): void;
+  changePageMode(pageType: AiAssistantManagementModalPageMode): void;
+};
 
 export const useAiAssistantManagementModal = (
-    status?: AiAssistantManagementModalStatus,
-): SWRResponse<AiAssistantManagementModalStatus, Error> & AiAssistantManagementModalUtils => {
-  const initialStatus = { isOpened: false, pageType: AiAssistantManagementModalPageMode.HOME };
-  const swrResponse = useSWRStatic<AiAssistantManagementModalStatus, Error>('AiAssistantManagementModal', status, { fallbackData: initialStatus });
+  status?: AiAssistantManagementModalStatus,
+): SWRResponse<AiAssistantManagementModalStatus, Error> &
+  AiAssistantManagementModalUtils => {
+  const initialStatus = {
+    isOpened: false,
+    pageType: AiAssistantManagementModalPageMode.HOME,
+  };
+  const swrResponse = useSWRStatic<AiAssistantManagementModalStatus, Error>(
+    'AiAssistantManagementModal',
+    status,
+    { fallbackData: initialStatus },
+  );
 
   return {
     ...swrResponse,
-    open: useCallback((aiAssistantData) => {
-      swrResponse.mutate({
-        isOpened: true,
-        aiAssistantData,
-        pageMode: aiAssistantData != null
-          ? AiAssistantManagementModalPageMode.HOME
-          : AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD,
-      });
-    }, [swrResponse]),
-    close: useCallback(() => swrResponse.mutate({ isOpened: false, aiAssistantData: undefined }), [swrResponse]),
-    changePageMode: useCallback((pageMode: AiAssistantManagementModalPageMode) => {
-      swrResponse.mutate({ isOpened: swrResponse.data?.isOpened ?? false, pageMode, aiAssistantData: swrResponse.data?.aiAssistantData });
-    }, [swrResponse]),
+    open: useCallback(
+      (aiAssistantData) => {
+        swrResponse.mutate({
+          isOpened: true,
+          aiAssistantData,
+          pageMode:
+            aiAssistantData != null
+              ? AiAssistantManagementModalPageMode.HOME
+              : AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD,
+        });
+      },
+      [swrResponse],
+    ),
+    close: useCallback(
+      () => swrResponse.mutate({ isOpened: false, aiAssistantData: undefined }),
+      [swrResponse],
+    ),
+    changePageMode: useCallback(
+      (pageMode: AiAssistantManagementModalPageMode) => {
+        swrResponse.mutate({
+          isOpened: swrResponse.data?.isOpened ?? false,
+          pageMode,
+          aiAssistantData: swrResponse.data?.aiAssistantData,
+        });
+      },
+      [swrResponse],
+    ),
   };
 };
 
-
-export const useSWRxAiAssistants = (): SWRResponse<AccessibleAiAssistantsHasId, Error> => {
+export const useSWRxAiAssistants = (): SWRResponse<
+  AccessibleAiAssistantsHasId,
+  Error
+> => {
   return useSWRImmutable<AccessibleAiAssistantsHasId>(
     ['/openai/ai-assistants'],
-    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.accessibleAiAssistants),
+    ([endpoint]) =>
+      apiv3Get(endpoint).then(
+        (response) => response.data.accessibleAiAssistants,
+      ),
   );
 };
 
-
 /*
-*  useAiAssistantSidebar
-*/
+ *  useAiAssistantSidebar
+ */
 type AiAssistantSidebarStatus = {
-  isOpened: boolean,
-  isEditorAssistant?: boolean,
-  aiAssistantData?: AiAssistantHasId,
-  threadData?: IThreadRelationHasId,
-}
+  isOpened: boolean;
+  isEditorAssistant?: boolean;
+  aiAssistantData?: AiAssistantHasId;
+  threadData?: IThreadRelationHasId;
+};
 
 type AiAssistantSidebarUtils = {
   openChat(
     aiAssistantData: AiAssistantHasId,
     threadData?: IThreadRelationHasId,
-  ): void
-  openEditor(): void
-  close(): void
-  refreshAiAssistantData(aiAssistantData?: AiAssistantHasId): void
-  refreshThreadData(threadData?: IThreadRelationHasId): void
-}
+  ): void;
+  openEditor(): void;
+  close(): void;
+  refreshAiAssistantData(aiAssistantData?: AiAssistantHasId): void;
+  refreshThreadData(threadData?: IThreadRelationHasId): void;
+};
 
 export const useAiAssistantSidebar = (
-    status?: AiAssistantSidebarStatus,
+  status?: AiAssistantSidebarStatus,
 ): SWRResponse<AiAssistantSidebarStatus, Error> & AiAssistantSidebarUtils => {
   const initialStatus = { isOpened: false };
-  const swrResponse = useSWRStatic<AiAssistantSidebarStatus, Error>('AiAssistantSidebar', status, { fallbackData: initialStatus });
+  const swrResponse = useSWRStatic<AiAssistantSidebarStatus, Error>(
+    'AiAssistantSidebar',
+    status,
+    { fallbackData: initialStatus },
+  );
 
   return {
     ...swrResponse,
     openChat: useCallback(
-      (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => {
+      (
+        aiAssistantData: AiAssistantHasId,
+        threadData?: IThreadRelationHasId,
+      ) => {
         swrResponse.mutate({ isOpened: true, aiAssistantData, threadData });
-      }, [swrResponse],
-    ),
-    openEditor: useCallback(
-      () => {
-        swrResponse.mutate({
-          isOpened: true, isEditorAssistant: true, aiAssistantData: undefined, threadData: undefined,
-        });
-      }, [swrResponse],
+      },
+      [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],
+      () =>
+        swrResponse.mutate({
+          isOpened: false,
+          isEditorAssistant: false,
+          aiAssistantData: undefined,
+          threadData: undefined,
+        }),
+      [swrResponse],
     ),
     refreshAiAssistantData: useCallback(
       (aiAssistantData) => {
         swrResponse.mutate((currentState = { isOpened: false }) => {
           return { ...currentState, aiAssistantData };
         });
-      }, [swrResponse],
+      },
+      [swrResponse],
     ),
     refreshThreadData: useCallback(
       (threadData?: IThreadRelationHasId) => {
         swrResponse.mutate((currentState = { isOpened: false }) => {
           return { ...currentState, threadData };
         });
-      }, [swrResponse],
+      },
+      [swrResponse],
     ),
   };
 };

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

@@ -4,10 +4,15 @@ 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 = aiAssistantId != null && threadId != null ? [`/openai/messages/${aiAssistantId}/${threadId}`] : null;
-  return useSWRMutation(
-    key,
-    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.messages),
+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),
   );
 };

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

@@ -1,34 +1,43 @@
-import { type SWRResponse, type SWRConfiguration } from 'swr';
+import type { SWRConfiguration, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
-import useSWRInfinite from 'swr/infinite';
 import type { SWRInfiniteResponse } from 'swr/infinite';
+import useSWRInfinite from 'swr/infinite';
 import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
-import type { IThreadRelationHasId, IThreadRelationPaginate } from '~/features/openai/interfaces/thread-relation';
+import type {
+  IThreadRelationHasId,
+  IThreadRelationPaginate,
+} from '~/features/openai/interfaces/thread-relation';
 
-const getKey = (aiAssistantId?: string) => (aiAssistantId != null ? [`/openai/threads/${aiAssistantId}`] : null);
+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,
-    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.threads),
+  return useSWRImmutable<IThreadRelationHasId[]>(key, ([endpoint]) =>
+    apiv3Get(endpoint).then((response) => response.data.threads),
   );
 };
 
-
-export const useSWRMUTxThreads = (aiAssistantId?: string): SWRMutationResponse<IThreadRelationHasId[], Error> => {
+export const useSWRMUTxThreads = (
+  aiAssistantId?: string,
+): SWRMutationResponse<IThreadRelationHasId[], Error> => {
   const key = getKey(aiAssistantId);
   return useSWRMutation(
     key,
-    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.threads),
+    ([endpoint]) =>
+      apiv3Get(endpoint).then((response) => response.data.threads),
     { revalidate: true },
   );
 };
 
-
-const getRecentThreadsKey = (pageIndex: number, previousPageData: IThreadRelationPaginate | null): [string, number, number] | null => {
+const getRecentThreadsKey = (
+  pageIndex: number,
+  previousPageData: IThreadRelationPaginate | null,
+): [string, number, number] | null => {
   if (previousPageData && !previousPageData.paginateResult.hasNextPage) {
     return null;
   }
@@ -39,13 +48,16 @@ const getRecentThreadsKey = (pageIndex: number, previousPageData: IThreadRelatio
   return ['/openai/threads/recent', page, PER_PAGE];
 };
 
-
 export const useSWRINFxRecentThreads = (
-    config?: SWRConfiguration,
+  config?: SWRConfiguration,
 ): SWRInfiniteResponse<IThreadRelationPaginate, Error> => {
   return useSWRInfinite(
-    (pageIndex, previousPageData) => getRecentThreadsKey(pageIndex, previousPageData),
-    ([endpoint, page, limit]) => apiv3Get<IThreadRelationPaginate>(endpoint, { page, limit }).then(response => response.data),
+    (pageIndex, previousPageData) =>
+      getRecentThreadsKey(pageIndex, previousPageData),
+    ([endpoint, page, limit]) =>
+      apiv3Get<IThreadRelationPaginate>(endpoint, { page, limit }).then(
+        (response) => response.data,
+      ),
     {
       ...config,
       revalidateFirstPage: false,

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

@@ -2,7 +2,10 @@ 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 => {
+export const getShareScopeIcon = (
+  shareScope: AiAssistantShareScope,
+  accessScope: AiAssistantAccessScope,
+): string => {
   const determinedSharedScope = determineShareScope(shareScope, accessScope);
   switch (determinedSharedScope) {
     case AiAssistantShareScope.OWNER:

+ 0 - 1
biome.json

@@ -29,7 +29,6 @@
       "!packages/pdf-converter-client/specs",
       "!apps/app/playwright",
       "!apps/app/src/client",
-      "!apps/app/src/features/openai/client",
       "!apps/app/src/server/middlewares",
       "!apps/app/src/server/models",
       "!apps/app/src/server/routes",