Przeglądaj źródła

Merge pull request #10098 from weseek/imprv/thread-history

imprv(ai): Threads history
Shun Miyazawa 9 miesięcy temu
rodzic
commit
38ace1163f
26 zmienionych plików z 650 dodań i 414 usunięć
  1. 4 1
      apps/app/public/static/locales/en_US/translation.json
  2. 4 1
      apps/app/public/static/locales/fr_FR/translation.json
  3. 4 1
      apps/app/public/static/locales/ja_JP/translation.json
  4. 4 1
      apps/app/public/static/locales/zh_CN/translation.json
  5. 11 2
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx
  6. 24 10
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView.tsx
  7. 1 4
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx
  8. 7 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/ThreadList.module.scss
  9. 57 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/ThreadList.tsx
  10. 207 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantList.tsx
  11. 34 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.module.scss
  12. 35 21
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx
  13. 0 45
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.module.scss
  14. 0 305
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx
  15. 86 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/ThreadList.tsx
  16. 4 3
      apps/app/src/features/openai/client/services/ai-assistant.ts
  17. 9 11
      apps/app/src/features/openai/client/services/knowledge-assistant.tsx
  18. 8 0
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  19. 31 3
      apps/app/src/features/openai/client/stores/thread.tsx
  20. 9 1
      apps/app/src/features/openai/interfaces/thread-relation.ts
  21. 27 2
      apps/app/src/features/openai/server/models/thread-relation.ts
  22. 73 0
      apps/app/src/features/openai/server/routes/get-recent-threads.ts
  23. 4 0
      apps/app/src/features/openai/server/routes/index.ts
  24. 2 2
      apps/app/src/features/openai/server/routes/message/post-message.ts
  25. 2 0
      apps/app/src/features/openai/server/services/delete-ai-assistant.ts
  26. 3 1
      apps/app/src/features/openai/server/services/openai.ts

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

