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

Merge remote-tracking branch 'origin/feat/growi-ai-next' into feat/unified-merge-view

Yuki Takei 1 год назад
Родитель
Сommit
27ae1afe76
42 измененных файлов с 1116 добавлено и 579 удалено
  1. 1 3
      apps/app/public/static/locales/en_US/translation.json
  2. 1 3
      apps/app/public/static/locales/fr_FR/translation.json
  3. 1 3
      apps/app/public/static/locales/ja_JP/translation.json
  4. 1 3
      apps/app/public/static/locales/zh_CN/translation.json
  5. 2 2
      apps/app/src/client/components/PageControls/PageControls.tsx
  6. 0 2
      apps/app/src/components/Layout/BasicLayout.tsx
  7. 0 27
      apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.module.scss
  8. 0 369
      apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx
  9. 0 1
      apps/app/src/features/openai/chat/components/AiChatModal/index.ts
  10. 11 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.module.scss
  11. 396 39
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx
  12. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.module.scss
  13. 5 5
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.tsx
  14. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/ResizableTextArea.tsx
  15. 70 43
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx
  16. 9 3
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  17. 12 0
      apps/app/src/features/openai/client/stores/message.tsx
  18. 26 0
      apps/app/src/features/openai/client/stores/thread.tsx
  19. 2 2
      apps/app/src/features/openai/interfaces/ai-assistant.ts
  20. 12 0
      apps/app/src/features/openai/interfaces/thread-relation.ts
  21. 4 0
      apps/app/src/features/openai/interfaces/vector-store.ts
  22. 19 2
      apps/app/src/features/openai/server/models/ai-assistant.ts
  23. 8 8
      apps/app/src/features/openai/server/models/thread-relation.ts
  24. 3 6
      apps/app/src/features/openai/server/models/vector-store.ts
  25. 72 0
      apps/app/src/features/openai/server/routes/get-messages.ts
  26. 62 0
      apps/app/src/features/openai/server/routes/get-threads.ts
  27. 8 0
      apps/app/src/features/openai/server/routes/index.ts
  28. 14 2
      apps/app/src/features/openai/server/routes/message.ts
  29. 2 2
      apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts
  30. 19 5
      apps/app/src/features/openai/server/routes/thread.ts
  31. 8 0
      apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts
  32. 1 0
      apps/app/src/features/openai/server/services/client-delegator/interfaces.ts
  33. 8 0
      apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts
  34. 3 0
      apps/app/src/features/openai/server/services/normalize-data/normalize-thread-relation-expired-at/normalize-thread-relation-expired-at.integ.ts
  35. 245 22
      apps/app/src/features/openai/server/services/openai.ts
  36. 5 5
      apps/app/src/features/openai/server/services/replace-annotation-with-page-link.ts
  37. 48 0
      apps/app/src/features/openai/server/utils/generate-glob-patterns.spec.ts
  38. 28 0
      apps/app/src/features/openai/server/utils/generate-glob-patterns.ts
  39. 1 2
      apps/app/src/server/routes/apiv3/page/create-page.ts
  40. 1 2
      apps/app/src/server/routes/apiv3/page/update-page.ts
  41. 7 17
      apps/app/src/server/service/page/index.ts
  42. 1 1
      packages/core/src/utils/page-path-utils/index.ts

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

@@ -488,9 +488,7 @@
     "latest_revision": "theirs",
     "selected_editable_revision": "Selected Page Body (Editable)"
   },
