Kaynağa Gözat

Merge pull request #9630 from weseek/feat/161504-implement-api-to-update-ai-assistant

Shun Miyazawa 1 yıl önce
ebeveyn
işleme
14024415d2

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

@@ -1,8 +1,8 @@
 import { apiv3Post, apiv3Delete } from '~/client/util/apiv3-client';
 
-import type { IApiv3AiAssistantCreateParams } from '../../interfaces/ai-assistant';
+import type { UpsertAiAssistantData } from '../../interfaces/ai-assistant';
 
-export const createAiAssistant = async(body: IApiv3AiAssistantCreateParams): Promise<void> => {
+export const createAiAssistant = async(body: UpsertAiAssistantData): Promise<void> => {
   await apiv3Post('/openai/ai-assistant', body);
 };
 

+ 1 - 1
apps/app/src/features/openai/interfaces/ai-assistant.ts

@@ -41,7 +41,7 @@ export interface AiAssistant {
 
 export type AiAssistantHasId = AiAssistant & HasObjectId
 
-export type IApiv3AiAssistantCreateParams = Omit<AiAssistant, 'owner' | 'vectorStore'>
+export type UpsertAiAssistantData = Omit<AiAssistant, 'owner' | 'vectorStore'>
 
 export type AccessibleAiAssistants = {
   myAiAssistants: AiAssistant[],

+ 13 - 87
apps/app/src/features/openai/server/routes/ai-assistant.ts

@@ -1,8 +1,6 @@
-import { type IUserHasId, GroupType } from '@growi/core';
+import { type IUserHasId } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
-import { isGrobPatternPath, isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import type { Request, RequestHandler } from 'express';
-import { type ValidationChain, body } from 'express-validator';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
@@ -10,108 +8,36 @@ import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
-import { type IApiv3AiAssistantCreateParams, AiAssistantShareScope, AiAssistantAccessScope } from '../../interfaces/ai-assistant';
+import { type UpsertAiAssistantData } from '../../interfaces/ai-assistant';
 import { getOpenaiService } from '../services/openai';
 
 import { certifyAiService } from './middlewares/certify-ai-service';
+import { upsertAiAssistantValidator } from './middlewares/upsert-ai-assistant-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:create-ai-assistant');
 
-
 type CreateAssistantFactory = (crowi: Crowi) => RequestHandler[];
 
-type Req = Request<undefined, Response, IApiv3AiAssistantCreateParams> & {
+type ReqBody = UpsertAiAssistantData;
+
+type Req = Request<undefined, Response, ReqBody> & {
   user: IUserHasId,
 }
 
 export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
-  const validator: ValidationChain[] = [
-    body('name')
-      .isString()
-      .withMessage('name must be a string')
-      .not()
-      .isEmpty()
-      .withMessage('name is required')
-      .escape(),
-
-    body('description')
-      .optional()
-      .isString()
-      .withMessage('description must be a string')
-      .escape(),
-
-    body('additionalInstruction')
-      .optional()
-      .isString()
-      .withMessage('additionalInstruction must be a string')
-      .escape(),
-
-    body('pagePathPatterns')
-      .isArray()
-      .withMessage('pagePathPatterns must be an array of strings')
-      .not()
-      .isEmpty()
-      .withMessage('pagePathPatterns must not be empty'),
-
-    body('pagePathPatterns.*') // each item of pagePathPatterns
-      .isString()
-      .withMessage('pagePathPatterns must be an array of strings')
-      .notEmpty()
-      .withMessage('pagePathPatterns must not be empty')
-      .custom((value: string) => {
-
-        // check if the value is a grob pattern path
-        if (value.includes('*')) {
-          return isGrobPatternPath(value) && isCreatablePage(value.replace('*', ''));
-        }
-
-        return isCreatablePage(value);
-      }),
-
-    body('grantedGroupsForShareScope')
-      .optional()
-      .isArray()
-      .withMessage('grantedGroupsForShareScope must be an array'),
-
-    body('grantedGroupsForShareScope.*.type') // each item of grantedGroupsForShareScope
-      .isIn(Object.values(GroupType))
-      .withMessage('Invalid grantedGroupsForShareScope type value'),
-
-    body('grantedGroupsForShareScope.*.item') // each item of grantedGroupsForShareScope
-      .isMongoId()
-      .withMessage('Invalid grantedGroupsForShareScope item value'),
-
-    body('grantedGroupsForAccessScope')
-      .optional()
-      .isArray()
-      .withMessage('grantedGroupsForAccessScope must be an array'),
-
-    body('grantedGroupsForAccessScope.*.type') // each item of grantedGroupsForAccessScope
-      .isIn(Object.values(GroupType))
-      .withMessage('Invalid grantedGroupsForAccessScope type value'),
-
-    body('grantedGroupsForAccessScope.*.item') // each item of grantedGroupsForAccessScope
-      .isMongoId()
-      .withMessage('Invalid grantedGroupsForAccessScope item value'),
-
-    body('shareScope')
-      .isIn(Object.values(AiAssistantShareScope))
-      .withMessage('Invalid shareScope value'),
-
-    body('accessScope')
-      .isIn(Object.values(AiAssistantAccessScope))
-      .withMessage('Invalid accessScope value'),
-  ];
-
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser, loginRequiredStrictly, certifyAiService, upsertAiAssistantValidator, 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 aiAssistantData = { ...req.body, owner: req.user._id };
-        const openaiService = getOpenaiService();
-        const aiAssistant = await openaiService?.createAiAssistant(aiAssistantData);
+        const aiAssistant = await openaiService.createAiAssistant(aiAssistantData);
 
         return res.apiv3({ aiAssistant });
       }

+ 6 - 2
apps/app/src/features/openai/server/routes/ai-assistants.ts

@@ -27,9 +27,13 @@ export const getAiAssistantsFactory: GetAiAssistantsFactory = (crowi) => {
   return [
     accessTokenParser, loginRequiredStrictly, certifyAiService,
     async(req: Req, res: ApiV3Response) => {
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
       try {
-        const openaiService = getOpenaiService();
-        const accessibleAiAssistants = await openaiService?.getAccessibleAiAssistants(req.user);
+        const accessibleAiAssistants = await openaiService.getAccessibleAiAssistants(req.user);
 
         return res.apiv3({ accessibleAiAssistants });
       }

+ 12 - 2
apps/app/src/features/openai/server/routes/delete-ai-assistant.ts

@@ -2,6 +2,7 @@ import { type IUserHasId } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import { type ValidationChain, param } from 'express-validator';
+import { isHttpError } from 'http-errors';
 
 
 import type Crowi from '~/server/crowi';
@@ -40,13 +41,22 @@ export const deleteAiAssistantsFactory: DeleteAiAssistantsFactory = (crowi) => {
       const { id } = req.params;
       const { user } = req;
 
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
       try {
-        const openaiService = getOpenaiService();
-        const deletedAiAssistant = await openaiService?.deleteAiAssistant(user._id, id);
+        const deletedAiAssistant = await openaiService.deleteAiAssistant(user._id, id);
         return res.apiv3({ deletedAiAssistant });
       }
       catch (err) {
         logger.error(err);
+
+        if (isHttpError(err)) {
+          return res.apiv3Err(new ErrorV3(err.message), err.status);
+        }
+
         return res.apiv3Err(new ErrorV3('Failed to delete AiAssistants'));
       }
     },

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

@@ -39,6 +39,10 @@ export const factory = (crowi: Crowi): express.Router => {
       router.get('/ai-assistants', getAiAssistantsFactory(crowi));
     });
 
+    import('./update-ai-assistant').then(({ updateAiAssistantsFactory }) => {
+      router.put('/ai-assistant/:id', updateAiAssistantsFactory(crowi));
+    });
+
     import('./delete-ai-assistant').then(({ deleteAiAssistantsFactory }) => {
       router.delete('/ai-assistant/:id', deleteAiAssistantsFactory(crowi));
     });

+ 83 - 0
apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts

@@ -0,0 +1,83 @@
+import { GroupType } from '@growi/core';
+import { isGrobPatternPath, isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
+import { type ValidationChain, body } from 'express-validator';
+
+import { AiAssistantShareScope, AiAssistantAccessScope } from '../../../interfaces/ai-assistant';
+
+export const upsertAiAssistantValidator: ValidationChain[] = [
+  body('name')
+    .isString()
+    .withMessage('name must be a string')
+    .not()
+    .isEmpty()
+    .withMessage('name is required')
+    .escape(),
+
+  body('description')
+    .optional()
+    .isString()
+    .withMessage('description must be a string')
+    .escape(),
+
+  body('additionalInstruction')
+    .optional()
+    .isString()
+    .withMessage('additionalInstruction must be a string')
+    .escape(),
+
+  body('pagePathPatterns')
+    .isArray()
+    .withMessage('pagePathPatterns must be an array of strings')
+    .not()
+    .isEmpty()
+    .withMessage('pagePathPatterns must not be empty'),
+
+  body('pagePathPatterns.*') // each item of pagePathPatterns
+    .isString()
+    .withMessage('pagePathPatterns must be an array of strings')
+    .notEmpty()
+    .withMessage('pagePathPatterns must not be empty')
+    .custom((value: string) => {
+
+      // check if the value is a grob pattern path
+      if (value.includes('*')) {
+        return isGrobPatternPath(value) && isCreatablePage(value.replace('*', ''));
+      }
+
+      return isCreatablePage(value);
+    }),
+
+  body('grantedGroupsForShareScope')
+    .optional()
+    .isArray()
+    .withMessage('grantedGroupsForShareScope must be an array'),
+
+  body('grantedGroupsForShareScope.*.type') // each item of grantedGroupsForShareScope
+    .isIn(Object.values(GroupType))
+    .withMessage('Invalid grantedGroupsForShareScope type value'),
+
+  body('grantedGroupsForShareScope.*.item') // each item of grantedGroupsForShareScope
+    .isMongoId()
+    .withMessage('Invalid grantedGroupsForShareScope item value'),
+
+  body('grantedGroupsForAccessScope')
+    .optional()
+    .isArray()
+    .withMessage('grantedGroupsForAccessScope must be an array'),
+
+  body('grantedGroupsForAccessScope.*.type') // each item of grantedGroupsForAccessScope
+    .isIn(Object.values(GroupType))
+    .withMessage('Invalid grantedGroupsForAccessScope type value'),
+
+  body('grantedGroupsForAccessScope.*.item') // each item of grantedGroupsForAccessScope
+    .isMongoId()
+    .withMessage('Invalid grantedGroupsForAccessScope item value'),
+
+  body('shareScope')
+    .isIn(Object.values(AiAssistantShareScope))
+    .withMessage('Invalid shareScope value'),
+
+  body('accessScope')
+    .isIn(Object.values(AiAssistantAccessScope))
+    .withMessage('Invalid accessScope value'),
+];

+ 5 - 1
apps/app/src/features/openai/server/routes/rebuild-vector-store.ts

@@ -28,8 +28,12 @@ export const rebuildVectorStoreHandlersFactory: RebuildVectorStoreFactory = (cro
     accessTokenParser, loginRequiredStrictly, adminRequired, certifyAiService, validator, apiV3FormValidator,
     async(req: Request, res: ApiV3Response) => {
 
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
       try {
-        const openaiService = getOpenaiService();
         // await openaiService?.rebuildVectorStoreAll();
         return res.apiv3({});
 

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

@@ -1,4 +1,5 @@
 import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
@@ -30,8 +31,13 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
   return [
     accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: CreateThreadReq, res: ApiV3Response) => {
+
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
       try {
-        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);

+ 69 - 0
apps/app/src/features/openai/server/routes/update-ai-assistant.ts

@@ -0,0 +1,69 @@
+import { type IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { type ValidationChain, param } from 'express-validator';
+import { isHttpError } from 'http-errors';
+
+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 { type UpsertAiAssistantData } from '../../interfaces/ai-assistant';
+import { getOpenaiService } from '../services/openai';
+
+import { certifyAiService } from './middlewares/certify-ai-service';
+import { upsertAiAssistantValidator } from './middlewares/upsert-ai-assistant-validator';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:update-ai-assistants');
+
+type UpdateAiAssistantsFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = {
+  id: string,
+}
+
+type ReqBody = UpsertAiAssistantData;
+
+type Req = Request<ReqParams, Response, ReqBody> & {
+  user: IUserHasId,
+}
+
+export const updateAiAssistantsFactory: UpdateAiAssistantsFactory = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    param('id').isMongoId().withMessage('aiAssistant id is required'),
+    ...upsertAiAssistantValidator,
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { id } = req.params;
+      const { user } = req;
+
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      try {
+        const aiAssistantData = { ...req.body, owner: user._id };
+        const updatedAiAssistant = await openaiService.updateAiAssistant(id, aiAssistantData);
+
+        return res.apiv3({ updatedAiAssistant });
+      }
+      catch (err) {
+        logger.error(err);
+
+        if (isHttpError(err)) {
+          return res.apiv3Err(new ErrorV3(err.message), err.status);
+        }
+
+        return res.apiv3Err(new ErrorV3('Failed to update AiAssistants'));
+      }
+    },
+  ];
+};

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

@@ -5,8 +5,10 @@ import { pipeline } from 'stream/promises';
 import {
   PageGrant, getIdForRef, getIdStringForRef, isPopulated, type IUserHasId,
 } from '@growi/core';
+import { deepEquals } from '@growi/core/dist/utils';
 import { isGrobPatternPath } from '@growi/core/dist/utils/page-path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
+import createError from 'http-errors';
 import mongoose, { type HydratedDocument, type Types } from 'mongoose';
 import { type OpenAI, toFile } from 'openai';
 
@@ -34,6 +36,7 @@ import { getClient } from './client-delegator';
 // import { splitMarkdownIntoChunks } from './markdown-splitter/markdown-token-splitter';
 import { openaiApiErrorHandler } from './openai-api-error-handler';
 
+const { isDeepEquals } = deepEquals;
 
 const BATCH_SIZE = 100;
 
@@ -68,6 +71,7 @@ export interface IOpenaiService {
   // rebuildVectorStoreAll(): Promise<void>;
   // rebuildVectorStore(page: HydratedDocument<PageDocument>): Promise<void>;
   createAiAssistant(data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument>;
+  updateAiAssistant(aiAssistantId: string, data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument>;
   getAccessibleAiAssistants(user: IUserHasId): Promise<AccessibleAiAssistants>
   deleteAiAssistant(ownerId: string, aiAssistantId: string): Promise<AiAssistantDocument>
 }
@@ -425,7 +429,7 @@ class OpenaiService implements IOpenaiService {
     await pipeline(pagesStream, batchStream, createVectorStoreFileStream);
   }
 
-  private async createConditionForCreateAiAssistant(
+  private async createConditionForCreateVectorStoreFile(
       owner: AiAssistant['owner'],
       accessScope: AiAssistant['accessScope'],
       grantedGroupsForAccessScope: AiAssistant['grantedGroupsForAccessScope'],
@@ -500,7 +504,7 @@ class OpenaiService implements IOpenaiService {
     throw new Error('Invalid accessScope value');
   }
 
-  private async validateGrantedUserGroupsForCreateAiAssistant(
+  private async validateGrantedUserGroupsForAiAssistant(
       owner: AiAssistant['owner'],
       shareScope: AiAssistant['shareScope'],
       accessScope: AiAssistant['accessScope'],
@@ -543,7 +547,7 @@ class OpenaiService implements IOpenaiService {
   }
 
   async createAiAssistant(data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument> {
-    await this.validateGrantedUserGroupsForCreateAiAssistant(
+    await this.validateGrantedUserGroupsForAiAssistant(
       data.owner,
       data.shareScope,
       data.accessScope,
@@ -551,7 +555,7 @@ class OpenaiService implements IOpenaiService {
       data.grantedGroupsForAccessScope,
     );
 
-    const conditions = await this.createConditionForCreateAiAssistant(
+    const conditions = await this.createConditionForCreateVectorStoreFile(
       data.owner,
       data.accessScope,
       data.grantedGroupsForAccessScope,
@@ -569,6 +573,58 @@ class OpenaiService implements IOpenaiService {
     return aiAssistant;
   }
 
+  async updateAiAssistant(aiAssistantId: string, data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument> {
+    const aiAssistant = await AiAssistantModel.findOne({ owner: data.owner, _id: aiAssistantId });
+    if (aiAssistant == null) {
+      throw createError(404, 'AiAssistant document does not exist');
+    }
+
+    await this.validateGrantedUserGroupsForAiAssistant(
+      data.owner,
+      data.shareScope,
+      data.accessScope,
+      data.grantedGroupsForShareScope,
+      data.grantedGroupsForAccessScope,
+    );
+
+    const grantedGroupIdsForAccessScopeFromReq = data.grantedGroupsForAccessScope?.map(group => getIdStringForRef(group.item)) ?? []; // ObjectId[] -> string[]
+    const grantedGroupIdsForAccessScopeFromDb = aiAssistant.grantedGroupsForAccessScope?.map(group => getIdStringForRef(group.item)) ?? []; // ObjectId[] -> string[]
+
+    // If accessScope, pagePathPatterns, grantedGroupsForAccessScope have not changed, do not build VectorStore
+    const shouldRebuildVectorStore = data.accessScope !== aiAssistant.accessScope
+      || !isDeepEquals(data.pagePathPatterns, aiAssistant.pagePathPatterns)
+      || !isDeepEquals(grantedGroupIdsForAccessScopeFromReq, grantedGroupIdsForAccessScopeFromDb);
+
+    let newVectorStoreRelation: VectorStoreDocument | undefined;
+    if (shouldRebuildVectorStore) {
+      const conditions = await this.createConditionForCreateVectorStoreFile(
+        data.owner,
+        data.accessScope,
+        data.grantedGroupsForAccessScope,
+        data.pagePathPatterns,
+      );
+
+      // Delete obsoleted VectorStore
+      const obsoletedVectorStoreRelationId = getIdStringForRef(aiAssistant.vectorStore);
+      await this.deleteVectorStore(obsoletedVectorStoreRelationId);
+
+      newVectorStoreRelation = await this.createVectorStore(data.name);
+
+      // VectorStore creation process does not await
+      this.createVectorStoreFileWithStream(newVectorStoreRelation, conditions);
+    }
+
+    const newData = {
+      ...data,
+      vectorStore: newVectorStoreRelation ?? aiAssistant.vectorStore,
+    };
+
+    aiAssistant.set({ ...newData });
+    const updatedAiAssistant = await aiAssistant.save();
+
+    return updatedAiAssistant;
+  }
+
   async getAccessibleAiAssistants(user: IUserHasId): Promise<AccessibleAiAssistants> {
     const userGroupIds = [
       ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
@@ -608,7 +664,7 @@ class OpenaiService implements IOpenaiService {
   async deleteAiAssistant(ownerId: string, aiAssistantId: string): Promise<AiAssistantDocument> {
     const aiAssistant = await AiAssistantModel.findOne({ owner: ownerId, _id: aiAssistantId });
     if (aiAssistant == null) {
-      throw new Error('AiAssistant document does not exist');
+      throw createError(404, 'AiAssistant document does not exist');
     }
 
     const vectorStoreRelationId = getIdStringForRef(aiAssistant.vectorStore);

+ 30 - 0
apps/app/src/utils/is-deep-equal.ts

@@ -0,0 +1,30 @@
+export const isDeepEquals = <T extends object>(obj1: T, obj2: T): boolean => {
+  const typedKeys1 = Object.keys(obj1) as (keyof T)[];
+  const typedKeys2 = Object.keys(obj2) as (keyof T)[];
+
+  if (typedKeys1.length !== typedKeys2.length) {
+    return false;
+  }
+
+  return typedKeys1.every((key) => {
+    const val1 = obj1[key];
+    const val2 = obj2[key];
+
+    if (typeof val1 === 'object' && typeof val2 === 'object') {
+      if (val1 === null || val2 === null) {
+        return val1 === val2;
+      }
+
+      // if array
+      if (Array.isArray(val1) && Array.isArray(val2)) {
+        return val1.length === val2.length && val1.every((item, i) => val2[i] === item);
+      }
+
+      // if object
+      return isDeepEquals(val1, val2);
+    }
+
+    // if primitive
+    return val1 === val2;
+  });
+};

+ 1 - 0
packages/core/src/utils/index.ts

@@ -9,6 +9,7 @@ export * as objectIdUtils from './objectid-utils';
 export * as pagePathUtils from './page-path-utils';
 export * as pathUtils from './path-utils';
 export * as pageUtils from './page-utils';
+export * as deepEquals from './is-deep-equals';
 
 export * from './browser-utils';
 export * from './growi-theme-metadata';

+ 30 - 0
packages/core/src/utils/is-deep-equals.ts

@@ -0,0 +1,30 @@
+export const isDeepEquals = <T extends object>(obj1: T, obj2: T): boolean => {
+  const typedKeys1 = Object.keys(obj1) as (keyof T)[];
+  const typedKeys2 = Object.keys(obj2) as (keyof T)[];
+
+  if (typedKeys1.length !== typedKeys2.length) {
+    return false;
+  }
+
+  return typedKeys1.every((key) => {
+    const val1 = obj1[key];
+    const val2 = obj2[key];
+
+    if (typeof val1 === 'object' && typeof val2 === 'object') {
+      if (val1 === null || val2 === null) {
+        return val1 === val2;
+      }
+
+      // if array
+      if (Array.isArray(val1) && Array.isArray(val2)) {
+        return val1.length === val2.length && val1.every((item, i) => val2[i] === item);
+      }
+
+      // if object
+      return isDeepEquals(val1, val2);
+    }
+
+    // if primitive
+    return val1 === val2;
+  });
+};

+ 3 - 6
packages/editor/src/client/stores/codemirror-editor.ts

@@ -1,23 +1,20 @@
 import { useMemo, useRef } from 'react';
 
 import { useSWRStatic } from '@growi/core/dist/swr';
+import { deepEquals } from '@growi/core/dist/utils';
 import type { ReactCodeMirrorProps, UseCodeMirror } from '@uiw/react-codemirror';
 import type { SWRResponse } from 'swr';
 import deepmerge from 'ts-deepmerge';
 
 import { type UseCodeMirrorEditor, useCodeMirrorEditor } from '../services';
 
+const { isDeepEquals } = deepEquals;
+
 
 const isValid = (u: UseCodeMirrorEditor) => {
   return u.state != null && u.view != null;
 };
 
-const isDeepEquals = <T extends object>(obj1: T, obj2: T): boolean => {
-  const typedKeys = Object.keys(obj1) as (keyof typeof obj1)[];
-  return typedKeys.every(key => obj1[key] === obj2[key]);
-};
-
-
 export const useCodeMirrorEditorIsolated = (
     key: string | null, container?: HTMLDivElement | null, props?: ReactCodeMirrorProps,
 ): SWRResponse<UseCodeMirrorEditor> => {