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

Merge branch 'feat/growi-ai-next' into feat/161511-implement-chat-view

Shun Miyazawa 1 год назад
Родитель
Сommit
fe713d03a8
24 измененных файлов с 269 добавлено и 105 удалено
  1. 1 1
      .devcontainer/devcontainer.json
  2. 2 0
      .npmrc
  3. 1 1
      apps/app/.env.production
  4. 1 1
      apps/app/docker/Dockerfile
  5. 9 5
      apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx
  6. 38 32
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  7. 41 0
      apps/app/src/client/components/Sidebar/AppTitle/AppTitle.module.scss
  8. 27 11
      apps/app/src/client/components/Sidebar/AppTitle/AppTitle.tsx
  9. 20 5
      apps/app/src/client/components/Sidebar/Sidebar.tsx
  10. 13 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx
  11. 4 2
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx
  12. 61 24
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx
  13. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx
  14. 26 4
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx
  15. 5 1
      apps/app/src/features/openai/client/services/ai-assistant.ts
  16. 5 4
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  17. 2 2
      apps/app/src/features/openai/server/models/ai-assistant.ts
  18. 3 1
      apps/app/src/features/openai/server/services/openai.ts
  19. 1 1
      apps/app/src/server/routes/apiv3/import.js
  20. 1 1
      apps/slackbot-proxy/docker/Dockerfile
  21. 1 1
      bin/data-migrations/src/migrations/v60x/csv.js
  22. 1 1
      bin/data-migrations/src/migrations/v60x/tsv.js
  23. 2 3
      package.json
  24. 3 2
      packages/remark-attachment-refs/src/server/routes/refs.ts

+ 1 - 1
.devcontainer/devcontainer.json

@@ -8,7 +8,7 @@
 
   "features": {
     "ghcr.io/devcontainers/features/node:1": {
-      "version": "20.18.0"
+      "version": "20.18.3"
     }
   },
 

+ 2 - 0
.npmrc

@@ -0,0 +1,2 @@
+# see: https://pnpm.io/next/npmrc#force-legacy-deploy
+force-legacy-deploy=true

+ 1 - 1
apps/app/.env.production

@@ -7,4 +7,4 @@ MIGRATIONS_DIR=dist/migrations/
 
 # OpenTelemetry Configuration
 OTEL_TRACES_SAMPLER_ARG=0.1
-
+OTEL_EXPORTER_OTLP_ENDPOINT="https://telemetry.growi.org"

+ 1 - 1
apps/app/docker/Dockerfile

@@ -14,7 +14,7 @@ WORKDIR ${optDir}
 RUN apt-get update && apt-get install -y ca-certificates wget curl --no-install-recommends
 
 # install pnpm
-RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" sh -
+RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" PNPM_VERSION="10.4.1" sh -
 ENV PNPM_HOME="/root/.local/share/pnpm"
 ENV PATH="$PNPM_HOME:$PATH"
 

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

@@ -8,16 +8,20 @@ import styles from './EditorNavbar.module.scss';
 
 const moduleClass = styles['editor-navbar'] ?? '';
 