-  "modal_aichat": {
-    "title": "Knowledge Assistant",
-    "title_beta_label": "(Beta)",
+  "sidebar_aichat": {
     "placeholder": "Ask me anything.",
     "summary_mode_label": "Summary mode",
     "summary_mode_help": "Concise answer within 2-3 sentences",

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

@@ -483,9 +483,7 @@
     "latest_revision": "les autres",
     "selected_editable_revision": "Corps de page sélectionné (Modifiable)"
   },
-  "modal_aichat": {
-    "title": "Assistant de Connaissance",
-    "title_beta_label": "(Bêta)",
+  "sidebar_aichat": {
     "placeholder": "Demandez-moi n'importe quoi.",
     "summary_mode_label": "Mode résumé",
     "summary_mode_help": "Réponse concise en 2-3 phrases",

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

@@ -521,9 +521,7 @@
     "latest_revision": "最新の本文",
     "selected_editable_revision": "保存するページ本文(編集可能)"
   },
-  "modal_aichat": {
-    "title": "ナレッジアシスタント",
-    "title_beta_label": "(ベータ)",
+  "sidebar_aichat": {
     "placeholder": "ききたいことを入力してください",
     "summary_mode_label": "要約モード",
     "summary_mode_help": "2~3文以内の簡潔な回答",

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

@@ -477,9 +477,7 @@
     "latest_revision": "最新页面正文",
     "selected_editable_revision": "选定的可编辑页面正文"
   },
-  "modal_aichat": {
-    "title": "知识助手",
-    "title_beta_label": "(测试版)",
+  "sidebar_aichat": {
     "placeholder": "问我任何问题。",
     "summary_mode_label": "摘要模式",
     "summary_mode_help": "简洁回答在2-3句话内",

+ 2 - 2
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -16,7 +16,7 @@ import {
   toggleLike, toggleSubscribe,
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
-import RagSearchButton from '~/features/openai/client/components/RagSearchButton';
+// import RagSearchButton from '~/features/openai/client/components/RagSearchButton';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/stores-universal/context';
 import {
   EditorMode, useEditorMode,
@@ -285,7 +285,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
       { isViewMode && isDeviceLargerThanMd && !isSearchPage && !isSearchPage && (
         <>
           <SearchButton />
-          <RagSearchButton />
+          {/* <RagSearchButton /> */}
         </>
       )}
 

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

@@ -40,7 +40,6 @@ const DeleteBookmarkFolderModal = dynamic(
   () => import('~/client/components/DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false },
 );
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
-const AiChatModal = dynamic(() => import('~/features/openai/chat/components/AiChatModal').then(mod => mod.AiChatModal), { ssr: false });
 const AiAssistantManagementModal = dynamic(
   () => import('~/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal')
     .then(mod => mod.AiAssistantManagementModal), { ssr: false },
@@ -81,7 +80,6 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <PutbackPageModal />
       <PageSelectModal />
       <SearchModal />
-      <AiChatModal />
       <AiAssistantManagementModal />
 
       <PagePresentationModal />

+ 0 - 27
apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.module.scss

@@ -1,27 +0,0 @@
-@use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '@growi/core-styles/scss/variables/growi-official-colors';
-@use '@growi/ui/scss/atoms/btn-muted';
-
-.grw-aichat-modal :global {
-
-  .textarea-ask {
-    max-height: 30vh;
-  }
-
-  .btn-submit {
-    font-size: 1.1em;
-  }
-}
-
-
-// == Colors
-.grw-aichat-modal :global {
-  .growi-ai-chat-icon {
-    color: growi-official-colors.$growi-ai-purple;
-  }
-
-  .btn-submit {
-    @include btn-muted.colorize(bs.$purple, bs.$purple);
-  }
-}
-

+ 0 - 369
apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx

@@ -1,369 +0,0 @@
-import type { KeyboardEvent } 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, Input,
-} from 'reactstrap';
-
-import { apiv3Post } from '~/client/util/apiv3-client';
-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';
-
-import { MessageCard } from './MessageCard';
-import { ResizableTextarea } from './ResizableTextArea';
-
-import styles from './AiChatModal.module.scss';
-
-const moduleClass = styles['grw-aichat-modal'] ?? '';
-
-const logger = loggerFactory('growi:clinet:components:RagSearchModal');
-
-
-type Message = {
-  id: string,
-  content: string,
-  isUserMessage?: boolean,
-}
-
-type FormData = {
-  input: string;
-  summaryMode?: boolean;
-};
-
-const AiChatModalSubstance = (): JSX.Element => {
-
-  const { t } = useTranslation();
-
-  const form = useForm<FormData>({
-    defaultValues: {
-      input: '',
-      summaryMode: true,
-    },
-  });
-
-  const [threadId, setThreadId] = useState<string | undefined>();
-  const [messageLogs, setMessageLogs] = useState<Message[]>([]);
-  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
-  const [errorMessage, setErrorMessage] = useState<string | undefined>();
-  const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
-
-  const { data: growiCloudUri } = useGrowiCloudUri();
-
-  const isGenerating = generatingAnswerMessage != null;
-
-  const submit = 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]);
-
-    // reset form
-    form.reset({ input: '', summaryMode: data.summaryMode });
-    setErrorMessage(undefined);
-
-    // add an empty assistant message
-    const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
-    setGeneratingAnswerMessage(newAnswerMessage);
-
-    // create thread
-    let currentThreadId = threadId;
-    if (threadId == null) {
-      try {
-        const res = await apiv3Post('/openai/thread');
-        const thread = res.data.thread;
-
-        setThreadId(thread.id);
-        currentThreadId = thread.id;
-      }
-      catch (err) {
-        logger.error(err.toString());
-        toastError(t('modal_aichat.failed_to_create_or_retrieve_thread'));
-      }
-    }
-
-    // post message
-    try {
-      const response = await fetch('/_api/v3/openai/message', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ userMessage: data.input, threadId: currentThreadId, summaryMode: data.summaryMode }),
-      });
-
-      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}` });
-
-          const hasThreadIdNotSetError = resJson.errors.some(err => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET);
-          if (hasThreadIdNotSetError) {
-            toastError(t('modal_aichat.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;
-
-        const { done, value } = await reader.read();
-
-        // add assistant message to the logs
-        if (done) {
-          setGeneratingAnswerMessage((generatingAnswerMessage) => {
-            if (generatingAnswerMessage == null) return;
-            setMessageLogs(msgs => [...msgs, generatingAnswerMessage]);
-            return undefined;
-          });
-          return;
-        }
-
-        const chunk = decoder.decode(value);
-
-        const textValues: string[] = [];
-        const lines = chunk.split('\n\n');
-        lines.forEach((line) => {
-          const trimedLine = line.trim();
-          if (trimedLine.startsWith('data:')) {
-            const data = JSON.parse(line.replace('data: ', ''));
-            textValues.push(data.content[0].text.value);
-          }
-          else if (trimedLine.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 ? 'modal_aichat.budget_exceeded_for_growi_cloud' : 'modal_aichat.budget_exceeded');
-            }
-          }
-        });
-
-
-        // append text values to the assistant message
-        setGeneratingAnswerMessage((prevMessage) => {
-          if (prevMessage == null) return;
-          return {
-            ...prevMessage,
-            content: prevMessage.content + textValues.join(''),
-          };
-        });
-
-        read();
-      };
-      read();
-    }
-    catch (err) {
-      logger.error(err.toString());
-      form.setError('input', { type: 'manual', message: err.toString() });
-    }
-
-  }, [form, growiCloudUri, isGenerating, messageLogs, t, threadId]);
-
-  const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
-    if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
-      form.handleSubmit(submit)();
-    }
-  };
-
-  return (
-    <>
-      <ModalBody className="pb-0 pt-3 pt-lg-4 px-3 px-lg-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>
-          )) }
-          { generatingAnswerMessage != null && (
-            <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
-          )}
-          { 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('modal_aichat.caution_against_hallucination')}
-              </span>
-            </div>
-          )}
-        </div>
-      </ModalBody>
-
-      <ModalFooter className="flex-column align-items-start pt-0 pb-3 pb-lg-4 px-3 px-lg-4">
-        <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-3">
-          <div className="flex-fill hstack gap-2 align-items-end m-0">
-            <Controller
-              name="input"
-              control={form.control}
-              render={({ field }) => (
-                <ResizableTextarea
-                  {...field}
-                  required
-                  className="form-control textarea-ask"
-                  style={{ resize: 'none' }}
-                  rows={1}
-                  placeholder={!form.formState.isSubmitting ? t('modal_aichat.placeholder') : ''}
-                  onKeyDown={keyDownHandler}
-                  disabled={form.formState.isSubmitting}
-                />
-              )}
-            />
-            <button
-              type="submit"
-              className="btn btn-submit no-border"
-              disabled={form.formState.isSubmitting || isGenerating}
-            >
-              <span className="material-symbols-outlined">send</span>
-            </button>
-          </div>
-          <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('modal_aichat.summary_mode_label')}
-            </label>
-
-            {/* Help */}
-            <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('modal_aichat.summary_mode_help')}
-            </UncontrolledTooltip>
-          </div>
-        </form>
-
-
-        {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('modal_aichat.error_message') }</span>
-            </div>
-
-            <button
-              type="button"
-              className="btn btn-link text-secondary p-0"
-              aria-expanded={isErrorDetailCollapsed}
-              onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
-            >
-              <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
-                chevron_right
-              </span>
-              <span className="small">{t('modal_aichat.show_error_detail')}</span>
-            </button>
-
-            <Collapse isOpen={isErrorDetailCollapsed}>
-              <div className="ms-2">
-                <div className="">
-                  <div className="text-secondary small">
-                    {form.formState.errors.input?.message}
-                  </div>
-                </div>
-              </div>
-            </Collapse>
-          </div>
-        )}
-      </ModalFooter>
-    </>
-  );
-};
-
-
-export const AiChatModal = (): JSX.Element => {
-
-  const { t } = useTranslation();
-
-  const { data: ragSearchModalData, close: closeRagSearchModal } = useRagSearchModal();
-
-  const isOpened = ragSearchModalData?.isOpened ?? false;
-
-  return (
-    <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">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>
-
-      { isOpened && (
-        <AiChatModalSubstance />
-      ) }
-
-    </Modal>
-  );
-};

+ 0 - 1
apps/app/src/features/openai/chat/components/AiChatModal/index.ts

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

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

@@ -2,6 +2,17 @@
 @use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '@growi/ui/scss/atoms/btn-muted';
 
+.grw-ai-assistant-chat-sidebar :global {
+
+  .textarea-ask {
+    max-height: 30vh;
+  }
+
+  .btn-submit {
+    font-size: 1.1em;
+  }
+}
+
 // == Colors
 .grw-ai-assistant-chat-sidebar :global {
   .growi-ai-chat-icon {

+ 396 - 39
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx

@@ -1,43 +1,396 @@
+import type { KeyboardEvent } from 'react';
 import {
-  type FC, memo, useRef, useEffect,
+  type FC, memo, useRef, useEffect, useState, useCallback,
 } from 'react';
 
+import { useForm, Controller } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { Collapse, UncontrolledTooltip } from 'reactstrap';
 import SimpleBar from 'simplebar-react';
 
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastError } from '~/client/util/toastr';
+import { MessageErrorCode, StreamErrorCode } from '~/features/openai/interfaces/message-error';
+import { useGrowiCloudUri } from '~/stores-universal/context';
+import loggerFactory from '~/utils/logger';
+
 import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
 import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
+import { useSWRMUTxMessages } from '../../../stores/message';
+import { useSWRMUTxThreads } from '../../../stores/thread';
+
+import { MessageCard } from './MessageCard';
+import { ResizableTextarea } from './ResizableTextArea';
 
 import styles from './AiAssistantChatSidebar.module.scss';
 
+const logger = loggerFactory('growi:openai:client:components:AiAssistantChatSidebar');
+
 const moduleClass = styles['grw-ai-assistant-chat-sidebar'] ?? '';
 
 const RIGHT_SIDEBAR_WIDTH = 500;
 
+type Message = {
+  id: string,
+  content: string,
+  isUserMessage?: boolean,
+}
+
+type FormData = {
+  input: string;
+  summaryMode?: boolean;
+};
+
 type AiAssistantChatSidebarSubstanceProps = {
-  aiAssistantData?: AiAssistantHasId;
+  aiAssistantData: AiAssistantHasId;
+  threadId?: string;
   closeAiAssistantChatSidebar: () => void
 }
 
 const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceProps> = (props: AiAssistantChatSidebarSubstanceProps) => {
-  const { aiAssistantData, closeAiAssistantChatSidebar } = props;
+  const {
+    threadId, aiAssistantData, closeAiAssistantChatSidebar,
+  } = props;
+
+  const [currentThreadId, setCurrentThreadId] = useState<string | undefined>(threadId);
+  const [messageLogs, setMessageLogs] = useState<Message[]>([]);
+  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
+  const [errorMessage, setErrorMessage] = useState<string | undefined>();
+  const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
+
+  const { t } = useTranslation();
+  const { data: growiCloudUri } = useGrowiCloudUri();
+  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData._id);
+  const { trigger: mutateMessageData } = useSWRMUTxMessages(aiAssistantData._id, threadId);
+
+  const form = useForm<FormData>({
+    defaultValues: {
+      input: '',
+      summaryMode: true,
+    },
+  });
+
+  useEffect(() => {
+    const getMessageData = async() => {
+      const messageData = await mutateMessageData();
+      if (messageData != null) {
+        const reversedMessageData = messageData.data.slice().reverse();
+        setMessageLogs(() => {
+          return reversedMessageData.map((message, index) => (
+            {
+              id: index.toString(),
+              content: message.content[0].type === 'text' ? message.content[0].text.value : '',
+              isUserMessage: message.role === 'user',
+            }
+          ));
+        });
+      }
+    };
+
+    if (threadId != null) {
+      getMessageData();
+    }
+  }, [mutateMessageData, threadId]);
+
+  const isGenerating = generatingAnswerMessage != null;
+  const submit = 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]);
+
+    // reset form
+    form.reset({ input: '', summaryMode: data.summaryMode });
+    setErrorMessage(undefined);
+
+    // add an empty assistant message
+    const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
+    setGeneratingAnswerMessage(newAnswerMessage);
+
+    // create thread
+    let currentThreadId_ = currentThreadId;
+    if (currentThreadId_ == null) {
+      try {
+        const res = await apiv3Post('/openai/thread', { aiAssistantId: aiAssistantData._id });
+        const thread = res.data.thread;
+
+        setCurrentThreadId(thread.id);
+        currentThreadId_ = thread.id;
+
+        // No need to await because data is not used
+        mutateThreadData();
+      }
+      catch (err) {
+        logger.error(err.toString());
+        toastError(t('sidebar_aichat.failed_to_create_or_retrieve_thread'));
+      }
+    }
+
+    // post message
+    try {
+      const response = await fetch('/_api/v3/openai/message', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          userMessage: data.input, threadId: currentThreadId_, summaryMode: data.summaryMode, aiAssistantId: aiAssistantData._id,
+        }),
+      });
+
+      if (!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}` });
+
+          const hasThreadIdNotSetError = resJson.errors.some(err => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET);
+          if (hasThreadIdNotSetError) {
+            toastError(t('sidebar_aichat.failed_to_create_or_retrieve_thread'));
+          }
+        }
+        setGeneratingAnswerMessage(undefined);
+        return;
+      }
+
+      const reader = response.body?.getReader();
+      const decoder = new TextDecoder('utf-8');
+
+      const read = async() => {
+        if (reader == null) return;
+
+        const { done, value } = await reader.read();
+
+        // add assistant message to the logs
+        if (done) {
+          setGeneratingAnswerMessage((generatingAnswerMessage) => {
+            if (generatingAnswerMessage == null) return;
+            setMessageLogs(msgs => [...msgs, generatingAnswerMessage]);
+            return undefined;
+          });
+          return;
+        }
+
+        const chunk = decoder.decode(value);
+
+        const textValues: string[] = [];
+        const lines = chunk.split('\n\n');
+        lines.forEach((line) => {
+          const trimedLine = line.trim();
+          if (trimedLine.startsWith('data:')) {
+            const data = JSON.parse(line.replace('data: ', ''));
+            textValues.push(data.content[0].text.value);
+          }
+          else if (trimedLine.startsWith('error:')) {
+            const error = JSON.parse(line.replace('error: ', ''));
+            logger.error(error.errorMessage);
+            form.setError('input', { type: 'manual', message: error.message });
+
+            if (error.code === StreamErrorCode.BUDGET_EXCEEDED) {
+              setErrorMessage(growiCloudUri != null ? 'sidebar_aichat.budget_exceeded_for_growi_cloud' : 'sidebar_aichat.budget_exceeded');
+            }
+          }
+        });
+
+
+        // append text values to the assistant message
+        setGeneratingAnswerMessage((prevMessage) => {
+          if (prevMessage == null) return;
+          return {
+            ...prevMessage,
+            content: prevMessage.content + textValues.join(''),
+          };
+        });
+
+        read();
+      };
+      read();
+    }
+    catch (err) {
+      logger.error(err.toString());
+      form.setError('input', { type: 'manual', message: err.toString() });
+    }
+
+  }, [aiAssistantData._id, currentThreadId, form, growiCloudUri, isGenerating, messageLogs, mutateThreadData, t]);
+
+  const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
+    if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
+      form.handleSubmit(submit)();
+    }
+  };
 
   return (
     <>
-      <div className="d-flex align-items-center p-3 border-bottom">
-        <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>
-        <h5 className="mb-0 fw-bold flex-grow-1 text-truncate">{aiAssistantData?.name}</h5>
-        <button
-          type="button"
-          className="btn btn-link p-0 border-0"
-          onClick={closeAiAssistantChatSidebar}
-        >
-          <span className="material-symbols-outlined">close</span>
-        </button>
-      </div>
+      <div className="d-flex flex-column vh-100">
+        <div className="d-flex align-items-center p-3 border-bottom">
+          <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>
+          <h5 className="mb-0 fw-bold flex-grow-1 text-truncate">{aiAssistantData.name}</h5>
+          <button
+            type="button"
+            className="btn btn-link p-0 border-0"
+            onClick={closeAiAssistantChatSidebar}
+          >
+            <span className="material-symbols-outlined">close</span>
+          </button>
+        </div>
+        <div className="p-4 d-flex flex-column gap-4 vh-100">
+
+
+          { currentThreadId != null
+            ? (
+              <div className="vstack gap-4 pb-2">
+                { messageLogs.map(message => (
+                  <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
+                )) }
+                { generatingAnswerMessage != null && (
+                  <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
+                )}
+                { messageLogs.length > 0 && (
+                  <div className="d-flex justify-content-center">
+                    <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
+                      {t('sidebar_aichat.caution_against_hallucination')}
+                    </span>
+                  </div>
+                )}
+              </div>
+            )
+            : (
+              <>
+                <p className="fs-6 text-secondary mb-0">
+                  {aiAssistantData.description}
+                </p>
 
-      <div className="p-3 w-100">
-        {/* AI Chat Screen Implementation */}
-        {/* TODO: https://redmine.weseek.co.jp/issues/161511 */}
+                <div>
+                  <p className="text-secondary">アシスタントへの指示</p>
+                  <div className="card bg-light border-0">
+                    <div className="card-body p-3">
+                      <p className="fs-6 text-secondary mb-0">
+                        {aiAssistantData.additionalInstruction}
+                      </p>
+                    </div>
+                  </div>
+                </div>
+
+                <div>
+                  <div className="d-flex align-items-center">
+                    <p className="text-secondary mb-0">参照するページ</p>
+                  </div>
+                  <div className="d-flex flex-column gap-1">
+                    { aiAssistantData.pagePathPatterns.map(pagePathPattern => (
+                      <a
+                        key={pagePathPattern}
+                        href="#"
+                        className="fs-6 text-secondary text-decoration-none"
+                      >
+                        {pagePathPattern}
+                      </a>
+                    ))}
+                  </div>
+                </div>
+
+              </>
+            )
+          }
+
+          <div className="mt-auto">
+            <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-3">
+              <div className="flex-fill hstack gap-2 align-items-end m-0">
+                <Controller
+                  name="input"
+                  control={form.control}
+                  render={({ field }) => (
+                    <ResizableTextarea
+                      {...field}
+                      required
+                      className="form-control textarea-ask"
+                      style={{ resize: 'none' }}
+                      rows={1}
+                      placeholder={!form.formState.isSubmitting ? t('sidebar_aichat.placeholder') : ''}
+                      onKeyDown={keyDownHandler}
+                      disabled={form.formState.isSubmitting}
+                    />
+                  )}
+                />
+                <button
+                  type="submit"
+                  className="btn btn-submit no-border"
+                  disabled={form.formState.isSubmitting || isGenerating}
+                >
+                  <span className="material-symbols-outlined">send</span>
+                </button>
+              </div>
+              <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_aichat.summary_mode_label')}
+                </label>
+
+                {/* Help */}
+                <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_aichat.summary_mode_help')}
+                </UncontrolledTooltip>
+              </div>
+            </form>
+
+            {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_aichat.error_message') }</span>
+                </div>
+
+                <button
+                  type="button"
+                  className="btn btn-link text-secondary p-0"
+                  aria-expanded={isErrorDetailCollapsed}
+                  onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
+                >
+                  <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
+                    chevron_right
+                  </span>
+                  <span className="small">{t('sidebar_aichat.show_error_detail')}</span>
+                </button>
+
+                <Collapse isOpen={isErrorDetailCollapsed}>
+                  <div className="ms-2">
+                    <div className="">
+                      <div className="text-secondary small">
+                        {form.formState.errors.input?.message}
+                      </div>
+                    </div>
+                  </div>
+                </Collapse>
+              </div>
+            )}
+
+          </div>
+        </div>
       </div>
     </>
   );
