فهرست منبع

Merge remote-tracking branch 'origin/master' into support/use-pnpm

Yuki Takei 1 سال پیش
والد
کامیت
c88656f62b

+ 8 - 1
CHANGELOG.md

@@ -1,9 +1,16 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.21...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.22...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v7.0.22](https://github.com/weseek/growi/compare/v7.0.21...v7.0.22) - 2024-10-21
+
+### 🐛 Bug Fixes
+
+* fix: Edit button appear for the side of header (#9270) @yuki-takei
+* fix: Collaborative editing occurs unstable behavior (#9267) @yuki-takei
+
 ## [v7.0.21](https://github.com/weseek/growi/compare/v7.0.20...v7.0.21) - 2024-10-15
 ## [v7.0.21](https://github.com/weseek/growi/compare/v7.0.20...v7.0.21) - 2024-10-15
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`7.0.21`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.21/apps/app/docker/Dockerfile)
+* [`7.0.22`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.22/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)

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

@@ -15,7 +15,7 @@
     "scope_of_page_disclosure": "Scope of page disclosure",
     "scope_of_page_disclosure": "Scope of page disclosure",
     "set_point": "Set point",
     "set_point": "Set point",
     "Guest Users Access": "Guest users access",
     "Guest Users Access": "Guest users access",
-    "readonly_users_access": "ROM users' access",
+    "readonly_users_access": "Read only users' access",
     "always_hidden": "Always hidden",
     "always_hidden": "Always hidden",
     "always_displayed": "Always displayed",
     "always_displayed": "Always displayed",
     "displayed_or_hidden": "Hidden / Displayed",
     "displayed_or_hidden": "Hidden / Displayed",
@@ -87,9 +87,9 @@
       "deny": "Deny (Registered users only)",
       "deny": "Deny (Registered users only)",
       "readonly": "Accept (Guests can read only)"
       "readonly": "Accept (Guests can read only)"
     },
     },
-    "rom_users_comment": {
-      "deny": "Deny (Prohibit ROM users from comment management)",
-      "accept": "Allow (ROM users can manage comments)"
+    "read_only_users_comment": {
+      "deny": "Deny (Prohibit reead only users from comment management)",
+      "accept": "Allow (Read only users can manage comments)"
     },
     },
     "registration_mode": {
     "registration_mode": {
       "open": "Open (Anyone can register)",
       "open": "Open (Anyone can register)",

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

@@ -491,7 +491,12 @@
     "placeholder": "Ask me anything.",
     "placeholder": "Ask me anything.",
     "caution_against_hallucination": "Please verify the information and check the sources.",
     "caution_against_hallucination": "Please verify the information and check the sources.",
     "progress_label": "Generating answers",
     "progress_label": "Generating answers",
-    "failed_to_create_or_retrieve_thread": "Failed to create or retrieve thread"
+    "failed_to_create_or_retrieve_thread": "Failed to create or retrieve thread",
+    "budget_exceeded": "You have reached your usage limit for OpenAI's API. To use the Knowledge Assistant again, please add credits from the OpenAI billing page.",
+    "budget_exceeded_for_growi_cloud": "You have reached your OpenAI API usage limit. To use the Knowledge Assistant again, please add credits from the GROWI.cloud admin page for Hosted users or from the OpenAI billing page for Owned users.",
+    "error_message": "An error has occurred",
+    "show_error_detail": "Show error details"
+
   },
   },
   "link_edit": {
   "link_edit": {
     "edit_link": "Edit Link",
     "edit_link": "Edit Link",

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

@@ -15,7 +15,7 @@
     "scope_of_page_disclosure": "Confidentialité de la page",
     "scope_of_page_disclosure": "Confidentialité de la page",
     "set_point": "Valeur",
     "set_point": "Valeur",
     "Guest Users Access": "Accès invité",
     "Guest Users Access": "Accès invité",
-    "readonly_users_access": "Accès des utilisateurs ROM",
+    "readonly_users_access": "Accès des utilisateurs lecture seule",
     "always_hidden": "Toujours caché",
     "always_hidden": "Toujours caché",
     "always_displayed": "Toujours affiché",
     "always_displayed": "Toujours affiché",
     "displayed_or_hidden": "Caché / Affiché",
     "displayed_or_hidden": "Caché / Affiché",
@@ -87,9 +87,9 @@
       "deny": "Refuser (Utilisateurs inscrits seulement)",
       "deny": "Refuser (Utilisateurs inscrits seulement)",
       "readonly": "Autoriser (Lecture seule)"
       "readonly": "Autoriser (Lecture seule)"
     },
     },
