Parcourir la source

Merge pull request #9730 from weseek/imprv/162890-refactor-ai-assistant-chat-sidebar-for-sharing-with-editor

imprv: Refactor AiAssistantChatSidebar for sharing with EditorAssistant
Yuki Takei il y a 1 an
Parent
commit
9f7c79e253
28 fichiers modifiés avec 387 ajouts et 178 suppressions
  1. 4 1
      apps/app/public/static/locales/en_US/translation.json
  2. 4 2
      apps/app/public/static/locales/fr_FR/translation.json
  3. 4 2
      apps/app/public/static/locales/ja_JP/translation.json
  4. 4 2
      apps/app/public/static/locales/zh_CN/translation.json
  5. 7 4
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorAssistantToggleButton.tsx
  6. 4 4
      apps/app/src/components/Layout/BasicLayout.tsx
  7. 47 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView.tsx
  8. 2 2
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.module.scss
  9. 118 102
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx
  10. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.module.scss
  11. 5 5
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.tsx
  12. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/ResizableTextArea.tsx
  13. 4 4
      apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.tsx
  14. 3 3
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx
  15. 55 0
      apps/app/src/features/openai/client/services/editor-assistant.ts
  16. 40 0
      apps/app/src/features/openai/client/services/knowledge-assistant.ts
  17. 25 9
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  18. 2 2
      apps/app/src/features/openai/client/stores/message.tsx
  19. 3 3
      apps/app/src/features/openai/client/stores/thread.tsx
  20. 16 0
      apps/app/src/features/openai/interfaces/knowledge-assistant/sse-schemas.ts
  21. 0 1
      apps/app/src/features/openai/server/models/thread-relation.ts
  22. 3 1
      apps/app/src/features/openai/server/routes/edit/index.ts
  23. 4 11
      apps/app/src/features/openai/server/routes/thread.ts
  24. 9 7
      apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts
  25. 1 1
      apps/app/src/features/openai/server/services/client-delegator/interfaces.ts
  26. 9 7
      apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts
  27. 4 5
      apps/app/src/features/openai/server/services/openai.ts
  28. 10 0
      apps/app/src/features/openai/utils/handle-if-successfully-parsed.ts

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

@@ -154,6 +154,7 @@
   "In-App Notification": "Notifications",
   "AI Assistant": "AI Assistant",
   "Knowledge Assistant": "Knowledge Assistant (Beta)",
+  "Editor Assistant": "Editor Assistant (Beta)",
   "original_path": "Original path",
   "new_path": "New path",
   "duplicated_path": "Duplicated path",
@@ -494,10 +495,12 @@
     "latest_revision": "theirs",
     "selected_editable_revision": "Selected Page Body (Editable)"
   },
