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

Merge pull request #10102 from weseek/feat/167670-show-recent-thread-items

feat: Show recent thread items
Shun Miyazawa 9 месяцев назад
Родитель
Сommit
4c20137e7a

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

@@ -601,11 +601,12 @@
   "default_ai_assistant": {
     "not_set": "Default assistant is not set"
   },
-  "ai_assistant_list": {
+  "ai_assistant_substance": {
     "add_assistant": "Add Assistant",
     "my_assistants": "My Assistants",
     "team_assistants": "Team Assistants",
     "thread_does_not_exist": "No threads exist",
+    "recent_threads": "Recent Items",
     "toaster": {
       "ai_assistant_deleted_success": "Assistant deleted",
       "ai_assistant_deleted_failed": "Failed to delete assistant",

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

@@ -595,11 +595,12 @@
   "default_ai_assistant": {
     "not_set": "L'assistant par défaut n'est pas configuré"
   },
- "ai_assistant_list": {
+ "ai_assistant_substance": {
     "add_assistant": "Ajouter un assistant",
     "my_assistants": "Mes assistants",
     "team_assistants": "Assistants d'équipe",
     "thread_does_not_exist": "Aucune discussion",
+    "recent_threads": "Éléments récents",
     "toaster": {
       "ai_assistant_deleted_success": "Assistant supprimé",
       "ai_assistant_deleted_failed": "Échec de la suppression de l'assistant",

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

@@ -633,11 +633,12 @@
   "default_ai_assistant": {
     "not_set": "デフォルトアシスタントが設定されていません"
   },
-  "ai_assistant_list": {
+  "ai_assistant_substance": {
     "add_assistant": "アシスタントを追加する",
     "my_assistants": "マイアシスタント",
     "team_assistants": "チームアシスタント",
     "thread_does_not_exist": "スレッドが存在しません",
+    "recent_threads": "最近の項目",
     "toaster": {
       "ai_assistant_deleted_success": "アシスタントを削除しました",
       "ai_assistant_deleted_failed": "アシスタントの削除に失敗しました",

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

@@ -590,11 +590,12 @@
   "default_ai_assistant": {
     "not_set": "未设置默认助手"
   },
-  "ai_assistant_list": {
+  "ai_assistant_substance": {
     "add_assistant": "添加助手",
     "my_assistants": "我的助手",
     "team_assistants": "团队助手",
     "thread_does_not_exist": "暂无会话",
+    "recent_threads": "最近的项目",
     "toaster": {
       "ai_assistant_deleted_success": "已删除助手",
       "ai_assistant_deleted_failed": "删除助手失败",

+ 0 - 44
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantList.module.scss

@@ -1,44 +0,0 @@
-// == Colors
-.ai-assistant-list :global {
-  .grw-ai-assistant-actions {
-    .btn-link {
-      &:hover {
-        color: var(--bs-gray-800) !important;
-      }
-    }
-  }
-}
-
-.ai-assistant-list :global {
-  .list-group-item {
-    height: 40px;
-    padding-left: 4px;
-
-    .grw-ai-assistant-triangle-btn {
-      border: 0;
-      transition: transform 0.2s ease-out;
-      transform: rotate(0deg);
-
-      &.grw-ai-assistant-open {
-        transform: rotate(90deg);
-      }
-    }
-
-    .grw-ai-assistant-title-anchor {
-      width: 100%;
-      overflow: hidden;
-      font-size: 14px;
-    }
-
-
-    .grw-ai-assistant-actions {
-      transition: opacity 0.2s ease-out;
-    }
-
-    &:hover {
-      .grw-ai-assistant-actions {
-        opacity: 1 !important;
-      }
-    }
-  }
-}

+ 13 - 16
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantList.tsx

@@ -19,12 +19,8 @@ import { useAiAssistantSidebar, useAiAssistantManagementModal } from '../../../s
 import { useSWRMUTxThreads, useSWRxThreads } from '../../../stores/thread';
 import { getShareScopeIcon } from '../../../utils/get-share-scope-Icon';
 
-import styles from './AiAssistantList.module.scss';
-
 const logger = loggerFactory('growi:openai:client:components:AiAssistantList');
 
-const moduleClass = styles['ai-assistant-list'] ?? '';
-
 
 /*
 *  ThreadItem
@@ -44,12 +40,12 @@ const ThreadItem: React.FC<ThreadItemProps> = ({
   const deleteThreadHandler = useCallback(async() => {
     try {
       await deleteThread({ aiAssistantId: aiAssistantData._id, threadRelationId: threadData._id });
-      toastSuccess(t('ai_assistant_list.toaster.thread_deleted_success'));
+      toastSuccess(t('ai_assistant_substance.toaster.thread_deleted_success'));
       onThreadDelete();
     }
     catch (err) {
       logger.error(err);
-      toastError(t('ai_assistant_list.toaster.thread_deleted_failed'));
+      toastError(t('ai_assistant_substance.toaster.thread_deleted_failed'));
     }
   }, [aiAssistantData._id, onThreadDelete, t, threadData._id]);
 
@@ -105,7 +101,7 @@ const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantData, onThreadClic
   const { data: threads } = useSWRxThreads(aiAssistantData._id);
 
   if (threads == null || threads.length === 0) {
-    return <p className="text-secondary ms-5">{t('ai_assistant_list.thread_does_not_exist')}</p>;
+    return <p className="text-secondary ms-5">{t('ai_assistant_substance.thread_does_not_exist')}</p>;
   }
 
   return (
@@ -166,11 +162,11 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
     try {
       await setDefaultAiAssistant(aiAssistant._id, !aiAssistant.isDefault);
       onUpdated?.();
-      toastSuccess(t('ai_assistant_list.toaster.ai_assistant_set_default_success'));
+      toastSuccess(t('ai_assistant_substance.toaster.ai_assistant_set_default_success'));
     }
     catch (err) {
       logger.error(err);
-      toastError(t('ai_assistant_list.toaster.ai_assistant_set_default_failed'));
+      toastError(t('ai_assistant_substance.toaster.ai_assistant_set_default_failed'));
     }
   }, [aiAssistant._id, aiAssistant.isDefault, onUpdated, t]);
 
@@ -178,11 +174,11 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
     try {
       await deleteAiAssistant(aiAssistant._id);
       onDeleted?.();
-      toastSuccess(t('ai_assistant_list.toaster.ai_assistant_deleted_success'));
+      toastSuccess(t('ai_assistant_substance.toaster.ai_assistant_deleted_success'));
     }
     catch (err) {
       logger.error(err);
-      toastError(t('ai_assistant_list.toaster.ai_assistant_deleted_failed'));
+      toastError(t('ai_assistant_substance.toaster.ai_assistant_deleted_failed'));
     }
   }, [aiAssistant._id, onDeleted, t]);
 
@@ -219,11 +215,11 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
           <span className="material-symbols-outlined fs-5">{getShareScopeIcon(aiAssistant.shareScope, aiAssistant.accessScope)}</span>
         </div>
 
-        <div className="grw-ai-assistant-title-anchor ps-1">
+        <div className="grw-item-title ps-1">
           <p className="text-truncate m-auto">{aiAssistant.name}</p>
         </div>
 
-        <div className="grw-ai-assistant-actions opacity-0 d-flex justify-content-center ">
+        <div className="grw-btn-actions opacity-0 d-flex justify-content-center ">
           {isPublicAiAssistantOperable && (
             <button
               type="button"
@@ -306,15 +302,16 @@ export const AiAssistantList: React.FC<AiAssistantListProps> = ({
   }, [onCollapsed]);
 
   return (
-    <div className={`${moduleClass}`}>
+    <>
       <button
         type="button"
         className="btn btn-link p-0 text-secondary d-flex align-items-center"
         aria-expanded={!isCollapsed}
         onClick={toggleCollapse}
+        disabled={aiAssistants.length === 0}
       >
         <h3 className="grw-ai-assistant-substance-header fw-bold mb-0 me-1">
-          {t(`ai_assistant_list.${isTeamAssistant ? 'team' : 'my'}_assistants`)}
+          {t(`ai_assistant_substance.${isTeamAssistant ? 'team' : 'my'}_assistants`)}
         </h3>
         <span
           className="material-symbols-outlined"
@@ -337,6 +334,6 @@ export const AiAssistantList: React.FC<AiAssistantListProps> = ({
           ))}
         </ul>
       </Collapse>
-    </div>
+    </>
   );
 };

+ 34 - 0
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.module.scss

@@ -1,5 +1,39 @@
+// == Colors
+.grw-ai-assistant-substance :global {
+  .grw-btn-actions {
+    .btn-link {
+      &:hover {
+        color: var(--bs-gray-800) !important;
+      }
+    }
+  }
+}
+
 .grw-ai-assistant-substance :global {
   .grw-ai-assistant-substance-header {
     font-size: 14px;
   }
 }
+
+.grw-ai-assistant-substance :global {
+  .list-group-item {
+    height: 40px;
+    padding-left: 4px;
+
+    .grw-item-title {
+      width: 100%;
+      overflow: hidden;
+      font-size: 14px;
+    }
+
+    .grw-btn-actions {
+      transition: opacity 0.2s ease-out;
+    }
+
+    &:hover {
+      .grw-btn-actions {
+        opacity: 1 !important;
+      }
+    }
+  }
+}

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

@@ -1,10 +1,12 @@
-import React, { type JSX } from 'react';
+import React, { type JSX, useCallback } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
 import { useAiAssistantManagementModal, useSWRxAiAssistants } from '../../../stores/ai-assistant';
+import { useSWRINFxRecentThreads } from '../../../stores/thread';
 
 import { AiAssistantList } from './AiAssistantList';
+import { ThreadList } from './ThreadList';
 
 import styles from './AiAssistantSubstance.module.scss';
 
@@ -13,8 +15,14 @@ const moduleClass = styles['grw-ai-assistant-substance'] ?? '';
 export const AiAssistantContent = (): JSX.Element => {
   const { t } = useTranslation();
   const { open } = useAiAssistantManagementModal();
+  const { mutate: mutateRecentThreads } = useSWRINFxRecentThreads();
   const { data: aiAssistants, mutate: mutateAiAssistants } = useSWRxAiAssistants();
 
+  const deleteAiAssistantHandler = useCallback(async() => {
+    await mutateAiAssistants();
+    await mutateRecentThreads();
+  }, [mutateAiAssistants, mutateRecentThreads]);
+
   return (
     <div className={moduleClass}>
       <button
@@ -23,14 +31,14 @@ export const AiAssistantContent = (): JSX.Element => {
         onClick={() => open()}
       >
         <span className="material-symbols-outlined fs-5 me-2">add</span>
-        <span className="fw-normal">{t('ai_assistant_list.add_assistant')}</span>
+        <span className="fw-normal">{t('ai_assistant_substance.add_assistant')}</span>
       </button>
 
       <div className="d-flex flex-column gap-4">
         <div>
           <AiAssistantList
             onUpdated={mutateAiAssistants}
-            onDeleted={mutateAiAssistants}
+            onDeleted={deleteAiAssistantHandler}
             onCollapsed={mutateAiAssistants}
             aiAssistants={aiAssistants?.myAiAssistants ?? []}
           />
@@ -44,6 +52,13 @@ export const AiAssistantContent = (): JSX.Element => {
             aiAssistants={aiAssistants?.teamAiAssistants ?? []}
           />
         </div>
+
+        <div>
+          <h3 className="fw-bold grw-ai-assistant-substance-header">
+            {t('ai_assistant_substance.recent_threads')}
+          </h3>
+          <ThreadList />
+        </div>
       </div>
     </div>
   );

+ 79 - 0
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/ThreadList.tsx

@@ -0,0 +1,79 @@
+import React, { useCallback } from 'react';
+
+import { getIdStringForRef } from '@growi/core';
+import { useTranslation } from 'react-i18next';
+
+import InfiniteScroll from '~/client/components/InfiniteScroll';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useSWRINFxRecentThreads } from '~/features/openai/client/stores/thread';
+import loggerFactory from '~/utils/logger';
+
+import { deleteThread } from '../../../services/thread';
+import { useAiAssistantSidebar } from '../../../stores/ai-assistant';
+
+const logger = loggerFactory('growi:openai:client:components:ThreadList');
+
+export const ThreadList: React.FC = () => {
+  const swrInifiniteThreads = useSWRINFxRecentThreads();
+  const { t } = useTranslation();
+  const { data, mutate } = swrInifiniteThreads;
+  const { openChat } = useAiAssistantSidebar();
+
+  const isEmpty = data?.[0]?.paginateResult.totalDocs === 0;
+  const isReachingEnd = isEmpty || (data != null && (data[data.length - 1].paginateResult.hasNextPage === false));
+
+  const deleteThreadHandler = useCallback(async(aiAssistantId: string, threadRelationId: string) => {
+    try {
+      await deleteThread({ aiAssistantId, threadRelationId });
+      toastSuccess(t('ai_assistant_substance.toaster.thread_deleted_success'));
+      mutate();
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(t('ai_assistant_substance.toaster.thread_deleted_failed'));
+    }
+  }, [mutate, t]);
+
+  return (
+    <>
+      <ul className="list-group">
+        <InfiniteScroll swrInifiniteResponse={swrInifiniteThreads} isReachingEnd={isReachingEnd}>
+          { data != null && data.map(thread => thread.paginateResult.docs).flat()
+            .map(thread => (
+              <li
+                key={thread._id}
+                role="button"
+                className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1"
+                onClick={(e) => {
+                  e.stopPropagation();
+                  openChat(thread.aiAssistant, thread);
+                }}
+              >
+                <div>
+                  <span className="material-symbols-outlined fs-5">chat</span>
+                </div>
+
+                <div className="grw-item-title ps-1">
+                  <p className="text-truncate m-auto">{thread.title ?? 'Untitled thread'}</p>
+                </div>
+
+                <div className="grw-btn-actions opacity-0 d-flex justify-content-center ">
+                  <button
+                    type="button"
+                    className="btn btn-link text-secondary p-0"
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      deleteThreadHandler(getIdStringForRef(thread.aiAssistant), thread._id);
+                    }}
+                  >
+                    <span className="material-symbols-outlined fs-5">delete</span>
+                  </button>
+                </div>
+              </li>
+            ))
+          }
+        </InfiniteScroll>
+      </ul>
+    </>
+  );
+};

+ 6 - 2
apps/app/src/features/openai/client/services/knowledge-assistant.tsx

@@ -21,7 +21,7 @@ import { ThreadType } from '../../interfaces/thread-relation';
 import { AiAssistantChatInitialView } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView';
 import { useAiAssistantSidebar } from '../stores/ai-assistant';
 import { useSWRMUTxMessages } from '../stores/message';
-import { useSWRMUTxThreads } from '../stores/thread';
+import { useSWRMUTxThreads, useSWRINFxRecentThreads } from '../stores/thread';
 
 interface CreateThread {
   (aiAssistantId: string, initialUserMessage: string): Promise<IThreadRelationHasId>;
@@ -69,6 +69,7 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
   const { data: aiAssistantSidebarData } = useAiAssistantSidebar();
   const { aiAssistantData } = aiAssistantSidebarData ?? {};
   const { threadData } = aiAssistantSidebarData ?? {};
+  const { mutate: mutateRecentThreads } = useSWRINFxRecentThreads();
   const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData?._id);
   const { t } = useTranslation();
 
@@ -118,8 +119,11 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
         extendedThinkingMode: form.getValues('extendedThinkingMode'),
       }),
     });
+
+    mutateRecentThreads();
+
     return response;
-  }, [form]);
+  }, [form, mutateRecentThreads]);
 
   const processMessage: ProcessMessage = useCallback((data, handler) => {
     handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {

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

@@ -1,10 +1,11 @@
-import { type SWRResponse } from 'swr';
+import { type SWRResponse, type SWRConfiguration } from 'swr';
 import useSWRImmutable from 'swr/immutable';
+import useSWRInfinite from 'swr/infinite';
+import type { SWRInfiniteResponse } from 'swr/infinite';
 import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
-
-import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
+import type { IThreadRelationHasId, IThreadRelationPaginate } from '~/features/openai/interfaces/thread-relation';
 
 const getKey = (aiAssistantId?: string) => (aiAssistantId != null ? [`/openai/threads/${aiAssistantId}`] : null);
 
@@ -25,3 +26,30 @@ export const useSWRMUTxThreads = (aiAssistantId?: string): SWRMutationResponse<I
     { revalidate: true },
   );
 };
+
+
+const getRecentThreadsKey = (pageIndex: number, previousPageData: IThreadRelationPaginate | null): [string, number, number] | null => {
+  if (previousPageData && !previousPageData.paginateResult.hasNextPage) {
+    return null;
+  }
+
+  const PER_PAGE = 20;
+  const page = pageIndex + 1;
+
+  return ['/openai/threads/recent', page, PER_PAGE];
+};
+
+
+export const useSWRINFxRecentThreads = (
+    config?: SWRConfiguration,
+): SWRInfiniteResponse<IThreadRelationPaginate, Error> => {
+  return useSWRInfinite(
+    (pageIndex, previousPageData) => getRecentThreadsKey(pageIndex, previousPageData),
+    ([endpoint, page, limit]) => apiv3Get<IThreadRelationPaginate>(endpoint, { page, limit }).then(response => response.data),
+    {
+      ...config,
+      revalidateFirstPage: false,
+      revalidateAll: true,
+    },
+  );
+};

+ 8 - 1
apps/app/src/features/openai/interfaces/thread-relation.ts

@@ -1,6 +1,7 @@
 import type { IUser, Ref, HasObjectId } from '@growi/core';
+import type { PaginateResult } from 'mongoose';
 
-import type { AiAssistant } from './ai-assistant';
+import type { AiAssistant, AiAssistantHasId } from './ai-assistant';
 
 
 export const ThreadType = {
@@ -22,6 +23,12 @@ export interface IThreadRelation {
 
 export type IThreadRelationHasId = IThreadRelation & HasObjectId;
 
+export type IThreadRelationPopulated = Omit<IThreadRelationHasId, 'aiAssistant'> & { aiAssistant: AiAssistantHasId }
+
+export type IThreadRelationPaginate = {
+  paginateResult: PaginateResult<IThreadRelationPopulated>;
+};
+
 export type IApiv3DeleteThreadParams = {
   aiAssistantId: string
   threadRelationId: string;

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

@@ -36,7 +36,7 @@ export const getRecentThreadsFactory: GetRecentThreadsFactory = (crowi) => {
   const validator: ValidationChain[] = [
     query('page').optional().isInt().withMessage('page must be a positive integer'),
     query('page').toInt(),
-    query('limit').optional().isInt({ min: 1, max: 10 }).withMessage('limit must be an integer between 1 and 10'),
+    query('limit').optional().isInt({ min: 1, max: 20 }).withMessage('limit must be an integer between 1 and 20'),
     query('limit').toInt(),
   ];
 
@@ -57,8 +57,9 @@ export const getRecentThreadsFactory: GetRecentThreadsFactory = (crowi) => {
           },
           {
             page: req.query.page ?? 1,
-            limit: req.query.limit ?? 10,
+            limit: req.query.limit ?? 20,
             sort: { updatedAt: -1 },
+            populate: 'aiAssistant',
           },
         );
         return res.apiv3({ paginateResult });

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

@@ -82,13 +82,13 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
         return res.apiv3Err(new ErrorV3('ThreadRelation not found'), 404);
       }
 
-      threadRelation.updateThreadExpiration();
-
       let stream: AssistantStream;
       const useSummaryMode = req.body.summaryMode ?? false;
       const useExtendedThinkingMode = req.body.extendedThinkingMode ?? false;
 
       try {
+        await threadRelation.updateThreadExpiration();
+
         const assistant = await getOrCreateChatAssistant();
 
         const thread = await openaiClient.beta.threads.retrieve(threadId);