-    "rom_users_comment": {
-      "deny": "Refuser (Interdire la gestion des commentaires aux utilisateurs ROM)",
-      "accept": "Autoriser (Les utilisateurs ROM peuvent gérer les commentaires)"
+    "read_only_users_comment": {
+      "deny": "Refuser (Interdire la gestion des commentaires aux utilisateurs lecture seule)",
+      "accept": "Autoriser (Les utilisateurs lecture seule peuvent gérer les commentaires)"
     },
     },
     "registration_mode": {
     "registration_mode": {
       "open": "Ouvert (Tout le monde peut s'inscrire)",
       "open": "Ouvert (Tout le monde peut s'inscrire)",

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

@@ -485,7 +485,11 @@
     "placeholder": "Demandez-moi n'importe quoi.",
     "placeholder": "Demandez-moi n'importe quoi.",
     "caution_against_hallucination": "Veuillez vérifier les informations et consulter les sources.",
     "caution_against_hallucination": "Veuillez vérifier les informations et consulter les sources.",
     "progress_label": "Génération des réponses",
     "progress_label": "Génération des réponses",
-    "failed_to_create_or_retrieve_thread": "Échec de la création ou de la récupération du fil de discussion"
+    "failed_to_create_or_retrieve_thread": "Échec de la création ou de la récupération du fil de discussion",
+    "budget_exceeded": "Vous avez atteint votre limite d'utilisation de l'API de l'OpenAI. Pour utiliser à nouveau l'assistant de connaissance, veuillez ajouter des crédits à partir de la page de facturation d'OpenAI.",
+    "budget_exceeded_for_growi_cloud": "Vous avez atteint votre limite d'utilisation de l'API de l'OpenAI. Pour utiliser à nouveau l'assistant de connaissance, veuillez ajouter des crédits à partir de la page d'administration de GROWI.cloud pour les utilisateurs hébergés ou à partir de la page de facturation de l'OpenAI pour les utilisateurs propriétaires.",
+    "error_message": "Erreur",
+    "show_error_detail": "Détails de l'exposition"
   },
   },
   "link_edit": {
   "link_edit": {
     "edit_link": "Modifier lien",
     "edit_link": "Modifier lien",

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

@@ -24,7 +24,7 @@
     "scope_of_page_disclosure": "ページの公開範囲",
     "scope_of_page_disclosure": "ページの公開範囲",
     "set_point": "設定値",
     "set_point": "設定値",
     "Guest Users Access":"ゲストユーザーのアクセス",
     "Guest Users Access":"ゲストユーザーのアクセス",
-    "readonly_users_access": "ROMユーザーのアクセス",
+    "readonly_users_access": "閲覧のみユーザーのアクセス",
     "always_hidden": "非表示 (固定)",
     "always_hidden": "非表示 (固定)",
     "always_displayed": "表示 (固定)",
     "always_displayed": "表示 (固定)",
     "displayed_or_hidden": "非表示 / 表示",
     "displayed_or_hidden": "非表示 / 表示",
@@ -96,9 +96,9 @@
       "deny": "拒否 (アカウントを持つユーザーのみ利用可能)",
       "deny": "拒否 (アカウントを持つユーザーのみ利用可能)",
       "readonly": "許可 (ゲストユーザーも閲覧のみ可能)"
       "readonly": "許可 (ゲストユーザーも閲覧のみ可能)"
     },
     },