@@ -49,7 +402,10 @@ export const AiAssistantChatSidebar: FC = memo((): JSX.Element => {
   const sidebarScrollerRef = useRef<HTMLDivElement>(null);
 
   const { data: aiAssistantChatSidebarData, close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
-  const isOpened = aiAssistantChatSidebarData?.isOpened ?? false;
+
+  const aiAssistantData = aiAssistantChatSidebarData?.aiAssistantData;
+  const threadId = aiAssistantChatSidebarData?.threadId;
+  const isOpened = aiAssistantChatSidebarData?.isOpened && aiAssistantData != null;
 
   useEffect(() => {
     const handleClickOutside = (event: MouseEvent) => {
@@ -64,27 +420,28 @@ export const AiAssistantChatSidebar: FC = memo((): JSX.Element => {
     };
   }, [closeAiAssistantChatSidebar, isOpened]);
 
+  if (!isOpened) {
+    return <></>;
+  }
+
   return (
-    <>
-      {isOpened && (
-        <div
-          ref={sidebarRef}
-          className={`position-fixed top-0 end-0 h-100 border-start bg-white shadow-sm ${moduleClass}`}
-          style={{ zIndex: 1500, width: `${RIGHT_SIDEBAR_WIDTH}px` }}
-          data-testid="grw-right-sidebar"
-        >
-          <SimpleBar
-            scrollableNodeProps={{ ref: sidebarScrollerRef }}
-            className="h-100 position-relative"
-            autoHide
-          >
-            <AiAssistantChatSidebarSubstance
-              aiAssistantData={aiAssistantChatSidebarData?.aiAssistantData}
-              closeAiAssistantChatSidebar={closeAiAssistantChatSidebar}
-            />
-          </SimpleBar>
-        </div>
-      )}
-    </>
+    <div
+      ref={sidebarRef}
+      className={`position-fixed top-0 end-0 h-100 border-start bg-white shadow-sm ${moduleClass}`}
+      style={{ zIndex: 1500, width: `${RIGHT_SIDEBAR_WIDTH}px` }}
+      data-testid="grw-right-sidebar"
+    >
+      <SimpleBar
+        scrollableNodeProps={{ ref: sidebarScrollerRef }}
+        className="h-100 position-relative"
+        autoHide
+      >
+        <AiAssistantChatSidebarSubstance
+          threadId={threadId}
+          aiAssistantData={aiAssistantData}
+          closeAiAssistantChatSidebar={closeAiAssistantChatSidebar}
+        />
+      </SimpleBar>
+    </div>
   );
 });

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


+ 5 - 5
apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.tsx → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.tsx

@@ -6,7 +6,7 @@ import ReactMarkdown from 'react-markdown';
 
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 
-import { useRagSearchModal } from '../../../client/stores/rag-search';
+import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
 
 import styles from './MessageCard.module.scss';
 
@@ -27,11 +27,11 @@ const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
 const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
 
 const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
-  const { close: closeRagSearchModal } = useRagSearchModal();
+  const { close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
 
   const onClick = useCallback(() => {
-    closeRagSearchModal();
-  }, [closeRagSearchModal]);
+    closeAiAssistantChatSidebar();
+  }, [closeAiAssistantChatSidebar]);
 
   return (
     <NextLink href={props.href} onClick={onClick} className="link-primary">
@@ -55,7 +55,7 @@ const AssistantMessageCard = ({ children }: { children: string }): JSX.Element =
             )
             : (
               <span className="text-thinking">
-                {t('modal_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
+                {t('sidebar_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
               </span>
             )
           }

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


+ 70 - 43
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx

@@ -3,43 +3,38 @@ import React, { useCallback, useState } from 'react';
 import { getIdStringForRef } from '@growi/core';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
+import type { IThreadRelationHasId } from '~/features/openai/interfaces/thread-relation';
 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, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+import { useSWRMUTxThreads, useSWRxThreads } from '../../../stores/thread';
 
 import styles from './AiAssistantTree.module.scss';
 
 const moduleClass = styles['ai-assistant-tree-item'] ?? '';
 
-type Thread = {
-  _id: string;
-  name: string;
-}
-
-const dummyThreads: Thread[] = [
-  { _id: '1', name: 'thread1' },
-  { _id: '2', name: 'thread2' },
-  { _id: '3', name: 'thread3' },
-];
 
+/*
+*  ThreadItem
+*/
 type ThreadItemProps = {
-  thread: Thread;
+  thread: IThreadRelationHasId
+  aiAssistantData: AiAssistantHasId;
+  onThreadClick: (aiAssistantData: AiAssistantHasId, threadId?: string) => void;
 };
 
-const ThreadItem: React.FC<ThreadItemProps> = ({
-  thread,
-}) => {
+const ThreadItem: React.FC<ThreadItemProps> = ({ thread, aiAssistantData, onThreadClick }) => {
 
   const deleteThreadHandler = useCallback(() => {
     // TODO: https://redmine.weseek.co.jp/issues/161490
   }, []);
 
   const openChatHandler = useCallback(() => {
-    // TODO: https://redmine.weseek.co.jp/issues/159530
-  }, []);
+    onThreadClick(aiAssistantData, thread.threadId);
+  }, [aiAssistantData, onThreadClick, thread.threadId]);
 
   return (
     <li
@@ -52,7 +47,7 @@ const ThreadItem: React.FC<ThreadItemProps> = ({
       </div>
 
       <div className="grw-ai-assistant-title-anchor ps-1">
-        <p className="text-truncate m-auto">{thread.name}</p>
+        <p className="text-truncate m-auto">{thread.threadId}</p>
       </div>
 
       <div className="grw-ai-assistant-actions opacity-0 d-flex justify-content-center ">
@@ -69,6 +64,39 @@ const ThreadItem: React.FC<ThreadItemProps> = ({
 };
 
 
+/*
+*  ThreadItems
+*/
+type ThreadItemsProps = {
+  aiAssistantData: AiAssistantHasId;
+  onThreadClick: (aiAssistantData: AiAssistantHasId, threadId?: string) => void;
+};
+
+const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantData, onThreadClick }) => {
+  const { data: threads } = useSWRxThreads(aiAssistantData._id);
+
+  if (threads == null || threads.length === 0) {
+    return <p className="text-secondary ms-5">スレッドが存在しません</p>;
+  }
+
+  return (
+    <div className="grw-ai-assistant-item-children">
+      {threads.map(thread => (
+        <ThreadItem
+          key={thread._id}
+          thread={thread}
+          aiAssistantData={aiAssistantData}
+          onThreadClick={onThreadClick}
+        />
+      ))}
+    </div>
+  );
+};
+
+
+/*
+*  AiAssistantItem
+*/
 const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAssistantAccessScope): string => {
   const determinedSharedScope = shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE ? accessScope : shareScope;
   switch (determinedSharedScope) {
@@ -84,34 +112,34 @@ const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAss
 type AiAssistantItemProps = {
   currentUserId?: string;
   aiAssistant: AiAssistantHasId;
-  threads: Thread[];
-  onEditClicked?: (aiAssistantData: AiAssistantHasId) => void;
-  onItemClicked?: (aiAssistantData: AiAssistantHasId) => void;
+  onEditClick: (aiAssistantData: AiAssistantHasId) => void;
+  onItemClick: (aiAssistantData: AiAssistantHasId, threadId?: string) => void;
   onDeleted?: () => void;
 };
 
 const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
   currentUserId,
   aiAssistant,
-  threads,
-  onEditClicked,
-  onItemClicked,
+  onEditClick,
+  onItemClick,
   onDeleted,
 }) => {
   const [isThreadsOpened, setIsThreadsOpened] = useState(false);
 
+  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistant._id);
+
   const openManagementModalHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
-    onEditClicked?.(aiAssistantData);
-  }, [onEditClicked]);
+    onEditClick(aiAssistantData);
+  }, [onEditClick]);
 
   const openChatHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
-    onItemClicked?.(aiAssistantData);
-  }, [onItemClicked]);
+    onItemClick(aiAssistantData);
+  }, [onItemClick]);
 
