diff --git a/apps/app/package.json b/apps/app/package.json index 03e6363742e..2524145964a 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -235,6 +235,7 @@ "@testing-library/user-event": "^14.5.2", "@types/express": "^4.17.21", "@types/jest": "^29.5.2", + "@types/node-cron": "^3.0.11", "@types/react-input-autosize": "^2.2.4", "@types/react-scroll": "^1.8.4", "@types/react-stickynode": "^4.0.3", @@ -275,8 +276,8 @@ "react-hotkeys": "^2.0.0", "react-input-autosize": "^3.0.0", "react-toastify": "^9.1.3", - "remark-github-admonitions-to-directives": "^2.0.0", "rehype-rewrite": "^4.0.2", + "remark-github-admonitions-to-directives": "^2.0.0", "replacestream": "^4.0.3", "sass": "^1.53.0", "simple-load-script": "^1.0.2", diff --git a/apps/app/src/features/openai/server/models/thread-relation.ts b/apps/app/src/features/openai/server/models/thread-relation.ts index 736a1c0c616..e333545f5f9 100644 --- a/apps/app/src/features/openai/server/models/thread-relation.ts +++ b/apps/app/src/features/openai/server/models/thread-relation.ts @@ -21,7 +21,9 @@ interface ThreadRelationDocument extends ThreadRelation, Document { updateThreadExpiration(): Promise; } -type ThreadRelationModel = Model +interface ThreadRelationModel extends Model { + getExpiredThreadRelations(limit?: number): Promise; +} const schema = new Schema({ userId: { @@ -41,6 +43,12 @@ const schema = new Schema({ }, }); +schema.statics.getExpiredThreadRelations = async function(limit?: number): Promise { + const currentDate = new Date(); + const expiredThreadRelations = await this.find({ expiredAt: { $lte: currentDate } }).limit(limit ?? 100).exec(); + return expiredThreadRelations; +}; + schema.methods.updateThreadExpiration = async function(): Promise { this.expiredAt = generateExpirationDate(); await this.save(); diff --git a/apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts b/apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts index 202d1be517b..63c0d3237fd 100644 --- a/apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts +++ b/apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts @@ -36,6 +36,10 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator { return this.client.beta.threads.retrieve(threadId); } + async deleteThread(threadId: string): Promise { + return this.client.beta.threads.del(threadId); + } + async createVectorStore(scopeType:VectorStoreScopeType): Promise { return this.client.beta.vectorStores.create({ name: `growi-vector-store-{${scopeType}` }); } diff --git a/apps/app/src/features/openai/server/services/client-delegator/interfaces.ts b/apps/app/src/features/openai/server/services/client-delegator/interfaces.ts index 01e9df8af89..e2f2f6dcd8e 100644 --- a/apps/app/src/features/openai/server/services/client-delegator/interfaces.ts +++ b/apps/app/src/features/openai/server/services/client-delegator/interfaces.ts @@ -6,6 +6,7 @@ import type { VectorStoreScopeType } from '~/features/openai/server/models/vecto export interface IOpenaiClientDelegator { createThread(vectorStoreId: string): Promise retrieveThread(threadId: string): Promise + deleteThread(threadId: string): Promise retrieveVectorStore(vectorStoreId: string): Promise createVectorStore(scopeType:VectorStoreScopeType): Promise uploadFile(file: Uploadable): Promise diff --git a/apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts b/apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts index 9c8c3e2b664..8a54ac9a565 100644 --- a/apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts +++ b/apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts @@ -38,6 +38,10 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator { return this.client.beta.threads.retrieve(threadId); } + async deleteThread(threadId: string): Promise { + return this.client.beta.threads.del(threadId); + } + async createVectorStore(scopeType:VectorStoreScopeType): Promise { return this.client.beta.vectorStores.create({ name: `growi-vector-store-${scopeType}` }); } diff --git a/apps/app/src/features/openai/server/services/openai.ts b/apps/app/src/features/openai/server/services/openai.ts index 8cb0f82b7a8..cf36aee39f8 100644 --- a/apps/app/src/features/openai/server/services/openai.ts +++ b/apps/app/src/features/openai/server/services/openai.ts @@ -32,6 +32,7 @@ let isVectorStoreForPublicScopeExist = false; export interface IOpenaiService { getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise; getOrCreateVectorStoreForPublicScope(): Promise; + deleteExpiredThreads(limit: number): Promise; createVectorStoreFile(pages: PageDocument[]): Promise; deleteVectorStoreFile(pageId: Types.ObjectId): Promise; rebuildVectorStoreAll(): Promise; @@ -70,6 +71,28 @@ class OpenaiService implements IOpenaiService { return thread; } + public async deleteExpiredThreads(limit: number): Promise { + const expiredThreadRelations = await ThreadRelationModel.getExpiredThreadRelations(limit); + if (expiredThreadRelations == null) { + return; + } + + const deletedThreadIds: string[] = []; + for (const expiredThreadRelation of expiredThreadRelations) { + try { + // eslint-disable-next-line no-await-in-loop + 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 { const vectorStoreDocument = await VectorStoreModel.findOne({ scorpeType: VectorStoreScopeType.PUBLIC }); diff --git a/apps/app/src/features/openai/server/services/thread-deletion-cron.ts b/apps/app/src/features/openai/server/services/thread-deletion-cron.ts new file mode 100644 index 00000000000..226e85783e5 --- /dev/null +++ b/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 { + // 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; diff --git a/apps/app/src/server/crowi/index.js b/apps/app/src/server/crowi/index.js index f3fccaa3fbe..9c7aec8fa1f 100644 --- a/apps/app/src/server/crowi/index.js +++ b/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 { 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 QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron'; import loggerFactory from '~/utils/logger'; @@ -102,6 +103,7 @@ class Crowi { this.commentService = null; this.questionnaireService = null; this.questionnaireCronService = null; + this.openaiThreadDeletionCronService = null; this.tokens = null; @@ -312,6 +314,9 @@ Crowi.prototype.setupSocketIoService = async function() { Crowi.prototype.setupCron = function() { this.questionnaireCronService = new QuestionnaireCronService(this); this.questionnaireCronService.startCron(); + + this.openaiThreadDeletionCronService = new OpenaiThreadDeletionCronService(); + this.openaiThreadDeletionCronService.startCron(); }; Crowi.prototype.setupQuestionnaireService = function() { diff --git a/yarn.lock b/yarn.lock index 58bb54b89b7..bc47ad2440b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4687,6 +4687,11 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" 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": version "2.6.11" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24"