Przeglądaj źródła

Merge pull request #9573 from weseek/feat/160587-implement-api-to-fetch-ai-assistants

feat: Implement API to fetch AiAssistants
Shun Miyazawa 1 rok temu
rodzic
commit
60c788403c

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

@@ -30,9 +30,15 @@ export interface AiAssistant {
   pagePathPatterns: string[],
   vectorStore: Ref<VectorStore>
   owner: Ref<IUser>
-  grantedGroups?: IGrantedGroup[]
+  grantedGroupsForShareScope?: IGrantedGroup[]
+  grantedGroupsForAccessScope?: IGrantedGroup[]
   shareScope: AiAssistantShareScope
   accessScope: AiAssistantAccessScope
 }
 
 export type IApiv3AiAssistantCreateParams = Omit<AiAssistant, 'owner' | 'vectorStore'>
+
+export type AccessibleAiAssistants = {
+  myAiAssistants: AiAssistant[],
+  teamAiAssistants: AiAssistant[],
+}

+ 23 - 1
apps/app/src/features/openai/server/models/ai-assistant.ts

@@ -43,7 +43,29 @@ const schema = new Schema<AiAssistantDocument>(
       ref: 'User',
       required: true,
     },
-    grantedGroups: {
+    grantedGroupsForShareScope: {
+      type: [{
+        type: {
+          type: String,
+          enum: Object.values(GroupType),
+          required: true,
+          default: 'UserGroup',
+        },
+        item: {
+          type: Schema.Types.ObjectId,
+          refPath: 'grantedGroups.type',
+          required: true,
+          index: true,
+        },
+      }],
+      validate: [function(arr: IGrantedGroup[]): boolean {
+        if (arr == null) return true;
+        const uniqueItemValues = new Set(arr.map(e => e.item));
+        return arr.length === uniqueItemValues.size;
+      }, 'grantedGroups contains non unique item'],
+      default: [],
+    },
+    grantedGroupsForAccessScope: {
       type: [{
         type: {
           type: String,

+ 21 - 8
apps/app/src/features/openai/server/routes/ai-assistant.ts

@@ -17,6 +17,7 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:create-ai-assistant');
 
+
 type CreateAssistantFactory = (crowi: Crowi) => RequestHandler[];
 
 type Req = Request<undefined, Response, IApiv3AiAssistantCreateParams> & {
@@ -25,7 +26,6 @@ type Req = Request<undefined, Response, IApiv3AiAssistantCreateParams> & {
 
 export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
-  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
 
   const validator: ValidationChain[] = [
     body('name')
@@ -70,18 +70,31 @@ export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
         return isCreatablePage(value);
       }),
 
-    body('grantedGroups')
+    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('Granted groups must be an array'),
+      .withMessage('grantedGroupsForAccessScope must be an array'),
 
-    body('grantedGroups.*.type') // each item of grantedGroups
+    body('grantedGroupsForAccessScope.*.type') // each item of grantedGroupsForAccessScope
       .isIn(Object.values(GroupType))
-      .withMessage('Invalid grantedGroups type value'),
+      .withMessage('Invalid grantedGroupsForAccessScope type value'),
 
-    body('grantedGroups.*.item') // each item of grantedGroups
+    body('grantedGroupsForAccessScope.*.item') // each item of grantedGroupsForAccessScope
       .isMongoId()
-      .withMessage('Invalid grantedGroups item value'),
+      .withMessage('Invalid grantedGroupsForAccessScope item value'),
 
     body('shareScope')
       .isIn(Object.values(AiAssistantShareScope))
@@ -93,7 +106,7 @@ export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly, adminRequired, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       try {
         const aiAssistantData = { ...req.body, owner: req.user._id };

+ 42 - 0
apps/app/src/features/openai/server/routes/ai-assistants.ts

@@ -0,0 +1,42 @@
+import { type IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+import { getOpenaiService } from '../services/openai';
+
+import { certifyAiService } from './middlewares/certify-ai-service';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:get-ai-assistants');
+
+
+type GetAiAssistantsFactory = (crowi: Crowi) => RequestHandler[];
+
+type Req = Request<undefined, Response, undefined> & {
+  user: IUserHasId,
+}
+
+export const getAiAssistantsFactory: GetAiAssistantsFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService,
+    async(req: Req, res: ApiV3Response) => {
+      try {
+        const openaiService = getOpenaiService();
+        const accessibleAiAssistants = await openaiService?.getAccessibleAiAssistants(req.user);
+
+        return res.apiv3({ accessibleAiAssistants });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Failed to get AiAssistants'));
+      }
+    },
+  ];
+};

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

@@ -34,6 +34,10 @@ export const factory = (crowi: Crowi): express.Router => {
     import('./ai-assistant').then(({ createAiAssistantFactory }) => {
       router.post('/ai-assistant', createAiAssistantFactory(crowi));
     });
+
+    import('./ai-assistants').then(({ getAiAssistantsFactory }) => {
+      router.get('/ai-assistants', getAiAssistantsFactory(crowi));
+    });
   }
 
   return router;

+ 112 - 27
apps/app/src/features/openai/server/services/openai.ts

@@ -2,13 +2,13 @@ import assert from 'node:assert';
 import { Readable, Transform } from 'stream';
 import { pipeline } from 'stream/promises';
 
-import { PageGrant, getIdForRef, isPopulated } from '@growi/core';
+import {
+  PageGrant, getIdForRef, isPopulated, type IUserHasId,
+} from '@growi/core';
 import { isGrobPatternPath } from '@growi/core/dist/utils/page-path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
-import type { HydratedDocument, Types } from 'mongoose';
-import mongoose from 'mongoose';
-import type OpenAI from 'openai';
-import { toFile } from 'openai';
+import mongoose, { type HydratedDocument, type Types } from 'mongoose';
+import { type OpenAI, toFile } from 'openai';
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import ThreadRelationModel from '~/features/openai/server/models/thread-relation';
@@ -24,7 +24,9 @@ import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 
 import { OpenaiServiceTypes } from '../../interfaces/ai';
-import { type AiAssistant, AiAssistantAccessScope } from '../../interfaces/ai-assistant';
+import {
+  type AccessibleAiAssistants, type AiAssistant, AiAssistantAccessScope, AiAssistantShareScope,
+} from '../../interfaces/ai-assistant';
 import AiAssistantModel, { type AiAssistantDocument } from '../models/ai-assistant';
 import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html';
 
@@ -66,6 +68,7 @@ export interface IOpenaiService {
   // rebuildVectorStoreAll(): Promise<void>;
   // rebuildVectorStore(page: HydratedDocument<PageDocument>): Promise<void>;
   createAiAssistant(data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument>;
+  getAccessibleAiAssistants(user: IUserHasId): Promise<AccessibleAiAssistants>
 }
 class OpenaiService implements IOpenaiService {
 
@@ -425,10 +428,11 @@ class OpenaiService implements IOpenaiService {
   private async createConditionForCreateAiAssistant(
       owner: AiAssistant['owner'],
       accessScope: AiAssistant['accessScope'],
-      grantedGroups: AiAssistant['grantedGroups'],
+      grantedGroupsForAccessScope: AiAssistant['grantedGroupsForAccessScope'],
       pagePathPatterns: AiAssistant['pagePathPatterns'],
   ): Promise<mongoose.FilterQuery<PageDocument>> {
-    const converterdPagePatgPatterns = convertPathPatternsToRegExp(pagePathPatterns);
+
+    const converterdPagePathPatterns = convertPathPatternsToRegExp(pagePathPatterns);
 
     // Include pages in search targets when their paths with 'Anyone with the link' permission are directly specified instead of using glob pattern
     const nonGrabPagePathPatterns = pagePathPatterns.filter(pagePathPattern => !isGrobPatternPath(pagePathPattern));
@@ -443,37 +447,27 @@ class OpenaiService implements IOpenaiService {
           baseCondition,
           {
             grant: PageGrant.GRANT_PUBLIC,
-            path: { $in: converterdPagePatgPatterns },
+            path: { $in: converterdPagePathPatterns },
           },
         ],
       };
     }
 
     if (accessScope === AiAssistantAccessScope.GROUPS) {
-      if (grantedGroups == null || grantedGroups.length === 0) {
+      if (grantedGroupsForAccessScope == null || grantedGroupsForAccessScope.length === 0) {
         throw new Error('grantedGroups is required when accessScope is GROUPS');
       }
 
-      const extractedGrantedGroupIds = grantedGroups.map(group => getIdForRef(group.item).toString());
-      const extractedOwnerGroupIds = [
-        ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)),
-        ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)),
-      ].map(group => group.toString());
-
-      // Check if the owner belongs to the group specified in grantedGroups
-      const isValid = extractedGrantedGroupIds.every(groupId => extractedOwnerGroupIds.includes(groupId));
-      if (!isValid) {
-        throw new Error('A group to which the owner does not belong is specified.');
-      }
+      const extractedGrantedGroupIdsForAccessScope = grantedGroupsForAccessScope.map(group => getIdForRef(group.item).toString());
 
       return {
         $or: [
           baseCondition,
           {
             grant: { $in: [PageGrant.GRANT_PUBLIC, PageGrant.GRANT_USER_GROUP] },
-            path: { $in: converterdPagePatgPatterns },
+            path: { $in: converterdPagePathPatterns },
             $or: [
-              { 'grantedGroups.item': { $in: extractedGrantedGroupIds } },
+              { 'grantedGroups.item': { $in: extractedGrantedGroupIdsForAccessScope } },
               { grant: PageGrant.GRANT_PUBLIC },
             ],
           },
@@ -482,7 +476,7 @@ class OpenaiService implements IOpenaiService {
     }
 
     if (accessScope === AiAssistantAccessScope.OWNER) {
-      const ownerUserGroup = [
+      const ownerUserGroups = [
         ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)),
         ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)),
       ].map(group => group.toString());
@@ -492,9 +486,9 @@ class OpenaiService implements IOpenaiService {
           baseCondition,
           {
             grant: { $in: [PageGrant.GRANT_PUBLIC, PageGrant.GRANT_USER_GROUP, PageGrant.GRANT_OWNER] },
-            path: { $in: converterdPagePatgPatterns },
+            path: { $in: converterdPagePathPatterns },
             $or: [
-              { 'grantedGroups.item': { $in: ownerUserGroup } },
+              { 'grantedGroups.item': { $in: ownerUserGroups } },
               { grantedUsers: { $in: [getIdForRef(owner)] } },
               { grant: PageGrant.GRANT_PUBLIC },
             ],
@@ -506,8 +500,63 @@ class OpenaiService implements IOpenaiService {
     throw new Error('Invalid accessScope value');
   }
 
+  private async validateGrantedUserGroupsForCreateAiAssistant(
+      owner: AiAssistant['owner'],
+      shareScope: AiAssistant['shareScope'],
+      accessScope: AiAssistant['accessScope'],
+      grantedGroupsForShareScope: AiAssistant['grantedGroupsForShareScope'],
+      grantedGroupsForAccessScope: AiAssistant['grantedGroupsForAccessScope'],
+  ) {
+
+    // Check if grantedGroupsForShareScope is not specified when shareScope is not a “group”
+    if (shareScope !== AiAssistantShareScope.GROUPS && grantedGroupsForShareScope != null) {
+      throw new Error('grantedGroupsForShareScope is specified when shareScope is not “groups”.');
+    }
+
+    // Check if grantedGroupsForAccessScope is not specified when accessScope is not a “group”
+    if (accessScope !== AiAssistantAccessScope.GROUPS && grantedGroupsForAccessScope != null) {
+      throw new Error('grantedGroupsForAccessScope is specified when accsessScope is not “groups”.');
+    }
+
+    const ownerUserGroupIds = [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(owner)),
+    ].map(group => group.toString());
+
+    // Check if the owner belongs to the group specified in grantedGroupsForShareScope
+    if (grantedGroupsForShareScope != null && grantedGroupsForShareScope.length > 0) {
+      const extractedGrantedGroupIdsForShareScope = grantedGroupsForShareScope.map(group => getIdForRef(group.item).toString());
+      const isValid = extractedGrantedGroupIdsForShareScope.every(groupId => ownerUserGroupIds.includes(groupId));
+      if (!isValid) {
+        throw new Error('A userGroup to which the owner does not belong is specified in grantedGroupsForShareScope');
+      }
+    }
+
+    // Check if the owner belongs to the group specified in grantedGroupsForAccessScope
+    if (grantedGroupsForAccessScope != null && grantedGroupsForAccessScope.length > 0) {
+      const extractedGrantedGroupIdsForAccessScope = grantedGroupsForAccessScope.map(group => getIdForRef(group.item).toString());
+      const isValid = extractedGrantedGroupIdsForAccessScope.every(groupId => ownerUserGroupIds.includes(groupId));
+      if (!isValid) {
+        throw new Error('A userGroup to which the owner does not belong is specified in grantedGroupsForAccessScope');
+      }
+    }
+  }
+
   async createAiAssistant(data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument> {
-    const conditions = await this.createConditionForCreateAiAssistant(data.owner, data.accessScope, data.grantedGroups, data.pagePathPatterns);
+    await this.validateGrantedUserGroupsForCreateAiAssistant(
+      data.owner,
+      data.shareScope,
+      data.accessScope,
+      data.grantedGroupsForShareScope,
+      data.grantedGroupsForAccessScope,
+    );
+
+    const conditions = await this.createConditionForCreateAiAssistant(
+      data.owner,
+      data.accessScope,
+      data.grantedGroupsForAccessScope,
+      data.pagePathPatterns,
+    );
 
     const vectorStoreRelation = await this.createVectorStore(data.name);
     const aiAssistant = await AiAssistantModel.create({
@@ -520,6 +569,42 @@ class OpenaiService implements IOpenaiService {
     return aiAssistant;
   }
 
+  async getAccessibleAiAssistants(user: IUserHasId): Promise<AccessibleAiAssistants> {
+    const userGroupIds = [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ];
+
+    const assistants = await AiAssistantModel.find({
+      $or: [
+        // Case 1: Assistants owned by the user
+        { owner: user },
+
+        // Case 2: Public assistants owned by others
+        {
+          $and: [
+            { owner: { $ne: user } },
+            { shareScope: AiAssistantShareScope.PUBLIC_ONLY },
+          ],
+        },
+
+        // Case 3: Group-restricted assistants where user is in granted groups
+        {
+          $and: [
+            { owner: { $ne: user } },
+            { shareScope: AiAssistantShareScope.GROUPS },
+            { 'grantedGroupsForShareScope.item': { $in: userGroupIds } },
+          ],
+        },
+      ],
+    });
+
+    return {
+      myAiAssistants: assistants.filter(assistant => assistant.owner.toString() === user._id.toString()) ?? [],
+      teamAiAssistants: assistants.filter(assistant => assistant.owner.toString() !== user._id.toString()) ?? [],
+    };
+  }
+
 }
 
 let instance: OpenaiService;