-
-  const openThreadsHandler = useCallback(() => {
+  const openThreadsHandler = useCallback(async() => {
+    mutateThreadData();
     setIsThreadsOpened(toggle => !toggle);
-  }, []);
+  }, [mutateThreadData]);
 
   const deleteAiAssistantHandler = useCallback(async() => {
     try {
@@ -185,20 +213,20 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
         )}
       </li>
 
-      {isThreadsOpened && threads.length > 0 && (
-        <div className="grw-ai-assistant-item-children">
-          {threads.map(thread => (
-            <ThreadItem
-              key={thread._id}
-              thread={thread}
-            />
-          ))}
-        </div>
-      )}
+      { isThreadsOpened && (
+        <ThreadItems
+          aiAssistantData={aiAssistant}
+          onThreadClick={onItemClick}
+        />
+      ) }
     </>
   );
 };
 
+
+/*
+*  AiAssistantTree
+*/
 type AiAssistantTreeProps = {
   aiAssistants: AiAssistantHasId[];
   onDeleted?: () => void;
@@ -216,9 +244,8 @@ export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants,
           key={assistant._id}
           currentUserId={currentUser?._id}
           aiAssistant={assistant}
-          threads={dummyThreads}
-          onEditClicked={openAiAssistantManagementModal}
-          onItemClicked={openAiAssistantChatSidebar}
+          onEditClick={openAiAssistantManagementModal}
+          onItemClick={openAiAssistantChatSidebar}
           onDeleted={onDeleted}
         />
       ))}

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

@@ -56,11 +56,15 @@ export const useSWRxAiAssistants = (): SWRResponse<AccessibleAiAssistantsHasId,
 
 type AiAssistantChatSidebarStatus = {
   isOpened: boolean,
-  aiAssistantData?: AiAssistantHasId;
+  aiAssistantData?: AiAssistantHasId,
+  threadId?: string,
 }
 
 type AiAssistantChatSidebarUtils = {
-  open(aiAssistantData: AiAssistantHasId): void
+  open(
+    aiAssistantData: AiAssistantHasId,
+    threadId?: string,
+  ): void
   close(): void
 }
 
@@ -72,7 +76,9 @@ export const useAiAssistantChatSidebar = (
 
   return {
     ...swrResponse,
-    open: useCallback((aiAssistantData: AiAssistantHasId) => { swrResponse.mutate({ isOpened: true, aiAssistantData }) }, [swrResponse]),
+    open: useCallback(
+      (aiAssistantData: AiAssistantHasId, threadId?: string) => { swrResponse.mutate({ isOpened: true, aiAssistantData, threadId }) }, [swrResponse],
+    ),
     close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
   };
 };

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

@@ -0,0 +1,12 @@
+import type OpenAI from 'openai';
+import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+export const useSWRMUTxMessages = (aiAssistantId: string, threadId?: string): SWRMutationResponse<OpenAI.Beta.Threads.Messages.MessagesPage | null> => {
+  const key = threadId != null ? [`/openai/messages/${aiAssistantId}/${threadId}`] : null;
+  return useSWRMutation(
+    key,
+    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.messages),
+  );
+};

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

@@ -0,0 +1,26 @@
+import { type SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
+
+const getKey = (aiAssistantId: string) => [`/openai/threads/${aiAssistantId}`];
+
+export const useSWRxThreads = (aiAssistantId: string): SWRResponse<IThreadRelationHasId[], Error> => {
+  const key = getKey(aiAssistantId);
+  return useSWRImmutable<IThreadRelationHasId[]>(
+    key,
+    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.threads),
+  );
+};
+
+
+export const useSWRMUTxThreads = (aiAssistantId: string): SWRMutationResponse<IThreadRelationHasId[], Error> => {
+  const key = getKey(aiAssistantId);
+  return useSWRMutation(
+    key,
+    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.threads),
+  );
+};

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

@@ -2,7 +2,7 @@ import type {
   IGrantedGroup, IUser, Ref, HasObjectId,
 } from '@growi/core';
 