-  "sidebar_aichat": {
+  "sidebar_ai_assistant": {
     "instruction_label": "Assistant instructions",
     "reference_pages_label": "Reference pages",
     "placeholder": "Ask me anything.",
+    "knowledge_assistant_placeholder": "Ask me anything.",
+    "editor_assistant_placeholder": "Can I help you with anything?",
     "summary_mode_label": "Summary mode",
     "summary_mode_help": "Concise answer within 2-3 sentences",
     "caution_against_hallucination": "Please verify the information and check the sources.",

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

@@ -155,6 +155,7 @@
   "In-App Notification": "Notifications",
   "AI Assistant": "Assistant IA",
   "Knowledge Assistant": "Assistant de Connaissances (Bêta)",
+  "Editor Assistant": "Assistante de rédaction (Bêta)",
   "original_path": "Chemin originel",
   "new_path": "Nouveau chemin",
   "duplicated_path": "Chemin dupliqué",
@@ -489,10 +490,11 @@
     "latest_revision": "les autres",
     "selected_editable_revision": "Corps de page sélectionné (Modifiable)"
   },
-  "sidebar_aichat": {
+  "sidebar_ai_assistant": {
     "instruction_label": "Instructions pour l'assistant",
     "reference_pages_label": "Pages de référence",
-    "placeholder": "Demandez-moi n'importe quoi.",
+    "knowledge_assistant_placeholder": "Demandez-moi n'importe quoi.",
+    "editor_assistant_placeholder": "Puis-je vous aider ?",
     "summary_mode_label": "Mode résumé",
     "summary_mode_help": "Réponse concise en 2-3 phrases",
     "caution_against_hallucination": "Veuillez vérifier les informations et consulter les sources.",

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

@@ -155,6 +155,7 @@
   "In-App Notification": "通知",
   "AI Assistant": "AI アシスタント",
   "Knowledge Assistant": "ナレッジアシスタント (ベータ版)",
+  "Editor Assistant": "エディターアシスタント (ベータ版)",
   "original_path": "元のパス",
   "new_path": "新しいパス",
   "duplicated_path": "重複したパス",
@@ -527,10 +528,11 @@
     "latest_revision": "最新の本文",
     "selected_editable_revision": "保存するページ本文(編集可能)"
   },
-  "sidebar_aichat": {
+  "sidebar_ai_assistant": {
     "instruction_label": "アシスタントへの指示",
     "reference_pages_label": "参照するページ",
-    "placeholder": "ききたいことを入力してください",
+    "knowledge_assistant_placeholder": "ききたいことを入力してください",
+    "editor_assistant_placeholder": "お手伝いできることはありますか?",
     "summary_mode_label": "要約モード",
     "summary_mode_help": "2~3文以内の簡潔な回答",
     "caution_against_hallucination": "情報が正しいか出典を確認しましょう",

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

@@ -160,6 +160,7 @@
   "In-App Notification": "通知",
   "AI Assistant": "AI助手",
   "Knowledge Assistant": "知识助手 (测试版)",
+  "Editor Assistant": "编辑助理 (测试版)",
   "original_path": "Original path",
   "new_path": "New path",
   "duplicated_path": "Duplicated path",
@@ -484,10 +485,11 @@
     "latest_revision": "最新页面正文",
     "selected_editable_revision": "选定的可编辑页面正文"
   },
-  "sidebar_aichat": {
+  "sidebar_ai_assistant": {
     "instruction_label": "助手指令",
     "reference_pages_label": "参考页面",
-    "placeholder": "问我任何问题。",
+    "knowledge_assistant_placeholder": "问我任何问题。",
+    "editor_assistant_placeholder": "有什么需要帮忙的吗?",
     "summary_mode_label": "摘要模式",
     "summary_mode_help": "简洁回答在2-3句话内",
     "caution_against_hallucination": "请核实信息并检查来源。",

+ 7 - 4
apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorAssistantToggleButton.tsx

@@ -2,18 +2,21 @@ import { useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { useAiAssistantChatSidebar } from '~/features/openai/client/stores/ai-assistant';
+import { useAiAssistantSidebar } from '~/features/openai/client/stores/ai-assistant';
 
 export const EditorAssistantToggleButton = (): JSX.Element => {
   const { t } = useTranslation();
-  const { data, close } = useAiAssistantChatSidebar();
+  const { data, close, openEditor } = useAiAssistantSidebar();
   const { isOpened } = data ?? {};
 
   const toggle = useCallback(() => {
     if (isOpened) {
       close();
+      return;
     }
-  }, [isOpened, close]);
+
+    openEditor();
+  }, [isOpened, openEditor, close]);
 
   return (
     <button
@@ -22,7 +25,7 @@ export const EditorAssistantToggleButton = (): JSX.Element => {
       onClick={toggle}
     >
       <span className="d-flex align-items-center">
-        <span className="growi-custom-icons py-0 fs-6">ai_assistant</span>
+        <span className="material-symbols-outlined">support_agent</span>
         <span className="ms-1 me-1">{t('page_edit.editor_assistant')}</span>
       </span>
     </button>

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

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

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

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

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

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

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

@@ -1,13 +1,12 @@
 import type { KeyboardEvent } from 'react';
 import {
-  type FC, memo, useRef, useEffect, useState, useCallback,
+  type FC, memo, useRef, useEffect, useState, useCallback, useMemo,
 } from 'react';
 
 import { useForm, Controller } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import { Collapse, UncontrolledTooltip } from 'reactstrap';
 import SimpleBar from 'simplebar-react';
-import type { z } from 'zod';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
@@ -15,30 +14,23 @@ import { useGrowiCloudUri } from '~/stores-universal/context';
 import loggerFactory from '~/utils/logger';
 
 import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
-import { SseMessageSchema, SseDetectedDiffSchema, SseFinalizedSchema } from '../../../../interfaces/editor-assistant/sse-schemas';
 import { MessageErrorCode, StreamErrorCode } from '../../../../interfaces/message-error';
 import type { IThreadRelationHasId } from '../../../../interfaces/thread-relation';
-import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
+import { useEditorAssistant } from '../../../services/editor-assistant';
+import { useKnowledgeAssistant } from '../../../services/knowledge-assistant';
+import { useAiAssistantSidebar } from '../../../stores/ai-assistant';
 import { useSWRMUTxMessages } from '../../../stores/message';
 import { useSWRMUTxThreads } from '../../../stores/thread';
 
+import { AiAssistantChatInitialView } from './AiAssistantChatInitialView';
 import { MessageCard } from './MessageCard';
 import { ResizableTextarea } from './ResizableTextArea';
 
-import styles from './AiAssistantChatSidebar.module.scss';
+import styles from './AiAssistantSidebar.module.scss';
 
-const logger = loggerFactory('growi:openai:client:components:AiAssistantChatSidebar');
+const logger = loggerFactory('growi:openai:client:components:AiAssistantSidebar');
 
-const moduleClass = styles['grw-ai-assistant-chat-sidebar'] ?? '';
-
-const handleIfSuccessfullyParsed = <T, >(data: T, zSchema: z.ZodSchema<T>,
-  callback: (data: T) => void,
-): void => {
-  const parsed = zSchema.safeParse(data);
-  if (parsed.success) {
-    callback(data);
-  }
-};
+const moduleClass = styles['grw-ai-assistant-sidebar'] ?? '';
 
 type Message = {
   id: string,
@@ -51,15 +43,19 @@ type FormData = {
   summaryMode?: boolean;
 };
 
-type AiAssistantChatSidebarSubstanceProps = {
-  aiAssistantData: AiAssistantHasId;
+type AiAssistantSidebarSubstanceProps = {
+  isEditorAssistant?: boolean;
+  aiAssistantData?: AiAssistantHasId;
   threadData?: IThreadRelationHasId;
-  closeAiAssistantChatSidebar: () => void
+  closeAiAssistantSidebar: () => void
 }
 
-const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceProps> = (props: AiAssistantChatSidebarSubstanceProps) => {
+const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> = (props: AiAssistantSidebarSubstanceProps) => {
   const {
-    aiAssistantData, threadData, closeAiAssistantChatSidebar,
+    isEditorAssistant,
+    aiAssistantData,
+    threadData,
+    closeAiAssistantSidebar,
   } = props;
 
   const [currentThreadTitle, setCurrentThreadTitle] = useState<string | undefined>(threadData?.title);
@@ -71,8 +67,11 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
 
   const { t } = useTranslation();
   const { data: growiCloudUri } = useGrowiCloudUri();
-  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData._id);
-  const { trigger: mutateMessageData } = useSWRMUTxMessages(aiAssistantData._id, threadData?.threadId);
+  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData?._id);
+  const { trigger: mutateMessageData } = useSWRMUTxMessages(aiAssistantData?._id, threadData?.threadId);
+
+  const { postMessage: postMessageForKnowledgeAssistant, processMessage: processMessageForKnowledgeAssistant } = useKnowledgeAssistant();
+  const { postMessage: postMessageForEditorAssistant, processMessage: processMessageForEditorAssistant } = useEditorAssistant();
 
   const form = useForm<FormData>({
     defaultValues: {
@@ -106,6 +105,27 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
     }
   }, [mutateMessageData, threadData]);
 
+  const headerIcon = useMemo(() => {
+    return isEditorAssistant
+      ? <span className="material-symbols-outlined growi-ai-chat-icon me-3 fs-4">support_agent</span>
+      : <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>;
+  }, [isEditorAssistant]);
+
+  const headerText = useMemo(() => {
+    return isEditorAssistant
+      ? <>{t('Editor Assistant')}</>
+      : <>{currentThreadTitle ?? aiAssistantData?.name}</>;
+  }, [isEditorAssistant, currentThreadTitle, aiAssistantData?.name, t]);
+
+  const placeHolder = useMemo(() => {
+    if (form.formState.isSubmitting) {
+      return '';
+    }
+    return t(isEditorAssistant
+      ? 'sidebar_ai_assistant.editor_assistant_placeholder'
+      : 'sidebar_ai_assistant.knowledge_assistant_placeholder');
+  }, [form.formState.isSubmitting, isEditorAssistant, t]);
+
   const isGenerating = generatingAnswerMessage != null;
   const submit = useCallback(async(data: FormData) => {
     // do nothing when the assistant is generating an answer
@@ -137,8 +157,8 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
     if (currentThreadId_ == null) {
       try {
         const res = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
-          aiAssistantId: aiAssistantData._id,
-          initialUserMessage: newUserMessage.content,
+          aiAssistantId: aiAssistantData?._id,
+          initialUserMessage: isEditorAssistant ? undefined : newUserMessage.content,
         });
 
         const thread = res.data;
@@ -149,23 +169,34 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
         currentThreadId_ = thread.threadId;
 
         // No need to await because data is not used
-        mutateThreadData();
+        if (!isEditorAssistant) {
+          mutateThreadData();
+        }
       }
       catch (err) {
         logger.error(err.toString());
-        toastError(t('sidebar_aichat.failed_to_create_or_retrieve_thread'));
+        toastError(t('sidebar_ai_assistant.failed_to_create_or_retrieve_thread'));
       }
     }
 
     // post message
     try {
-      const response = await fetch('/_api/v3/openai/message', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          userMessage: data.input, threadId: currentThreadId_, summaryMode: data.summaryMode, aiAssistantId: aiAssistantData._id,
-        }),
-      });
+      if (currentThreadId_ == null) {
+        return;
+      }
+
+      const response = await (async() => {
+        if (isEditorAssistant) {
+          return postMessageForEditorAssistant(currentThreadId_, data.input, '# markdown');
+        }
+        if (aiAssistantData?._id != null) {
+          return postMessageForKnowledgeAssistant(aiAssistantData._id, currentThreadId_, data.input, data.summaryMode);
+        }
+      })();
+
+      if (response == null) {
+        return;
+      }
 
       if (!response.ok) {
         const resJson = await response.json();
@@ -176,7 +207,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
 
           const hasThreadIdNotSetError = resJson.errors.some(err => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET);
           if (hasThreadIdNotSetError) {
-            toastError(t('sidebar_aichat.failed_to_create_or_retrieve_thread'));
+            toastError(t('sidebar_ai_assistant.failed_to_create_or_retrieve_thread'));
           }
         }
         setGeneratingAnswerMessage(undefined);
@@ -206,30 +237,35 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
         const textValues: string[] = [];
         const lines = chunk.split('\n\n');
         lines.forEach((line) => {
-          const trimedLine = line.trim();
-          if (trimedLine.startsWith('data:')) {
+          const trimmedLine = line.trim();
+          if (trimmedLine.startsWith('data:')) {
             const data = JSON.parse(line.replace('data: ', ''));
-            if (data.content != null) {
-              textValues.push(data.content[0].text.value);
-            }
 
-            handleIfSuccessfullyParsed(data, SseMessageSchema, (data) => {
-              textValues.push(data.appendedMessage);
+            processMessageForKnowledgeAssistant(data, {
+              onMessage: (data) => {
+                textValues.push(data.content[0].text.value);
+              },
             });
-            handleIfSuccessfullyParsed(data, SseDetectedDiffSchema, (data) => {
-              console.log('sse diff', { data });
-            });
-            handleIfSuccessfullyParsed(data, SseFinalizedSchema, (data) => {
-              console.log('sse finalized', { data });
+
+            processMessageForEditorAssistant(data, {
+              onMessage: (data) => {
+                textValues.push(data.appendedMessage);
+              },
+              onDetectedDiff: (data) => {
+                console.log('sse diff', { data });
+              },
+              onFinalized: (data) => {
+                console.log('sse finalized', { data });
+              },
             });
           }
-          else if (trimedLine.startsWith('error:')) {
+          else if (trimmedLine.startsWith('error:')) {
             const error = JSON.parse(line.replace('error: ', ''));
             logger.error(error.errorMessage);
             form.setError('input', { type: 'manual', message: error.message });
 
             if (error.code === StreamErrorCode.BUDGET_EXCEEDED) {
-              setErrorMessage(growiCloudUri != null ? 'sidebar_aichat.budget_exceeded_for_growi_cloud' : 'sidebar_aichat.budget_exceeded');
+              setErrorMessage(growiCloudUri != null ? 'sidebar_ai_assistant.budget_exceeded_for_growi_cloud' : 'sidebar_ai_assistant.budget_exceeded');
             }
           }
         });
@@ -253,7 +289,8 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
       form.setError('input', { type: 'manual', message: err.toString() });
     }
 
-  }, [isGenerating, messageLogs, form, currentThreadId, aiAssistantData._id, mutateThreadData, t, growiCloudUri]);
+  // eslint-disable-next-line max-len
+  }, [isGenerating, messageLogs, form, currentThreadId, aiAssistantData?._id, isEditorAssistant, mutateThreadData, t, postMessageForEditorAssistant, postMessageForKnowledgeAssistant, processMessageForKnowledgeAssistant, processMessageForEditorAssistant, growiCloudUri]);
 
   const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
     if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
@@ -265,19 +302,20 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
     <>
       <div className="d-flex flex-column vh-100">
         <div className="d-flex align-items-center p-3 border-bottom position-sticky top-0 bg-body z-1">
-          <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>
-          <h5 className="mb-0 fw-bold flex-grow-1 text-truncate">{currentThreadTitle ?? aiAssistantData.name}</h5>
+          {headerIcon}
+          <h5 className="mb-0 fw-bold flex-grow-1 text-truncate">
+            {headerText}
+          </h5>
           <button
             type="button"
             className="btn btn-link p-0 border-0"
-            onClick={closeAiAssistantChatSidebar}
+            onClick={closeAiAssistantSidebar}
           >
             <span className="material-symbols-outlined">close</span>
           </button>
         </div>
         <div className="p-4 d-flex flex-column gap-4 vh-100">
 
-
           { currentThreadId != null
             ? (
               <div className="vstack gap-4 pb-2">
@@ -290,46 +328,22 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
                 { messageLogs.length > 0 && (
                   <div className="d-flex justify-content-center">
                     <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
-                      {t('sidebar_aichat.caution_against_hallucination')}
+                      {t('sidebar_ai_assistant.caution_against_hallucination')}
                     </span>
                   </div>
                 )}
               </div>
             )
             : (
-              <>
-                <p className="fs-6 text-body-secondary mb-0">
-                  {aiAssistantData.description}
-                </p>
-
-                <div>
-                  <p className="text-body-secondary">{t('sidebar_aichat.instruction_label')}</p>
-                  <div className="card bg-body-tertiary border-0">
-                    <div className="card-body p-3">
-                      <p className="fs-6 text-body-secondary mb-0">
-                        {aiAssistantData.additionalInstruction}
-                      </p>
-                    </div>
-                  </div>
-                </div>
-
-                <div>
-                  <div className="d-flex align-items-center">
-                    <p className="text-body-secondary mb-0">{t('sidebar_aichat.reference_pages_label')}</p>
-                  </div>
-                  <div className="d-flex flex-column gap-1">
-                    { aiAssistantData.pagePathPatterns.map(pagePathPattern => (
-                      <a
-                        key={pagePathPattern}
-                        href="#"
-                        className="fs-6 text-body-secondary text-decoration-none"
-                      >
-                        {pagePathPattern}
-                      </a>
-                    ))}
-                  </div>
-                </div>
-
+              <>{isEditorAssistant
+                ? <></> // TODO https://redmine.weseek.co.jp/issues/163079
+                : (
+                  <AiAssistantChatInitialView
+                    description={aiAssistantData?.description ?? ''}
+                    additionalInstruction={aiAssistantData?.additionalInstruction ?? ''}
+                    pagePathPatterns={aiAssistantData?.pagePathPatterns ?? []}
+                  />
+                )}
               </>
             )
           }
@@ -347,7 +361,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
                       className="form-control textarea-ask"
                       style={{ resize: 'none' }}
                       rows={1}
-                      placeholder={!form.formState.isSubmitting ? t('sidebar_aichat.placeholder') : ''}
+                      placeholder={placeHolder}
                       onKeyDown={keyDownHandler}
                       disabled={form.formState.isSubmitting}
                     />
@@ -371,7 +385,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
                   disabled={form.formState.isSubmitting || isGenerating}
                 />
                 <label className="form-check-label" htmlFor="swSummaryMode">
-                  {t('sidebar_aichat.summary_mode_label')}
+                  {t('sidebar_ai_assistant.summary_mode_label')}
                 </label>
 
                 {/* Help */}
@@ -385,7 +399,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
                 <UncontrolledTooltip
                   target="tooltipForHelpOfSummaryMode"
                 >
-                  {t('sidebar_aichat.summary_mode_help')}
+                  {t('sidebar_ai_assistant.summary_mode_help')}
                 </UncontrolledTooltip>
               </div>
             </form>
@@ -394,7 +408,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
               <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
                 <div>
                   <span className="material-symbols-outlined text-danger me-2">error</span>
-                  <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('sidebar_aichat.error_message') }</span>
+                  <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('sidebar_ai_assistant.error_message') }</span>
                 </div>
 
                 <button
@@ -406,7 +420,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
                   <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
                     chevron_right
                   </span>
-                  <span className="small">{t('sidebar_aichat.show_error_detail')}</span>
+                  <span className="small">{t('sidebar_ai_assistant.show_error_detail')}</span>
                 </button>
 
                 <Collapse isOpen={isErrorDetailCollapsed}>
@@ -429,20 +443,21 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
 };
 
 
-export const AiAssistantChatSidebar: FC = memo((): JSX.Element => {
+export const AiAssistantSidebar: FC = memo((): JSX.Element => {
   const sidebarRef = useRef<HTMLDivElement>(null);
   const sidebarScrollerRef = useRef<HTMLDivElement>(null);
 
-  const { data: aiAssistantChatSidebarData, close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+  const { data: aiAssistantSidebarData, close: closeAiAssistantSidebar } = useAiAssistantSidebar();
 
-  const aiAssistantData = aiAssistantChatSidebarData?.aiAssistantData;
-  const threadData = aiAssistantChatSidebarData?.threadData;
-  const isOpened = aiAssistantChatSidebarData?.isOpened && aiAssistantData != null;
+  const aiAssistantData = aiAssistantSidebarData?.aiAssistantData;
+  const threadData = aiAssistantSidebarData?.threadData;
+  const isOpened = aiAssistantSidebarData?.isOpened;
+  const isEditorAssistant = aiAssistantSidebarData?.isEditorAssistant ?? false;
 
   useEffect(() => {
     const handleClickOutside = (event: MouseEvent) => {
       if (isOpened && sidebarRef.current && !sidebarRef.current.contains(event.target as Node)) {
-        closeAiAssistantChatSidebar();
+        closeAiAssistantSidebar();
       }
     };
 
@@ -450,7 +465,7 @@ export const AiAssistantChatSidebar: FC = memo((): JSX.Element => {
     return () => {
       document.removeEventListener('mousedown', handleClickOutside);
     };
-  }, [closeAiAssistantChatSidebar, isOpened]);
+  }, [closeAiAssistantSidebar, isOpened]);
 
   if (!isOpened) {
     return <></>;
@@ -467,10 +482,11 @@ export const AiAssistantChatSidebar: FC = memo((): JSX.Element => {
         className="h-100 position-relative"
         autoHide
       >
-        <AiAssistantChatSidebarSubstance
+        <AiAssistantSidebarSubstance
+          isEditorAssistant={isEditorAssistant}
           threadData={threadData}
           aiAssistantData={aiAssistantData}
-          closeAiAssistantChatSidebar={closeAiAssistantChatSidebar}
+          closeAiAssistantSidebar={closeAiAssistantSidebar}
         />
       </SimpleBar>
     </div>

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


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

@@ -6,7 +6,7 @@ import ReactMarkdown from 'react-markdown';
 
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 
-import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
+import { useAiAssistantSidebar } 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: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+  const { close: closeAiAssistantSidebar } = useAiAssistantSidebar();
 
   const onClick = useCallback(() => {
-    closeAiAssistantChatSidebar();
-  }, [closeAiAssistantChatSidebar]);
+    closeAiAssistantSidebar();
+  }, [closeAiAssistantSidebar]);
 
   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('sidebar_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
+                {t('sidebar_ai_assistant.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
               </span>
             )
           }

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


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

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

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

@@ -14,7 +14,7 @@ import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interf
 import { determineShareScope } from '../../../../utils/determine-share-scope';
 import { deleteAiAssistant, setDefaultAiAssistant } from '../../../services/ai-assistant';
 import { deleteThread } from '../../../services/thread';
-import { useAiAssistantChatSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+import { useAiAssistantSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
 import { useSWRMUTxThreads, useSWRxThreads } from '../../../stores/thread';
 
 import styles from './AiAssistantTree.module.scss';
@@ -298,7 +298,7 @@ type AiAssistantTreeProps = {
 
 export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onUpdated, onDeleted }) => {
   const { data: currentUser } = useCurrentUser();
-  const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+  const { openChat } = useAiAssistantSidebar();
   const { open: openAiAssistantManagementModal } = useAiAssistantManagementModal();
 
   return (
@@ -309,7 +309,7 @@ export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants,
           currentUser={currentUser}
           aiAssistant={assistant}
           onEditClick={openAiAssistantManagementModal}
-          onItemClick={openAiAssistantChatSidebar}
+          onItemClick={openChat}
           onUpdated={onUpdated}
           onDeleted={onDeleted}
         />

+ 55 - 0
apps/app/src/features/openai/client/services/editor-assistant.ts

@@ -0,0 +1,55 @@
+import { useCallback } from 'react';
+
+import {
+  SseMessageSchema,
+  SseDetectedDiffSchema,
+  SseFinalizedSchema,
+  type SseMessage,
+  type SseDetectedDiff,
+  type SseFinalized,
+} from '~/features/openai/interfaces/editor-assistant/sse-schemas';
+import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
+
+interface PostMessage {
+  (threadId: string, userMessage: string, markdown: string, aiAssistantId?: string): Promise<Response>;
+}
+interface ProcessMessage {
+  (data: unknown, handler: {
+    onMessage: (data: SseMessage) => void;
+    onDetectedDiff: (data: SseDetectedDiff) => void;
+    onFinalized: (data: SseFinalized) => void;
+  }): void;
+}
+
+export const useEditorAssistant = (): { postMessage: PostMessage, processMessage: ProcessMessage } => {
+  const postMessage: PostMessage = useCallback(async(threadId, userMessage, markdown, aiAssistantId) => {
+    const response = await fetch('/_api/v3/openai/edit', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        aiAssistantId,
+        threadId,
+        userMessage,
+        markdown,
+      }),
+    });
+    return response;
+  }, []);
+
+  const processMessage: ProcessMessage = useCallback((data, handler) => {
+    handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
+      handler.onMessage(data);
+    });
+    handleIfSuccessfullyParsed(data, SseDetectedDiffSchema, (data: SseDetectedDiff) => {
+      handler.onDetectedDiff(data);
+    });
+    handleIfSuccessfullyParsed(data, SseFinalizedSchema, (data: SseFinalized) => {
+      handler.onFinalized(data);
+    });
+  }, []);
+
+  return {
+    postMessage,
+    processMessage,
+  };
+};

+ 40 - 0
apps/app/src/features/openai/client/services/knowledge-assistant.ts

@@ -0,0 +1,40 @@
+import { useCallback } from 'react';
+
+import { SseMessageSchema, type SseMessage } from '~/features/openai/interfaces/knowledge-assistant/sse-schemas';
+import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
+
+interface PostMessage {
+  (aiAssistantId: string, threadId: string, userMessage: string, summaryMode?: boolean): Promise<Response>;
+}
+interface ProcessMessage {
+  (data: unknown, handler: {
+    onMessage: (data: SseMessage) => void}
+  ): void;
+}
+
+export const useKnowledgeAssistant = (): { postMessage: PostMessage, processMessage: ProcessMessage } => {
+  const postMessage: PostMessage = useCallback(async(aiAssistantId, threadId, userMessage, summaryMode) => {
+    const response = await fetch('/_api/v3/openai/message', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        aiAssistantId,
+        threadId,
+        userMessage,
+        summaryMode,
+      }),
+    });
+    return response;
+  }, []);
+
+  const processMessage: ProcessMessage = useCallback((data, handler) => {
+    handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
+      handler.onMessage(data);
+    });
+  }, []);
+
+  return {
+    postMessage,
+    processMessage,
+  };
+};

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

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

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

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

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

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

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

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

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

@@ -28,7 +28,6 @@ const schema = new Schema<ThreadRelationDocument, ThreadRelationModel>({
   aiAssistant: {
     type: Schema.Types.ObjectId,
     ref: 'AiAssistant',
-    required: true,
   },
   threadId: {
     type: String,

+ 3 - 1
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -122,6 +122,7 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (cro
         const thread = await openaiClient.beta.threads.retrieve(threadId);
 
         // Create stream
+        /* eslint-disable max-len */
         const stream = openaiClient.beta.threads.runs.stream(thread.id, {
           assistant_id: assistant.id,
           additional_messages: [
@@ -147,7 +148,7 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (cro
               }
 
               The array should contain:
-              - [At the begining of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
+              - [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
               - Objects with a "message" key for explanatory text to the user if needed.
               - Objects with "insert", "delete", and "retain" keys for replacements (Delta format by Quill Rich Text Editor)
               - [At the end of the list] A "message" object that contains your friendly message explaining that the operation was completed and what changes were made.
@@ -162,6 +163,7 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (cro
           ],
           response_format: zodResponseFormat(LlmEditorAssistantResponseSchema, 'editor_assistant_response'),
         });
+        /* eslint-disable max-len */
 
         // Message delta handler
         const messageDeltaHandler = async(delta: MessageDelta) => {

+ 4 - 11
apps/app/src/features/openai/server/routes/thread.ts

@@ -17,8 +17,8 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 const logger = loggerFactory('growi:routes:apiv3:openai:thread');
 
 type ReqBody = {
-  aiAssistantId: string,
-  initialUserMessage: string,
+  aiAssistantId?: string,
+  initialUserMessage?: string,
 }
 
 type CreateThreadReq = Request<undefined, ApiV3Response, ReqBody> & { user: IUserHasId };
@@ -29,8 +29,8 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
   const validator: ValidationChain[] = [
-    body('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
-    body('initialUserMessage').isString().withMessage('initialUserMessage must be string'),
+    body('aiAssistantId').optional().isMongoId().withMessage('aiAssistantId must be string'),
+    body('initialUserMessage').optional().isString().withMessage('initialUserMessage must be string'),
   ];
 
   return [
@@ -44,14 +44,7 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
 
       try {
         const { aiAssistantId, initialUserMessage } = 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 thread = await openaiService.createThread(req.user._id, aiAssistantId, initialUserMessage);
-
         return res.apiv3(thread);
       }
       catch (err) {

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

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

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

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

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

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

+ 4 - 5
apps/app/src/features/openai/server/services/openai.ts

@@ -65,7 +65,7 @@ const convertPathPatternsToRegExp = (pagePathPatterns: string[]): Array<string |
 };
 
 export interface IOpenaiService {
-  createThread(userId: string, aiAssistantId: string, initialUserMessage: string): Promise<ThreadRelationDocument>;
+  createThread(userId: string, aiAssistantId?: string, initialUserMessage?: string): Promise<ThreadRelationDocument>;
   getThreadsByAiAssistantId(aiAssistantId: string): Promise<ThreadRelationDocument[]>
   deleteThread(threadRelationId: string): Promise<ThreadRelationDocument>;
   deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
@@ -117,9 +117,7 @@ class OpenaiService implements IOpenaiService {
     return threadTitle;
   }
 
-  async createThread(userId: string, aiAssistantId: string, initialUserMessage: string): Promise<ThreadRelationDocument> {
-    const vectorStoreRelation = await this.getVectorStoreRelationByAiAssistantId(aiAssistantId);
-
+  async createThread(userId: string, aiAssistantId?: string, initialUserMessage?: string): Promise<ThreadRelationDocument> {
     let threadTitle: string | null = null;
     if (initialUserMessage != null) {
       try {
@@ -131,7 +129,8 @@ class OpenaiService implements IOpenaiService {
     }
 
     try {
-      const thread = await this.client.createThread(vectorStoreRelation.vectorStoreId);
+      const vectorStoreRelation = aiAssistantId != null ? await this.getVectorStoreRelationByAiAssistantId(aiAssistantId) : null;
+      const thread = await this.client.createThread(vectorStoreRelation?.vectorStoreId);
       const threadRelation = await ThreadRelationModel.create({
         userId,
         aiAssistant: aiAssistantId,

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

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