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

Merge pull request #9672 from weseek/feat/162181-show-thread-messages

feat: Show thread messages
Shun Miyazawa 1 год назад
Родитель
Сommit
0d2b9e17f5

+ 80 - 47
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx

@@ -16,6 +16,8 @@ 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';
@@ -39,16 +41,27 @@ type FormData = {
   summaryMode?: boolean;
 };
 
-
 type AiAssistantChatSidebarSubstanceProps = {
-  aiAssistantData?: AiAssistantHasId;
+  aiAssistantData: AiAssistantHasId;
+  threadId?: string;
   closeAiAssistantChatSidebar: () => void
 }
 
 const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceProps> = (props: AiAssistantChatSidebarSubstanceProps) => {
-  const { t } = useTranslation();
+  const {
+    threadId, aiAssistantData, closeAiAssistantChatSidebar,
+  } = props;
 
-  const { 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: {
@@ -57,16 +70,29 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
     },
   });
 
-  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);
+  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',
+            }
+          ));
+        });
+      }
+    };
 
-  const { data: growiCloudUri } = useGrowiCloudUri();
+    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) {
@@ -93,15 +119,17 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
     setGeneratingAnswerMessage(newAnswerMessage);
 
     // create thread
-    let currentThreadId = threadId;
-    const aiAssistantId = aiAssistantData?._id;
-    if (threadId == null && aiAssistantId) {
+    let currentThreadId_ = currentThreadId;
+    if (currentThreadId_ == null) {
       try {
-        const res = await apiv3Post('/openai/thread', { aiAssistantId });
+        const res = await apiv3Post('/openai/thread', { aiAssistantId: aiAssistantData._id });
         const thread = res.data.thread;
 
-        setThreadId(thread.id);
-        currentThreadId = thread.id;
+        setCurrentThreadId(thread.id);
+        currentThreadId_ = thread.id;
+
+        // No need to await because data is not used
+        mutateThreadData();
       }
       catch (err) {
         logger.error(err.toString());
@@ -114,7 +142,9 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
       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 }),
+        body: JSON.stringify({
+          userMessage: data.input, threadId: currentThreadId_, summaryMode: data.summaryMode, aiAssistantId: aiAssistantData._id,
+        }),
       });
 
       if (!response.ok) {
@@ -191,7 +221,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
       form.setError('input', { type: 'manual', message: err.toString() });
     }
 
-  }, [form, growiCloudUri, isGenerating, messageLogs, t, threadId]);
+  }, [aiAssistantData._id, currentThreadId, form, growiCloudUri, isGenerating, messageLogs, mutateThreadData, t]);
 
   const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
     if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
@@ -199,13 +229,12 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
     }
   };
 
-
   return (
     <>
       <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>
+          <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"
@@ -217,7 +246,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
         <div className="p-4 d-flex flex-column gap-4 vh-100">
 
 
-          { threadId != null
+          { currentThreadId != null
             ? (
               <div className="vstack gap-4 pb-2">
                 { messageLogs.map(message => (
@@ -238,7 +267,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
             : (
               <>
                 <p className="fs-6 text-secondary mb-0">
-                  {aiAssistantData?.description}
+                  {aiAssistantData.description}
                 </p>
 
                 <div>
@@ -246,7 +275,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
                   <div className="card bg-light border-0">
                     <div className="card-body p-3">
                       <p className="fs-6 text-secondary mb-0">
-                        {aiAssistantData?.additionalInstruction}
+                        {aiAssistantData.additionalInstruction}
                       </p>
                     </div>
                   </div>
@@ -257,7 +286,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
                     <p className="text-secondary mb-0">参照するページ</p>
                   </div>
                   <div className="d-flex flex-column gap-1">
-                    { aiAssistantData?.pagePathPatterns.map(pagePathPattern => (
+                    { aiAssistantData.pagePathPatterns.map(pagePathPattern => (
                       <a
                         key={pagePathPattern}
                         href="#"
@@ -373,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) => {
@@ -388,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>
   );
 });

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

@@ -10,7 +10,7 @@ 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 { useSWRxThreads } from '../../../stores/thread';
+import { useSWRMUTxThreads, useSWRxThreads } from '../../../stores/thread';
 
 import styles from './AiAssistantTree.module.scss';
 
@@ -22,17 +22,19 @@ const moduleClass = styles['ai-assistant-tree-item'] ?? '';
 */
 type ThreadItemProps = {
   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
@@ -66,11 +68,12 @@ const ThreadItem: React.FC<ThreadItemProps> = ({ thread }) => {
 *  ThreadItems
 */
 type ThreadItemsProps = {
-  aiAssistantId: string;
+  aiAssistantData: AiAssistantHasId;
+  onThreadClick: (aiAssistantData: AiAssistantHasId, threadId?: string) => void;
 };
 
-const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantId }) => {
-  const { data: threads } = useSWRxThreads(aiAssistantId);
+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>;
@@ -82,6 +85,8 @@ const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantId }) => {
         <ThreadItem
           key={thread._id}
           thread={thread}
+          aiAssistantData={aiAssistantData}
+          onThreadClick={onThreadClick}
         />
       ))}
     </div>
@@ -107,32 +112,34 @@ const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAss
 type AiAssistantItemProps = {
   currentUserId?: string;
   aiAssistant: AiAssistantHasId;
-  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,
-  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 {
@@ -207,7 +214,10 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
       </li>
 
       { isThreadsOpened && (
-        <ThreadItems aiAssistantId={aiAssistant._id} />
+        <ThreadItems
+          aiAssistantData={aiAssistant}
+          onThreadClick={onItemClick}
+        />
       ) }
     </>
   );
@@ -234,8 +244,8 @@ export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants,
           key={assistant._id}
           currentUserId={currentUser?._id}
           aiAssistant={assistant}
-          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),
+  );
+};

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

@@ -1,15 +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';
 
-export const useSWRxThreads = (aiAssistantId: string): SWRResponse<IThreadRelationHasId[], Error> => {
-  const key = [`openai/threads/${aiAssistantId}`];
+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),
+  );
+};

+ 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 {