-import type { VectorStore } from '../server/models/vector-store';
+import type { IVectorStore } from './vector-store';
 
 /*
 *  Objects
@@ -31,7 +31,7 @@ export interface AiAssistant {
   description: string
   additionalInstruction: string
   pagePathPatterns: string[],
-  vectorStore: Ref<VectorStore>
+  vectorStore: Ref<IVectorStore>
   owner: Ref<IUser>
   grantedGroupsForShareScope?: IGrantedGroup[]
   grantedGroupsForAccessScope?: IGrantedGroup[]

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

@@ -0,0 +1,12 @@
+import type { IUser, Ref, HasObjectId } from '@growi/core';
+
+import type { IVectorStore } from './vector-store';
+
+export interface IThreadRelation {
+  userId: Ref<IUser>
+  vectorStore: Ref<IVectorStore>
+  threadId: string;
+  expiredAt: Date;
+}
+
+export type IThreadRelationHasId = IThreadRelation & HasObjectId;

+ 4 - 0
apps/app/src/features/openai/interfaces/vector-store.ts

@@ -0,0 +1,4 @@
+export interface IVectorStore {
+  vectorStoreId: string
+  isDeleted: boolean
+}

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

@@ -4,11 +4,13 @@ import { type Model, type Document, Schema } from 'mongoose';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 import { type AiAssistant, AiAssistantShareScope, AiAssistantAccessScope } from '../../interfaces/ai-assistant';
+import { generateGlobPatterns } from '../utils/generate-glob-patterns';
 
 export interface AiAssistantDocument extends AiAssistant, Document {}
 
-type AiAssistantModel = Model<AiAssistantDocument>
-
+interface AiAssistantModel extends Model<AiAssistantDocument> {
+  findByPagePaths(pagePaths: string[]): Promise<AiAssistantDocument[]>;
+}
 
 /*
  * Schema Definition
@@ -103,4 +105,19 @@ const schema = new Schema<AiAssistantDocument>(
   },
 );
 
+
+schema.statics.findByPagePaths = async function(pagePaths: string[]): Promise<AiAssistantDocument[]> {
+  const pagePathsWithGlobPattern = pagePaths.map(pagePath => generateGlobPatterns(pagePath)).flat();
+  const assistants = await this.find({
+    $or: [
+      // Case 1: Exact match
+      { pagePathPatterns: { $in: pagePaths } },
+      // Case 2: Glob pattern match
+      { pagePathPatterns: { $in: pagePathsWithGlobPattern } },
+    ],
+  }).populate('vectorStore');
+
+  return assistants;
+};
+
 export default getOrCreateModel<AiAssistantDocument, AiAssistantModel>('AiAssistant', schema);

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

@@ -1,22 +1,17 @@
 import { addDays } from 'date-fns';
-import type mongoose from 'mongoose';
 import { type Model, type Document, Schema } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
+import type { IThreadRelation } from '../../interfaces/thread-relation';
+
 const DAYS_UNTIL_EXPIRATION = 3;
 
 const generateExpirationDate = (): Date => {
   return addDays(new Date(), DAYS_UNTIL_EXPIRATION);
 };
 
-interface ThreadRelation {
-  userId: mongoose.Types.ObjectId;
-  threadId: string;
-  expiredAt: Date;
-}
-
-interface ThreadRelationDocument extends ThreadRelation, Document {
+export interface ThreadRelationDocument extends IThreadRelation, Document {
   updateThreadExpiration(): Promise<void>;
 }
 
@@ -30,6 +25,11 @@ const schema = new Schema<ThreadRelationDocument, ThreadRelationModel>({
     ref: 'User',
     required: true,
   },
+  vectorStore: {
+    type: Schema.Types.ObjectId,
+    ref: 'VectorStore',
+    required: true,
+  },
   threadId: {
     type: String,
     required: true,

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

@@ -2,16 +2,13 @@ import { type Model, type Document, Schema } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-export interface VectorStore {
-  vectorStoreId: string
-  isDeleted: boolean
-}
+import type { IVectorStore } from '../../interfaces/vector-store';
 
-export interface VectorStoreDocument extends VectorStore, Document {
+export interface VectorStoreDocument extends IVectorStore, Document {
   markAsDeleted(): Promise<void>
 }
 
-type VectorStoreModel = Model<VectorStore>
+type VectorStoreModel = Model<VectorStoreDocument>;
 
 const schema = new Schema<VectorStoreDocument, VectorStoreModel>({
   vectorStoreId: {

+ 72 - 0
apps/app/src/features/openai/server/routes/get-messages.ts

@@ -0,0 +1,72 @@
+import { type IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { type ValidationChain, param } from 'express-validator';
+
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+import { getOpenaiService } from '../services/openai';
+
+import { certifyAiService } from './middlewares/certify-ai-service';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:get-message');
+
+type GetMessagesFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParam = {
+  threadId: string,
+  aiAssistantId: string,
+  before?: string,
+  after?: string,
+  limit?: number,
+}
+
+type Req = Request<ReqParam, Response, undefined> & {
+  user: IUserHasId,
+}
+
+export const getMessagesFactory: GetMessagesFactory = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    param('threadId').isString().withMessage('threadId must be string'),
+    param('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
+    param('limit').optional().isInt().withMessage('limit must be integer'),
+    param('before').optional().isString().withMessage('before must be string'),
+    param('after').optional().isString().withMessage('after must be string'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      try {
+        const {
+          threadId, aiAssistantId, limit, before, after,
+        } = req.params;
+
+        const isAiAssistantUsable = openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+        if (!isAiAssistantUsable) {
+          return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+        }
+
+        const options = { limit, before, after };
+        const messages = await openaiService.getMessageData(threadId, req.user.lang, options);
+
+        return res.apiv3({ messages });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Failed to get messages'));
+      }
+    },
+  ];
+};

+ 62 - 0
apps/app/src/features/openai/server/routes/get-threads.ts

@@ -0,0 +1,62 @@
+import { type IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { type ValidationChain, param } from 'express-validator';
+
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+import { getOpenaiService } from '../services/openai';
+
+import { certifyAiService } from './middlewares/certify-ai-service';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:get-threads');
+
+type GetThreadsFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = {
+  aiAssistantId: string,
+}
+
+type Req = Request<ReqParams, Response, undefined> & {
+  user: IUserHasId,
+}
+
+export const getThreadsFactory: GetThreadsFactory = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    param('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      try {
+        const { aiAssistantId } = req.params;
+
+        const isAiAssistantUsable = openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+        if (!isAiAssistantUsable) {
+          return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+        }
+
+        const vectorStoreRelation = await openaiService.getVectorStoreRelation(aiAssistantId);
+        const threads = await openaiService.getThreads(vectorStoreRelation._id);
+
+        return res.apiv3({ threads });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Failed to get threads'));
+      }
+    },
+  ];
+};

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

@@ -27,10 +27,18 @@ export const factory = (crowi: Crowi): express.Router => {
       router.post('/thread', createThreadHandlersFactory(crowi));
     });
 
+    import('./get-threads').then(({ getThreadsFactory }) => {
+      router.get('/threads/:aiAssistantId', getThreadsFactory(crowi));
+    });
+
     import('./message').then(({ postMessageHandlersFactory }) => {
       router.post('/message', postMessageHandlersFactory(crowi));
     });
 
+    import('./get-messages').then(({ getMessagesFactory }) => {
+      router.get('/messages/:aiAssistantId/:threadId', getMessagesFactory(crowi));
+    });
+
     import('./ai-assistant').then(({ createAiAssistantFactory }) => {
       router.post('/ai-assistant', createAiAssistantFactory(crowi));
     });

+ 14 - 2
apps/app/src/features/openai/server/routes/message.ts

@@ -16,6 +16,7 @@ import loggerFactory from '~/utils/logger';
 import { MessageErrorCode, type StreamErrorCode } from '../../interfaces/message-error';
 import { openaiClient } from '../services/client';
 import { getStreamErrorCode } from '../services/getStreamErrorCode';
+import { getOpenaiService } from '../services/openai';
 import { replaceAnnotationWithPageLink } from '../services/replace-annotation-with-page-link';
 
 import { certifyAiService } from './middlewares/certify-ai-service';
@@ -25,6 +26,7 @@ const logger = loggerFactory('growi:routes:apiv3:openai:message');
 
 type ReqBody = {
   userMessage: string,
+  aiAssistantId: string,
   threadId?: string,
   summaryMode?: boolean,
 }
@@ -44,19 +46,29 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
       .withMessage('userMessage must be string')
       .notEmpty()
       .withMessage('userMessage must be set'),
+    body('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
     body('threadId').optional().isString().withMessage('threadId must be string'),
   ];
 
   return [
     accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
-
-      const threadId = req.body.threadId;
+      const { aiAssistantId, threadId } = req.body;
 
       if (threadId == null) {
         return res.apiv3Err(new ErrorV3('threadId is not set', MessageErrorCode.THREAD_ID_IS_NOT_SET), 400);
       }
 
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+      if (!isAiAssistantUsable) {
+        return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+      }
+
       let stream: AssistantStream;
 
       try {

+ 2 - 2
apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts

@@ -1,5 +1,5 @@
 import { GroupType } from '@growi/core';
-import { isGrobPatternPath, isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
+import { isGlobPatternPath, isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { type ValidationChain, body } from 'express-validator';
 
 import { AiAssistantShareScope, AiAssistantAccessScope } from '../../../interfaces/ai-assistant';
@@ -41,7 +41,7 @@ export const upsertAiAssistantValidator: ValidationChain[] = [
 
       // check if the value is a grob pattern path
       if (value.includes('*')) {
-        return isGrobPatternPath(value) && isCreatablePage(value.replace('*', ''));
+        return isGlobPatternPath(value) && isCreatablePage(value.replace('*', ''));
       }
 
       return isCreatablePage(value);

+ 19 - 5
apps/app/src/features/openai/server/routes/thread.ts

@@ -17,7 +17,12 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:thread');
 
-type CreateThreadReq = Request<undefined, ApiV3Response, { threadId?: string }> & { user: IUserHasId };
+type ReqBody = {
+  aiAssistantId: string,
+  threadId?: string,
+}
+
+type CreateThreadReq = Request<undefined, ApiV3Response, ReqBody> & { user: IUserHasId };
 
 type CreateThreadFactory = (crowi: Crowi) => RequestHandler[];
 
@@ -25,6 +30,7 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
   const validator: ValidationChain[] = [
+    body('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
     body('threadId').optional().isString().withMessage('threadId must be string'),
   ];
 
@@ -38,10 +44,18 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
       }
 
       try {
-        const filterdThreadId = req.body.threadId != null ? filterXSS(req.body.threadId) : undefined;
-        // const vectorStore = await openaiService?.getOrCreateVectorStoreForPublicScope();
-        // const thread = await openaiService?.getOrCreateThread(req.user._id, vectorStore?.vectorStoreId, filterdThreadId);
-        return res.apiv3({ });
+        const { aiAssistantId, threadId } = req.body;
+
+        const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+        if (!isAiAssistantUsable) {
+          return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+        }
+
+        const filteredThreadId = threadId != null ? filterXSS(threadId) : undefined;
+        const vectorStoreRelation = await openaiService.getVectorStoreRelation(aiAssistantId);
+
+        const thread = await openaiService.getOrCreateThread(req.user._id, vectorStoreRelation, filteredThreadId);
+        return res.apiv3({ thread });
       }
       catch (err) {
         logger.error(err);

+ 8 - 0
apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts

@@ -38,6 +38,14 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.beta.threads.del(threadId);
   }
 
+  async getMessages(threadId: string, options?: { before: string, after: string, limit: number }): Promise<OpenAI.Beta.Threads.Messages.MessagesPage> {
+    return this.client.beta.threads.messages.list(threadId, {
+      limit: options?.limit,
+      before: options?.before,
+      after: options?.after,
+    });
+  }
+
   async createVectorStore(name: string): Promise<OpenAI.Beta.VectorStores.VectorStore> {
     return this.client.beta.vectorStores.create({ name: `growi-vector-store-for-${name}` });
   }

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

@@ -5,6 +5,7 @@ export interface IOpenaiClientDelegator {
   createThread(vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread>
   retrieveThread(threadId: string): Promise<OpenAI.Beta.Threads.Thread>
   deleteThread(threadId: string): Promise<OpenAI.Beta.Threads.ThreadDeleted>
+  getMessages(threadId: string, options?: { limit: number, before: string, after: string }): Promise<OpenAI.Beta.Threads.Messages.MessagesPage>
   retrieveVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStore>
   createVectorStore(name: string): Promise<OpenAI.Beta.VectorStores.VectorStore>
   deleteVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStoreDeleted>

+ 8 - 0
apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts

@@ -41,6 +41,14 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.beta.threads.del(threadId);
   }
 
+  async getMessages(threadId: string, options?: { before?: string, after?: string, limit?: number }): Promise<OpenAI.Beta.Threads.Messages.MessagesPage> {
+    return this.client.beta.threads.messages.list(threadId, {
+      limit: options?.limit,
+      before: options?.before,
+      after: options?.after,
+    });
+  }
+
   async createVectorStore(name: string): Promise<OpenAI.Beta.VectorStores.VectorStore> {
     return this.client.beta.vectorStores.create({ name: `growi-vector-store-for-${name}` });
   }

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

@@ -15,6 +15,7 @@ describe('normalizeExpiredAtForThreadRelations', () => {
     const threadRelation = new ThreadRelation({
       userId: new Types.ObjectId(),
       threadId: 'test-thread',
+      vectorStore: new Types.ObjectId(),
       expiredAt: expiredDate,
     });
     await threadRelation.save();
@@ -36,6 +37,7 @@ describe('normalizeExpiredAtForThreadRelations', () => {
     const threadRelation = new ThreadRelation({
       userId: new Types.ObjectId(),
       threadId: 'test-thread-2',
+      vectorStore: new Types.ObjectId(),
       expiredAt: nonExpiredDate,
     });
     await threadRelation.save();
@@ -55,6 +57,7 @@ describe('normalizeExpiredAtForThreadRelations', () => {
     const threadRelation = new ThreadRelation({
       userId: new Types.ObjectId(),
       threadId: 'test-thread-3',
+      vectorStore: new Types.ObjectId(),
       expiredAt: nonExpiredDate,
     });
     await threadRelation.save();

+ 245 - 22
apps/app/src/features/openai/server/services/openai.ts

@@ -2,18 +2,19 @@ import assert from 'node:assert';
 import { Readable, Transform } from 'stream';
 import { pipeline } from 'stream/promises';
 
+import type { IUser, Ref, Lang } from '@growi/core';
 import {
   PageGrant, getIdForRef, getIdStringForRef, isPopulated, type IUserHasId,
 } from '@growi/core';
 import { deepEquals } from '@growi/core/dist/utils';
-import { isGrobPatternPath } from '@growi/core/dist/utils/page-path-utils';
+import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
 import createError from 'http-errors';
 import mongoose, { type HydratedDocument, type Types } from 'mongoose';
 import { type OpenAI, toFile } from 'openai';
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import ThreadRelationModel from '~/features/openai/server/models/thread-relation';
+import ThreadRelationModel, { type ThreadRelationDocument } from '~/features/openai/server/models/thread-relation';
 import VectorStoreModel, { type VectorStoreDocument } from '~/features/openai/server/models/vector-store';
 import VectorStoreFileRelationModel, {
   type VectorStoreFileRelation,
@@ -35,6 +36,7 @@ import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html';
 import { getClient } from './client-delegator';
 // import { splitMarkdownIntoChunks } from './markdown-splitter/markdown-token-splitter';
 import { openaiApiErrorHandler } from './openai-api-error-handler';
+import { replaceAnnotationWithPageLink } from './replace-annotation-with-page-link';
 
 const { isDeepEquals } = deepEquals;
 
@@ -49,27 +51,36 @@ type VectorStoreFileRelationsMap = Map<string, VectorStoreFileRelation>
 
 const convertPathPatternsToRegExp = (pagePathPatterns: string[]): Array<string | RegExp> => {
   return pagePathPatterns.map((pagePathPattern) => {
-    if (isGrobPatternPath(pagePathPattern)) {
+    if (isGlobPatternPath(pagePathPattern)) {
       const trimedPagePathPattern = pagePathPattern.replace('/*', '');
       const escapedPagePathPattern = escapeStringRegexp(trimedPagePathPattern);
-      return new RegExp(`^${escapedPagePathPattern}`);
+      // https://regex101.com/r/x5KIZL/1
+      return new RegExp(`^${escapedPagePathPattern}($|/)`);
     }
-
     return pagePathPattern;
   });
 };
 
-
 export interface IOpenaiService {
-  getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise<OpenAI.Beta.Threads.Thread | undefined>;
+  getOrCreateThread(userId: string, vectorStoreRelation: VectorStoreDocument, threadId?: string): Promise<OpenAI.Beta.Threads.Thread | undefined>;
+  getThreads(vectorStoreRelationId: string): Promise<ThreadRelationDocument[]>
   // getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument>;
   deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
   deleteObsolatedVectorStoreRelations(): Promise<void> // for CronJob
+  getMessageData(
+    threadId: string, lang?: Lang, options?: { before?: string, after?: string, limit?: number }
+  ): Promise<OpenAI.Beta.Threads.Messages.MessagesPage>;
+  getVectorStoreRelation(aiAssistantId: string): Promise<VectorStoreDocument>
+  getVectorStoreRelationsByPageIds(pageId: Types.ObjectId[]): Promise<VectorStoreDocument[]>;
   createVectorStoreFile(vectorStoreRelation: VectorStoreDocument, pages: PageDocument[]): Promise<void>;
+  createVectorStoreFileOnPageCreate(pages: PageDocument[]): Promise<void>;
+  updateVectorStoreFileOnPageUpdate(page: HydratedDocument<PageDocument>): Promise<void>;
   deleteVectorStoreFile(vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId): Promise<void>;
+  deleteVectorStoreFilesByPageIds(pageIds: Types.ObjectId[]): Promise<void>;
   deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
   // rebuildVectorStoreAll(): Promise<void>;
   // rebuildVectorStore(page: HydratedDocument<PageDocument>): Promise<void>;
+  isAiAssistantUsable(aiAssistantId: string, user: IUserHasId): Promise<boolean>;
   createAiAssistant(data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument>;
   updateAiAssistant(aiAssistantId: string, data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument>;
   getAccessibleAiAssistants(user: IUserHasId): Promise<AccessibleAiAssistants>
@@ -82,11 +93,11 @@ class OpenaiService implements IOpenaiService {
     return getClient({ openaiServiceType });
   }
 
-  public async getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise<OpenAI.Beta.Threads.Thread> {
-    if (vectorStoreId != null && threadId == null) {
+  public async getOrCreateThread(userId: string, vectorStoreRelation: VectorStoreDocument, threadId?: string): Promise<OpenAI.Beta.Threads.Thread> {
+    if (threadId == null) {
       try {
-        const thread = await this.client.createThread(vectorStoreId);
-        await ThreadRelationModel.create({ userId, threadId: thread.id });
+        const thread = await this.client.createThread(vectorStoreRelation.vectorStoreId);
+        await ThreadRelationModel.create({ userId, threadId: thread.id, vectorStore: vectorStoreRelation._id });
         return thread;
       }
       catch (err) {
@@ -115,6 +126,11 @@ class OpenaiService implements IOpenaiService {
     }
   }
 
+  async getThreads(vectorStoreRelationId: string): Promise<ThreadRelationDocument[]> {
+    const threadRelations = await ThreadRelationModel.find({ vectorStore: vectorStoreRelationId });
+    return threadRelations;
+  }
+
   public async deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void> {
     const expiredThreadRelations = await ThreadRelationModel.getExpiredThreadRelations(limit);
     if (expiredThreadRelations == null) {
@@ -139,6 +155,22 @@ class OpenaiService implements IOpenaiService {
     await ThreadRelationModel.deleteMany({ threadId: { $in: deletedThreadIds } });
   }
 
+  async getMessageData(
+      threadId: string, lang?: Lang, options?: { limit: number, before: string, after: string },
+  ): Promise<OpenAI.Beta.Threads.Messages.MessagesPage> {
+    const messages = await this.client.getMessages(threadId, options);
+
+    for await (const message of messages.data) {
+      for await (const content of message.content) {
+        if (content.type === 'text') {
+          await replaceAnnotationWithPageLink(content, lang);
+        }
+      }
+    }
+
+    return messages;
+  }
+
   // TODO: https://redmine.weseek.co.jp/issues/160332
   // public async getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument> {
   //   const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ scopeType: VectorStoreScopeType.PUBLIC, isDeleted: false });
@@ -172,6 +204,69 @@ class OpenaiService implements IOpenaiService {
   //   return newVectorStoreDocument;
   // }
 
+  async getVectorStoreRelation(aiAssistantId: string): Promise<VectorStoreDocument> {
+    const aiAssistant = await AiAssistantModel.findById({ _id: aiAssistantId }).populate('vectorStore');
+    if (aiAssistant == null) {
+      throw createError(404, 'AiAssistant document does not exist');
+    }
+
+    return aiAssistant.vectorStore as VectorStoreDocument;
+  }
+
+  async getVectorStoreRelationsByPageIds(pageIds: Types.ObjectId[]): Promise<VectorStoreDocument[]> {
+    const pipeline = [
+      // Stage 1: Match documents with the given pageId
+      {
+        $match: {
+          page: {
+            $in: pageIds,
+          },
+        },
+      },
+      // Stage 2: Lookup VectorStore documents
+      {
+        $lookup: {
+          from: 'vectorstores',
+          localField: 'vectorStoreRelationId',
+          foreignField: '_id',
+          as: 'vectorStore',
+        },
+      },
+      // Stage 3: Unwind the vectorStore array
+      {
+        $unwind: '$vectorStore',
+      },
+      // Stage 4: Match non-deleted vector stores
+      {
+        $match: {
+          'vectorStore.isDeleted': false,
+        },
+      },
+      // Stage 5: Replace the root with vectorStore document
+      {
+        $replaceRoot: {
+          newRoot: '$vectorStore',
+        },
+      },
+      // Stage 6: Group by _id to remove duplicates
+      {
+        $group: {
+          _id: '$_id',
+          doc: { $first: '$$ROOT' },
+        },
+      },
+      // Stage 7: Restore the document structure
+      {
+        $replaceRoot: {
+          newRoot: '$doc',
+        },
+      },
+    ];
+
+    const vectorStoreRelations = await VectorStoreFileRelationModel.aggregate<VectorStoreDocument>(pipeline);
+    return vectorStoreRelations;
+  }
+
   private async createVectorStore(name: string): Promise<VectorStoreDocument> {
     try {
       const newVectorStore = await this.client.createVectorStore(name);
@@ -230,7 +325,7 @@ class OpenaiService implements IOpenaiService {
     // const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
     const vectorStoreFileRelationsMap: VectorStoreFileRelationsMap = new Map();
     const processUploadFile = async(page: HydratedDocument<PageDocument>) => {
-      if (page._id != null && page.grant === PageGrant.GRANT_PUBLIC && page.revision != null) {
+      if (page._id != null && page.revision != null) {
         if (isPopulated(page.revision) && page.revision.body.length > 0) {
           const uploadedFile = await this.uploadFile(page._id, page.path, page.revision.body);
           prepareVectorStoreFileRelations(vectorStoreRelation._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
@@ -348,6 +443,16 @@ class OpenaiService implements IOpenaiService {
     await vectorStoreFileRelation.save();
   }
 
+  async deleteVectorStoreFilesByPageIds(pageIds: Types.ObjectId[]): Promise<void> {
+    const vectorStoreRelations = await this.getVectorStoreRelationsByPageIds(pageIds);
+    if (vectorStoreRelations != null && vectorStoreRelations.length !== 0) {
+      for await (const pageId of pageIds) {
+        const deleteVectorStoreFilePromises = vectorStoreRelations.map(vectorStoreRelation => this.deleteVectorStoreFile(vectorStoreRelation._id, pageId));
+        await Promise.allSettled(deleteVectorStoreFilePromises);
+      }
+    }
+  }
+
   async deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void> {
     // Retrieves all VectorStore documents that are marked as deleted
     const deletedVectorStoreRelations = await VectorStoreModel.find({ isDeleted: true });
@@ -396,11 +501,93 @@ class OpenaiService implements IOpenaiService {
   //   await pipeline(pagesStream, batchStrem, createVectorStoreFileStream);
   // }
 
-  // async rebuildVectorStore(page: HydratedDocument<PageDocument>) {
-  //   const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
-  //   await this.deleteVectorStoreFile(vectorStore._id, page._id);
-  //   await this.createVectorStoreFile([page]);
-  // }
+  async filterPagesByAccessScope(aiAssistant: AiAssistantDocument, pages: HydratedDocument<PageDocument>[]) {
+    const isPublicPage = (page :HydratedDocument<PageDocument>) => page.grant === PageGrant.GRANT_PUBLIC;
+
+    const isUserGroupAccessible = (page :HydratedDocument<PageDocument>, ownerUserGroupIds: string[]) => {
+      if (page.grant !== PageGrant.GRANT_USER_GROUP) return false;
+      return page.grantedGroups.some(group => ownerUserGroupIds.includes(getIdStringForRef(group.item)));
+    };
+
+    const isOwnerAccessible = (page: HydratedDocument<PageDocument>, ownerId: Ref<IUser>) => {
+      if (page.grant !== PageGrant.GRANT_OWNER) return false;
+      return page.grantedUsers.some(user => getIdStringForRef(user) === getIdStringForRef(ownerId));
+    };
+
+    const getOwnerUserGroupIds = async(owner: Ref<IUser>) => {
+      const userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(owner);
+      const externalGroups = await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(owner);
+      return [...userGroups, ...externalGroups].map(group => getIdStringForRef(group));
+    };
+
+    switch (aiAssistant.accessScope) {
+      case AiAssistantAccessScope.PUBLIC_ONLY:
+        return pages.filter(isPublicPage);
+
+      case AiAssistantAccessScope.GROUPS: {
+        const ownerUserGroupIds = await getOwnerUserGroupIds(aiAssistant.owner);
+        return pages.filter(page => isPublicPage(page) || isUserGroupAccessible(page, ownerUserGroupIds));
+      }
+
+      case AiAssistantAccessScope.OWNER: {
+        const ownerUserGroupIds = await getOwnerUserGroupIds(aiAssistant.owner);
+        return pages.filter(page => isPublicPage(page) || isOwnerAccessible(page, aiAssistant.owner) || isUserGroupAccessible(page, ownerUserGroupIds));
+      }
+
+      default:
+        return [];
+    }
+  }
+
+  async createVectorStoreFileOnPageCreate(pages: HydratedDocument<PageDocument>[]): Promise<void> {
+    const pagePaths = pages.map(page => page.path);
+    const aiAssistants = await AiAssistantModel.findByPagePaths(pagePaths);
+
+    if (aiAssistants.length === 0) {
+      return;
+    }
+
+    for await (const aiAssistant of aiAssistants) {
+      const pagesToVectorize = await this.filterPagesByAccessScope(aiAssistant, pages);
+      const vectorStoreRelation = aiAssistant.vectorStore;
+      if (vectorStoreRelation == null || !isPopulated(vectorStoreRelation)) {
+        continue;
+      }
+
+      logger.debug('--------- createVectorStoreFileOnPageCreate ---------');
+      logger.debug('AccessScopeType of aiAssistant: ', aiAssistant.accessScope);
+      logger.debug('VectorStoreFile pagePath to be created: ', pagesToVectorize.map(page => page.path));
+      logger.debug('-----------------------------------------------------');
+
+      await this.createVectorStoreFile(vectorStoreRelation as VectorStoreDocument, pagesToVectorize);
+    }
+  }
+
+  async updateVectorStoreFileOnPageUpdate(page: HydratedDocument<PageDocument>) {
+    const aiAssistants = await AiAssistantModel.findByPagePaths([page.path]);
+
+    if (aiAssistants.length === 0) {
+      return;
+    }
+
+    for await (const aiAssistant of aiAssistants) {
+      const pagesToVectorize = await this.filterPagesByAccessScope(aiAssistant, [page]);
+      const vectorStoreRelation = aiAssistant.vectorStore;
+      if (vectorStoreRelation == null || !isPopulated(vectorStoreRelation)) {
+        continue;
+      }
+
+      logger.debug('---------- updateVectorStoreOnPageUpdate ------------');
+      logger.debug('AccessScopeType of aiAssistant: ', aiAssistant.accessScope);
+      logger.debug('PagePath of VectorStoreFile to be deleted: ', page.path);
+      logger.debug('pagePath of VectorStoreFile to be created: ', pagesToVectorize.map(page => page.path));
+      logger.debug('-----------------------------------------------------');
+
+      // Do not create a new VectorStoreFile if page is changed to a permission that AiAssistant does not have access to
+      await this.createVectorStoreFile(vectorStoreRelation as VectorStoreDocument, pagesToVectorize);
+      await this.deleteVectorStoreFile((vectorStoreRelation as VectorStoreDocument)._id, page._id);
+    }
+  }
 
   private async createVectorStoreFileWithStream(vectorStoreRelation: VectorStoreDocument, conditions: mongoose.FilterQuery<PageDocument>): Promise<void> {
     const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
@@ -436,10 +623,10 @@ class OpenaiService implements IOpenaiService {
       pagePathPatterns: AiAssistant['pagePathPatterns'],
   ): Promise<mongoose.FilterQuery<PageDocument>> {
 
-    const converterdPagePathPatterns = convertPathPatternsToRegExp(pagePathPatterns);
+    const convertedPagePathPatterns = convertPathPatternsToRegExp(pagePathPatterns);
 
     // Include pages in search targets when their paths with 'Anyone with the link' permission are directly specified instead of using glob pattern
-    const nonGrabPagePathPatterns = pagePathPatterns.filter(pagePathPattern => !isGrobPatternPath(pagePathPattern));
+    const nonGrabPagePathPatterns = pagePathPatterns.filter(pagePathPattern => !isGlobPatternPath(pagePathPattern));
     const baseCondition: mongoose.FilterQuery<PageDocument> = {
       grant: PageGrant.GRANT_RESTRICTED,
       path: { $in: nonGrabPagePathPatterns },
@@ -451,7 +638,7 @@ class OpenaiService implements IOpenaiService {
           baseCondition,
           {
             grant: PageGrant.GRANT_PUBLIC,
-            path: { $in: converterdPagePathPatterns },
+            path: { $in: convertedPagePathPatterns },
           },
         ],
       };
@@ -469,7 +656,7 @@ class OpenaiService implements IOpenaiService {
           baseCondition,
           {
             grant: { $in: [PageGrant.GRANT_PUBLIC, PageGrant.GRANT_USER_GROUP] },
-            path: { $in: converterdPagePathPatterns },
+            path: { $in: convertedPagePathPatterns },
             $or: [
               { 'grantedGroups.item': { $in: extractedGrantedGroupIdsForAccessScope } },
               { grant: PageGrant.GRANT_PUBLIC },
@@ -490,7 +677,7 @@ class OpenaiService implements IOpenaiService {
           baseCondition,
           {
             grant: { $in: [PageGrant.GRANT_PUBLIC, PageGrant.GRANT_USER_GROUP, PageGrant.GRANT_OWNER] },
-            path: { $in: converterdPagePathPatterns },
+            path: { $in: convertedPagePathPatterns },
             $or: [
               { 'grantedGroups.item': { $in: ownerUserGroups } },
               { grantedUsers: { $in: [getIdForRef(owner)] } },
@@ -546,6 +733,42 @@ class OpenaiService implements IOpenaiService {
     }
   }
 
+  async isAiAssistantUsable(aiAssistantId: string, user: IUserHasId): Promise<boolean> {
+    const aiAssistant = await AiAssistantModel.findById(aiAssistantId);
+
+    if (aiAssistant == null) {
+      throw createError(404, 'AiAssistant document does not exist');
+    }
+
+    const isOwner = getIdStringForRef(aiAssistant.owner) === getIdStringForRef(user._id);
+
+    if (aiAssistant.shareScope === AiAssistantShareScope.PUBLIC_ONLY) {
+      return true;
+    }
+
+    if ((aiAssistant.shareScope === AiAssistantShareScope.OWNER) && isOwner) {
+      return true;
+    }
+
+    if ((aiAssistant.shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE) && (aiAssistant.accessScope === AiAssistantAccessScope.OWNER) && isOwner) {
+      return true;
+    }
+
+    if ((aiAssistant.shareScope === AiAssistantShareScope.GROUPS)
+      || ((aiAssistant.shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE) && (aiAssistant.accessScope === AiAssistantAccessScope.GROUPS))) {
+      const userGroupIds = [
+        ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+        ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ].map(group => group.toString());
+
+      const grantedGroupIdsForShareScope = aiAssistant.grantedGroupsForShareScope?.map(group => getIdStringForRef(group.item)) ?? [];
+      const isShared = userGroupIds.some(userGroupId => grantedGroupIdsForShareScope.includes(userGroupId));
+      return isShared;
+    }
+
+    return false;
+  }
+
   async createAiAssistant(data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument> {
     await this.validateGrantedUserGroupsForAiAssistant(
       data.owner,

+ 5 - 5
apps/app/src/features/openai/server/services/replace-annotation-with-page-link.ts

@@ -1,14 +1,14 @@
 // See: https://platform.openai.com/docs/assistants/tools/file-search#step-5-create-a-run-and-check-the-output
 
 import type { IPageHasId, Lang } from '@growi/core/dist/interfaces';
-import type { MessageContentDelta } from 'openai/resources/beta/threads/messages.mjs';
+import type { MessageContentDelta, MessageContent } from 'openai/resources/beta/threads/messages.mjs';
 
 import VectorStoreFileRelationModel from '~/features/openai/server/models/vector-store-file-relation';
 import { getTranslation } from '~/server/service/i18next';
 
-export const replaceAnnotationWithPageLink = async(messageContentDelta: MessageContentDelta, lang?: Lang): Promise<void> => {
-  if (messageContentDelta?.type === 'text' && messageContentDelta?.text?.annotations != null) {
-    const annotations = messageContentDelta?.text?.annotations;
+export const replaceAnnotationWithPageLink = async(messageContent: MessageContentDelta | MessageContent, lang?: Lang): Promise<void> => {
+  if (messageContent?.type === 'text' && messageContent?.text?.annotations != null) {
+    const annotations = messageContent?.text?.annotations;
     for await (const annotation of annotations) {
       if (annotation.type === 'file_citation' && annotation.text != null) {
 
@@ -18,7 +18,7 @@ export const replaceAnnotationWithPageLink = async(messageContentDelta: MessageC
 
         if (vectorStoreFileRelation != null) {
           const { t } = await getTranslation({ lang });
-          messageContentDelta.text.value = messageContentDelta.text.value?.replace(
+          messageContent.text.value = messageContent.text.value?.replace(
             annotation.text,
             ` [${t('source')}: [${vectorStoreFileRelation.page.path}](/${vectorStoreFileRelation.page._id})]`,
           );

+ 48 - 0
apps/app/src/features/openai/server/utils/generate-glob-patterns.spec.ts

@@ -0,0 +1,48 @@
+import { describe, test, expect } from 'vitest';
+
+import { generateGlobPatterns } from './generate-glob-patterns';
+
+describe('generateGlobPatterns', () => {
+  test('generates glob patterns for basic path with trailing slash', () => {
+    const path = '/Sandbox/Bootstrap5/';
+    const patterns = generateGlobPatterns(path);
+
+    expect(patterns).toEqual([
+      '/Sandbox/*',
+      '/Sandbox/Bootstrap5/*',
+    ]);
+  });
+
+  test('generates glob patterns for multi-level path with trailing slash', () => {
+    const path = '/user/admin/memo/';
+    const patterns = generateGlobPatterns(path);
+
+    expect(patterns).toEqual([
+      '/user/*',
+      '/user/admin/*',
+      '/user/admin/memo/*',
+    ]);
+  });
+
+  test('generates glob patterns for path without trailing slash', () => {
+    const path = '/path/to/directory';
+    const patterns = generateGlobPatterns(path);
+
+    expect(patterns).toEqual([
+      '/path/*',
+      '/path/to/*',
+      '/path/to/directory/*',
+    ]);
+  });
+
+  test('handles path with empty segments correctly', () => {
+    const path = '/path//to///dir';
+    const patterns = generateGlobPatterns(path);
+
+    expect(patterns).toEqual([
+      '/path/*',
+      '/path/to/*',
+      '/path/to/dir/*',
+    ]);
+  });
+});

+ 28 - 0
apps/app/src/features/openai/server/utils/generate-glob-patterns.ts

@@ -0,0 +1,28 @@
+import { pathUtils } from '@growi/core/dist/utils';
+
+/**
+  * @example
+  * // Input: '/Sandbox/Bootstrap5/'
+  * // Output: ['/Sandbox/*', '/Sandbox/Bootstrap5/*']
+  *
+  * // Input: '/user/admin/memo/'
+  * // Output: ['/user/*', '/user/admin/*', '/user/admin/memo/*']
+  */
+export const generateGlobPatterns = (path: string): string[] => {
+  // Remove trailing slash if exists
+  const normalizedPath = pathUtils.removeTrailingSlash(path);
+
+  // Split path into segments
+  const segments = normalizedPath.split('/').filter(Boolean);
+
+  // Generate patterns
+  const patterns: string[] = [];
+  let currentPath = '';
+
+  for (let i = 0; i < segments.length; i++) {
+    currentPath += `/${segments[i]}`;
+    patterns.push(`${currentPath}/*`);
+  }
+
+  return patterns;
+};