-export const EditorNavbar = (): JSX.Element => {
+const EditingUsers = (): JSX.Element => {
   const { data: editingUsers } = useEditingUsers();
+  return (
+    <EditingUserList
+      userList={editingUsers?.userList ?? []}
+    />
+  );
+};
 
+export const EditorNavbar = (): JSX.Element => {
   return (
     <div className={`${moduleClass} d-flex flex-column flex-sm-row justify-content-between ps-3 ps-md-5 ps-xl-4 pe-4 py-1 align-items-sm-end`}>
       <div className="order-2 order-sm-1"><PageHeader /></div>
-      <div className="order-1 order-sm-2"><EditingUserList
-        userList={editingUsers?.userList ?? []}
-      />
-      </div>
+      <div className="order-1 order-sm-2"><EditingUsers /></div>
     </div>
   );
 };

+ 38 - 32
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -81,7 +81,7 @@ type Props = {
   visibility?: boolean,
 }
 
-export const PageEditor = React.memo((props: Props): JSX.Element => {
+export const PageEditorSubstance = (props: Props): JSX.Element => {
 
   const { t } = useTranslation();
 
@@ -361,42 +361,48 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     return <></>;
   }
 
+  return (
+    <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
+      <div className="page-editor-editor-container flex-expand-vert border-end">
+        <CodeMirrorEditorMain
+          isEditorMode={editorMode === EditorMode.Editor}
+          onSave={saveWithShortcut}
+          onUpload={uploadHandler}
+          acceptedUploadFileType={acceptedUploadFileType}
+          onScroll={scrollEditorHandlerThrottle}
+          indentSize={currentIndentSize ?? defaultIndentSize}
+          user={user ?? undefined}
+          pageId={pageId ?? undefined}
+          initialValue={initialValue}
+          editorSettings={editorSettings}
+          onEditorsUpdated={onEditorsUpdated}
+          cmProps={cmProps}
+        />
+      </div>
+      <div
+        ref={previewRef}
+        onScroll={scrollPreviewHandlerThrottle}
+        className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex"
+      >
+        <Preview
+          rendererOptions={rendererOptions}
+          markdown={markdownToPreview}
+          pagePath={currentPagePath}
+          expandContentWidth={shouldExpandContent}
+          style={pastEndStyle}
+        />
+      </div>
+    </div>
+  );
+};
+
+export const PageEditor = React.memo((props: Props): JSX.Element => {
   return (
     <div data-testid="page-editor" id="page-editor" className={`flex-expand-vert ${props.visibility ? '' : 'd-none'}`}>
 
       <EditorNavbar />
 
-      <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
-        <div className="page-editor-editor-container flex-expand-vert border-end">
-          <CodeMirrorEditorMain
-            isEditorMode={editorMode === EditorMode.Editor}
-            onSave={saveWithShortcut}
-            onUpload={uploadHandler}
-            acceptedUploadFileType={acceptedUploadFileType}
-            onScroll={scrollEditorHandlerThrottle}
-            indentSize={currentIndentSize ?? defaultIndentSize}
-            user={user ?? undefined}
-            pageId={pageId ?? undefined}
-            initialValue={initialValue}
-            editorSettings={editorSettings}
-            onEditorsUpdated={onEditorsUpdated}
-            cmProps={cmProps}
-          />
-        </div>
-        <div
-          ref={previewRef}
-          onScroll={scrollPreviewHandlerThrottle}
-          className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex"
-        >
-          <Preview
-            rendererOptions={rendererOptions}
-            markdown={markdownToPreview}
-            pagePath={currentPagePath}
-            expandContentWidth={shouldExpandContent}
-            style={pastEndStyle}
-          />
-        </div>
-      </div>
+      <PageEditorSubstance visibility={props.visibility} />
 
       <EditorNavbarBottom />
 

+ 41 - 0
apps/app/src/client/components/Sidebar/AppTitle/AppTitle.module.scss

@@ -2,6 +2,7 @@
 @use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '~/styles/variables' as var;
 @use '../button-styles';
+@use '~/styles/mixins';
 
 // GROWI Logo
 .grw-app-title :global {
@@ -25,6 +26,22 @@
   }
 }
 
+// == GROWI Logo when Editor mode
+@include mixins.at-editing() {
+  @include bs.media-breakpoint-up(xl) {
+    .grw-app-title :global {
+      .grw-logo {
+          opacity: 0.5;
+          transition: opacity 0.8s ease;
+
+          &:hover {
+            opacity: 1;
+          }
+      }
+    }
+  }
+}
+
 
 // == Location
 .on-subnavigation {
@@ -64,6 +81,30 @@
   width: calc(100% - $toggle-collapse-button-width);
 }
 
+// ==Sidebar Head when Editor mode
+@include bs.color-mode(light) {
+  .on-editor-sidebar-head {
+    background-color: var(
+      --on-editor-sidebar-head-bg,
+      var(
+        --grw-sidebar-nav-bg,
+        var(--grw-highlight-100)
+      )
+    );
+  }
+}
+
+@include bs.color-mode(dark) {
+  .on-editor-sidebar-head {
+    background-color: var(
+      --on-editor-sidebar-head-bg,
+      var(
+        --grw-sidebar-nav-bg,
+        var(--grw-highlight-800)
+      )
+    );
+  }
+}
 
 // == Interaction
 @keyframes bounce-to-right {

+ 27 - 11
apps/app/src/client/components/Sidebar/AppTitle/AppTitle.tsx

@@ -12,28 +12,29 @@ import styles from './AppTitle.module.scss';
 
 type Props = {
   className?: string,
+  hideAppTitle?: boolean;
 }
 
-const AppTitleSubstance = memo((props: Props): JSX.Element => {
-
-  const { className } = props;
+const AppTitleSubstance = memo(({ className = '', hideAppTitle = false }: Props): JSX.Element => {
 
   const { data: isDefaultLogo } = useIsDefaultLogo();
   const { data: appTitle } = useAppTitle();
   const { data: confidential } = useConfidential();
 
   return (
-    <div className={`${styles['grw-app-title']} ${className} d-flex d-edit-none`}>
+    <div className={`${styles['grw-app-title']} ${className} d-flex`}>
       {/* Brand Logo  */}
       <Link href="/" className="grw-logo d-block">
         <SidebarBrandLogo isDefaultLogo={isDefaultLogo} />
       </Link>
       <div className="flex-grow-1 d-flex align-items-center justify-content-between gap-3 overflow-hidden">
-        <div id="grw-site-name" className="grw-site-name text-truncate">
-          <Link href="/" className="fs-4">
-            {appTitle}
-          </Link>
-        </div>
+        {!hideAppTitle && (
+          <div id="grw-site-name" className="grw-site-name text-truncate">
+            <Link href="/" className="fs-4">
+              {appTitle}
+            </Link>
+          </div>
+        )}
       </div>
       {!(confidential == null || confidential === '')
       && (
@@ -56,6 +57,21 @@ export const AppTitleOnSubnavigation = memo((): JSX.Element => {
   return <AppTitleSubstance className={`position-absolute ${styles['on-subnavigation']}`} />;
 });
 
-export const AppTitleOnSidebarHead = memo((): JSX.Element => {
-  return <AppTitleSubstance className={`position-absolute z-1 ${styles['on-sidebar-head']}`} />;
+export const AppTitleOnSidebarHead = memo(({ hideAppTitle }: Props): JSX.Element => {
+  return (
+    <AppTitleSubstance
+      className={`position-absolute z-1 ${styles['on-sidebar-head']}`}
+      hideAppTitle={hideAppTitle}
+    />
+  );
+});
+
+export const AppTitleOnEditorSidebarHead = memo((): JSX.Element => {
+  return (
+    <div className={`${styles['on-editor-sidebar-head']}`}>
+      <AppTitleSubstance
+        className={`${styles['on-sidebar-head']}`}
+      />
+    </div>
+  );
 });

+ 20 - 5
apps/app/src/client/components/Sidebar/Sidebar.tsx

@@ -11,6 +11,7 @@ import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
 import { SidebarMode } from '~/interfaces/ui';
 import { useIsSearchPage } from '~/stores-universal/context';
+import { EditorMode, useEditorMode } from '~/stores-universal/ui';
 import {
   useDrawerOpened,
   useCollapsedContentsOpened,
@@ -18,11 +19,13 @@ import {
   usePreferCollapsedMode,
   useSidebarMode,
   useSidebarScrollerRef,
+  useIsDeviceLargerThanMd,
+  useIsDeviceLargerThanXl,
 } from '~/stores/ui';
 
 import { DrawerToggler } from '../Common/DrawerToggler';
 
-import { AppTitleOnSidebarHead, AppTitleOnSubnavigation } from './AppTitle/AppTitle';
+import { AppTitleOnSidebarHead, AppTitleOnEditorSidebarHead, AppTitleOnSubnavigation } from './AppTitle/AppTitle';
 import { ResizableAreaFallback } from './ResizableArea/ResizableAreaFallback';
 import type { ResizableAreaProps } from './ResizableArea/props';
 import { SidebarHead } from './SidebarHead';
@@ -230,6 +233,14 @@ export const Sidebar = (): JSX.Element => {
   } = useSidebarMode();
 
   const { data: isSearchPage } = useIsSearchPage();
+  const { data: editorMode } = useEditorMode();
+  const { data: isMdSize } = useIsDeviceLargerThanMd();
+  const { data: isXlSize } = useIsDeviceLargerThanXl();
+
+  const isEditorMode = editorMode === EditorMode.Editor;
+  const shouldHideSiteName = isEditorMode && isXlSize;
+  const shouldHideSubnavAppTitle = isEditorMode && isMdSize && (isDrawerMode() || isCollapsedMode());
+  const shouldShowEditorSidebarHead = isEditorMode && isXlSize;
 
   // css styles
   const grwSidebarClass = styles['grw-sidebar'];
@@ -253,12 +264,16 @@ export const Sidebar = (): JSX.Element => {
         <DrawerToggler className="position-fixed d-none d-md-block">
           <span className="material-symbols-outlined">reorder</span>
         </DrawerToggler>
-      ) }
-      { sidebarMode != null && !isDockMode() && !isSearchPage && <AppTitleOnSubnavigation /> }
+      )}
+      { sidebarMode != null && !isDockMode() && !isSearchPage && !shouldHideSubnavAppTitle && (
+        <AppTitleOnSubnavigation />
+      )}
       <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} divProps={{ 'data-testid': 'grw-sidebar' }}>
         <ResizableContainer>
-          { sidebarMode != null && !isCollapsedMode() && <AppTitleOnSidebarHead /> }
-          <SidebarHead />
+          { sidebarMode != null && !isCollapsedMode() && (
+            <AppTitleOnSidebarHead hideAppTitle={shouldHideSiteName} />
+          )}
+          {shouldShowEditorSidebarHead ? <AppTitleOnEditorSidebarHead /> : <SidebarHead />}
           <CollapsibleContainer Nav={SidebarNav} className="border-top">
             <SidebarContents />
           </CollapsibleContainer>

+ 13 - 1
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx

@@ -1,4 +1,6 @@
-import React, { useCallback, useState } from 'react';
+import React, {
+  useCallback, useState, useEffect,
+} from 'react';
 
 import {
   ModalBody, Input, Label,
@@ -50,6 +52,15 @@ export const AiAssistantManagementEditShare = (props: Props): JSX.Element => {
   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;
+    });
+  }, [isShared, selectedAccessScope, selectedShareScope]);
+
   const changeShareToggleHandler = useCallback(() => {
     setIsShared((prev) => {
       if (prev) { // if isShared === true
@@ -95,6 +106,7 @@ export const AiAssistantManagementEditShare = (props: Props): JSX.Element => {
             id="shareAssistantSwitch"
             className="form-check-input"
             checked={isShared}
+            defaultChecked={isShared}
             onChange={changeShareToggleHandler}
           />
           <Label className="form-check-label" for="shareAssistantSwitch">

+ 4 - 2
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx

@@ -13,6 +13,7 @@ import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } fro
 import { ShareScopeWarningModal } from './ShareScopeWarningModal';
 
 type Props = {
+  shouldEdit: boolean;
   name: string;
   description: string;
   instruction: string;
@@ -24,6 +25,7 @@ type Props = {
 
 export const AiAssistantManagementHome = (props: Props): JSX.Element => {
   const {
+    shouldEdit,
     name,
     description,
     instruction,
@@ -61,7 +63,7 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
     <>
       <ModalHeader tag="h4" toggle={closeAiAssistantManagementModal} className="pe-4">
         <span className="growi-custom-icons growi-ai-assistant-icon me-3 fs-4">ai_assistant</span>
-        <span className="fw-bold">新規アシスタントの追加</span> {/* TODO i18n */}
+        <span className="fw-bold">{t(shouldEdit ? 'アシスタントの更新' : '新規アシスタントの追加')}</span> {/* TODO i18n */}
       </ModalHeader>
 
       <div className="px-4">
@@ -137,7 +139,7 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
 
         <ModalFooter>
           <button type="button" className="btn btn-outline-secondary" onClick={closeAiAssistantManagementModal}>キャンセル</button>
-          <button type="button" className="btn btn-primary" onClick={createAiAssistantHandler}>アシスタントを作成する</button>
+          <button type="button" className="btn btn-primary" onClick={createAiAssistantHandler}>{t(shouldEdit ? 'アシスタントを更新する' : 'アシスタントを作成する')}</button>
         </ModalFooter>
       </div>
 

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

@@ -1,6 +1,6 @@
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useState, useEffect } from 'react';
 
-import type { IGrantedGroup } from '@growi/core';
+import { type IGrantedGroup, isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { Modal, TabContent, TabPane } from 'reactstrap';
 
@@ -11,7 +11,7 @@ import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import loggerFactory from '~/utils/logger';
 
 import type { SelectedPage } from '../../../../interfaces/selected-page';
-import { createAiAssistant } from '../../../services/ai-assistant';
+import { createAiAssistant, updateAiAssistant } from '../../../services/ai-assistant';
 import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode, useSWRxAiAssistants } from '../../../stores/ai-assistant';
 
 import { AiAssistantManagementEditInstruction } from './AiAssistantManagementEditInstruction';
@@ -33,12 +33,32 @@ const convertToGrantedGroups = (selectedGroups: PopulatedGrantedGroup[]): IGrant
   }));
 };
 
+// IGrantedGroup[] -> PopulatedGrantedGroup[]
+const convertToPopulatedGrantedGroups = (selectedGroups: IGrantedGroup[]): PopulatedGrantedGroup[] => {
+  const populatedGrantedGroups = selectedGroups.filter(group => isPopulated(group.item)) as PopulatedGrantedGroup[];
+  return populatedGrantedGroups;
+};
+
+// string[] -> SelectedPage[]
+const convertToSelectedPages = (pagePathPatterns: string[]): SelectedPage[] => {
+  return pagePathPatterns.map((pagePathPattern) => {
+    const isIncludeSubPage = pagePathPattern.endsWith('/*');
+    const path = isIncludeSubPage ? pagePathPattern.slice(0, -2) : pagePathPattern;
+    return {
+      page: { path },
+      isIncludeSubPage,
+    };
+  });
+};
+
 const AiAssistantManagementModalSubstance = (): JSX.Element => {
   // Hooks
   const { t } = useTranslation();
   const { mutate: mutateAiAssistants } = useSWRxAiAssistants();
   const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal();
 
+  const aiAssistant = aiAssistantManagementModalData?.aiAssistantData;
+  const shouldEdit = aiAssistant != null;
   const pageMode = aiAssistantManagementModalData?.pageMode ?? AiAssistantManagementModalPageMode.HOME;
 
   // States
@@ -51,6 +71,20 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   const [selectedPages, setSelectedPages] = useState<SelectedPage[]>([]);
   const [instruction, setInstruction] = useState<string>(t('modal_ai_assistant.default_instruction'));
 
+  // Effects
+  useEffect(() => {
+    if (shouldEdit) {
+      setName(aiAssistant.name);
+      setDescription(aiAssistant.description);
+      setInstruction(aiAssistant.additionalInstruction);
+      setSelectedPages(convertToSelectedPages(aiAssistant.pagePathPatterns));
+      setSelectedShareScope(aiAssistant.shareScope);
+      setSelectedAccessScope(aiAssistant.accessScope);
+      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]);
 
   /*
   *  For AiAssistantManagementHome methods
@@ -69,40 +103,42 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
         .map(selectedPage => (selectedPage.isIncludeSubPage ? `${selectedPage.page.path}/*` : selectedPage.page.path))
         .filter((path): path is string => path !== undefined && path !== null);
 
-      const grantedGroupsForShareScope = convertToGrantedGroups(selectedUserGroupsForShareScope);
-      const grantedGroupsForAccessScope = convertToGrantedGroups(selectedUserGroupsForAccessScope);
+      const grantedGroupsForShareScope = selectedShareScope === AiAssistantShareScope.GROUPS
+        ? convertToGrantedGroups(selectedUserGroupsForShareScope)
+        : undefined;
+
+      const grantedGroupsForAccessScope = selectedAccessScope === AiAssistantAccessScope.GROUPS
+        ? convertToGrantedGroups(selectedUserGroupsForAccessScope)
+        : undefined;
 
-      await createAiAssistant({
+      const reqBody = {
         name,
         description,
         additionalInstruction: instruction,
         pagePathPatterns,
         shareScope: selectedShareScope,
         accessScope: selectedAccessScope,
-        grantedGroupsForShareScope: selectedShareScope === AiAssistantShareScope.GROUPS ? grantedGroupsForShareScope : undefined,
-        grantedGroupsForAccessScope: selectedAccessScope === AiAssistantAccessScope.GROUPS ? grantedGroupsForAccessScope : undefined,
-      });
-
-      toastSuccess('アシスタントを作成しました');
+        grantedGroupsForShareScope,
+        grantedGroupsForAccessScope,
+      };
+
+      if (shouldEdit) {
+        await updateAiAssistant(aiAssistant._id, reqBody);
+      }
+      else {
+        await createAiAssistant(reqBody);
+      }
+
+      toastSuccess(shouldEdit ? 'アシスタントが更新されました' : 'アシスタントが作成されました');
       mutateAiAssistants();
       closeAiAssistantManagementModal();
     }
     catch (err) {
-      toastError('アシスタントの作成に失敗しました');
+      toastError(shouldEdit ? 'アシスタントの更新に失敗しました' : 'アシスタントの作成に失敗しました');
       logger.error(err);
     }
-  }, [
-    mutateAiAssistants,
-    closeAiAssistantManagementModal,
-    description,
-    instruction,
-    name,
-    selectedAccessScope,
-    selectedPages,
-    selectedShareScope,
-    selectedUserGroupsForAccessScope,
-    selectedUserGroupsForShareScope,
-  ]);
+  // eslint-disable-next-line max-len
+  }, [selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope, selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, mutateAiAssistants, closeAiAssistantManagementModal, aiAssistant?._id]);
 
 
   /*
@@ -172,6 +208,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       <TabContent activeTab={pageMode}>
         <TabPane tabId={AiAssistantManagementModalPageMode.HOME}>
           <AiAssistantManagementHome
+            shouldEdit={shouldEdit}
             name={name}
             description={description}
             shareScope={selectedShareScope}

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

@@ -17,7 +17,7 @@ export const AiAssistantContent = (): JSX.Element => {
       <button
         type="button"
         className="btn btn-outline-secondary px-3 d-flex align-items-center mb-4"
-        onClick={open}
+        onClick={() => open()}
       >
         <span className="material-symbols-outlined fs-5 me-2">add</span>
         <span className="fw-normal">アシスタントを追加する</span>

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

@@ -8,7 +8,7 @@ import { useCurrentUser } from '~/stores-universal/context';
 import type { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant';
 import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
 import { deleteAiAssistant } from '../../../services/ai-assistant';
-import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
+import { useAiAssistantChatSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
 
 import styles from './AiAssistantTree.module.scss';
 
@@ -85,6 +85,7 @@ type AiAssistantItemProps = {
   currentUserId?: string;
   aiAssistant: AiAssistantHasId;
   threads: Thread[];
+  onEditClicked?: (aiAssistantData: AiAssistantHasId) => void;
   onItemClicked?: (aiAssistantData: AiAssistantHasId) => void;
   onDeleted?: () => void;
 };
@@ -93,15 +94,21 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
   currentUserId,
   aiAssistant,
   threads,
+  onEditClicked,
   onItemClicked,
   onDeleted,
 }) => {
   const [isThreadsOpened, setIsThreadsOpened] = useState(false);
 
+  const openManagementModalHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
+    onEditClicked?.(aiAssistantData);
+  }, [onEditClicked]);
+
   const openChatHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
     onItemClicked?.(aiAssistantData);
   }, [onItemClicked]);
 
+
   const openThreadsHandler = useCallback(() => {
     setIsThreadsOpened(toggle => !toggle);
   }, []);
@@ -122,14 +129,20 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
   return (
     <>
       <li
-        onClick={() => openChatHandler(aiAssistant)}
+        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">
           <button
             type="button"
-            onClick={openThreadsHandler}
+            onClick={(e) => {
+              e.stopPropagation();
+              openThreadsHandler();
+            }}
             className={`grw-ai-assistant-triangle-btn btn px-0 ${isThreadsOpened ? 'grw-ai-assistant-open' : ''}`}
           >
             <div className="d-flex justify-content-center">
@@ -151,13 +164,20 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
             <button
               type="button"
               className="btn btn-link text-secondary p-0 ms-2"
+              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={deleteAiAssistantHandler}
+              onClick={(e) => {
+                e.stopPropagation();
+                deleteAiAssistantHandler();
+              }}
             >
               <span className="material-symbols-outlined fs-5">delete</span>
             </button>
@@ -187,6 +207,7 @@ type AiAssistantTreeProps = {
 export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onDeleted }) => {
   const { data: currentUser } = useCurrentUser();
   const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+  const { open: openAiAssistantManagementModal } = useAiAssistantManagementModal();
 
   return (
     <ul className={`list-group ${moduleClass}`}>
@@ -196,6 +217,7 @@ export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants,
           currentUserId={currentUser?._id}
           aiAssistant={assistant}
           threads={dummyThreads}
+          onEditClicked={openAiAssistantManagementModal}
           onItemClicked={openAiAssistantChatSidebar}
           onDeleted={onDeleted}
         />

+ 5 - 1
apps/app/src/features/openai/client/services/ai-assistant.ts

@@ -1,4 +1,4 @@
-import { apiv3Post, apiv3Delete } from '~/client/util/apiv3-client';
+import { apiv3Post, apiv3Put, apiv3Delete } from '~/client/util/apiv3-client';
 
 import type { UpsertAiAssistantData } from '../../interfaces/ai-assistant';
 
@@ -6,6 +6,10 @@ export const createAiAssistant = async(body: UpsertAiAssistantData): Promise<voi
   await apiv3Post('/openai/ai-assistant', body);
 };
 
+export const updateAiAssistant = async(id: string, body: UpsertAiAssistantData): Promise<void> => {
+  await apiv3Put(`/openai/ai-assistant/${id}`, body);
+};
+
 export const deleteAiAssistant = async(id: string): Promise<void> => {
   await apiv3Delete(`/openai/ai-assistant/${id}`);
 };

+ 5 - 4
apps/app/src/features/openai/client/stores/ai-assistant.tsx

@@ -20,10 +20,11 @@ type AiAssistantManagementModalPageMode = typeof AiAssistantManagementModalPageM
 type AiAssistantManagementModalStatus = {
   isOpened: boolean,
   pageMode?: AiAssistantManagementModalPageMode,
+  aiAssistantData?: AiAssistantHasId;
 }
 
 type AiAssistantManagementModalUtils = {
-  open(): void
+  open(aiAssistantData?: AiAssistantHasId): void
   close(): void
   changePageMode(pageType: AiAssistantManagementModalPageMode): void
 }
@@ -36,10 +37,10 @@ export const useAiAssistantManagementModal = (
 
   return {
     ...swrResponse,
-    open: useCallback(() => { swrResponse.mutate({ isOpened: true }) }, [swrResponse]),
-    close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
+    open: useCallback((aiAssistantData) => { swrResponse.mutate({ isOpened: true, aiAssistantData }) }, [swrResponse]),
+    close: useCallback(() => swrResponse.mutate({ isOpened: false, aiAssistantData: undefined }), [swrResponse]),
     changePageMode: useCallback((pageMode: AiAssistantManagementModalPageMode) => {
-      swrResponse.mutate({ isOpened: swrResponse.data?.isOpened ?? false, pageMode });
+      swrResponse.mutate({ isOpened: swrResponse.data?.isOpened ?? false, pageMode, aiAssistantData: swrResponse.data?.aiAssistantData });
     }, [swrResponse]),
   };
 };

+ 2 - 2
apps/app/src/features/openai/server/models/ai-assistant.ts

@@ -53,7 +53,7 @@ const schema = new Schema<AiAssistantDocument>(
         },
         item: {
           type: Schema.Types.ObjectId,
-          refPath: 'grantedGroups.type',
+          refPath: 'grantedGroupsForShareScope.type',
           required: true,
           index: true,
         },
@@ -75,7 +75,7 @@ const schema = new Schema<AiAssistantDocument>(
         },
         item: {
           type: Schema.Types.ObjectId,
-          refPath: 'grantedGroups.type',
+          refPath: 'grantedGroupsForAccessScope.type',
           required: true,
           index: true,
         },

+ 3 - 1
apps/app/src/features/openai/server/services/openai.ts

@@ -653,7 +653,9 @@ class OpenaiService implements IOpenaiService {
           ],
         },
       ],
-    });
+    })
+      .populate('grantedGroupsForShareScope.item')
+      .populate('grantedGroupsForAccessScope.item');
 
     return {
       myAiAssistants: assistants.filter(assistant => assistant.owner.toString() === user._id.toString()) ?? [],

+ 1 - 1
apps/app/src/server/routes/apiv3/import.js

@@ -319,7 +319,7 @@ export default function route(crowi) {
    *                      type: object
    *                      description: the property of each extracted file
    */
-  router.post('/upload', uploads.single('file'), accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post('/upload', accessTokenParser, loginRequired, adminRequired, uploads.single('file'), addActivity, async(req, res) => {
     const { file } = req;
     const zipFile = importService.getFile(file.filename);
     let data = null;

+ 1 - 1
apps/slackbot-proxy/docker/Dockerfile

@@ -11,7 +11,7 @@ WORKDIR ${optDir}
 
 # install pnpm
 RUN apt-get update && apt-get install -y ca-certificates wget --no-install-recommends \
-  && wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" sh -
+  && wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" PNPM_VERSION="10.4.1" sh -
 ENV PNPM_HOME="/root/.local/share/pnpm"
 ENV PATH="$PNPM_HOME:$PATH"
 

+ 1 - 1
bin/data-migrations/src/migrations/v60x/csv.js

@@ -7,7 +7,7 @@ module.exports = [
    * @type {MigrationModule}
    */
   (body) => {
-    const oldCsvTableRegExp = /::: csv(-h)?\n([\s\S]*?)\n:::/g; // CSV old format
+    const oldCsvTableRegExp = /:::\s?csv(-h)?\n([\s\S]*?)\n:::/g; // CSV old format
     return body.replace(oldCsvTableRegExp, '``` csv$1\n$2\n```');
   },
 ];

+ 1 - 1
bin/data-migrations/src/migrations/v60x/tsv.js

@@ -7,7 +7,7 @@ module.exports = [
    * @type {MigrationModule}
    */
   (body) => {
-    const oldTsvTableRegExp = /::: tsv(-h)?\n([\s\S]*?)\n:::/g; // TSV old format
+    const oldTsvTableRegExp = /:::\s?tsv(-h)?\n([\s\S]*?)\n:::/g; // TSV old format
     return body.replace(oldTsvTableRegExp, '``` tsv$1\n$2\n```');
   },
 ];

+ 2 - 3
package.json

@@ -20,7 +20,7 @@
   "bugs": {
     "url": "https://github.com/weseek/growi/issues"
   },
-  "packageManager": "pnpm@9.4.0",
+  "packageManager": "pnpm@10.4.1",
   "scripts": {
     "bootstrap": "pnpm install",
     "start": "pnpm run app:server",
@@ -114,7 +114,6 @@
     }
   },
   "engines": {
-    "node": "^18 || ^20",
-    "pnpm": ">=9.4 <10"
+    "node": "^18 || ^20"
   }
 }

+ 3 - 2
packages/remark-attachment-refs/src/server/routes/refs.ts

@@ -86,6 +86,7 @@ export const routesFactory = (crowi): any => {
   router.get('/ref', accessTokenParser, loginRequired, async(req: RequestWithUser, res) => {
     const user = req.user;
     const { pagePath, fileNameOrId } = req.query;
+    const filterXSS = new FilterXSS();
 
     if (pagePath == null) {
       res.status(400).send('the param \'pagePath\' must be set.');
@@ -96,7 +97,7 @@ export const routesFactory = (crowi): any => {
 
     // not found
     if (page == null) {
-      res.status(404).send(`pagePath: '${pagePath}' is not found or forbidden.`);
+      res.status(404).send(filterXSS.process(`pagePath: '${pagePath}' is not found or forbidden.`));
       return;
     }
 
@@ -117,7 +118,7 @@ export const routesFactory = (crowi): any => {
 
     // not found
     if (attachment == null) {
-      res.status(404).send(`attachment '${fileNameOrId}' is not found.`);
+      res.status(404).send(filterXSS.process(`attachment '${fileNameOrId}' is not found.`));
       return;
     }