@@ -497,6 +497,8 @@
   },
   "sidebar_ai_assistant": {
     "reference_pages_label": "Reference pages",
+    "recent_chat": "Recent chat",
+    "no_recent_chat": "No recent chat",
     "placeholder": "Ask me anything.",
     "knowledge_assistant_placeholder": "Ask me anything.",
     "editor_assistant_placeholder": "Can I help you with anything?",
@@ -603,11 +605,12 @@
   "default_ai_assistant": {
     "not_set": "Default assistant is not set"
   },
-  "ai_assistant_tree": {
+  "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",

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

@@ -492,6 +492,8 @@
   },
   "sidebar_ai_assistant": {
     "reference_pages_label": "Pages de référence",
+    "recent_chat": "Chat récent",
+    "no_recent_chat": "Pas de chat récent",
     "knowledge_assistant_placeholder": "Demandez-moi n'importe quoi.",
     "editor_assistant_placeholder": "Puis-je vous aider ?",
     "summary_mode_label": "Mode résumé",
@@ -597,11 +599,12 @@
   "default_ai_assistant": {
     "not_set": "L'assistant par défaut n'est pas configuré"
   },
-  "ai_assistant_tree": {
+ "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",

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

@@ -530,6 +530,8 @@
   },
   "sidebar_ai_assistant": {
     "reference_pages_label": "参照するページ",
+    "recent_chat": "最近のチャット",
+    "no_recent_chat": "チャットがありません",
     "knowledge_assistant_placeholder": "ききたいことを入力してください",
     "editor_assistant_placeholder": "お手伝いできることはありますか?",
     "summary_mode_label": "要約モード",
@@ -635,11 +637,12 @@
   "default_ai_assistant": {
     "not_set": "デフォルトアシスタントが設定されていません"
   },
-  "ai_assistant_tree": {
+  "ai_assistant_substance": {
     "add_assistant": "アシスタントを追加する",
     "my_assistants": "マイアシスタント",
     "team_assistants": "チームアシスタント",
     "thread_does_not_exist": "スレッドが存在しません",
+    "recent_threads": "最近の項目",
     "toaster": {
       "ai_assistant_deleted_success": "アシスタントを削除しました",
       "ai_assistant_deleted_failed": "アシスタントの削除に失敗しました",

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

@@ -487,6 +487,8 @@
   },
   "sidebar_ai_assistant": {
     "reference_pages_label": "参考页面",
+    "recent_chat": "最近聊天",
+    "no_recent_chat": "最近没有聊天",
     "knowledge_assistant_placeholder": "问我任何问题。",
     "editor_assistant_placeholder": "有什么需要帮忙的吗?",
     "summary_mode_label": "摘要模式",
@@ -592,11 +594,12 @@
   "default_ai_assistant": {
     "not_set": "未设置默认助手"
   },
-  "ai_assistant_tree": {
+  "ai_assistant_substance": {
     "add_assistant": "添加助手",
     "my_assistants": "我的助手",
     "team_assistants": "团队助手",
     "thread_does_not_exist": "暂无会话",
+    "recent_threads": "最近的项目",
     "toaster": {
       "ai_assistant_deleted_success": "已删除助手",
       "ai_assistant_deleted_failed": "删除助手失败",

+ 11 - 2
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx

@@ -19,7 +19,12 @@ import loggerFactory from '~/utils/logger';
 import type { SelectedPage } from '../../../../interfaces/selected-page';
 import { removeGlobPath } from '../../../../utils/remove-glob-path';
 import { createAiAssistant, updateAiAssistant } from '../../../services/ai-assistant';
-import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode, useSWRxAiAssistants } from '../../../stores/ai-assistant';
+import {
+  useSWRxAiAssistants,
+  useAiAssistantSidebar,
+  useAiAssistantManagementModal,
+  AiAssistantManagementModalPageMode,
+} from '../../../stores/ai-assistant';
 
 import { AiAssistantManagementEditInstruction } from './AiAssistantManagementEditInstruction';
 import { AiAssistantManagementEditPages } from './AiAssistantManagementEditPages';
@@ -63,6 +68,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   const { t } = useTranslation();
   const { mutate: mutateAiAssistants } = useSWRxAiAssistants();
   const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal();
+  const { data: aiAssistantSidebarData, refreshAiAssistantData } = useAiAssistantSidebar();
   const { data: pagePathsWithDescendantCount } = useSWRxPagePathsWithDescendantCount(
     removeGlobPath(aiAssistantManagementModalData?.aiAssistantData?.pagePathPatterns) ?? null,
     undefined,
@@ -144,7 +150,10 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       };
 
       if (shouldEdit) {
-        await updateAiAssistant(aiAssistant._id, reqBody);
+        const updatedAiAssistant = await updateAiAssistant(aiAssistant._id, reqBody);
+        if (aiAssistantSidebarData?.aiAssistantData?._id === updatedAiAssistant._id) {
+          refreshAiAssistantData(updatedAiAssistant);
+        }
       }
       else {
         await createAiAssistant(reqBody);

+ 24 - 10
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView.tsx

@@ -1,5 +1,10 @@
+import Link from 'next/link';
 import { useTranslation } from 'react-i18next';
 
+import { removeGlobPath } from '../../../../utils/remove-glob-path';
+
+import { ThreadList } from './ThreadList';
+
 type Props = {
   description: string,
   pagePathPatterns: string[],
@@ -10,26 +15,35 @@ export const AiAssistantChatInitialView: React.FC<Props> = ({ description, pageP
 
   return (
     <>
-      <p className="fs-6 text-body-secondary mb-0">
-        {description}
-      </p>
+      {description.length !== 0 && (
+        <p className="text-body-secondary mb-0">
+          {description}
+        </p>
+      )}
 
       <div>
-        <div className="d-flex align-items-center">
-          <p className="text-body-secondary mb-0">{t('sidebar_ai_assistant.reference_pages_label')}</p>
-        </div>
+        <p className="text-body-secondary mb-1">
+          {t('sidebar_ai_assistant.reference_pages_label')}
+        </p>
         <div className="d-flex flex-column gap-1">
           { pagePathPatterns.map(pagePathPattern => (
-            <a
+            <Link
               key={pagePathPattern}
-              href="#"
-              className="fs-6 text-body-secondary text-decoration-none"
+              href={removeGlobPath([pagePathPattern])[0]}
+              className="text-body-secondary text-decoration-underline link-underline-secondary"
             >
               {pagePathPattern}
-            </a>
+            </Link>
           ))}
         </div>
       </div>
+
+      <div>
+        <p className="text-body-secondary mb-1">
+          {t('sidebar_ai_assistant.recent_chat')}
+        </p>
+        <ThreadList />
+      </div>
     </>
   );
 };

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

@@ -361,13 +361,10 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
   }, [headerIconForEditorAssistant, headerIconForKnowledgeAssistant, isEditorAssistant]);
 
   const headerText = useMemo(() => {
-    if (threadData?.title) {
-      return threadData.title;
-    }
     return isEditorAssistant
       ? headerTextForEditorAssistant
       : headerTextForKnowledgeAssistant;
-  }, [threadData?.title, isEditorAssistant, headerTextForEditorAssistant, headerTextForKnowledgeAssistant]);
+  }, [isEditorAssistant, headerTextForEditorAssistant, headerTextForKnowledgeAssistant]);
 
   const placeHolder = useMemo(() => {
     if (form.formState.isSubmitting) {

+ 7 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/ThreadList.module.scss

@@ -0,0 +1,7 @@
+.thread-list :global {
+  li {
+    &:hover {
+      background-color: var(--bs-secondary-bg) !important;
+    }
+  }
+}

+ 57 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/ThreadList.tsx

@@ -0,0 +1,57 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import type { IThreadRelationHasId } from '~/features/openai/interfaces/thread-relation';
+
+import { useAiAssistantSidebar } from '../../../stores/ai-assistant';
+import { useSWRxThreads } from '../../../stores/thread';
+
+import styles from './ThreadList.module.scss';
+
+const moduleClass = styles['thread-list'] ?? '';
+
+
+export const ThreadList: React.FC = () => {
+  const { t } = useTranslation();
+  const { openChat, data: aiAssistantSidebarData } = useAiAssistantSidebar();
+  const { data: threads } = useSWRxThreads(aiAssistantSidebarData?.aiAssistantData?._id);
+
+  const openChatHandler = useCallback((threadData: IThreadRelationHasId) => {
+    const aiAssistantData = aiAssistantSidebarData?.aiAssistantData;
+    if (aiAssistantData == null) {
+      return;
+    }
+
+    openChat(aiAssistantData, threadData);
+  }, [aiAssistantSidebarData?.aiAssistantData, openChat]);
+
+  if (threads == null || threads.length === 0) {
+    return (
+      <p className="text-body-secondary">
+        {t('sidebar_ai_assistant.no_recent_chat')}
+      </p>
+    );
+  }
+
+  return (
+    <>
+      <ul className={`list-group ${moduleClass}`}>
+        {threads.map(thread => (
+          <li
+            onClick={() => { openChatHandler(thread) }}
+            key={thread._id}
+            role="button"
+            tabIndex={0}
+            className="d-flex align-items-center list-group-item list-group-item-action border-0 rounded-1 bg-body-tertiary mb-2"
+          >
+            <div className="text-body-secondary">
+              <span className="material-symbols-outlined fs-5 me-2">chat</span>
+              <span className="flex-grow-1">{thread.title}</span>
+            </div>
+          </li>
+        ))}
+      </ul>
+    </>
+  );
+};

+ 207 - 0
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantList.tsx

@@ -0,0 +1,207 @@
+import React, { useCallback, useState } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
+import { useTranslation } from 'react-i18next';
+import { Collapse } from 'reactstrap';
+
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useCurrentUser } from '~/stores-universal/context';
+import loggerFactory from '~/utils/logger';
+
+import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+import { determineShareScope } from '../../../../utils/determine-share-scope';
+import { deleteAiAssistant, setDefaultAiAssistant } from '../../../services/ai-assistant';
+import { useAiAssistantSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+import { getShareScopeIcon } from '../../../utils/get-share-scope-Icon';
+
+const logger = loggerFactory('growi:openai:client:components:AiAssistantList');
+
+/*
+*  AiAssistantItem
+*/
+type AiAssistantItemProps = {
+  currentUser?: IUserHasId | null;
+  aiAssistant: AiAssistantHasId;
+  onEditClick: (aiAssistantData: AiAssistantHasId) => void;
+  onItemClick: (aiAssistantData: AiAssistantHasId) => void;
+  onUpdated?: () => void;
+  onDeleted?: (aiAssistantId: string) => void;
+};
+
+const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
+  currentUser,
+  aiAssistant,
+  onEditClick,
+  onItemClick,
+  onUpdated,
+  onDeleted,
+}) => {
+
+  const { t } = useTranslation();
+
+  const openManagementModalHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
+    onEditClick(aiAssistantData);
+  }, [onEditClick]);
+
+  const openChatHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
+    onItemClick(aiAssistantData);
+  }, [onItemClick]);
+
+
+  const setDefaultAiAssistantHandler = useCallback(async() => {
+    try {
+      await setDefaultAiAssistant(aiAssistant._id, !aiAssistant.isDefault);
+      onUpdated?.();
+      toastSuccess(t('ai_assistant_substance.toaster.ai_assistant_set_default_success'));
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(t('ai_assistant_substance.toaster.ai_assistant_set_default_failed'));
+    }
+  }, [aiAssistant._id, aiAssistant.isDefault, onUpdated, t]);
+
+  const deleteAiAssistantHandler = useCallback(async() => {
+    try {
+      await deleteAiAssistant(aiAssistant._id);
+      onDeleted?.(aiAssistant._id);
+      toastSuccess(t('ai_assistant_substance.toaster.ai_assistant_deleted_success'));
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(t('ai_assistant_substance.toaster.ai_assistant_deleted_failed'));
+    }
+  }, [aiAssistant._id, onDeleted, t]);
+
+  const isOperable = currentUser?._id != null && getIdStringForRef(aiAssistant.owner) === currentUser._id;
+  const isPublicAiAssistantOperable = currentUser?.admin
+    && determineShareScope(aiAssistant.shareScope, aiAssistant.accessScope) === AiAssistantShareScope.PUBLIC_ONLY;
+
+  return (
+    <>
+      <li
+        onClick={(e) => {
+          e.stopPropagation();
+          openChatHandler(aiAssistant);
+        }}
+        role="button"
+        className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1"
+      >
+        <div className="d-flex justify-content-center">
+          <span className="material-symbols-outlined fs-5">{getShareScopeIcon(aiAssistant.shareScope, aiAssistant.accessScope)}</span>
+        </div>
+
+        <div className="grw-item-title ps-1">
+          <p className="text-truncate m-auto">{aiAssistant.name}</p>
+        </div>
+
+        <div className="grw-btn-actions opacity-0 d-flex justify-content-center ">
+          {isPublicAiAssistantOperable && (
+            <button
+              type="button"
+              className="btn btn-link text-secondary p-0"
+              onClick={(e) => {
+                e.stopPropagation();
+                setDefaultAiAssistantHandler();
+              }}
+            >
+              <span className={`material-symbols-outlined fs-5 ${aiAssistant.isDefault ? 'fill' : ''}`}>star</span>
+            </button>
+          )}
+          {isOperable && (
+            <>
+              <button
+                type="button"
+                className="btn btn-link text-secondary p-0"
+                onClick={(e) => {
+                  e.stopPropagation();
+                  openManagementModalHandler(aiAssistant);
+                }}
+              >
+                <span className="material-symbols-outlined fs-5">edit</span>
+              </button>
+              <button
+                type="button"
+                className="btn btn-link text-secondary p-0"
+                onClick={(e) => {
+                  e.stopPropagation();
+                  deleteAiAssistantHandler();
+                }}
+              >
+                <span className="material-symbols-outlined fs-5">delete</span>
+              </button>
+            </>
+          )}
+        </div>
+      </li>
+    </>
+  );
+};
+
+
+/*
+*  AiAssistantList
+*/
+type AiAssistantListProps = {
+  isTeamAssistant?: boolean;
+  aiAssistants: AiAssistantHasId[];
+  onUpdated?: () => void;
+  onDeleted?: (aiAssistantId: string) => void;
+  onCollapsed?: () => void;
+};
+
+export const AiAssistantList: React.FC<AiAssistantListProps> = ({
+  isTeamAssistant, aiAssistants, onUpdated, onDeleted, onCollapsed,
+}) => {
+  const { t } = useTranslation();
+  const { openChat } = useAiAssistantSidebar();
+  const { data: currentUser } = useCurrentUser();
+  const { open: openAiAssistantManagementModal } = useAiAssistantManagementModal();
+
+  const [isCollapsed, setIsCollapsed] = useState(false);
+
+  const toggleCollapse = useCallback(() => {
+    setIsCollapsed((prev) => {
+      if (!prev) {
+        onCollapsed?.();
+      }
+      return !prev;
+    });
+  }, [onCollapsed]);
+
+  return (
+    <>
+      <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_substance.${isTeamAssistant ? 'team' : 'my'}_assistants`)}
+        </h3>
+        <span
+          className="material-symbols-outlined"
+        >{`keyboard_arrow_${isCollapsed ? 'up' : 'down'}`}
+        </span>
+      </button>
+
+      <Collapse isOpen={isCollapsed}>
+        <ul className="list-group">
+          {aiAssistants.map(assistant => (
+            <AiAssistantItem
+              key={assistant._id}
+              currentUser={currentUser}
+              aiAssistant={assistant}
+              onEditClick={openAiAssistantManagementModal}
+              onItemClick={openChat}
+              onUpdated={onUpdated}
+              onDeleted={onDeleted}
+            />
+          ))}
+        </ul>
+      </Collapse>
+    </>
+  );
+};

+ 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;
+      }
+    }
+  }
+}

+ 35 - 21
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 { useAiAssistantManagementModal, useSWRxAiAssistants, useAiAssistantSidebar } from '../../../stores/ai-assistant';
+import { useSWRINFxRecentThreads } from '../../../stores/thread';
 
-import { AiAssistantTree } from './AiAssistantTree';
+import { AiAssistantList } from './AiAssistantList';
+import { ThreadList } from './ThreadList';
 
 import styles from './AiAssistantSubstance.module.scss';
 
@@ -13,8 +15,20 @@ const moduleClass = styles['grw-ai-assistant-substance'] ?? '';
 export const AiAssistantContent = (): JSX.Element => {
   const { t } = useTranslation();
   const { open } = useAiAssistantManagementModal();
+  const { data: aiAssistantSidebarData, close: closeAiAssistantSidebar } = useAiAssistantSidebar();
+  const { mutate: mutateRecentThreads } = useSWRINFxRecentThreads();
   const { data: aiAssistants, mutate: mutateAiAssistants } = useSWRxAiAssistants();
 
+  const deleteAiAssistantHandler = useCallback(async(aiAssistantId: string) => {
+    await mutateAiAssistants();
+    await mutateRecentThreads();
+
+    // If the sidebar is opened for the assistant being deleted, close it
+    if (aiAssistantSidebarData?.isOpened && aiAssistantSidebarData?.aiAssistantData?._id === aiAssistantId) {
+      closeAiAssistantSidebar();
+    }
+  }, [aiAssistantSidebarData?.aiAssistantData?._id, aiAssistantSidebarData?.isOpened, closeAiAssistantSidebar, mutateAiAssistants, mutateRecentThreads]);
+
   return (
     <div className={moduleClass}>
       <button
@@ -23,33 +37,33 @@ 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_tree.add_assistant')}</span>
+        <span className="fw-normal">{t('ai_assistant_substance.add_assistant')}</span>
       </button>
 
       <div className="d-flex flex-column gap-4">
         <div>
-          <h3 className="fw-bold grw-ai-assistant-substance-header">
-            {t('ai_assistant_tree.my_assistants')}
-          </h3>
-          {aiAssistants?.myAiAssistants != null && aiAssistants.myAiAssistants.length !== 0 && (
-            <AiAssistantTree
-              onUpdated={mutateAiAssistants}
-              onDeleted={mutateAiAssistants}
-              aiAssistants={aiAssistants.myAiAssistants}
-            />
-          )}
+          <AiAssistantList
+            onUpdated={mutateAiAssistants}
+            onDeleted={deleteAiAssistantHandler}
+            onCollapsed={mutateAiAssistants}
+            aiAssistants={aiAssistants?.myAiAssistants ?? []}
+          />
+        </div>
+
+        <div>
+          <AiAssistantList
+            isTeamAssistant
+            onUpdated={mutateAiAssistants}
+            onCollapsed={mutateAiAssistants}
+            aiAssistants={aiAssistants?.teamAiAssistants ?? []}
+          />
         </div>
 
         <div>
           <h3 className="fw-bold grw-ai-assistant-substance-header">
-            {t('ai_assistant_tree.team_assistants')}
+            {t('ai_assistant_substance.recent_threads')}
           </h3>
-          {aiAssistants?.teamAiAssistants != null && aiAssistants.teamAiAssistants.length !== 0 && (
-            <AiAssistantTree
-              onUpdated={mutateAiAssistants}
-              aiAssistants={aiAssistants.teamAiAssistants}
-            />
-          )}
+          <ThreadList />
         </div>
       </div>
     </div>

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

@@ -1,45 +0,0 @@
-// == Colors
-.ai-assistant-tree-item :global {
-  .grw-ai-assistant-actions {
-    .btn-link {
-      &:hover {
-        color: var(--bs-gray-800) !important;
-      }
-    }
-  }
-}
-
-
-.ai-assistant-tree-item :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;
-      }
-    }
-  }
-}

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

@@ -1,305 +0,0 @@
-import React, { useCallback, useState } from 'react';
-
-import type { IUserHasId } from '@growi/core';
-import { getIdStringForRef } from '@growi/core';
-import { useTranslation } from 'react-i18next';
-
-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 { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
-import { determineShareScope } from '../../../../utils/determine-share-scope';
-import { deleteAiAssistant, setDefaultAiAssistant } from '../../../services/ai-assistant';
-import { deleteThread } from '../../../services/thread';
-import { useAiAssistantSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
-import { useSWRMUTxThreads, useSWRxThreads } from '../../../stores/thread';
-import { getShareScopeIcon } from '../../../utils/get-share-scope-Icon';
-
-import styles from './AiAssistantTree.module.scss';
-
-const logger = loggerFactory('growi:openai:client:components:AiAssistantTree');
-
-const moduleClass = styles['ai-assistant-tree-item'] ?? '';
-
-
-/*
-*  ThreadItem
-*/
-type ThreadItemProps = {
-  threadData: IThreadRelationHasId
-  aiAssistantData: AiAssistantHasId;
-  onThreadClick: (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => void;
-  onThreadDelete: () => void;
-};
-
-const ThreadItem: React.FC<ThreadItemProps> = ({
-  threadData, aiAssistantData, onThreadClick, onThreadDelete,
-}) => {
-  const { t } = useTranslation();
-
-  const deleteThreadHandler = useCallback(async() => {
-    try {
-      await deleteThread({ aiAssistantId: aiAssistantData._id, threadRelationId: threadData._id });
-      toastSuccess(t('ai_assistant_tree.toaster.thread_deleted_success'));
-      onThreadDelete();
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(t('ai_assistant_tree.toaster.thread_deleted_failed'));
-    }
-  }, [aiAssistantData._id, onThreadDelete, t, threadData._id]);
-
-  const openChatHandler = useCallback(() => {
-    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={(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">{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={(e) => {
-            e.stopPropagation();
-            deleteThreadHandler();
-          }}
-        >
-          <span className="material-symbols-outlined fs-5">delete</span>
-        </button>
-      </div>
-    </li>
-  );
-};
-
-
-/*
-*  ThreadItems
-*/
-type ThreadItemsProps = {
-  aiAssistantData: AiAssistantHasId;
-  onThreadClick: (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => void;
-  onThreadDelete: () => void;
-};
-
-const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantData, onThreadClick, onThreadDelete }) => {
-  const { t } = useTranslation();
-  const { data: threads } = useSWRxThreads(aiAssistantData._id);
-
-  if (threads == null || threads.length === 0) {
-    return <p className="text-secondary ms-5">{t('ai_assistant_tree.thread_does_not_exist')}</p>;
-  }
-
-  return (
-    <div className="grw-ai-assistant-item-children">
-      {threads.map(thread => (
-        <ThreadItem
-          key={thread._id}
-          threadData={thread}
-          aiAssistantData={aiAssistantData}
-          onThreadClick={onThreadClick}
-          onThreadDelete={onThreadDelete}
-        />
-      ))}
-    </div>
-  );
-};
-
-
-/*
-*  AiAssistantItem
-*/
-type AiAssistantItemProps = {
-  currentUser?: IUserHasId | null;
-  aiAssistant: AiAssistantHasId;
-  onEditClick: (aiAssistantData: AiAssistantHasId) => void;
-  onItemClick: (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => void;
-  onUpdated?: () => void;
-  onDeleted?: () => void;
-};
-
-const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
-  currentUser,
-  aiAssistant,
-  onEditClick,
-  onItemClick,
-  onUpdated,
-  onDeleted,
-}) => {
-  const [isThreadsOpened, setIsThreadsOpened] = useState(false);
-
-  const { t } = useTranslation();
-  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistant._id);
-
-  const openManagementModalHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
-    onEditClick(aiAssistantData);
-  }, [onEditClick]);
-
-  const openChatHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
-    onItemClick(aiAssistantData);
-  }, [onItemClick]);
-
-  const openThreadsHandler = useCallback(async() => {
-    mutateThreadData();
-    setIsThreadsOpened(toggle => !toggle);
-  }, [mutateThreadData]);
-
-  const setDefaultAiAssistantHandler = useCallback(async() => {
-    try {
-      await setDefaultAiAssistant(aiAssistant._id, !aiAssistant.isDefault);
-      onUpdated?.();
-      toastSuccess(t('ai_assistant_tree.toaster.ai_assistant_set_default_success'));
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(t('ai_assistant_tree.toaster.ai_assistant_set_default_failed'));
-    }
-  }, [aiAssistant._id, aiAssistant.isDefault, onUpdated, t]);
-
-  const deleteAiAssistantHandler = useCallback(async() => {
-    try {
-      await deleteAiAssistant(aiAssistant._id);
-      onDeleted?.();
-      toastSuccess('ai_assistant_tree.toaster.assistant_deleted_success');
-    }
-    catch (err) {
-      logger.error(err);
-      toastError('ai_assistant_tree.toaster.assistant_deleted');
-    }
-  }, [aiAssistant._id, onDeleted]);
-
-  const isOperable = currentUser?._id != null && getIdStringForRef(aiAssistant.owner) === currentUser._id;
-  const isPublicAiAssistantOperable = currentUser?.admin
-    && determineShareScope(aiAssistant.shareScope, aiAssistant.accessScope) === AiAssistantShareScope.PUBLIC_ONLY;
-
-  return (
-    <>
-      <li
-        onClick={(e) => {
-          e.stopPropagation();
-          openChatHandler(aiAssistant);
-        }}
-        role="button"
-        className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1"
-      >
-        <div className="d-flex justify-content-center">
-          <button
-            type="button"
-            onClick={(e) => {
-              e.stopPropagation();
-              openThreadsHandler();
-            }}
-            className={`grw-ai-assistant-triangle-btn btn px-0 ${isThreadsOpened ? 'grw-ai-assistant-open' : ''}`}
-          >
-            <div className="d-flex justify-content-center">
-              <span className="material-symbols-outlined fs-5">arrow_right</span>
-            </div>
-          </button>
-        </div>
-
-        <div className="d-flex justify-content-center">
-          <span className="material-symbols-outlined fs-5">{getShareScopeIcon(aiAssistant.shareScope, aiAssistant.accessScope)}</span>
-        </div>
-
-        <div className="grw-ai-assistant-title-anchor 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 ">
-          {isPublicAiAssistantOperable && (
-            <button
-              type="button"
-              className="btn btn-link text-secondary p-0"
-              onClick={(e) => {
-                e.stopPropagation();
-                setDefaultAiAssistantHandler();
-              }}
-            >
-              <span className={`material-symbols-outlined fs-5 ${aiAssistant.isDefault ? 'fill' : ''}`}>star</span>
-            </button>
-          )}
-          {isOperable && (
-            <>
-              <button
-                type="button"
-                className="btn btn-link text-secondary p-0"
-                onClick={(e) => {
-                  e.stopPropagation();
-                  openManagementModalHandler(aiAssistant);
-                }}
-              >
-                <span className="material-symbols-outlined fs-5">edit</span>
-              </button>
-              <button
-                type="button"
-                className="btn btn-link text-secondary p-0"
-                onClick={(e) => {
-                  e.stopPropagation();
-                  deleteAiAssistantHandler();
-                }}
-              >
-                <span className="material-symbols-outlined fs-5">delete</span>
-              </button>
-            </>
-          )}
-        </div>
-      </li>
-
-      { isThreadsOpened && (
-        <ThreadItems
-          aiAssistantData={aiAssistant}
-          onThreadClick={onItemClick}
-          onThreadDelete={mutateThreadData}
-        />
-      ) }
-    </>
-  );
-};
-
-
-/*
-*  AiAssistantTree
-*/
-type AiAssistantTreeProps = {
-  aiAssistants: AiAssistantHasId[];
-  onUpdated?: () => void;
-  onDeleted?: () => void;
-};
-
-export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onUpdated, onDeleted }) => {
-  const { data: currentUser } = useCurrentUser();
-  const { openChat } = useAiAssistantSidebar();
-  const { open: openAiAssistantManagementModal } = useAiAssistantManagementModal();
-
-  return (
-    <ul className={`list-group ${moduleClass}`}>
-      {aiAssistants.map(assistant => (
-        <AiAssistantItem
-          key={assistant._id}
-          currentUser={currentUser}
-          aiAssistant={assistant}
-          onEditClick={openAiAssistantManagementModal}
-          onItemClick={openChat}
-          onUpdated={onUpdated}
-          onDeleted={onDeleted}
-        />
-      ))}
-    </ul>
-  );
-};

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

@@ -0,0 +1,86 @@
+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 { useSWRMUTxThreads, 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: mutateRecentThreads } = swrInifiniteThreads;
+  const { openChat, data: aiAssistantSidebarData, close: closeAiAssistantSidebar } = useAiAssistantSidebar();
+  const { trigger: mutateAssistantThreadData } = useSWRMUTxThreads(aiAssistantSidebarData?.aiAssistantData?._id);
+
+  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'));
+
+      await Promise.all([mutateAssistantThreadData(), mutateRecentThreads()]);
+
+      // Close if the thread to be deleted is open in right sidebar
+      if (aiAssistantSidebarData?.isOpened && aiAssistantSidebarData?.threadData?._id === threadRelationId) {
+        closeAiAssistantSidebar();
+      }
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(t('ai_assistant_substance.toaster.thread_deleted_failed'));
+    }
+  }, [aiAssistantSidebarData?.isOpened, aiAssistantSidebarData?.threadData?._id, closeAiAssistantSidebar, mutateAssistantThreadData, mutateRecentThreads, 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>
+    </>
+  );
+};

+ 4 - 3
apps/app/src/features/openai/client/services/ai-assistant.ts

@@ -1,13 +1,14 @@
 import { apiv3Post, apiv3Put, apiv3Delete } from '~/client/util/apiv3-client';
 
-import type { UpsertAiAssistantData } from '../../interfaces/ai-assistant';
+import type { UpsertAiAssistantData, AiAssistantHasId } from '../../interfaces/ai-assistant';
 
 export const createAiAssistant = async(body: UpsertAiAssistantData): Promise<void> => {
   await apiv3Post('/openai/ai-assistant', body);
 };
 
-export const updateAiAssistant = async(id: string, body: UpsertAiAssistantData): Promise<void> => {
-  await apiv3Put(`/openai/ai-assistant/${id}`, body);
+export const updateAiAssistant = async(id: string, body: UpsertAiAssistantData): Promise<AiAssistantHasId> => {
+  const res = await apiv3Put<{updatedAiAssistant: AiAssistantHasId}>(`/openai/ai-assistant/${id}`, body);
+  return res.data.updatedAiAssistant;
 };
 
 export const setDefaultAiAssistant = async(id: string, isDefault: boolean): Promise<void> => {

+ 9 - 11
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>;
@@ -67,8 +67,8 @@ type UseKnowledgeAssistant = () => {
 export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
   // Hooks
   const { data: aiAssistantSidebarData } = useAiAssistantSidebar();
-  const { aiAssistantData } = aiAssistantSidebarData ?? {};
-  const { threadData } = aiAssistantSidebarData ?? {};
+  const { aiAssistantData, threadData } = aiAssistantSidebarData ?? {};
+  const { mutate: mutateRecentThreads } = useSWRINFxRecentThreads();
   const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData?._id);
   const { t } = useTranslation();
 
@@ -80,9 +80,6 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
     },
   });
 
-  // States
-  const [currentThreadTitle, setCurrentThreadId] = useState(threadData?.title);
-
   // Functions
   const resetForm = useCallback(() => {
     const summaryMode = form.getValues('summaryMode');
@@ -98,8 +95,6 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
     });
     const thread = response.data;
 
-    setCurrentThreadId(thread.title);
-
     // No need to await because data is not used
     mutateThreadData();
 
@@ -118,8 +113,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) => {
@@ -137,8 +135,8 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
   }, []);
 
   const headerText = useMemo(() => {
-    return <>{currentThreadTitle ?? aiAssistantData?.name}</>;
-  }, [aiAssistantData?.name, currentThreadTitle]);
+    return <>{threadData?.title ?? aiAssistantData?.name}</>;
+  }, [aiAssistantData?.name, threadData?.title]);
 
   const placeHolder = useMemo(() => { return 'sidebar_ai_assistant.knowledge_assistant_placeholder' }, []);
 

+ 8 - 0
apps/app/src/features/openai/client/stores/ai-assistant.tsx

@@ -72,6 +72,7 @@ type AiAssistantSidebarUtils = {
   ): void
   openEditor(): void
   close(): void
+  refreshAiAssistantData(aiAssistantData?: AiAssistantHasId): void
   refreshThreadData(threadData?: IThreadRelationHasId): void
 }
 
@@ -100,6 +101,13 @@ export const useAiAssistantSidebar = (
         isOpened: false, isEditorAssistant: false, aiAssistantData: undefined, threadData: undefined,
       }), [swrResponse],
     ),
+    refreshAiAssistantData: useCallback(
+      (aiAssistantData) => {
+        swrResponse.mutate((currentState = { isOpened: false }) => {
+          return { ...currentState, aiAssistantData };
+        });
+      }, [swrResponse],
+    ),
     refreshThreadData: useCallback(
       (threadData?: IThreadRelationHasId) => {
         swrResponse.mutate((currentState = { isOpened: false }) => {

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

+ 9 - 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 = {
@@ -17,10 +18,17 @@ export interface IThreadRelation {
   title?: string;
   type: ThreadType;
   expiredAt: Date;
+  isActive: boolean;
 }
 
 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;

+ 27 - 2
apps/app/src/features/openai/server/models/thread-relation.ts

@@ -1,10 +1,12 @@
 import { addDays } from 'date-fns';
-import { type Model, type Document, Schema } from 'mongoose';
+import { type Document, Schema, type PaginateModel } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 import { type IThreadRelation, ThreadType } from '../../interfaces/thread-relation';
 
+
 const DAYS_UNTIL_EXPIRATION = 3;
 
 const generateExpirationDate = (): Date => {
@@ -15,8 +17,9 @@ export interface ThreadRelationDocument extends IThreadRelation, Document {
   updateThreadExpiration(): Promise<void>;
 }
 
-interface ThreadRelationModel extends Model<ThreadRelationDocument> {
+interface ThreadRelationModel extends PaginateModel<ThreadRelationDocument> {
   getExpiredThreadRelations(limit?: number): Promise<ThreadRelationDocument[] | undefined>;
+  deactivateByAiAssistantId(aiAssistantId: string): Promise<void>;
 }
 
 const schema = new Schema<ThreadRelationDocument, ThreadRelationModel>({
@@ -47,14 +50,36 @@ const schema = new Schema<ThreadRelationDocument, ThreadRelationModel>({
     default: generateExpirationDate,
     required: true,
   },
+  isActive: {
+    type: Boolean,
+    default: true,
+    required: true,
+  },
+}, {
+  timestamps: { createdAt: false, updatedAt: true },
 });
 
+schema.plugin(mongoosePaginate);
+
 schema.statics.getExpiredThreadRelations = async function(limit?: number): Promise<ThreadRelationDocument[] | undefined> {
   const currentDate = new Date();
   const expiredThreadRelations = await this.find({ expiredAt: { $lte: currentDate } }).limit(limit ?? 100).exec();
   return expiredThreadRelations;
 };
 
+schema.statics.deactivateByAiAssistantId = async function(aiAssistantId: string): Promise<void> {
+  await this.updateMany(
+    {
+      aiAssistant: aiAssistantId,
+      isActive: true,
+    },
+    {
+      $set: { isActive: false },
+    },
+  );
+};
+
+
 schema.methods.updateThreadExpiration = async function(): Promise<void> {
   this.expiredAt = generateExpirationDate();
   await this.save();

+ 73 - 0
apps/app/src/features/openai/server/routes/get-recent-threads.ts

@@ -0,0 +1,73 @@
+import { type IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { type ValidationChain, query } from 'express-validator';
+import type { PaginateResult } from 'mongoose';
+
+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 { ThreadType } from '../../interfaces/thread-relation';
+import type { ThreadRelationDocument } from '../models/thread-relation';
+import ThreadRelationModel from '../models/thread-relation';
+import { getOpenaiService } from '../services/openai';
+
+import { certifyAiService } from './middlewares/certify-ai-service';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:get-recent-threads');
+
+type GetRecentThreadsFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqQuery = {
+  page?: number,
+  limit?: number,
+}
+
+type Req = Request<undefined, Response, undefined, ReqQuery> & {
+  user: IUserHasId,
+}
+
+export const getRecentThreadsFactory: GetRecentThreadsFactory = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(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: 20 }).withMessage('limit must be an integer between 1 and 20'),
+    query('limit').toInt(),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      try {
+        const paginateResult: PaginateResult<ThreadRelationDocument> = await ThreadRelationModel.paginate(
+          {
+            userId: req.user._id,
+            type: ThreadType.KNOWLEDGE,
+            isActive: true,
+          },
+          {
+            page: req.query.page ?? 1,
+            limit: req.query.limit ?? 20,
+            sort: { updatedAt: -1 },
+            populate: 'aiAssistant',
+          },
+        );
+        return res.apiv3({ paginateResult });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Failed to get recent threads'));
+      }
+    },
+  ];
+};

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

@@ -23,6 +23,10 @@ export const factory = (crowi: Crowi): express.Router => {
       router.post('/thread', createThreadHandlersFactory(crowi));
     });
 
+    import('./get-recent-threads').then(({ getRecentThreadsFactory }) => {
+      router.get('/threads/recent', getRecentThreadsFactory(crowi));
+    });
+
     import('./get-threads').then(({ getThreadsFactory }) => {
       router.get('/threads/:aiAssistantId', getThreadsFactory(crowi));
     });

+ 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);

+ 2 - 0
apps/app/src/features/openai/server/services/delete-ai-assistant.ts

@@ -7,6 +7,7 @@ import loggerFactory from '~/utils/logger';
 
 import type { AiAssistantDocument } from '../models/ai-assistant';
 import AiAssistantModel from '../models/ai-assistant';
+import ThreadRelationModel from '../models/thread-relation';
 
 import { isAiEnabled } from './is-ai-enabled';
 import { getOpenaiService } from './openai';
@@ -27,6 +28,7 @@ export const deleteAiAssistant = async(ownerId: string, aiAssistantId: string):
 
   const vectorStoreRelationId = getIdStringForRef(aiAssistant.vectorStore);
   await openaiService.deleteVectorStore(vectorStoreRelationId);
+  await ThreadRelationModel.deactivateByAiAssistantId(aiAssistant._id);
 
   const deletedAiAssistant = await aiAssistant.remove();
   return deletedAiAssistant;

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

@@ -217,7 +217,9 @@ class OpenaiService implements IOpenaiService {
   }
 
   async getThreadsByAiAssistantId(aiAssistantId: string, type: ThreadType = ThreadType.KNOWLEDGE): Promise<ThreadRelationDocument[]> {
-    const threadRelations = await ThreadRelationModel.find({ aiAssistant: aiAssistantId, type });
+    const threadRelations = await ThreadRelationModel
+      .find({ aiAssistant: aiAssistantId, type })
+      .sort({ updatedAt: -1 });
     return threadRelations;
   }