Przeglądaj źródła

Merge pull request #9683 from weseek/feat/162038-implement-api-to-delete-thread

feat: Thread deletion
Yuki Takei 1 rok temu
rodzic
commit
1ba190da96

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

+ 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 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'));
+      }
+    },
+  ];
+};

+ 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));
       router.get('/threads/:aiAssistantId', getThreadsFactory(crowi));
     });
     });
 
 
+    import('./delete-thread').then(({ deleteThreadFactory }) => {
+      router.delete('/thread/:aiAssistantId/:threadRelationId', deleteThreadFactory(crowi));
+    });
+
     import('./message').then(({ postMessageHandlersFactory }) => {
     import('./message').then(({ postMessageHandlersFactory }) => {
       router.post('/message', postMessageHandlersFactory(crowi));
       router.post('/message', postMessageHandlersFactory(crowi));
     });
     });

+ 19 - 0
apps/app/src/features/openai/server/services/openai.ts

@@ -68,6 +68,7 @@ export interface IOpenaiService {
   ): Promise<ThreadRelationDocument>;
   ): Promise<ThreadRelationDocument>;
   getThreads(vectorStoreRelationId: string): Promise<ThreadRelationDocument[]>
   getThreads(vectorStoreRelationId: string): Promise<ThreadRelationDocument[]>
   // getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument>;
   // getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument>;
+  deleteThread(threadRelationId: string): Promise<ThreadRelationDocument>;
   deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
   deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
   deleteObsolatedVectorStoreRelations(): Promise<void> // for CronJob
   deleteObsolatedVectorStoreRelations(): Promise<void> // for CronJob
   getMessageData(threadId: string, lang?: Lang, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage>;
   getMessageData(threadId: string, lang?: Lang, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage>;
@@ -175,6 +176,24 @@ class OpenaiService implements IOpenaiService {
     return threadRelations;
     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> {
   public async deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void> {
     const expiredThreadRelations = await ThreadRelationModel.getExpiredThreadRelations(limit);
     const expiredThreadRelations = await ThreadRelationModel.getExpiredThreadRelations(limit);
     if (expiredThreadRelations == null) {
     if (expiredThreadRelations == null) {