+ 1 - 2
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -205,9 +205,8 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
     if (isAiEnabled()) {
       const { getOpenaiService } = await import('~/features/openai/server/services/openai');
       try {
-        // TODO: https://redmine.weseek.co.jp/issues/160334
         const openaiService = getOpenaiService();
-        // await openaiService?.rebuildVectorStore(createdPage);
+        await openaiService?.createVectorStoreFileOnPageCreate([createdPage]);
       }
       catch (err) {
         logger.error('Rebuild vector store failed', err);

+ 1 - 2
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -121,9 +121,8 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     if (isAiEnabled()) {
       const { getOpenaiService } = await import('~/features/openai/server/services/openai');
       try {
-        // TODO: https://redmine.weseek.co.jp/issues/160335
         const openaiService = getOpenaiService();
-        // await openaiService?.rebuildVectorStore(updatedPage);
+        await openaiService?.updateVectorStoreFileOnPageUpdate(updatedPage);
       }
       catch (err) {
         logger.error('Rebuild vector store failed', err);

+ 7 - 17
apps/app/src/server/service/page/index.ts

@@ -1171,12 +1171,10 @@ class PageService implements IPageService {
       );
 
       if (isAiEnabled()) {
-        // TODO: https://redmine.weseek.co.jp/issues/160336
         const { getOpenaiService } = await import('~/features/openai/server/services/openai');
-
-        // Do not await because communication with OpenAI takes time
         const openaiService = getOpenaiService();
-        // openaiService?.createVectorStoreFile([duplicatedTarget]);
+        // Do not await because communication with OpenAI takes time
+        openaiService?.createVectorStoreFileOnPageCreate([duplicatedTarget]);
       }
     }
     this.pageEvent.emit('duplicate', page, user);
@@ -1409,16 +1407,14 @@ class PageService implements IPageService {
     await Revision.insertMany(newRevisions, { ordered: false });
     await this.duplicateTags(pageIdMapping);
 
-    const duplicatedPagesWithPopulatedToShowRevison = await Page
-      .find({ _id: { $in: duplicatedPageIds }, grant: PageGrant.GRANT_PUBLIC }).populate('revision') as PageDocument[];
+    const duplicatedPagesWithPopulatedToShowRevision: HydratedDocument<PageDocument>[] = await Page
+      .find({ _id: { $in: duplicatedPageIds }, grant: PageGrant.GRANT_PUBLIC }).populate('revision');
 
     if (isAiEnabled()) {
-      // TODO: https://redmine.weseek.co.jp/issues/160336
       const { getOpenaiService } = await import('~/features/openai/server/services/openai');
-
-      // Do not await because communication with OpenAI takes time
       const openaiService = getOpenaiService();
-      // openaiService?.createVectorStoreFile(duplicatedPagesWithPopulatedToShowRevison);
+      // Do not await because communication with OpenAI takes time
+      openaiService?.createVectorStoreFileOnPageCreate(duplicatedPagesWithPopulatedToShowRevision);
     }
   }
 
@@ -1899,14 +1895,8 @@ class PageService implements IPageService {
 
     if (isAiEnabled()) {
       const { getOpenaiService } = await import('~/features/openai/server/services/openai');
-
-      // TODO: https://redmine.weseek.co.jp/issues/160337
       const openaiService = getOpenaiService();
-      if (openaiService != null) {
-        // const vectorStore = await openaiService.getOrCreateVectorStoreForPublicScope();
-        // const deleteVectorStoreFilePromises = pageIds.map(pageId => openaiService.deleteVectorStoreFile(vectorStore._id, pageId));
-        // await Promise.allSettled(deleteVectorStoreFilePromises);
-      }
+      await openaiService?.deleteVectorStoreFilesByPageIds(pageIds);
     }
   }
 

+ 1 - 1
packages/core/src/utils/page-path-utils/index.ts

@@ -293,7 +293,7 @@ export const getUsernameByPath = (path: string): string | null => {
   return username;
 };
 
-export const isGrobPatternPath = (path: string): boolean => {
+export const isGlobPatternPath = (path: string): boolean => {
   // https://regex101.com/r/IBy7HS/1
   const globPattern = /^(?:\/[^/*?[\]{}]+)*\/\*$/;
   return globPattern.test(path);