Explorar el Código

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

Yuki Takei hace 1 año
padre
commit
78246200e8

+ 14 - 13
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx

@@ -73,12 +73,13 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
   });
 
   useEffect(() => {
-    const getMessageData = async() => {
+    const fetchAndSetMessageData = async() => {
       const messageData = await mutateMessageData();
       if (messageData != null) {
-        const reversedMessageData = messageData.data.slice().reverse();
+        const normalizedMessageData = messageData.data.filter(message => message.metadata?.shouldHideMessage !== 'true');
+
         setMessageLogs(() => {
-          return reversedMessageData.map((message, index) => (
+          return normalizedMessageData.map((message, index) => (
             {
               id: index.toString(),
               content: message.content[0].type === 'text' ? message.content[0].text.value : '',
@@ -90,7 +91,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
     };
 
     if (threadData != null) {
-      getMessageData();
+      fetchAndSetMessageData();
     }
   }, [mutateMessageData, threadData]);
 
@@ -269,15 +270,15 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
             )
             : (
               <>
-                <p className="fs-6 text-secondary mb-0">
+                <p className="fs-6 text-body-secondary mb-0">
                   {aiAssistantData.description}
                 </p>
 
                 <div>
-                  <p className="text-secondary">アシスタントへの指示</p>
-                  <div className="card bg-light border-0">
+                  <p className="text-body-secondary">アシスタントへの指示</p>
+                  <div className="card bg-body-tertiary border-0">
                     <div className="card-body p-3">
-                      <p className="fs-6 text-secondary mb-0">
+                      <p className="fs-6 text-body-secondary mb-0">
                         {aiAssistantData.additionalInstruction}
                       </p>
                     </div>
@@ -286,14 +287,14 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
 
                 <div>
                   <div className="d-flex align-items-center">
-                    <p className="text-secondary mb-0">参照するページ</p>
+                    <p className="text-body-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"
+                        className="fs-6 text-body-secondary text-decoration-none"
                       >
                         {pagePathPattern}
                       </a>
@@ -370,7 +371,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
 
                 <button
                   type="button"
-                  className="btn btn-link text-secondary p-0"
+                  className="btn btn-link text-body-secondary p-0"
                   aria-expanded={isErrorDetailCollapsed}
                   onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
                 >
@@ -383,7 +384,7 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
                 <Collapse isOpen={isErrorDetailCollapsed}>
                   <div className="ms-2">
                     <div className="">
-                      <div className="text-secondary small">
+                      <div className="text-body-secondary small">
                         {form.formState.errors.input?.message}
                       </div>
                     </div>
@@ -430,7 +431,7 @@ export const AiAssistantChatSidebar: FC = memo((): JSX.Element => {
   return (
     <div
       ref={sidebarRef}
-      className={`position-fixed top-0 end-0 h-100 border-start bg-white shadow-sm ${moduleClass}`}
+      className={`position-fixed top-0 end-0 h-100 border-start bg-body shadow-sm ${moduleClass}`}
       style={{ zIndex: 1500, width: `${RIGHT_SIDEBAR_WIDTH}px` }}
       data-testid="grw-right-sidebar"
     >

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

@@ -5,15 +5,19 @@ 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 loggerFactory from '~/utils/logger';
 
 import type { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant';
 import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
 import { deleteAiAssistant } from '../../../services/ai-assistant';
+import { deleteThread } from '../../../services/thread';
 import { useAiAssistantChatSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
 import { useSWRMUTxThreads, useSWRxThreads } from '../../../stores/thread';
 
 import styles from './AiAssistantTree.module.scss';
 
+const logger = loggerFactory('growi:openai:client:components:AiAssistantTree');
+
 const moduleClass = styles['ai-assistant-tree-item'] ?? '';
 
 
@@ -21,40 +25,57 @@ const moduleClass = styles['ai-assistant-tree-item'] ?? '';
 *  ThreadItem
 */
 type ThreadItemProps = {
-  thread: IThreadRelationHasId
+  threadData: IThreadRelationHasId
   aiAssistantData: AiAssistantHasId;
   onThreadClick: (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => void;
+  onThreadDelete: () => void;
 };
 
-const ThreadItem: React.FC<ThreadItemProps> = ({ thread, aiAssistantData, onThreadClick }) => {
+const ThreadItem: React.FC<ThreadItemProps> = ({
+  threadData, aiAssistantData, onThreadClick, onThreadDelete,
+}) => {
 
-  const deleteThreadHandler = useCallback(() => {
-    // TODO: https://redmine.weseek.co.jp/issues/161490
-  }, []);
+  const deleteThreadHandler = useCallback(async() => {
+    try {
+      await deleteThread({ aiAssistantId: aiAssistantData._id, threadRelationId: threadData._id });
+      toastSuccess('スレッドを削除しました');
+      onThreadDelete();
+    }
+    catch (err) {
+      logger.error(err);
+      toastError('スレッドの削除に失敗しました');
+    }
+  }, [aiAssistantData._id, onThreadDelete, threadData._id]);
 
   const openChatHandler = useCallback(() => {
-    onThreadClick(aiAssistantData, thread);
-  }, [aiAssistantData, onThreadClick, thread]);
+    onThreadClick(aiAssistantData, threadData);
+  }, [aiAssistantData, onThreadClick, threadData]);
 
   return (
     <li
       role="button"
       className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1 ps-5"
-      onClick={openChatHandler}
+      onClick={(e) => {
+        e.stopPropagation();
+        openChatHandler();
+      }}
     >
       <div>
         <span className="material-symbols-outlined fs-5">chat</span>
       </div>
 
       <div className="grw-ai-assistant-title-anchor ps-1">
-        <p className="text-truncate m-auto">{thread?.title ?? 'Untitled thread'}</p>
+        <p className="text-truncate m-auto">{threadData?.title ?? 'Untitled thread'}</p>
       </div>
 
       <div className="grw-ai-assistant-actions opacity-0 d-flex justify-content-center ">
         <button
           type="button"
           className="btn btn-link text-secondary p-0"
-          onClick={deleteThreadHandler}
+          onClick={(e) => {
+            e.stopPropagation();
+            deleteThreadHandler();
+          }}
         >
           <span className="material-symbols-outlined fs-5">delete</span>
         </button>
@@ -70,9 +91,10 @@ const ThreadItem: React.FC<ThreadItemProps> = ({ thread, aiAssistantData, onThre
 type ThreadItemsProps = {
   aiAssistantData: AiAssistantHasId;
   onThreadClick: (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => void;
+  onThreadDelete: () => void;
 };
 
-const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantData, onThreadClick }) => {
+const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantData, onThreadClick, onThreadDelete }) => {
   const { data: threads } = useSWRxThreads(aiAssistantData._id);
 
   if (threads == null || threads.length === 0) {
@@ -84,9 +106,10 @@ const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantData, onThreadClic
       {threads.map(thread => (
         <ThreadItem
           key={thread._id}
-          thread={thread}
+          threadData={thread}
           aiAssistantData={aiAssistantData}
           onThreadClick={onThreadClick}
+          onThreadDelete={onThreadDelete}
         />
       ))}
     </div>
@@ -148,6 +171,7 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
       toastSuccess('アシスタントを削除しました');
     }
     catch (err) {
+      logger.error(err);
       toastError('アシスタントの削除に失敗しました');
     }
   }, [aiAssistant._id, onDeleted]);
@@ -217,6 +241,7 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
         <ThreadItems
           aiAssistantData={aiAssistant}
           onThreadClick={onItemClick}
+          onThreadDelete={mutateThreadData}
         />
       ) }
     </>

+ 7 - 0
apps/app/src/features/openai/client/services/thread.ts

@@ -0,0 +1,7 @@
+import { apiv3Delete } from '~/client/util/apiv3-client';
+
+import type { IApiv3DeleteThreadParams } from '../../interfaces/thread-relation';
+
+export const deleteThread = async(params: IApiv3DeleteThreadParams): Promise<void> => {
+  await apiv3Delete(`/openai/thread/${params.aiAssistantId}/${params.threadRelationId}`);
+};

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

@@ -1,9 +1,10 @@
-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> => {
+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;
   return useSWRMutation(
     key,

+ 13 - 0
apps/app/src/features/openai/interfaces/message.ts

@@ -0,0 +1,13 @@
+import type OpenAI from 'openai';
+
+export const shouldHideMessageKey = 'shouldHideMessage';
+
+export type MessageWithCustomMetaData = Omit<OpenAI.Beta.Threads.Messages.MessagesPage, 'data'> & {
+  data: Array<OpenAI.Beta.Threads.Message & {
+    metadata?: {
+      shouldHideMessage?: 'true' | 'false',
+    }
+  }>;
+};
+
+export type MessageListParams = OpenAI.Beta.Threads.Messages.MessageListParams;

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

@@ -11,3 +11,8 @@ export interface IThreadRelation {
 }
 
 export type IThreadRelationHasId = IThreadRelation & HasObjectId;
+
+export type IApiv3DeleteThreadParams = {
+  aiAssistantId: string
+  threadRelationId: string;
+}

+ 68 - 0
apps/app/src/features/openai/server/routes/delete-thread.ts

@@ -0,0 +1,68 @@
+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 { isHttpError } from 'http-errors';
+
+
+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 type { IApiv3DeleteThreadParams } from '../../interfaces/thread-relation';
+import { getOpenaiService } from '../services/openai';
+
+import { certifyAiService } from './middlewares/certify-ai-service';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:delete-thread');
+
+type DeleteThreadFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = IApiv3DeleteThreadParams;
+
+type Req = Request<ReqParams, Response, undefined> & {
+  user: IUserHasId,
+}
+
+export const deleteThreadFactory: DeleteThreadFactory = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    param('aiAssistantId').isMongoId().withMessage('threadId is required'),
+    param('threadRelationId').isMongoId().withMessage('threadRelationId is required'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { aiAssistantId, threadRelationId } = req.params;
+      const { user } = req;
+
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      const isAiAssistantUsable = openaiService.isAiAssistantUsable(aiAssistantId, user);
+      if (!isAiAssistantUsable) {
+        return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+      }
+
+      try {
+        const deletedThreadRelation = await openaiService.deleteThread(threadRelationId);
+        return res.apiv3({ deletedThreadRelation });
+      }
+      catch (err) {
+        logger.error(err);
+
+        if (isHttpError(err)) {
+          return res.apiv3Err(new ErrorV3(err.message), err.status);
+        }
+
+        return res.apiv3Err(new ErrorV3('Failed to delete thread'));
+      }
+    },
+  ];
+};

+ 3 - 2
apps/app/src/features/openai/server/routes/get-messages.ts

@@ -58,8 +58,9 @@ export const getMessagesFactory: GetMessagesFactory = (crowi) => {
           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);
+        const messages = await openaiService.getMessageData(threadId, req.user.lang, {
+          limit, before, after, order: 'asc',
+        });
 
         return res.apiv3({ messages });
       }

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

@@ -31,6 +31,10 @@ export const factory = (crowi: Crowi): express.Router => {
       router.get('/threads/:aiAssistantId', getThreadsFactory(crowi));
     });
 
+    import('./delete-thread').then(({ deleteThreadFactory }) => {
+      router.delete('/thread/:aiAssistantId/:threadRelationId', deleteThreadFactory(crowi));
+    });
+
     import('./message').then(({ postMessageHandlersFactory }) => {
       router.post('/message', postMessageHandlersFactory(crowi));
     });

+ 11 - 1
apps/app/src/features/openai/server/routes/message.ts

@@ -13,7 +13,9 @@ import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
+import { shouldHideMessageKey } from '../../interfaces/message';
 import { MessageErrorCode, type StreamErrorCode } from '../../interfaces/message-error';
+import AiAssistantModel from '../models/ai-assistant';
 import { openaiClient } from '../services/client';
 import { getStreamErrorCode } from '../services/getStreamErrorCode';
 import { getOpenaiService } from '../services/openai';
@@ -69,13 +71,17 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
         return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
       }
 
+      const aiAssistant = await AiAssistantModel.findById(aiAssistantId);
+      if (aiAssistant == null) {
+        return res.apiv3Err(new ErrorV3('AI assistant not found'), 404);
+      }
+
       let stream: AssistantStream;
 
       try {
         const assistant = await getOrCreateChatAssistant();
 
         const thread = await openaiClient.beta.threads.retrieve(threadId);
-
         stream = openaiClient.beta.threads.runs.stream(thread.id, {
           assistant_id: assistant.id,
           additional_messages: [
@@ -84,9 +90,13 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
               content: req.body.summaryMode
                 ? 'Turn on summary mode: I will try to answer concisely, aiming for 1-3 sentences.'
                 : 'I will turn off summary mode and answer.',
+              metadata: {
+                [shouldHideMessageKey]: 'true',
+              },
             },
             { role: 'user', content: req.body.userMessage },
           ],
+          additional_instructions: aiAssistant.additionalInstruction,
         });
 
       }

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

@@ -3,6 +3,9 @@ import type OpenAI from 'openai';
 import { AzureOpenAI } from 'openai';
 import { type Uploadable } from 'openai/uploads';
 
+import type { MessageListParams } from '../../../interfaces/message';
+
+
 import type { IOpenaiClientDelegator } from './interfaces';
 
 
@@ -38,8 +41,9 @@ 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> {
+  async getMessages(threadId: string, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage> {
     return this.client.beta.threads.messages.list(threadId, {
+      order: options?.order,
       limit: options?.limit,
       before: options?.before,
       after: options?.after,

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

@@ -1,11 +1,13 @@
 import type OpenAI from 'openai';
 import type { Uploadable } from 'openai/uploads';
 
+import type { MessageListParams } from '../../../interfaces/message';
+
 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>
+  getMessages(threadId: string, options?: MessageListParams): 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>

+ 4 - 2
apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts

@@ -3,8 +3,9 @@ import { type Uploadable } from 'openai/uploads';
 
 import { configManager } from '~/server/service/config-manager';
 
-import type { IOpenaiClientDelegator } from './interfaces';
+import type { MessageListParams } from '../../../interfaces/message';
 
+import type { IOpenaiClientDelegator } from './interfaces';
 
 export class OpenaiClientDelegator implements IOpenaiClientDelegator {
 
@@ -41,8 +42,9 @@ 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> {
+  async getMessages(threadId: string, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage> {
     return this.client.beta.threads.messages.list(threadId, {
+      order: options?.order,
       limit: options?.limit,
       before: options?.before,
       after: options?.after,

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

@@ -30,6 +30,7 @@ import { OpenaiServiceTypes } from '../../interfaces/ai';
 import {
   type AccessibleAiAssistants, type AiAssistant, AiAssistantAccessScope, AiAssistantShareScope,
 } from '../../interfaces/ai-assistant';
+import type { MessageListParams } from '../../interfaces/message';
 import AiAssistantModel, { type AiAssistantDocument } from '../models/ai-assistant';
 import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html';
 
@@ -67,11 +68,10 @@ export interface IOpenaiService {
   ): Promise<ThreadRelationDocument>;
   getThreads(vectorStoreRelationId: string): Promise<ThreadRelationDocument[]>
   // getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument>;
+  deleteThread(threadRelationId: string): Promise<ThreadRelationDocument>;
   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>;
+  getMessageData(threadId: string, lang?: Lang, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage>;
   getVectorStoreRelation(aiAssistantId: string): Promise<VectorStoreDocument>
   getVectorStoreRelationsByPageIds(pageId: Types.ObjectId[]): Promise<VectorStoreDocument[]>;
   createVectorStoreFile(vectorStoreRelation: VectorStoreDocument, pages: PageDocument[]): Promise<void>;
@@ -176,6 +176,24 @@ class OpenaiService implements IOpenaiService {
     return threadRelations;
   }
 
+  async deleteThread(threadRelationId: string): Promise<ThreadRelationDocument> {
+    const threadRelation = await ThreadRelationModel.findById(threadRelationId);
+    if (threadRelation == null) {
+      throw createError(404, 'ThreadRelation document does not exist');
+    }
+
+    try {
+      const deletedThreadResponse = await this.client.deleteThread(threadRelation.threadId);
+      logger.debug('Delete thread', deletedThreadResponse);
+      await threadRelation.remove();
+    }
+    catch (err) {
+      throw err;
+    }
+
+    return threadRelation;
+  }
+
   public async deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void> {
     const expiredThreadRelations = await ThreadRelationModel.getExpiredThreadRelations(limit);
     if (expiredThreadRelations == null) {
@@ -200,9 +218,7 @@ 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> {
+  async getMessageData(threadId: string, lang?: Lang, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage> {
     const messages = await this.client.getMessages(threadId, options);
 
     for await (const message of messages.data) {