-    "rom_users_comment": {
-      "deny": "拒否 (ROMユーザーのコメント操作を禁止)",
-      "accept": "許可 (ROMユーザーもコメント操作可能)"
+    "read_only_users_comment": {
+      "deny": "拒否 (閲覧のみユーザーのコメント操作を禁止)",
+      "accept": "許可 (閲覧のみユーザーもコメント操作可能)"
     },
     },
     "registration_mode": {
     "registration_mode": {
       "open": "公開 (だれでも登録可能)",
       "open": "公開 (だれでも登録可能)",

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

@@ -524,7 +524,11 @@
     "placeholder": "ききたいことを入力してください",
     "placeholder": "ききたいことを入力してください",
     "caution_against_hallucination": "情報が正しいか出典を確認しましょう",
     "caution_against_hallucination": "情報が正しいか出典を確認しましょう",
     "progress_label": "回答を生成しています",
     "progress_label": "回答を生成しています",
-    "failed_to_create_or_retrieve_thread": "スレッドの作成または取得に失敗しました"
+    "failed_to_create_or_retrieve_thread": "スレッドの作成または取得に失敗しました",
+    "budget_exceeded": "OpenAI の API の利用上限に達しました。ナレッジアシスタントを再度利用するには OpenAI の請求ページからクレジットを追加してください。",
+    "budget_exceeded_for_growi_cloud": "OpenAI の API の利用上限に達しました。ナレッジアシスタントを再度利用するには Hosted の場合は GROWI.cloud の管理画面から Owned の場合は OpenAI の請求ページからクレジットを追加してください。",
+    "error_message": "エラーが発生しました",
+    "show_error_detail": "詳細を表示"
   },
   },
   "link_edit": {
   "link_edit": {
     "edit_link": "リンク編集",
     "edit_link": "リンク編集",

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

@@ -27,7 +27,7 @@
     "always_hidden": "总是隐藏",
     "always_hidden": "总是隐藏",
     "displayed_or_hidden": "隐藏 / 显示",
     "displayed_or_hidden": "隐藏 / 显示",
     "Guest Users Access": "来宾用户访问",
     "Guest Users Access": "来宾用户访问",
-    "readonly_users_access": "ROM用户的访问",
+    "readonly_users_access": "只浏览用户的访问",
 		"Fixed by env var": "这是由env var<code>%s=%s</code>修复的。",
 		"Fixed by env var": "这是由env var<code>%s=%s</code>修复的。",
 		"register_limitation": "注册限制",
 		"register_limitation": "注册限制",
 		"register_limitation_desc": "限制新用户注册",
 		"register_limitation_desc": "限制新用户注册",
@@ -96,9 +96,9 @@
 			"deny": "拒绝(仅限注册用户)",
 			"deny": "拒绝(仅限注册用户)",
 			"readonly": "接受(来宾可以只读)"
 			"readonly": "接受(来宾可以只读)"
 		},
 		},
