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

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

Yuki Takei 1 год назад
Родитель
Сommit
ee73049c36

+ 2 - 1
apps/app/package.json

@@ -241,6 +241,7 @@
     "@types/jest": "^29.5.2",
     "@types/jest": "^29.5.2",
     "@types/ldapjs": "^2.2.5",
     "@types/ldapjs": "^2.2.5",
     "@types/mdast": "^4.0.4",
     "@types/mdast": "^4.0.4",
+    "@types/node-cron": "^3.0.11",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
     "@types/react-stickynode": "^4.0.3",
@@ -284,8 +285,8 @@
     "react-hotkeys": "^2.0.0",
     "react-hotkeys": "^2.0.0",
     "react-input-autosize": "^3.0.0",
     "react-input-autosize": "^3.0.0",
     "react-toastify": "^9.1.3",
     "react-toastify": "^9.1.3",
-    "remark-github-admonitions-to-directives": "^2.0.0",
     "rehype-rewrite": "^4.0.2",
     "rehype-rewrite": "^4.0.2",
+    "remark-github-admonitions-to-directives": "^2.0.0",
     "replacestream": "^4.0.3",
     "replacestream": "^4.0.3",
     "sass": "^1.53.0",
     "sass": "^1.53.0",
     "simple-load-script": "^1.0.2",
     "simple-load-script": "^1.0.2",

+ 14 - 3
apps/app/src/components/ReactMarkdownComponents/CodeBlock.tsx

@@ -13,6 +13,17 @@ Object.entries<object>(oneDark).forEach(([key, value]) => {
 });
 });
 
 
 
 
