فهرست منبع

Merge branch 'feat/growi-ai-next' into feat/159153-implement-ai-assistant-creation-api

Shun Miyazawa 1 سال پیش
والد
کامیت
744f5882cf
26فایلهای تغییر یافته به همراه751 افزوده شده و 222 حذف شده
  1. 18 1
      CHANGELOG.md
  2. 4 2
      apps/app/package.json
  3. 2 2
      apps/app/public/static/locales/en_US/translation.json
  4. 2 2
      apps/app/public/static/locales/fr_FR/translation.json
  5. 2 2
      apps/app/public/static/locales/ja_JP/translation.json
  6. 2 2
      apps/app/public/static/locales/zh_CN/translation.json
  7. 19 6
      apps/app/src/client/components/PageHeader/PagePathHeader.tsx
  8. 52 26
      apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx
  9. 2 2
      apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss
  10. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  11. 44 4
      apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx
  12. 53 6
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManegementModal.tsx
  13. 29 0
      apps/app/src/features/openai/client/components/Common/SelectedPageList.tsx
  14. 6 0
      apps/app/src/features/openai/interfaces/selected-page.ts
  15. 141 0
      apps/app/src/features/openai/server/models/ai-assistant.ts
  16. 1 1
      apps/app/src/features/openai/server/models/vector-store.ts
  17. 8 7
      apps/app/src/features/openai/server/services/openai.ts
  18. 89 0
      apps/app/src/features/openai/server/utils/convert-markdown-to-html.ts
  19. 0 65
      apps/app/src/features/openai/server/utils/sanitize-markdown.ts
  20. 3 1
      apps/app/src/interfaces/ui.ts
  21. 4 3
      apps/app/src/stores/modal.tsx
  22. 1 1
      apps/slackbot-proxy/package.json
  23. 1 1
      package.json
  24. 1 1
      packages/presentation/package.json
  25. 2 2
      packages/remark-lsx/src/server/index.ts
  26. 263 85
      pnpm-lock.yaml

+ 18 - 1
CHANGELOG.md