-    "rom_users_comment": {
-      "deny": "拒绝 (禁止ROM用户操作评论)",
-      "accept": "允许 (ROM用户可以管理评论)"
+    "read_only_users_comment": {
+      "deny": "拒绝 (禁止只浏览用户操作评论)",
+      "accept": "允许 (只浏览用户可以管理评论)"
     },
     },
 		"registration_mode": {
 		"registration_mode": {
 			"open": "打开(任何人都可以注册)",
 			"open": "打开(任何人都可以注册)",

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

@@ -480,7 +480,11 @@
     "placeholder": "问我任何问题。",
     "placeholder": "问我任何问题。",
     "caution_against_hallucination": "请核实信息并检查来源。",
     "caution_against_hallucination": "请核实信息并检查来源。",
     "progress_label": "生成答案中",
     "progress_label": "生成答案中",
-    "failed_to_create_or_retrieve_thread": "创建或获取线程失败"
+    "failed_to_create_or_retrieve_thread": "创建或获取线程失败",
+    "budget_exceeded": "您已达到 OpenAI API 的使用上限。要再次使用知识助手,请从 OpenAI 账单页面添加点数。",
+    "budget_exceeded_for_growi_cloud": "您已达到 OpenAI API 使用上限。如需再次使用知识助手,请从GROWI.cloud管理页面为托管用户添加点数,或从OpenAI计费页面为自有用户添加点数。",
+    "error_message": "错误",
+    "show_error_detail": "显示详情"
   },
   },
   "link_edit": {
   "link_edit": {
     "edit_link": "Edit Link",
     "edit_link": "Edit Link",

+ 4 - 4
apps/app/src/client/components/Admin/Security/SecuritySetting.jsx

@@ -526,16 +526,16 @@ class SecuritySetting extends React.Component {
                 aria-expanded="true"
                 aria-expanded="true"
               >
               >
                 <span className="float-start">
                 <span className="float-start">
-                  {isRomUserAllowedToComment === true && t('security_settings.rom_users_comment.accept')}
-                  {isRomUserAllowedToComment === false && t('security_settings.rom_users_comment.deny')}
+                  {isRomUserAllowedToComment === true && t('security_settings.read_only_users_comment.accept')}
+                  {isRomUserAllowedToComment === false && t('security_settings.read_only_users_comment.deny')}
                 </span>
                 </span>
               </button>
               </button>
               <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
               <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
                 <button className="dropdown-item" type="button" onClick={() => { adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(false) }}>
                 <button className="dropdown-item" type="button" onClick={() => { adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(false) }}>
-                  {t('security_settings.rom_users_comment.deny')}
+                  {t('security_settings.read_only_users_comment.deny')}
                 </button>
                 </button>
                 <button className="dropdown-item" type="button" onClick={() => { adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(true) }}>
                 <button className="dropdown-item" type="button" onClick={() => { adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(true) }}>
-                  {t('security_settings.rom_users_comment.accept')}
+                  {t('security_settings.read_only_users_comment.accept')}
                 </button>
                 </button>
               </div>
               </div>
             </div>
             </div>

+ 55 - 10
apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx

@@ -4,15 +4,17 @@ import React, { useCallback, useEffect, useState } from 'react';
 import { useForm, Controller } from 'react-hook-form';
 import { useForm, Controller } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import {
 import {
+  Collapse,
   Modal, ModalBody, ModalFooter, ModalHeader,
   Modal, ModalBody, ModalFooter, ModalHeader,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
+import { useGrowiCloudUri } from '~/stores-universal/context';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { useRagSearchModal } from '../../../client/stores/rag-search';
 import { useRagSearchModal } from '../../../client/stores/rag-search';
-import { MessageErrorCode } from '../../../interfaces/message-error';
+import { MessageErrorCode, StreamErrorCode } from '../../../interfaces/message-error';
 
 
 import { MessageCard } from './MessageCard';
 import { MessageCard } from './MessageCard';
 import { ResizableTextarea } from './ResizableTextArea';
 import { ResizableTextarea } from './ResizableTextArea';
@@ -47,6 +49,10 @@ const AiChatModalSubstance = (): JSX.Element => {
   const [threadId, setThreadId] = useState<string | undefined>();
   const [threadId, setThreadId] = useState<string | undefined>();
   const [messageLogs, setMessageLogs] = useState<Message[]>([]);
   const [messageLogs, setMessageLogs] = useState<Message[]>([]);
   const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
   const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
+  const [errorMessage, setErrorMessage] = useState<string | undefined>();
+  const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
+
+  const { data: growiCloudUri } = useGrowiCloudUri();
 
 
   const isGenerating = generatingAnswerMessage != null;
   const isGenerating = generatingAnswerMessage != null;
 
 
@@ -92,6 +98,7 @@ const AiChatModalSubstance = (): JSX.Element => {
 
 
     // reset form
     // reset form
     form.reset();
     form.reset();
+    setErrorMessage(undefined);
 
 
     // add an empty assistant message
     // add an empty assistant message
     const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
     const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
@@ -141,14 +148,25 @@ const AiChatModalSubstance = (): JSX.Element => {
 
 
         const chunk = decoder.decode(value);
         const chunk = decoder.decode(value);
 
 
-        // Extract text values from the chunk
-        const textValues = chunk
-          .split('\n\n')
-          .filter(line => line.trim().startsWith('data:'))
-          .map((line) => {
+        const textValues: string[] = [];
+        const lines = chunk.split('\n\n');
+        lines.forEach((line) => {
+          const trimedLine = line.trim();
+          if (trimedLine.startsWith('data:')) {
             const data = JSON.parse(line.replace('data: ', ''));
             const data = JSON.parse(line.replace('data: ', ''));
-            return data.content[0].text.value;
-          });
+            textValues.push(data.content[0].text.value);
+          }
+          else if (trimedLine.startsWith('error:')) {
+            const error = JSON.parse(line.replace('error: ', ''));
+            logger.error(error.errorMessage);
+            form.setError('input', { type: 'manual', message: error.message });
+
+            if (error.code === StreamErrorCode.BUDGET_EXCEEDED) {
+              setErrorMessage(growiCloudUri != null ? 'modal_aichat.budget_exceeded_for_growi_cloud' : 'modal_aichat.budget_exceeded');
+            }
+          }
+        });
+
 
 
         // append text values to the assistant message
         // append text values to the assistant message
         setGeneratingAnswerMessage((prevMessage) => {
         setGeneratingAnswerMessage((prevMessage) => {
@@ -168,7 +186,7 @@ const AiChatModalSubstance = (): JSX.Element => {
       form.setError('input', { type: 'manual', message: err.toString() });
       form.setError('input', { type: 'manual', message: err.toString() });
     }
     }
 
 
-  }, [form, isGenerating, messageLogs, t, threadId]);
+  }, [form, growiCloudUri, isGenerating, messageLogs, t, threadId]);
 
 
   const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
   const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
     if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
     if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
@@ -224,7 +242,34 @@ const AiChatModalSubstance = (): JSX.Element => {
         </form>
         </form>
 
 
         {form.formState.errors.input != null && (
         {form.formState.errors.input != null && (
-          <span className="text-danger small">{form.formState.errors.input?.message}</span>
+          <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
+            <div>
+              <span className="material-symbols-outlined text-danger me-2">error</span>
+              <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('modal_aichat.error_message') }</span>
+            </div>
+
+            <button
+              type="button"
+              className="btn btn-link text-secondary p-0"
+              aria-expanded={isErrorDetailCollapsed}
+              onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
+            >
+              <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
+                chevron_right
+              </span>
+              <span className="small">{t('modal_aichat.show_error_detail')}</span>
+            </button>
+
+            <Collapse isOpen={isErrorDetailCollapsed}>
+              <div className="ms-2">
+                <div className="">
+                  <div className="text-secondary small">
+                    {form.formState.errors.input?.message}
+                  </div>
+                </div>
+              </div>
+            </Collapse>
+          </div>
         )}
         )}
       </ModalFooter>
       </ModalFooter>
     </>
     </>

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

@@ -1,3 +1,9 @@
 export const MessageErrorCode = {
 export const MessageErrorCode = {
   THREAD_ID_IS_NOT_SET: 'thread-id-is-not-set',
   THREAD_ID_IS_NOT_SET: 'thread-id-is-not-set',
 } as const;
 } as const;
+
+export const StreamErrorCode = {
+  BUDGET_EXCEEDED: 'budget-exceeded',
+} as const;
+
+export type StreamErrorCode = typeof StreamErrorCode[keyof typeof StreamErrorCode];

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

@@ -11,8 +11,9 @@ import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { MessageErrorCode } from '../../interfaces/message-error';
+import { MessageErrorCode, type StreamErrorCode } from '../../interfaces/message-error';
 import { openaiClient } from '../services';
 import { openaiClient } from '../services';
+import { getStreamErrorCode } from '../services/getStreamErrorCode';
 
 
 import { certifyAiService } from './middlewares/certify-ai-service';
 import { certifyAiService } from './middlewares/certify-ai-service';
 
 
@@ -80,6 +81,20 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
         res.write(`data: ${JSON.stringify(delta)}\n\n`);
         res.write(`data: ${JSON.stringify(delta)}\n\n`);
       };
       };
 
 
+      const sendError = (message: string, code?: StreamErrorCode) => {
+        res.write(`error: ${JSON.stringify({ code, message })}\n\n`);
+      };
+
+      stream.on('event', (delta) => {
+        if (delta.event === 'thread.run.failed') {
+          const errorMessage = delta.data.last_error?.message;
+          if (errorMessage == null) {
+            return;
+          }
+          logger.error(errorMessage);
+          sendError(errorMessage, getStreamErrorCode(errorMessage));
+        }
+      });
       stream.on('messageDelta', messageDeltaHandler);
       stream.on('messageDelta', messageDeltaHandler);
       stream.once('messageDone', () => {
       stream.once('messageDone', () => {
         stream.off('messageDelta', messageDeltaHandler);
         stream.off('messageDelta', messageDeltaHandler);

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

@@ -0,0 +1,13 @@
+import { StreamErrorCode } from '../../interfaces/message-error';
+
+const OpenaiStreamErrorMessageRegExp = {
+  BUDGET_EXCEEDED: /exceeded your current quota/i, // stream-error-message: "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors."
+} as const;
+
+export const getStreamErrorCode = (errorMessage: string): StreamErrorCode | undefined => {
+  for (const [code, regExp] of Object.entries(OpenaiStreamErrorMessageRegExp)) {
+    if (regExp.test(errorMessage)) {
+      return StreamErrorCode[code];
+    }
+  }
+};

+ 5 - 2
apps/app/src/features/openai/server/services/openai.ts

@@ -32,7 +32,7 @@ let isVectorStoreForPublicScopeExist = false;
 export interface IOpenaiService {
 export interface IOpenaiService {
   getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise<OpenAI.Beta.Threads.Thread | undefined>;
   getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise<OpenAI.Beta.Threads.Thread | undefined>;
   getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument>;
   getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument>;
-  deleteExpiredThreads(limit: number): Promise<void>;
+  deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void>;
   createVectorStoreFile(pages: PageDocument[]): Promise<void>;
   createVectorStoreFile(pages: PageDocument[]): Promise<void>;
   deleteVectorStoreFile(pageId: Types.ObjectId): Promise<void>;
   deleteVectorStoreFile(pageId: Types.ObjectId): Promise<void>;
   rebuildVectorStoreAll(): Promise<void>;
   rebuildVectorStoreAll(): Promise<void>;
@@ -78,7 +78,7 @@ class OpenaiService implements IOpenaiService {
     }
     }
   }
   }
 
 
-  public async deleteExpiredThreads(limit: 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) {
       return;
       return;
@@ -90,6 +90,9 @@ class OpenaiService implements IOpenaiService {
         const deleteThreadResponse = await this.client.deleteThread(expiredThreadRelation.threadId);
         const deleteThreadResponse = await this.client.deleteThread(expiredThreadRelation.threadId);
         logger.debug('Delete thread', deleteThreadResponse);
         logger.debug('Delete thread', deleteThreadResponse);
         deletedThreadIds.push(expiredThreadRelation.threadId);
         deletedThreadIds.push(expiredThreadRelation.threadId);
+
+        // sleep
+        await new Promise(resolve => setTimeout(resolve, apiCallInterval));
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);

+ 13 - 11
apps/app/src/features/openai/server/services/thread-deletion-cron.ts

@@ -5,17 +5,20 @@ import loggerFactory from '~/utils/logger';
 
 
 import { getOpenaiService, type IOpenaiService } from './openai';
 import { getOpenaiService, type IOpenaiService } from './openai';
 
 
-
 const logger = loggerFactory('growi:service:thread-deletion-cron');
 const logger = loggerFactory('growi:service:thread-deletion-cron');
 
 
-const DELETE_LIMIT = 100;
-
 class ThreadDeletionCronService {
 class ThreadDeletionCronService {
 
 
   cronJob: nodeCron.ScheduledTask;
   cronJob: nodeCron.ScheduledTask;
 
 
   openaiService: IOpenaiService;
   openaiService: IOpenaiService;
 
 
+  threadDeletionCronExpression: string;
+
+  threadDeletionBarchSize: number;
+
+  threadDeletionApiCallInterval: number;
+
   startCron(): void {
   startCron(): void {
     const isAiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
     const isAiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
     if (!isAiEnabled) {
     if (!isAiEnabled) {
@@ -28,23 +31,22 @@ class ThreadDeletionCronService {
     }
     }
 
 
     this.openaiService = openaiService;
     this.openaiService = openaiService;
-
-    // Executed at 0 minutes of every hour
-    const cronSchedule = '0 * * * *';
+    this.threadDeletionCronExpression = configManager.getConfig('crowi', 'openai:threadDeletionCronExpression');
+    this.threadDeletionBarchSize = configManager.getConfig('crowi', 'openai:threadDeletionBarchSize');
+    this.threadDeletionApiCallInterval = configManager.getConfig('crowi', 'openai:threadDeletionApiCallInterval');
 
 
     this.cronJob?.stop();
     this.cronJob?.stop();
-    this.cronJob = this.generateCronJob(cronSchedule);
+    this.cronJob = this.generateCronJob();
     this.cronJob.start();
     this.cronJob.start();
   }
   }
 
 
   private async executeJob(): Promise<void> {
   private async executeJob(): Promise<void> {
     // Must be careful of OpenAI's rate limit
     // Must be careful of OpenAI's rate limit
-    // Delete up to 100 threads per hour
-    await this.openaiService.deleteExpiredThreads(DELETE_LIMIT);
+    await this.openaiService.deleteExpiredThreads(this.threadDeletionBarchSize, this.threadDeletionApiCallInterval);
   }
   }
 
 
-  private generateCronJob(cronSchedule: string) {
-    return nodeCron.schedule(cronSchedule, async() => {
+  private generateCronJob() {
+    return nodeCron.schedule(this.threadDeletionCronExpression, async() => {
       try {
       try {
         await this.executeJob();
         await this.executeJob();
       }
       }

+ 0 - 43
apps/app/src/server/routes/apiv3/docs.js

@@ -1,43 +0,0 @@
-import loggerFactory from '~/utils/logger';
-import swaggerDefinition from '^/config/swagger-definition';
-
-const express = require('express');
-const swaggerJSDoc = require('swagger-jsdoc');
-
-const logger = loggerFactory('growi:routes:apiv3:docs'); // eslint-disable-line no-unused-vars
-
-const router = express.Router();
-
-// paths to scan
-const APIS = [
-  'src/server/routes/apiv3/**/*.js',
-  'src/server/models/**/*.js',
-];
-
-module.exports = (crowi) => {
-
-  // skip if disabled
-  if (!crowi.configManager.getConfig('crowi', 'app:publishOpenAPI')) {
-    return router;
-  }
-
-  // generate swagger spec
-  const options = {
-    swaggerDefinition,
-    apis: APIS,
-  };
-  const swaggerSpec = swaggerJSDoc(options);
-
-  // publish swagger spec
-  router.get('/swagger-spec.json', (req, res) => {
-    res.setHeader('Content-Type', 'application/json');
-    res.send(swaggerSpec);
-  });
-
-  // publish redoc
-  router.get('/', (req, res) => {
-    res.render('redoc');
-  });
-
-  return router;
-};

+ 0 - 2
apps/app/src/server/routes/index.js

@@ -57,8 +57,6 @@ module.exports = function(crowi, app) {
 
 
   const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi, app);
   const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi, app);
 
 
-  app.use('/api-docs', require('./apiv3/docs')(crowi, app));
-
   // Rate limiter
   // Rate limiter
   app.use(rateLimiterFactory());
   app.use(rateLimiterFactory());
 
 

+ 18 - 0
apps/app/src/server/service/config-loader.ts

@@ -801,6 +801,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO: Record<string, EnvConfig> = {
     type: ValueType.STRING,
     type: ValueType.STRING,
     default: null,
     default: null,
   },
   },
+  OPENAI_THREAD_DELETION_CRON_EXPRESSION: {
+    ns: 'crowi',
+    key: 'openai:threadDeletionCronExpression',
+    type: ValueType.STRING,
+    default: '0 * * * *', // every hour
+  },
+  OPENAI_THREAD_DELETION_BARCH_SIZE: {
+    ns: 'crowi',
+    key: 'openai:threadDeletionBarchSize',
+    type: ValueType.NUMBER,
+    default: 100,
+  },
+  OPENAI_THREAD_DELETION_API_CALL_INTERVAL: {
+    ns: 'crowi',
+    key: 'openai:threadDeletionApiCallInterval',
+    type: ValueType.NUMBER,
+    default: 36000, // msec
+  },
 };
 };
 
 
 
 

+ 8 - 1
apps/app/src/stores/page.tsx

@@ -139,7 +139,14 @@ export const useSWRMUTxCurrentPage = (): SWRMutationResponse<IPagePopulatedToSho
   return useSWRMutation(
   return useSWRMutation(
     key,
     key,
     () => apiv3Get<{ page: IPagePopulatedToShowRevision }>('/page', { pageId: currentPageId, shareLinkId, revisionId })
     () => apiv3Get<{ page: IPagePopulatedToShowRevision }>('/page', { pageId: currentPageId, shareLinkId, revisionId })
-      .then(result => result.data.page)
+      .then((result) => {
+        const newData = result.data.page;
+
+        // for the issue https://redmine.weseek.co.jp/issues/156150
+        mutate('currentPage', newData, false);
+
+        return newData;
+      })
       .catch(getPageApiErrorHandler),
       .catch(getPageApiErrorHandler),
     {
     {
       populateCache: true,
       populateCache: true,