+type InlineCodeBlockProps = {
+  children: ReactNode,
+  className?: string,
+}
+
+const InlineCodeBlockSubstance = (props: InlineCodeBlockProps): JSX.Element => {
+  const { children, className, ...rest } = props;
+  return <code className={`code-inline ${className ?? ''}`} {...rest}>{children}</code>;
+};
+
+
 function extractChildrenToIgnoreReactNode(children: ReactNode): ReactNode {
 function extractChildrenToIgnoreReactNode(children: ReactNode): ReactNode {
 
 
   if (children == null) {
   if (children == null) {
@@ -70,15 +81,15 @@ function CodeBlockSubstance({ lang, children }: { lang: string, children: ReactN
 type CodeBlockProps = {
 type CodeBlockProps = {
   children: ReactNode,
   children: ReactNode,
   className?: string,
   className?: string,
-  inline?: string, // "" or undefined
+  inline?: true,
 }
 }
 
 
 export const CodeBlock = (props: CodeBlockProps): JSX.Element => {
 export const CodeBlock = (props: CodeBlockProps): JSX.Element => {
 
 
   // TODO: set border according to the value of 'customize:highlightJsStyleBorder'
   // TODO: set border according to the value of 'customize:highlightJsStyleBorder'
   const { className, children, inline } = props;
   const { className, children, inline } = props;
-  if (inline != null) {
-    return <code className={`code-inline ${className ?? ''}`}>{children}</code>;
+  if (inline) {
+    return <InlineCodeBlockSubstance className={`code-inline ${className ?? ''}`}>{children}</InlineCodeBlockSubstance>;
   }
   }
 
 
   const match = /language-(\w+)(:?.+)?/.exec(className || '');
   const match = /language-(\w+)(:?.+)?/.exec(className || '');

+ 57 - 0
apps/app/src/features/openai/server/models/thread-relation.ts

@@ -0,0 +1,57 @@
+import type mongoose from 'mongoose';
+import { type Model, type Document, Schema } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+const DAYS_UNTIL_EXPIRATION = 30;
+
+const generateExpirationDate = (): Date => {
+  const currentDate = new Date();
+  const expirationDate = new Date(currentDate.setDate(currentDate.getDate() + DAYS_UNTIL_EXPIRATION));
+  return expirationDate;
+};
+
+interface ThreadRelation {
+  userId: mongoose.Types.ObjectId;
+  threadId: string;
+  expiredAt: Date;
+}
+
+interface ThreadRelationDocument extends ThreadRelation, Document {
+  updateThreadExpiration(): Promise<void>;
+}
+
+interface ThreadRelationModel extends Model<ThreadRelationDocument> {
+  getExpiredThreadRelations(limit?: number): Promise<ThreadRelationDocument[] | undefined>;
+}
+
+const schema = new Schema<ThreadRelationDocument, ThreadRelationModel>({
+  userId: {
+    type: Schema.Types.ObjectId,
+    ref: 'User',
+    required: true,
+  },
+  threadId: {
+    type: String,
+    required: true,
+    unique: true,
+  },
+  expiredAt: {
+    type: Date,
+    default: generateExpirationDate,
+    required: true,
+  },
+});
+
+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.methods.updateThreadExpiration = async function(): Promise<void> {
+  this.expiredAt = generateExpirationDate();
+  await this.save();
+};
+
+export default getOrCreateModel<ThreadRelationDocument, ThreadRelationModel>('ThreadRelation', schema);

+ 7 - 22
apps/app/src/features/openai/server/routes/thread.ts

@@ -1,23 +1,21 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
 import type { Request, RequestHandler } from 'express';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
+import { filterXSS } from 'xss';
 
 
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 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 { openaiClient } from '../services';
 import { getOpenaiService } from '../services/openai';
 import { getOpenaiService } from '../services/openai';
 
 
 import { certifyAiService } from './middlewares/certify-ai-service';
 import { certifyAiService } from './middlewares/certify-ai-service';
 
 
 const logger = loggerFactory('growi:routes:apiv3:openai:thread');
 const logger = loggerFactory('growi:routes:apiv3:openai:thread');
 
 
-type CreateThreadReq = Request<undefined, ApiV3Response, {
-  userMessage: string,
-  threadId?: string,
-}>
+type CreateThreadReq = Request<undefined, ApiV3Response, { threadId?: string }> & { user: IUserHasId };
 
 
 type CreateThreadFactory = (crowi: Crowi) => RequestHandler[];
 type CreateThreadFactory = (crowi: Crowi) => RequestHandler[];
 
 
@@ -32,24 +30,11 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
   return [
   return [
     accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: CreateThreadReq, res: ApiV3Response) => {
     async(req: CreateThreadReq, res: ApiV3Response) => {
-      const openaiService = getOpenaiService();
-      if (openaiService == null) {
-        return res.apiv3Err('OpenaiService is not available', 503);
-      }
-
       try {
       try {
-        const vectorStore = await openaiService.getOrCreateVectorStoreForPublicScope();
-        const threadId = req.body.threadId;
-        const thread = threadId == null
-          ? await openaiClient.beta.threads.create({
-            tool_resources: {
-              file_search: {
-                vector_store_ids: [vectorStore.vectorStoreId],
-              },
-            },
-          })
-          : await openaiClient.beta.threads.retrieve(threadId);
-
+        const openaiService = getOpenaiService();
+        const filterdThreadId = req.body.threadId != null ? filterXSS(req.body.threadId) : undefined;
+        const vectorStore = await openaiService?.getOrCreateVectorStoreForPublicScope();
+        const thread = await openaiService?.getOrCreateThread(req.user._id, vectorStore?.vectorStoreId, filterdThreadId);
         return res.apiv3({ thread });
         return res.apiv3({ thread });
       }
       }
       catch (err) {
       catch (err) {

+ 18 - 0
apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts

@@ -22,6 +22,24 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator {
     // TODO: initialize openaiVectorStoreId property
     // TODO: initialize openaiVectorStoreId property
   }
   }
 
 
+  async createThread(vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread> {
+    return this.client.beta.threads.create({
+      tool_resources: {
+        file_search: {
+          vector_store_ids: [vectorStoreId],
+        },
+      },
+    });
+  }
+
+  async retrieveThread(threadId: string): Promise<OpenAI.Beta.Threads.Thread> {
+    return this.client.beta.threads.retrieve(threadId);
+  }
+
+  async deleteThread(threadId: string): Promise<OpenAI.Beta.Threads.ThreadDeleted> {
+    return this.client.beta.threads.del(threadId);
+  }
+
   async createVectorStore(scopeType:VectorStoreScopeType): Promise<OpenAI.Beta.VectorStores.VectorStore> {
   async createVectorStore(scopeType:VectorStoreScopeType): Promise<OpenAI.Beta.VectorStores.VectorStore> {
     return this.client.beta.vectorStores.create({ name: `growi-vector-store-{${scopeType}` });
     return this.client.beta.vectorStores.create({ name: `growi-vector-store-{${scopeType}` });
   }
   }

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

@@ -4,6 +4,9 @@ import type { Uploadable } from 'openai/uploads';
 import type { VectorStoreScopeType } from '~/features/openai/server/models/vector-store';
 import type { VectorStoreScopeType } from '~/features/openai/server/models/vector-store';
 
 
 export interface IOpenaiClientDelegator {
 export interface IOpenaiClientDelegator {
+  createThread(vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread>
+  retrieveThread(threadId: string): Promise<OpenAI.Beta.Threads.Thread>
+  deleteThread(threadId: string): Promise<OpenAI.Beta.Threads.ThreadDeleted>
   retrieveVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStore>
   retrieveVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStore>
   createVectorStore(scopeType:VectorStoreScopeType): Promise<OpenAI.Beta.VectorStores.VectorStore>
   createVectorStore(scopeType:VectorStoreScopeType): Promise<OpenAI.Beta.VectorStores.VectorStore>
   uploadFile(file: Uploadable): Promise<OpenAI.Files.FileObject>
   uploadFile(file: Uploadable): Promise<OpenAI.Files.FileObject>

+ 18 - 0
apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts

@@ -24,6 +24,24 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator {
     this.client = new OpenAI({ apiKey });
     this.client = new OpenAI({ apiKey });
   }
   }
 
 
+  async createThread(vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread> {
+    return this.client.beta.threads.create({
+      tool_resources: {
+        file_search: {
+          vector_store_ids: [vectorStoreId],
+        },
+      },
+    });
+  }
+
+  async retrieveThread(threadId: string): Promise<OpenAI.Beta.Threads.Thread> {
+    return this.client.beta.threads.retrieve(threadId);
+  }
+
+  async deleteThread(threadId: string): Promise<OpenAI.Beta.Threads.ThreadDeleted> {
+    return this.client.beta.threads.del(threadId);
+  }
+
   async createVectorStore(scopeType:VectorStoreScopeType): Promise<OpenAI.Beta.VectorStores.VectorStore> {
   async createVectorStore(scopeType:VectorStoreScopeType): Promise<OpenAI.Beta.VectorStores.VectorStore> {
     return this.client.beta.vectorStores.create({ name: `growi-vector-store-${scopeType}` });
     return this.client.beta.vectorStores.create({ name: `growi-vector-store-${scopeType}` });
   }
   }

+ 29 - 0
apps/app/src/features/openai/server/services/openai-api-error-handler.ts

@@ -0,0 +1,29 @@
+import OpenAI from 'openai';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:openai');
+
+// Error Code Reference
+// https://platform.openai.com/docs/guides/error-codes/api-errors
+
+// Error Handling Reference
+// https://github.com/openai/openai-node/tree/d08bf1a8fa779e6a9349d92ddf65530dd84e686d?tab=readme-ov-file#handling-errors
+
+type ErrorHandler = {
+  notFoundError?: () => Promise<void>;
+}
+
+export const oepnaiApiErrorHandler = async(error: unknown, handler: ErrorHandler): Promise<void> => {
+  if (!(error instanceof OpenAI.APIError)) {
+    return;
+  }
+
+  logger.error(error);
+
+  if (error.status === 404 && handler.notFoundError != null) {
+    await handler.notFoundError();
+    return;
+  }
+
+};

+ 76 - 14
apps/app/src/features/openai/server/services/openai.ts

@@ -7,6 +7,7 @@ import mongoose from 'mongoose';
 import type OpenAI from 'openai';
 import type OpenAI from 'openai';
 import { toFile } from 'openai';
 import { toFile } from 'openai';
 
 
+import ThreadRelationModel from '~/features/openai/server/models/thread-relation';
 import VectorStoreModel, { VectorStoreScopeType, type VectorStoreDocument } from '~/features/openai/server/models/vector-store';
 import VectorStoreModel, { VectorStoreScopeType, type VectorStoreDocument } from '~/features/openai/server/models/vector-store';
 import VectorStoreFileRelationModel, {
 import VectorStoreFileRelationModel, {
   type VectorStoreFileRelation,
   type VectorStoreFileRelation,
@@ -19,8 +20,8 @@ import loggerFactory from '~/utils/logger';
 
 
 import { OpenaiServiceTypes } from '../../interfaces/ai';
 import { OpenaiServiceTypes } from '../../interfaces/ai';
 
 
-
 import { getClient } from './client-delegator';
 import { getClient } from './client-delegator';
+import { oepnaiApiErrorHandler } from './openai-api-error-handler';
 
 
 const BATCH_SIZE = 100;
 const BATCH_SIZE = 100;
 
 
@@ -29,7 +30,9 @@ const logger = loggerFactory('growi:service:openai');
 let isVectorStoreForPublicScopeExist = false;
 let isVectorStoreForPublicScopeExist = false;
 
 
 export interface IOpenaiService {
 export interface IOpenaiService {
+  getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise<OpenAI.Beta.Threads.Thread | undefined>;
   getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument>;
   getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument>;
+  deleteExpiredThreads(limit: 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>;
@@ -42,6 +45,60 @@ class OpenaiService implements IOpenaiService {
     return getClient({ openaiServiceType });
     return getClient({ openaiServiceType });
   }
   }
 
 
+  public async getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise<OpenAI.Beta.Threads.Thread> {
+    if (vectorStoreId != null && threadId == null) {
+      try {
+        const thread = await this.client.createThread(vectorStoreId);
+        await ThreadRelationModel.create({ userId, threadId: thread.id });
+        return thread;
+      }
+      catch (err) {
+        throw new Error(err);
+      }
+    }
+
+    const threadRelation = await ThreadRelationModel.findOne({ threadId });
+    if (threadRelation == null) {
+      throw new Error('ThreadRelation document is not exists');
+    }
+
+    // Check if a thread entity exists
+    // If the thread entity does not exist, the thread-relation document is deleted
+    try {
+      const thread = await this.client.retrieveThread(threadRelation.threadId);
+
+      // Update expiration date if thread entity exists
+      await threadRelation.updateThreadExpiration();
+
+      return thread;
+    }
+    catch (err) {
+      await oepnaiApiErrorHandler(err, { notFoundError: async() => { await threadRelation.remove() } });
+      throw new Error(err);
+    }
+  }
+
+  public async deleteExpiredThreads(limit: number): Promise<void> {
+    const expiredThreadRelations = await ThreadRelationModel.getExpiredThreadRelations(limit);
+    if (expiredThreadRelations == null) {
+      return;
+    }
+
+    const deletedThreadIds: string[] = [];
+    for await (const expiredThreadRelation of expiredThreadRelations) {
+      try {
+        const deleteThreadResponse = await this.client.deleteThread(expiredThreadRelation.threadId);
+        logger.debug('Delete thread', deleteThreadResponse);
+        deletedThreadIds.push(expiredThreadRelation.threadId);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    }
+
+    await ThreadRelationModel.deleteMany({ threadId: { $in: deletedThreadIds } });
+  }
+
   public async getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument> {
   public async getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument> {
     const vectorStoreDocument = await VectorStoreModel.findOne({ scorpeType: VectorStoreScopeType.PUBLIC });
     const vectorStoreDocument = await VectorStoreModel.findOne({ scorpeType: VectorStoreScopeType.PUBLIC });
 
 
@@ -50,11 +107,17 @@ class OpenaiService implements IOpenaiService {
     }
     }
 
 
     if (vectorStoreDocument != null && !isVectorStoreForPublicScopeExist) {
     if (vectorStoreDocument != null && !isVectorStoreForPublicScopeExist) {
-      const vectorStore = await this.client.retrieveVectorStore(vectorStoreDocument.vectorStoreId);
-      if (vectorStore != null) {
+      try {
+        // Check if vector store entity exists
+        // If the vector store entity does not exist, the vector store document is deleted
+        await this.client.retrieveVectorStore(vectorStoreDocument.vectorStoreId);
         isVectorStoreForPublicScopeExist = true;
         isVectorStoreForPublicScopeExist = true;
         return vectorStoreDocument;
         return vectorStoreDocument;
       }
       }
+      catch (err) {
+        await oepnaiApiErrorHandler(err, { notFoundError: async() => { await vectorStoreDocument.remove() } });
+        throw new Error(err);
+      }
     }
     }
 
 
     const newVectorStore = await this.client.createVectorStore(VectorStoreScopeType.PUBLIC);
     const newVectorStore = await this.client.createVectorStore(VectorStoreScopeType.PUBLIC);
@@ -74,7 +137,7 @@ class OpenaiService implements IOpenaiService {
     return uploadedFile;
     return uploadedFile;
   }
   }
 
 
-  async createVectorStoreFile(pages: Array<PageDocument>): Promise<void> {
+  async createVectorStoreFile(pages: Array<HydratedDocument<PageDocument>>): Promise<void> {
     const vectorStoreFileRelationsMap: Map<string, VectorStoreFileRelation> = new Map();
     const vectorStoreFileRelationsMap: Map<string, VectorStoreFileRelation> = new Map();
     const processUploadFile = async(page: PageDocument) => {
     const processUploadFile = async(page: PageDocument) => {
       if (page._id != null && page.grant === PageGrant.GRANT_PUBLIC && page.revision != null) {
       if (page._id != null && page.grant === PageGrant.GRANT_PUBLIC && page.revision != null) {
@@ -112,22 +175,22 @@ class OpenaiService implements IOpenaiService {
     }
     }
 
 
     try {
     try {
+      // Save vector store file relation
+      await VectorStoreFileRelationModel.upsertVectorStoreFileRelations(vectorStoreFileRelations);
+
       // Create vector store file
       // Create vector store file
       const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
       const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
       const createVectorStoreFileBatchResponse = await this.client.createVectorStoreFileBatch(vectorStore.vectorStoreId, uploadedFileIds);
       const createVectorStoreFileBatchResponse = await this.client.createVectorStoreFileBatch(vectorStore.vectorStoreId, uploadedFileIds);
       logger.debug('Create vector store file', createVectorStoreFileBatchResponse);
       logger.debug('Create vector store file', createVectorStoreFileBatchResponse);
-
-      // Save vector store file relation
-      await VectorStoreFileRelationModel.upsertVectorStoreFileRelations(vectorStoreFileRelations);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
 
 
       // Delete all uploaded files if createVectorStoreFileBatch fails
       // Delete all uploaded files if createVectorStoreFileBatch fails
-      uploadedFileIds.forEach(async(fileId) => {
-        const deleteFileResponse = await this.client.deleteFile(fileId);
-        logger.debug('Delete vector store file (Due to createVectorStoreFileBatch failure)', deleteFileResponse);
-      });
+      const pageIds = pages.map(page => page._id);
+      for await (const pageId of pageIds) {
+        await this.deleteVectorStoreFile(pageId);
+      }
     }
     }
 
 
   }
   }
@@ -140,9 +203,8 @@ class OpenaiService implements IOpenaiService {
     }
     }
 
 
     const deletedFileIds: string[] = [];
     const deletedFileIds: string[] = [];
-    for (const fileId of vectorStoreFileRelation.fileIds) {
+    for await (const fileId of vectorStoreFileRelation.fileIds) {
       try {
       try {
-        // eslint-disable-next-line no-await-in-loop
         const deleteFileResponse = await this.client.deleteFile(fileId);
         const deleteFileResponse = await this.client.deleteFile(fileId);
         logger.debug('Delete vector store file', deleteFileResponse);
         logger.debug('Delete vector store file', deleteFileResponse);
         deletedFileIds.push(fileId);
         deletedFileIds.push(fileId);
@@ -174,7 +236,7 @@ class OpenaiService implements IOpenaiService {
     const createVectorStoreFile = this.createVectorStoreFile.bind(this);
     const createVectorStoreFile = this.createVectorStoreFile.bind(this);
     const createVectorStoreFileStream = new Transform({
     const createVectorStoreFileStream = new Transform({
       objectMode: true,
       objectMode: true,
-      async transform(chunk: PageDocument[], encoding, callback) {
+      async transform(chunk: HydratedDocument<PageDocument>[], encoding, callback) {
         await createVectorStoreFile(chunk);
         await createVectorStoreFile(chunk);
         this.push(chunk);
         this.push(chunk);
         callback();
         callback();

+ 59 - 0
apps/app/src/features/openai/server/services/thread-deletion-cron.ts

@@ -0,0 +1,59 @@
+import nodeCron from 'node-cron';
+
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+import { getOpenaiService, type IOpenaiService } from './openai';
+
+
+const logger = loggerFactory('growi:service:thread-deletion-cron');
+
+const DELETE_LIMIT = 100;
+
+class ThreadDeletionCronService {
+
+  cronJob: nodeCron.ScheduledTask;
+
+  openaiService: IOpenaiService;
+
+  startCron(): void {
+    const isAiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
+    if (!isAiEnabled) {
+      return;
+    }
+
+    const openaiService = getOpenaiService();
+    if (openaiService == null) {
+      throw new Error('OpenAI service is not initialized');
+    }
+
+    this.openaiService = openaiService;
+
+    // Executed at 0 minutes of every hour
+    const cronSchedule = '0 * * * *';
+
+    this.cronJob?.stop();
+    this.cronJob = this.generateCronJob(cronSchedule);
+    this.cronJob.start();
+  }
+
+  private async executeJob(): Promise<void> {
+    // Must be careful of OpenAI's rate limit
+    // Delete up to 100 threads per hour
+    await this.openaiService.deleteExpiredThreads(DELETE_LIMIT);
+  }
+
+  private generateCronJob(cronSchedule: string) {
+    return nodeCron.schedule(cronSchedule, async() => {
+      try {
+        await this.executeJob();
+      }
+      catch (e) {
+        logger.error(e);
+      }
+    });
+  }
+
+}
+
+export default ThreadDeletionCronService;

+ 5 - 0
apps/app/src/server/crowi/index.js

@@ -12,6 +12,7 @@ import pkg from '^/package.json';
 
 
 import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
 import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
 import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
+import OpenaiThreadDeletionCronService from '~/features/openai/server/services/thread-deletion-cron';
 import QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
 import QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
 import QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -102,6 +103,7 @@ class Crowi {
     this.commentService = null;
     this.commentService = null;
     this.questionnaireService = null;
     this.questionnaireService = null;
     this.questionnaireCronService = null;
     this.questionnaireCronService = null;
+    this.openaiThreadDeletionCronService = null;
 
 
     this.tokens = null;
     this.tokens = null;
 
 
@@ -312,6 +314,9 @@ Crowi.prototype.setupSocketIoService = async function() {
 Crowi.prototype.setupCron = function() {
 Crowi.prototype.setupCron = function() {
   this.questionnaireCronService = new QuestionnaireCronService(this);
   this.questionnaireCronService = new QuestionnaireCronService(this);
   this.questionnaireCronService.startCron();
   this.questionnaireCronService.startCron();
+
+  this.openaiThreadDeletionCronService = new OpenaiThreadDeletionCronService();
+  this.openaiThreadDeletionCronService.startCron();
 };
 };
 
 
 Crowi.prototype.setupQuestionnaireService = function() {
 Crowi.prototype.setupQuestionnaireService = function() {

+ 1 - 1
apps/app/src/services/renderer/remark-plugins/codeblock.ts

@@ -10,7 +10,7 @@ export const remarkPlugin: Plugin = () => {
   return (tree) => {
   return (tree) => {
     visit(tree, 'inlineCode', (node: InlineCode) => {
     visit(tree, 'inlineCode', (node: InlineCode) => {
       const data = node.data || (node.data = {});
       const data = node.data || (node.data = {});
-      data.hProperties = { inline: true };
+      data.hProperties = { inline: 'true' }; // set 'true' explicitly because the empty string is evaluated as false for `if (inline) { ... }`
     });
     });
   };
   };
 };
 };

+ 14 - 12
apps/app/src/services/renderer/renderer.tsx

@@ -44,15 +44,18 @@ let commonSanitizeOption: SanitizeOption;
 export const getCommonSanitizeOption = (config:RendererConfig): SanitizeOption => {
 export const getCommonSanitizeOption = (config:RendererConfig): SanitizeOption => {
   if (commonSanitizeOption == null || config.sanitizeType !== currentInitializedSanitizeType) {
   if (commonSanitizeOption == null || config.sanitizeType !== currentInitializedSanitizeType) {
     // initialize
     // initialize
-    commonSanitizeOption = {
-      tagNames: config.sanitizeType === RehypeSanitizeType.RECOMMENDED
-        ? recommendedTagNames
-        : config.customTagWhitelist ?? recommendedTagNames,
-      attributes: config.sanitizeType === RehypeSanitizeType.RECOMMENDED
-        ? recommendedAttributes
-        : config.customAttrWhitelist ?? recommendedAttributes,
-      clobberPrefix: '', // remove clobber prefix
-    };
+    commonSanitizeOption = deepmerge(
+      {
+        tagNames: config.sanitizeType === RehypeSanitizeType.RECOMMENDED
+          ? recommendedTagNames
+          : config.customTagWhitelist ?? recommendedTagNames,
+        attributes: config.sanitizeType === RehypeSanitizeType.RECOMMENDED
+          ? recommendedAttributes
+          : config.customAttrWhitelist ?? recommendedAttributes,
+        clobberPrefix: '', // remove clobber prefix
+      },
+      codeBlock.sanitizeOption,
+    );
 
 
     currentInitializedSanitizeType = config.sanitizeType;
     currentInitializedSanitizeType = config.sanitizeType;
   }
   }
@@ -123,6 +126,7 @@ export const generateSSRViewOptions = (
     config: RendererConfig,
     config: RendererConfig,
     pagePath: string,
     pagePath: string,
 ): RendererOptions => {
 ): RendererOptions => {
+
   const options = generateCommonOptions(pagePath);
   const options = generateCommonOptions(pagePath);
 
 
   const { remarkPlugins, rehypePlugins } = options;
   const { remarkPlugins, rehypePlugins } = options;
@@ -140,9 +144,7 @@ export const generateSSRViewOptions = (
   }
   }
 
 
   const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
   const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
-    ? [sanitize, deepmerge(
-      getCommonSanitizeOption(config),
-    )]
+    ? [sanitize, getCommonSanitizeOption(config)]
     : () => {};
     : () => {};
 
 
   // add rehype plugins
   // add rehype plugins

+ 5 - 0
yarn.lock

@@ -4701,6 +4701,11 @@
   resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
   resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
   integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
   integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
 
 
+"@types/node-cron@^3.0.11":
+  version "3.0.11"
+  resolved "https://registry.yarnpkg.com/@types/node-cron/-/node-cron-3.0.11.tgz#70b7131f65038ae63cfe841354c8aba363632344"
+  integrity sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==
+
 "@types/node-fetch@^2.5.0", "@types/node-fetch@^2.6.4":
 "@types/node-fetch@^2.5.0", "@types/node-fetch@^2.6.4":
   version "2.6.11"
   version "2.6.11"
   resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24"
   resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24"