@@ -1,9 +1,26 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.1.5...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.1.6...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.1.6](https://github.com/weseek/growi/compare/v7.1.5...v7.1.6) - 2024-12-26
+
+### 💎 Features
+
+* feat(ai): Save file to VectorStore in HTML format   (#9462) @miya
+
+### 🐛 Bug Fixes
+
+* fix: remark-lsx pagination (#9513) @miya
+* fix: Spelling miss of external_link in i18n (#9456) @reiji-h
+* fix: Wider copy to clipboard area (#9450) @Ryosei-Fukushima
+* fix: Error when creating pages with deep hierarchy (#9487) @reiji-h
+
+### 🧰 Maintenance
+
+* ci(deps): bump next from 14.2.13 to 14.2.15 (#9501) @dependabot
+
 ## [v7.1.5](https://github.com/weseek/growi/compare/v7.1.4...v7.1.5) - 2024-12-13
 
 ### 🚀 Improvement

+ 4 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.1.6-RC.0",
+  "version": "7.1.7-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -157,7 +157,7 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^14.2.13",
+    "next": "^14.2.15",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-superjson": "^0.0.4",
@@ -197,9 +197,11 @@
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rehype-katex": "^7.0.1",
+    "rehype-meta": "^4.0.1",
     "rehype-raw": "^7.0.0",
     "rehype-sanitize": "^6.0.0",
     "rehype-slug": "^6.0.0",
+    "rehype-stringify": "^10.0.1",
     "rehype-toc": "^3.0.2",
     "remark-breaks": "^4.0.0",
     "remark-directive": "^3.0.0",

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

@@ -117,7 +117,7 @@
   "Create under": "Create page under below:",
   "V5 Page Migration": "Convert To V5 Compatibility",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
-  "See_more_detail_on_new_schema": "See more detail on <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'>{{title}}</a> <span className='growi-custom-icons'>external_link</span> ",
+  "See_more_detail_on_new_schema": "See more detail on <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'>{{title}}</a> <span class='growi-custom-icons'>external_link</span> ",
   "external_account_management": "External Account Management",
   "UserGroup": "UserGroup",
   "Basic Settings": "Basic Settings",
@@ -617,7 +617,7 @@
     "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
     "nopages_title": "Congratulations. Ready to use GROWI v5!",
     "nopages_desc1": "Now all the pages you can manage seem to be in v5 compatible format.",
-    "detail_info": "See the detail information from <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Upgrading GROWI to v5.0.x <span className='growi-custom-icons'>external_link</span></a>.",
+    "detail_info": "See the detail information from <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Upgrading GROWI to v5.0.x <span class='growi-custom-icons'>external_link</span></a>.",
     "modal": {
       "title": "Convert to new v5 compatible format",
       "converting_pages": "Converting pages",

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

@@ -117,7 +117,7 @@
   "Create under": "Créer la page sous:",
   "V5 Page Migration": "Convertir vers la V5",
   "GROWI.5.0_new_schema": "Nouveau schéma GROWI.5.0",
-  "See_more_detail_on_new_schema": "Plus de détails sur <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'>{{title}}</a> <i class='icon-share-alt'></i> ",
+  "See_more_detail_on_new_schema": "Plus de détails sur <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'>{{title}}</a><span class='growi-custom-icons'>external_link</span> ",
   "external_account_management": "Gestion des comptes externes",
   "UserGroup": "Groupe utilisateur",
   "Basic Settings": "Paramètres de base",
@@ -610,7 +610,7 @@
     "alert_desc1": "Sélectionner les pages à convertir vers le format V5 avec le bouton \"Opération de masse\".",
     "nopages_title": "GROWI V5 est maintenant utilisable!",
     "nopages_desc1": "Toutes les pages ont été converties au format V5.",
-    "detail_info": "Pour plus de détails, voir <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Convertir vers GROWI v5.0.x <span className='growi-custom-icons'>external_link</span></a>.",
+    "detail_info": "Pour plus de détails, voir <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Convertir vers GROWI v5.0.x <span class='growi-custom-icons'>external_link</span></a>.",
     "modal": {
       "title": "Convertir au format V5",
       "converting_pages": "Conversion des pages",

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

@@ -116,7 +116,7 @@
   "Create under": "ページを以下に作成",
   "V5 Page Migration": "V5 互換形式 への変換",
   "GROWI.5.0_new_schema": "GROWI.5.0における新スキーマについて",
-  "See_more_detail_on_new_schema": "詳しくは<a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html#新しい-v5-互換形式について' target='_blank'>{{title}}</a><span className='growi-custom-icons'>external_link</span>を参照ください。",
+  "See_more_detail_on_new_schema": "詳しくは<a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html#新しい-v5-互換形式について' target='_blank'>{{title}}</a><span class='growi-custom-icons'>external_link</span>を参照ください。",
   "external_account_management": "外部アカウント管理",
   "UserGroup": "グループ",
   "Basic Settings": "基本設定",
@@ -649,7 +649,7 @@
     "alert_desc1": "このページでは、チェックボックスでページを選択し、画面上部の「一括操作」ボタンから新しい v5 互換形式に一括変換できます。",
     "nopages_title": "おめでとうございます。GROWI v5 を使う準備が完了しました!",
     "nopages_desc1": "今あなたが管理可能なページはすべて v5 互換形式になっているようです。",
-    "detail_info": "詳しくは <a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>GROWI v5.0.x へのアップグレード <span className='growi-custom-icons'>external_link</span></a> を参照ください。",
+    "detail_info": "詳しくは <a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>GROWI v5.0.x へのアップグレード <span class='growi-custom-icons'>external_link</span></a> を参照ください。",
     "modal": {
       "title": "新しい v5 互換形式への変換",
       "converting_pages": "以下のページを変換します",

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

@@ -122,7 +122,7 @@
   "Create under": "Create page under below:",
   "V5 Page Migration": "转换为V5的兼容性",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
-  "See_more_detail_on_new_schema": "更多详情请见<a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'> {{title}}</a> <span className='growi-custom-icons'>external_link</span> ",
+  "See_more_detail_on_new_schema": "更多详情请见<a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'> {{title}}</a> <span class='growi-custom-icons'>external_link</span> ",
 	"Markdown Settings": "Markdown设置",
 	"external_account_management": "外部账户管理",
   "UserGroup": "用户组",
@@ -619,7 +619,7 @@
     "alert_desc1": "在这一页,你可以用复选框选择页面,并通过屏幕上方的批量操作按钮批量转换为新的v5兼容格式。",
     "nopages_title": "恭喜你。准备使用GROWI v5!",
     "nopages_desc1": "现在你能管理的所有页面似乎都是v5兼容的格式。",
-    "detail_info": "请参见 <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>升级GROWI到v5.0.x <span className='growi-custom-icons'>external_link</span></a>.的详细内容。",
+    "detail_info": "请参见 <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>升级GROWI到v5.0.x <span class='growi-custom-icons'>external_link</span></a>.的详细内容。",
     "modal": {
       "title": "转换为新的v5兼容格式",
       "converting_pages": "转换页面",

+ 19 - 6
apps/app/src/client/components/PageHeader/PagePathHeader.tsx

@@ -3,6 +3,8 @@ import {
   useState, useCallback, memo,
 } from 'react';
 
+import nodePath from 'path';
+
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
@@ -11,13 +13,13 @@ import { debounce } from 'throttle-debounce';
 
 import type { InputValidationResult } from '~/client/util/use-input-validator';
 import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
+import type { IPageForItem } from '~/interfaces/page';
 import LinkedPagePath from '~/models/linked-page-path';
 import { usePageSelectModal } from '~/stores/modal';
 
 import { PagePathHierarchicalLink } from '../../../components/Common/PagePathHierarchicalLink';
 import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
 import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
-import { PageSelectModal } from '../PageSelectModal/PageSelectModal';
 
 import styles from './PagePathHeader.module.scss';
 
@@ -45,8 +47,7 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [isHover, setHover] = useState(false);
 
-  const { data: PageSelectModalData, open: openPageSelectModal } = usePageSelectModal();
-  const isOpened = PageSelectModalData?.isOpened ?? false;
+  const { open: openPageSelectModal } = usePageSelectModal();
 
   const [validationResult, setValidationResult] = useState<InputValidationResult>();
 
@@ -61,6 +62,20 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
 
   const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
 
+  const onClickOpenPageSelectModalButton = useCallback(() => {
+    const onSelected = (page: IPageForItem): void => {
+      if (page == null || page.path == null) {
+        return;
+      }
+
+      const currentPageTitle = nodePath.basename(currentPage?.path ?? '') || '/';
+      const newPagePath = nodePath.resolve(page.path, currentPageTitle);
+
+      pagePathRenameHandler(newPagePath);
+    };
+
+    openPageSelectModal({ onSelected });
+  }, [currentPage?.path, openPageSelectModal, pagePathRenameHandler]);
 
   const rename = useCallback((inputText) => {
     const pathToRename = normalizePath(`${inputText}/${dPagePath.latter}`);
@@ -144,13 +159,11 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
         <button
           type="button"
           className="btn btn-outline-neutral-secondary d-flex align-items-center justify-content-center"
-          onClick={openPageSelectModal}
+          onClick={onClickOpenPageSelectModalButton}
         >
           <span className="material-symbols-outlined fs-6">account_tree</span>
         </button>
       </div>
-
-      {isOpened && <PageSelectModal />}
     </div>
   );
 });

+ 52 - 26
apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx

@@ -19,20 +19,17 @@ import { useSWRxCurrentPage } from '~/stores/page';
 
 import { ItemsTree } from '../ItemsTree';
 import ItemsTreeContentSkeleton from '../ItemsTree/ItemsTreeContentSkeleton';
-import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
 import { TreeItemForModal } from './TreeItemForModal';
 
-
-export const PageSelectModal: FC = () => {
+const PageSelectModalSubstance: FC = () => {
   const {
     data: PageSelectModalData,
     close: closeModal,
   } = usePageSelectModal();
 
-  const isOpened = PageSelectModalData?.isOpened ?? false;
-
-  const [clickedParentPagePath, setClickedParentPagePath] = useState<string | null>(null);
+  const [clickedParentPage, setClickedParentPage] = useState<IPageForItem | null>(null);
+  const [isIncludeSubPage, setIsIncludeSubPage] = useState(true);
 
   const { t } = useTranslation();
 
@@ -40,51 +37,45 @@ export const PageSelectModal: FC = () => {
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
   const { data: currentPage } = useSWRxCurrentPage();
+  const { data: pageSelectModalData } = usePageSelectModal();
 
-  const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
+  const isHierarchicalSelectionMode = pageSelectModalData?.opts?.isHierarchicalSelectionMode ?? false;
 
   const onClickTreeItem = useCallback((page: IPageForItem) => {
     const parentPagePath = page.path;
 
     if (parentPagePath == null) {
-      return <></>;
+      return;
     }
 
-    setClickedParentPagePath(parentPagePath);
+    setClickedParentPage(page);
   }, []);
 
   const onClickCancel = useCallback(() => {
-    setClickedParentPagePath(null);
+    setClickedParentPage(null);
     closeModal();
   }, [closeModal]);
 
   const onClickDone = useCallback(() => {
-    if (clickedParentPagePath != null) {
-      const currentPageTitle = nodePath.basename(currentPage?.path ?? '') || '/';
-      const newPagePath = nodePath.resolve(clickedParentPagePath, currentPageTitle);
-
-      pagePathRenameHandler(newPagePath);
+    if (clickedParentPage != null) {
+      PageSelectModalData?.opts?.onSelected?.(clickedParentPage, isIncludeSubPage);
     }
 
     closeModal();
-  }, [clickedParentPagePath, closeModal, currentPage?.path, pagePathRenameHandler]);
+  }, [PageSelectModalData?.opts, clickedParentPage, closeModal, isIncludeSubPage]);
 
   const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage?.path ?? ''));
 
-  const targetPathOrId = clickedParentPagePath || parentPagePath;
+  const targetPathOrId = clickedParentPage?.path || parentPagePath;
 
-  const targetPath = clickedParentPagePath || parentPagePath;
+  const targetPath = clickedParentPage?.path || parentPagePath;
 
   if (isGuestUser == null) {
     return <></>;
   }
 
   return (
-    <Modal
-      isOpen={isOpened}
-      toggle={closeModal}
-      centered
-    >
+    <>
       <ModalHeader toggle={closeModal}>{t('page_select_modal.select_page_location')}</ModalHeader>
       <ModalBody className="p-0">
         <Suspense fallback={<ItemsTreeContentSkeleton />}>
@@ -103,10 +94,45 @@ export const PageSelectModal: FC = () => {
           </SimpleBar>
         </Suspense>
       </ModalBody>
-      <ModalFooter>
-        <Button color="secondary" onClick={onClickCancel}>{t('Cancel')}</Button>
-        <Button color="primary" onClick={onClickDone}>{t('Done')}</Button>
+      <ModalFooter className="border-top d-flex flex-column">
+        { isHierarchicalSelectionMode && (
+          <div className="form-check form-check-info align-self-start ms-4">
+            <input
+              type="checkbox"
+              id="includeSubPages"
+              className="form-check-input"
+              name="fileUpload"
+              checked={isIncludeSubPage}
+              onChange={() => setIsIncludeSubPage(!isIncludeSubPage)}
+            />
+            <label
+              className="form-label form-check-label"
+              htmlFor="includeSubPages"
+            >
+              {t('Include Subordinated Page')}
+            </label>
+          </div>
+        )}
+        <div className="d-flex gap-2 align-self-end">
+          <Button color="secondary" onClick={onClickCancel}>{t('Cancel')}</Button>
+          <Button color="primary" onClick={onClickDone}>{t('Done')}</Button>
+        </div>
       </ModalFooter>
+    </>
+  );
+};
+
+export const PageSelectModal = (): JSX.Element => {
+  const { data: pageSelectModalData, close: closePageSelectModal } = usePageSelectModal();
+  const isOpen = pageSelectModalData?.isOpened ?? false;
+
+  if (!isOpen) {
+    return <></>;
+  }
+
+  return (
+    <Modal isOpen={isOpen} toggle={closePageSelectModal} centered>
+      <PageSelectModalSubstance />
     </Modal>
   );
 };

+ 2 - 2
apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss

@@ -4,7 +4,7 @@
 
 .grw-page-path-nav-layout :global {
   .grw-page-path-nav-copydropdown {
-    display: none;
+    visibility: hidden;
     @include bs.media-breakpoint-down(md) {
       display: block;
     }
@@ -15,7 +15,7 @@
   &:global {
     &:hover {
       .grw-page-path-nav-copydropdown {
-        display: block;
+        visibility: visible;
       }
     }
   }

+ 2 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -39,6 +39,7 @@ const AiAssistantManegementModal = dynamic(
   () => import('~/features/openai/client/components/AiAssistant/AiAssistantManegementModal')
     .then(mod => mod.AiAssistantManegementModal), { ssr: false },
 );
+const PageSelectModal = dynamic(() => import('~/client/components/PageSelectModal/PageSelectModal').then(mod => mod.PageSelectModal), { ssr: false });
 
 type Props = {
   children?: ReactNode
@@ -70,6 +71,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <DeleteAttachmentModal />
       <DeleteBookmarkFolderModal />
       <PutbackPageModal />
+      <PageSelectModal />
       <SearchModal />
       <AiChatModal />
       <AiAssistantManegementModal />

+ 44 - 4
apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx

@@ -1,12 +1,12 @@
 import type { KeyboardEvent } from 'react';
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useState } from 'react';
 
 import { useForm, Controller } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import {
   Collapse,
   Modal, ModalBody, ModalFooter, ModalHeader,
-  UncontrolledTooltip,
+  UncontrolledTooltip, Input,
 } from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
@@ -14,6 +14,7 @@ import { toastError } from '~/client/util/toastr';
 import { useGrowiCloudUri } from '~/stores-universal/context';
 import loggerFactory from '~/utils/logger';
 
+import { SelectedPageList } from '../../../client/components/Common/SelectedPageList';
 import { useRagSearchModal } from '../../../client/stores/rag-search';
 import { MessageErrorCode, StreamErrorCode } from '../../../interfaces/message-error';
 
@@ -193,7 +194,46 @@ const AiChatModalSubstance = (): JSX.Element => {
   return (
     <>
       <ModalBody className="pb-0 pt-3 pt-lg-4 px-3 px-lg-4">
-        <div className="vstack gap-4 pb-4">
+        <div className="d-flex mb-4">
+          <Input type="select" className="border rounded">
+            <option>
+              GROWI AI の機能について
+            </option>
+          </Input>
+
+          <button type="button" className="btn btn-outline-secondary bg-transparent ms-2">
+            <span className="fs-5 material-symbols-outlined">edit</span>
+          </button>
+
+          <button type="button" className="btn btn-outline-secondary bg-transparent ms-2">
+            <span className="fs-5 material-symbols-outlined">add</span>
+          </button>
+        </div>
+
+        <div className="text-muted mb-4">
+          ここに設定したアシスタントの説明が入ります。ここに設定したアシスタントの説明が入ります。
+        </div>
+
+        <div className="mb-4">
+          <p className="mb-2">アシスタントへの指示</p>
+          <div className="p-3 alert alert-primary">
+            <p className="mb-0 text-break">
+              あなたは生成AIの専門家および、リサーチャーです。ナレッジベースのWikiツールである GROWIのAI機能に関する情報を提示したり、使われている技術に関する説明をしたりします。
+            </p>
+          </div>
+        </div>
+
+        <div className="d-flex align-items-center mb-2">
+          <p className="mb-0">参照するページ</p>
+          <span className="ms-1 fs-5 material-symbols-outlined text-secondary">help</span>
+        </div>
+        <SelectedPageList selectedPages={[
+          { page: { _id: '1', path: '/Project/GROWI/新機能/GROWI AI' }, isIncludeSubPage: true },
+          { page: { _id: '2', path: '/AI導入検討/調査' }, isIncludeSubPage: false },
+        ]}
+        />
+
+        <div className="vstack gap-4 pb-2">
           { messageLogs.map(message => (
             <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
           )) }
@@ -315,7 +355,7 @@ export const AiChatModal = (): JSX.Element => {
     <Modal size="lg" isOpen={isOpened} toggle={closeRagSearchModal} className={moduleClass} scrollable>
 
       <ModalHeader tag="h4" toggle={closeRagSearchModal} className="pe-4">
-        <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">knowledge_assistant</span>
+        <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>
         <span className="fw-bold">{t('modal_aichat.title')}</span>
         <span className="fs-5 text-body-secondary ms-3">{t('modal_aichat.title_beta_label')}</span>
       </ModalHeader>

+ 53 - 6
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManegementModal.tsx

@@ -1,18 +1,42 @@
-import React from 'react';
+import React, { useCallback, useState } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter, Form, FormGroup, Label, Input,
 } from 'reactstrap';
 
+import type { IPageForItem } from '~/interfaces/page';
+import { usePageSelectModal } from '~/stores/modal';
+
+import type { SelectedPage } from '../../../interfaces/selected-page';
 import { useAiAssistantManegementModal } from '../../stores/ai-assistant';
+import { SelectedPageList } from '../Common/SelectedPageList';
 
 import styles from './AiAssistantManegementModal.module.scss';
 
 const moduleClass = styles['grw-ai-assistant-manegement'] ?? '';
 
-
 const AiAssistantManegementModalSubstance = (): JSX.Element => {
+  const { open: openPageSelectModal } = usePageSelectModal();
+  const [selectedPages, setSelectedPages] = useState<SelectedPage[]>([]);
+
+  const clickOpenPageSelectModalHandler = useCallback(() => {
+    const onSelected = (page: IPageForItem, isIncludeSubPage: boolean) => {
+      const selectedPageIds = selectedPages.map(selectedPage => selectedPage.page._id);
+      if (page._id != null && !selectedPageIds.includes(page._id)) {
+        setSelectedPages([...selectedPages, { page, isIncludeSubPage }]);
+      }
+    };
+
+    openPageSelectModal({ onSelected, isHierarchicalSelectionMode: true });
+  }, [openPageSelectModal, selectedPages]);
+
+
+  const clickRmoveSelectedPageHandler = useCallback((pageId: string) => {
+    setSelectedPages(selectedPages.filter(selectedPage => selectedPage.page._id !== pageId));
+  }, [selectedPages]);
+
+
   return (
     <div className="px-4">
       <ModalBody>
@@ -62,10 +86,11 @@ const AiAssistantManegementModalSubstance = (): JSX.Element => {
               <Label className="mb-0">参照するページ</Label>
               <span className="ms-1 fs-5 material-symbols-outlined text-secondary">help</span>
             </div>
+            <SelectedPageList selectedPages={selectedPages} onRemove={clickRmoveSelectedPageHandler} />
             <button
               type="button"
               className="btn btn-outline-primary d-flex align-items-center gap-1"
-              onClick={() => {}}
+              onClick={clickOpenPageSelectModalHandler}
             >
               <span>+</span>
               追加する
@@ -74,15 +99,37 @@ const AiAssistantManegementModalSubstance = (): JSX.Element => {
 
           <FormGroup>
             <div className="d-flex align-items-center mb-2">
-              <Label className="mb-0">アシスタントの役割</Label>
-              <span className="ms-1 fs-5 material-symbols-outlined text-secondary">help</span>
+              <Label className="mb-0 me-2">アシスタントへの指示</Label>
+              <label className="form-label form-check-label">
+                <span className="badge text-bg-danger mt-2">
+                  必須
+                </span>
+              </label>
+            </div>
+            <Input
+              type="textarea"
+              placeholder="アシスタントに実行して欲しい内容を具体的に記入してください"
+              className="border rounded"
+              rows={4}
+            />
+          </FormGroup>
+
+          <FormGroup>
+            <div className="d-flex align-items-center mb-2">
+              <Label className="mb-0 me-2">アシスタントのメモ</Label>
+              <label className="form-label form-check-label">
+                <span className="badge text-bg-secondary mt-2">
+                  必須
+                </span>
+              </label>
             </div>
             <Input
               type="textarea"
-              placeholder="アシスタントの役割をいれてください"
+              placeholder="内容や用途のメモを表示させることができます"
               className="border rounded"
               rows={4}
             />
+            <p className="mt-1 text-muted">メモ内容はアシスタントには影響しません。</p>
           </FormGroup>
         </Form>
       </ModalBody>

+ 29 - 0
apps/app/src/features/openai/client/components/Common/SelectedPageList.tsx

@@ -0,0 +1,29 @@
+import { memo } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import type { SelectedPage } from '../../../interfaces/selected-page';
+
+export const SelectedPageList = memo(({ selectedPages, onRemove }: { selectedPages: SelectedPage[], onRemove?: (pageId?: string) => void }): JSX.Element => {
+  const { t } = useTranslation();
+
+  if (selectedPages.length === 0) {
+    return <></>;
+  }
+
+  return (
+    <div className="mb-3">
+      {selectedPages.map(({ page, isIncludeSubPage }) => (
+        <div key={page._id} className="mb-1 d-flex align-items-center">
+          <code>{ page.path }</code>
+          {isIncludeSubPage && <span className="badge rounded-pill text-bg-secondary ms-2">{t('Include Subordinated Page')}</span>}
+          {onRemove != null && page._id != null && page._id && (
+            <button className="btn border-0 " type="button" onClick={() => onRemove(page._id)}>
+              <span className="fs-5 material-symbols-outlined text-secondary">delete</span>
+            </button>
+          )}
+        </div>
+      ))}
+    </div>
+  );
+});

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

@@ -0,0 +1,6 @@
+import type { IPageForItem } from '~/interfaces/page';
+
+export type SelectedPage = {
+  page: IPageForItem,
+  isIncludeSubPage: boolean,
+}

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

@@ -0,0 +1,141 @@
+import {
+  type IGrantedGroup, GroupType, type IUser, type Ref,
+} from '@growi/core';
+import { type Model, type Document, Schema } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import type { VectorStore } from './vector-store';
+
+/*
+*  Objects
+*/
+const AiAssistantType = {
+  KNOWLEDGE: 'knowledge',
+  // EDITOR: 'editor',
+  // LEARNING: 'learning',
+} as const;
+
+const AiAssistantShareScope = {
+  PUBLIC: 'public',
+  ONLY_ME: 'onlyMe',
+  USER_GROUP: 'userGroup',
+} as const;
+
+const AiAssistantOwnerAccessScope = {
+  PUBLIC: 'public',
+  ONLY_ME: 'onlyMe',
+  USER_GROUP: 'userGroup',
+} as const;
+
+
+/*
+*  Interfaces
+*/
+type AiAssistantType = typeof AiAssistantType[keyof typeof AiAssistantType];
+type AiAssistantShareScope = typeof AiAssistantShareScope[keyof typeof AiAssistantShareScope];
+type AiAssistantOwnerAccessScope = typeof AiAssistantOwnerAccessScope[keyof typeof AiAssistantOwnerAccessScope];
+
+interface AiAssistant {
+  name: string;
+  description: string
+  additionalInstruction: string
+  pagePathPatterns: string[],
+  vectorStore: Ref<VectorStore>
+  types: AiAssistantType[]
+  owner: Ref<IUser>
+  grantedUsers?: IUser[]
+  grantedGroups?: IGrantedGroup[]
+  shareScope: AiAssistantShareScope
+  ownerAccessScope: AiAssistantOwnerAccessScope
+}
+
+interface AiAssistantDocument extends AiAssistant, Document {}
+
+type AiAssistantModel = Model<AiAssistantDocument>
+
+
+/*
+ * Schema Definition
+ */
+const schema = new Schema<AiAssistantDocument>(
+  {
+    name: {
+      type: String,
+      required: true,
+    },
+    description: {
+      type: String,
+      required: true,
+      default: '',
+    },
+    additionalInstruction: {
+      type: String,
+      required: true,
+      default: '',
+    },
+    pagePathPatterns: [{
+      type: String,
+      required: true,
+    }],
+    vectorStore: {
+      type: Schema.Types.ObjectId,
+      ref: 'VectorStore',
+      required: true,
+    },
+    types: [{
+      type: String,
+      enum: Object.values(AiAssistantType),
+      required: true,
+    }],
+    owner: {
+      type: Schema.Types.ObjectId,
+      ref: 'User',
+      required: true,
+    },
+    grantedUsers: [
+      {
+        type: Schema.Types.ObjectId,
+        ref: 'User',
+        required: true,
+      },
+    ],
+    grantedGroups: {
+      type: [{
+        type: {
+          type: String,
+          enum: Object.values(GroupType),
+          required: true,
+          default: 'UserGroup',
+        },
+        item: {
+          type: Schema.Types.ObjectId,
+          refPath: 'grantedGroups.type',
+          required: true,
+          index: true,
+        },
+      }],
+      validate: [function(arr: IGrantedGroup[]): boolean {
+        if (arr == null) return true;
+        const uniqueItemValues = new Set(arr.map(e => e.item));
+        return arr.length === uniqueItemValues.size;
+      }, 'grantedGroups contains non unique item'],
+      default: [],
+    },
+    shareScope: {
+      type: String,
+      enum: Object.values(AiAssistantShareScope),
+      required: true,
+    },
+    ownerAccessScope: {
+      type: String,
+      enum: Object.values(AiAssistantOwnerAccessScope),
+      required: true,
+    },
+  },
+  {
+    timestamps: true,
+  },
+);
+
+export default getOrCreateModel<AiAssistantDocument, AiAssistantModel>('AiAssistant', schema);

+ 1 - 1
apps/app/src/features/openai/server/models/vector-store.ts

@@ -9,7 +9,7 @@ export const VectorStoreScopeType = {
 export type VectorStoreScopeType = typeof VectorStoreScopeType[keyof typeof VectorStoreScopeType];
 
 const VectorStoreScopeTypes = Object.values(VectorStoreScopeType);
-interface VectorStore {
+export interface VectorStore {
   vectorStoreId: string
   scopeType: VectorStoreScopeType
   isDeleted: boolean

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

@@ -2,6 +2,7 @@ import assert from 'node:assert';
 import { Readable, Transform } from 'stream';
 import { pipeline } from 'stream/promises';
 
+import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { PageGrant, isPopulated } from '@growi/core';
 import type { HydratedDocument, Types } from 'mongoose';
 import mongoose from 'mongoose';
@@ -20,7 +21,7 @@ import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 
 import { OpenaiServiceTypes } from '../../interfaces/ai';
-import { sanitizeMarkdown } from '../utils/sanitize-markdown';
+import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html';
 
 import { getClient } from './client-delegator';
 // import { splitMarkdownIntoChunks } from './markdown-splitter/markdown-token-splitter';
@@ -157,9 +158,9 @@ class OpenaiService implements IOpenaiService {
   //   }
   // }
 
-  private async uploadFile(pageId: Types.ObjectId, body: string): Promise<OpenAI.Files.FileObject> {
-    const sanitizedMarkdown = await sanitizeMarkdown(body);
-    const file = await toFile(Readable.from(sanitizedMarkdown), `${pageId}.md`);
+  private async uploadFile(pageId: Types.ObjectId, pagePath: string, revisionBody: string): Promise<OpenAI.Files.FileObject> {
+    const convertedHtml = await convertMarkdownToHtml({ pagePath, revisionBody });
+    const file = await toFile(Readable.from(convertedHtml), `${pageId}.html`);
     const uploadedFile = await this.client.uploadFile(file);
     return uploadedFile;
   }
@@ -183,17 +184,17 @@ class OpenaiService implements IOpenaiService {
   async createVectorStoreFile(pages: Array<HydratedDocument<PageDocument>>): Promise<void> {
     const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
     const vectorStoreFileRelationsMap: VectorStoreFileRelationsMap = new Map();
-    const processUploadFile = async(page: PageDocument) => {
+    const processUploadFile = async(page: HydratedDocument<PageDocument>) => {
       if (page._id != null && page.grant === PageGrant.GRANT_PUBLIC && page.revision != null) {
         if (isPopulated(page.revision) && page.revision.body.length > 0) {
-          const uploadedFile = await this.uploadFile(page._id, page.revision.body);
+          const uploadedFile = await this.uploadFile(page._id, page.path, page.revision.body);
           prepareVectorStoreFileRelations(vectorStore._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
           return;
         }
 
         const pagePopulatedToShowRevision = await page.populateDataToShowRevision();
         if (pagePopulatedToShowRevision.revision != null && pagePopulatedToShowRevision.revision.body.length > 0) {
-          const uploadedFile = await this.uploadFile(page._id, pagePopulatedToShowRevision.revision.body);
+          const uploadedFile = await this.uploadFile(page._id, page.path, pagePopulatedToShowRevision.revision.body);
           prepareVectorStoreFileRelations(vectorStore._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
         }
       }

+ 89 - 0
apps/app/src/features/openai/server/utils/convert-markdown-to-html.ts

@@ -0,0 +1,89 @@
+import { dynamicImport } from '@cspell/dynamic-import';
+import type { Root, Code } from 'mdast';
+import type * as RehypeMeta from 'rehype-meta';
+import type * as RehypeStringify from 'rehype-stringify';
+import type * as RemarkParse from 'remark-parse';
+import type * as RemarkRehype from 'remark-rehype';
+import type * as Unified from 'unified';
+import type * as UnistUtilVisit from 'unist-util-visit';
+
+interface ModuleCache {
+  unified?: typeof Unified.unified;
+  visit?: typeof UnistUtilVisit.visit;
+  remarkParse?: typeof RemarkParse.default;
+  remarkRehype?: typeof RemarkRehype.default;
+  rehypeMeta?: typeof RehypeMeta.default;
+  rehypeStringify?: typeof RehypeStringify.default;
+}
+
+let moduleCache: ModuleCache = {};
+
+const initializeModules = async(): Promise<void> => {
+  if (moduleCache.unified != null
+    && moduleCache.visit != null
+    && moduleCache.remarkParse != null
+    && moduleCache.remarkRehype != null
+    && moduleCache.rehypeMeta != null
+    && moduleCache.rehypeStringify != null
+  ) {
+    return;
+  }
+
+  const [
+    { unified },
+    { visit },
+    { default: remarkParse },
+    { default: remarkRehype },
+    { default: rehypeMeta },
+    { default: rehypeStringify },
+  ] = await Promise.all([
+    dynamicImport<typeof Unified>('unified', __dirname),
+    dynamicImport<typeof UnistUtilVisit>('unist-util-visit', __dirname),
+    dynamicImport<typeof RemarkParse>('remark-parse', __dirname),
+    dynamicImport<typeof RemarkRehype>('remark-rehype', __dirname),
+    dynamicImport<typeof RehypeMeta>('rehype-meta', __dirname),
+    dynamicImport<typeof RehypeStringify>('rehype-stringify', __dirname),
+  ]);
+
+  moduleCache = {
+    unified,
+    visit,
+    remarkParse,
+    remarkRehype,
+    rehypeMeta,
+    rehypeStringify,
+  };
+};
+
+export const convertMarkdownToHtml = async({ pagePath, revisionBody }: { pagePath: string, revisionBody: string }): Promise<string> => {
+  await initializeModules();
+
+  const {
+    unified, visit, remarkParse, remarkRehype, rehypeMeta, rehypeStringify,
+  } = moduleCache;
+
+  if (unified == null || visit == null || remarkParse == null || remarkRehype == null || rehypeMeta == null || rehypeStringify == null) {
+    throw new Error('Failed to initialize required modules');
+  }
+
+  const sanitizeMarkdown = () => {
+    return (tree: Root) => {
+      visit(tree, 'code', (node: Code) => {
+        if (node.lang === 'drawio') {
+          node.value = '<!-- drawio content replaced -->';
+        }
+      });
+    };
+  };
+
+  const processor = unified()
+    .use(remarkParse)
+    .use(sanitizeMarkdown)
+    .use(remarkRehype)
+    .use(rehypeMeta, {
+      title: pagePath,
+    })
+    .use(rehypeStringify);
+
+  return processor.processSync(revisionBody).toString();
+};

+ 0 - 65
apps/app/src/features/openai/server/utils/sanitize-markdown.ts

@@ -1,65 +0,0 @@
-import { dynamicImport } from '@cspell/dynamic-import';
-import type { Root, Code } from 'mdast';
-import type * as RemarkParse from 'remark-parse';
-import type * as RemarkStringify from 'remark-stringify';
-import type * as Unified from 'unified';
-import type * as UnistUtilVisit from 'unist-util-visit';
-
-interface ModuleCache {
-  remarkParse?: typeof RemarkParse.default;
-  remarkStringify?: typeof RemarkStringify.default;
-  unified?: typeof Unified.unified;
-  visit?: typeof UnistUtilVisit.visit;
-}
-
-let moduleCache: ModuleCache = {};
-
-const initializeModules = async(): Promise<void> => {
-  if (moduleCache.remarkParse != null && moduleCache.remarkStringify != null && moduleCache.unified != null && moduleCache.visit != null) {
-    return;
-  }
-
-  const [{ default: remarkParse }, { default: remarkStringify }, { unified }, { visit }] = await Promise.all([
-    dynamicImport<typeof RemarkParse>('remark-parse', __dirname),
-    dynamicImport<typeof RemarkStringify>('remark-stringify', __dirname),
-    dynamicImport<typeof Unified>('unified', __dirname),
-    dynamicImport<typeof UnistUtilVisit>('unist-util-visit', __dirname),
-  ]);
-
-  moduleCache = {
-    remarkParse,
-    remarkStringify,
-    unified,
-    visit,
-  };
-};
-
-export const sanitizeMarkdown = async(markdown: string): Promise<string> => {
-  await initializeModules();
-
-  const {
-    remarkParse, remarkStringify, unified, visit,
-  } = moduleCache;
-
-
-  if (remarkParse == null || remarkStringify == null || unified == null || visit == null) {
-    throw new Error('Failed to initialize required modules');
-  }
-
-  const sanitize = () => {
-    return (tree: Root) => {
-      visit(tree, 'code', (node: Code) => {
-        if (node.lang === 'drawio') {
-          node.value = '<!-- drawio content replaced -->';
-        }
-      });
-    };
-  };
-
-  const processor = unified()
-    .use(remarkParse)
-    .use(sanitize)
-    .use(remarkStringify);
-
-  return processor.processSync(markdown).toString();
-};

+ 3 - 1
apps/app/src/interfaces/ui.ts

@@ -1,5 +1,7 @@
 import type { Nullable } from '@growi/core';
 
+import type { IPageForItem } from '~/interfaces/page';
+
 
 export const SidebarMode = {
   DRAWER: 'drawer',
@@ -36,4 +38,4 @@ export type OnRenamedFunction = (path: string) => void;
 export type OnDuplicatedFunction = (fromPath: string, toPath: string) => void;
 export type OnPutBackedFunction = (path: string) => void;
 export type onDeletedBookmarkFolderFunction = (bookmarkFolderId: string) => void;
-export type OnSelectedFunction = () => void;
+export type OnSelectedFunction = (page: IPageForItem, isIncludeSubPage: boolean) => void;

+ 4 - 3
apps/app/src/stores/modal.tsx

@@ -720,7 +720,8 @@ export const useDeleteAttachmentModal = (): SWRResponse<DeleteAttachmentModalSta
 /*
 * PageSelectModal
 */
-export type IPageSelectModalOption = {
+type IPageSelectModalOption = {
+  isHierarchicalSelectionMode?: boolean,
   onSelected?: OnSelectedFunction,
 }
 
@@ -730,8 +731,8 @@ type PageSelectModalStatus = {
 }
 
 type PageSelectModalStatusUtils = {
-  open(): Promise<PageSelectModalStatus | undefined>
-  close(): Promise<PageSelectModalStatus | undefined>
+  open(opts?: IPageSelectModalOption): void
+  close(): void
 }
 
 export const usePageSelectModal = (

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.1.6-slackbot-proxy.0",
+  "version": "7.1.7-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.1.6-RC.0",
+  "version": "7.1.7-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",

+ 1 - 1
packages/presentation/package.json

@@ -42,7 +42,7 @@
     "@growi/core": "workspace:^"
   },
   "devDependencies": {
-    "@marp-team/marp-core": "^3.9.0",
+    "@marp-team/marp-core": "^3.9.1",
     "@marp-team/marpit": "^2.6.1",
     "@types/mdast": "^4.0.4",
     "@types/reveal.js": "^4.4.1",

+ 2 - 2
packages/remark-lsx/src/server/index.ts

@@ -14,8 +14,8 @@ const filterXSS = new FilterXSS();
 
 const lsxValidator = [
   query('pagePath').notEmpty().isString(),
-  query('offset').optional().isInt(),
-  query('limit').optional().isInt(),
+  query('offset').optional().isInt().toInt(),
+  query('limit').optional().isInt().toInt(),
   query('options')
     .optional()
     .customSanitizer((options) => {

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 263 - 85
pnpm-lock.yaml


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است