diff --git a/meet-ce/backend/openapi/components/requestBodies/internal/create-ai-assistant-request.yaml b/meet-ce/backend/openapi/components/requestBodies/internal/create-ai-assistant-request.yaml new file mode 100644 index 00000000..e8a56bb0 --- /dev/null +++ b/meet-ce/backend/openapi/components/requestBodies/internal/create-ai-assistant-request.yaml @@ -0,0 +1,6 @@ +description: Create AI assistant activation request +required: true +content: + application/json: + schema: + $ref: '../../schemas/internal/ai-assistant-create-request.yaml' diff --git a/meet-ce/backend/openapi/components/responses/internal/success-create-ai-assistant.yaml b/meet-ce/backend/openapi/components/responses/internal/success-create-ai-assistant.yaml new file mode 100644 index 00000000..caea3448 --- /dev/null +++ b/meet-ce/backend/openapi/components/responses/internal/success-create-ai-assistant.yaml @@ -0,0 +1,5 @@ +description: Successfully created or reused AI assistant activation +content: + application/json: + schema: + $ref: '../../schemas/internal/ai-assistant-create-response.yaml' diff --git a/meet-ce/backend/openapi/components/schemas/internal/ai-assistant-create-request.yaml b/meet-ce/backend/openapi/components/schemas/internal/ai-assistant-create-request.yaml new file mode 100644 index 00000000..50a085e8 --- /dev/null +++ b/meet-ce/backend/openapi/components/schemas/internal/ai-assistant-create-request.yaml @@ -0,0 +1,37 @@ +type: object +required: + # - scope + - capabilities +properties: + # scope: + # type: object + # required: + # - resourceType + # - resourceIds + # properties: + # resourceType: + # type: string + # enum: ['meeting'] + # description: Scope resource type where assistant will be activated. + # example: meeting + # resourceIds: + # type: array + # minItems: 1 + # items: + # type: string + # minLength: 1 + # description: List of target resource ids. + # example: ['meeting_123'] + capabilities: + type: array + minItems: 1 + items: + type: object + required: + - name + properties: + name: + type: string + enum: ['live_captions'] + description: AI capability to activate. + example: live_captions diff --git a/meet-ce/backend/openapi/components/schemas/internal/ai-assistant-create-response.yaml b/meet-ce/backend/openapi/components/schemas/internal/ai-assistant-create-response.yaml new file mode 100644 index 00000000..03fd8844 --- /dev/null +++ b/meet-ce/backend/openapi/components/schemas/internal/ai-assistant-create-response.yaml @@ -0,0 +1,14 @@ +type: object +required: + - id + - status +properties: + id: + type: string + description: Identifier of the assistant activation. + example: asst_123 + status: + type: string + enum: ['active'] + description: Current assistant activation state. + example: active diff --git a/meet-ce/backend/openapi/openvidu-meet-internal-api.yaml b/meet-ce/backend/openapi/openvidu-meet-internal-api.yaml index 05099651..71ef1cdc 100644 --- a/meet-ce/backend/openapi/openvidu-meet-internal-api.yaml +++ b/meet-ce/backend/openapi/openvidu-meet-internal-api.yaml @@ -46,6 +46,10 @@ paths: $ref: './paths/internal/meetings.yaml#/~1meetings~1{roomId}~1participants~1{participantIdentity}' /meetings/{roomId}/participants/{participantIdentity}/role: $ref: './paths/internal/meetings.yaml#/~1meetings~1{roomId}~1participants~1{participantIdentity}~1role' + /ai/assistants: + $ref: './paths/internal/ai-assistant.yaml#/~1ai~1assistants' + /ai/assistants/{assistantId}: + $ref: './paths/internal/ai-assistant.yaml#/~1ai~1assistants~1{assistantId}' /analytics: $ref: './paths/internal/analytics.yaml#/~1analytics' @@ -67,5 +71,9 @@ components: $ref: components/schemas/meet-room.yaml MeetAnalytics: $ref: components/schemas/internal/meet-analytics.yaml + AiAssistantCreateRequest: + $ref: components/schemas/internal/ai-assistant-create-request.yaml + AiAssistantCreateResponse: + $ref: components/schemas/internal/ai-assistant-create-response.yaml Error: $ref: components/schemas/error.yaml diff --git a/meet-ce/backend/openapi/paths/internal/ai-assistant.yaml b/meet-ce/backend/openapi/paths/internal/ai-assistant.yaml new file mode 100644 index 00000000..5454791c --- /dev/null +++ b/meet-ce/backend/openapi/paths/internal/ai-assistant.yaml @@ -0,0 +1,56 @@ +/ai/assistants: + post: + operationId: createAiAssistant + summary: Create AI assistant + description: | + Activates AI assistance. + + > Currently only meeting AI Assistand and `live_captions` capability is supported. + tags: + - Internal API - AI Assistants + security: + - roomMemberTokenHeader: [] + requestBody: + $ref: '../../components/requestBodies/internal/create-ai-assistant-request.yaml' + responses: + '200': + $ref: '../../components/responses/internal/success-create-ai-assistant.yaml' + '401': + $ref: '../../components/responses/unauthorized-error.yaml' + '403': + $ref: '../../components/responses/forbidden-error.yaml' + '422': + $ref: '../../components/responses/validation-error.yaml' + '500': + $ref: '../../components/responses/internal-server-error.yaml' + +/ai/assistants/{assistantId}: + delete: + operationId: cancelAiAssistant + summary: Cancel AI assistant + description: | + Cancels AI assistant. + + The assistant process (live_captions) is stopped only when the last participant cancels it. + tags: + - Internal API - AI Assistants + security: + - roomMemberTokenHeader: [] + parameters: + - in: path + name: assistantId + required: true + schema: + type: string + minLength: 1 + description: Identifier of the assistant activation returned by create operation. + example: asst_123 + responses: + '204': + description: AI assistant canceled successfully. + '401': + $ref: '../../components/responses/unauthorized-error.yaml' + '422': + $ref: '../../components/responses/validation-error.yaml' + '500': + $ref: '../../components/responses/internal-server-error.yaml' diff --git a/meet-ce/backend/openapi/tags/tags.yaml b/meet-ce/backend/openapi/tags/tags.yaml index 16025c1b..e2a9785e 100644 --- a/meet-ce/backend/openapi/tags/tags.yaml +++ b/meet-ce/backend/openapi/tags/tags.yaml @@ -18,5 +18,7 @@ description: Operations related to managing members within OpenVidu Meet rooms - name: Internal API - Meetings description: Operations related to managing meetings in OpenVidu Meet rooms +- name: Internal API - AI Assistants + description: High-level operations to manage AI assistance capabilities in meetings - name: Internal API - Recordings description: Operations related to managing OpenVidu Meet recordings diff --git a/meet-ce/backend/src/config/dependency-injector.config.ts b/meet-ce/backend/src/config/dependency-injector.config.ts index 218c81e3..641cd71c 100644 --- a/meet-ce/backend/src/config/dependency-injector.config.ts +++ b/meet-ce/backend/src/config/dependency-injector.config.ts @@ -55,6 +55,7 @@ import { LivekitWebhookService } from '../services/livekit-webhook.service.js'; import { RoomScheduledTasksService } from '../services/room-scheduled-tasks.service.js'; import { RecordingScheduledTasksService } from '../services/recording-scheduled-tasks.service.js'; import { AnalyticsService } from '../services/analytics.service.js'; +import { AiAssistantService } from '../services/ai-assistant.service.js'; export const container: Container = new Container(); @@ -115,6 +116,7 @@ export const registerDependencies = () => { container.bind(RoomScheduledTasksService).toSelf().inSingletonScope(); container.bind(RecordingScheduledTasksService).toSelf().inSingletonScope(); container.bind(AnalyticsService).toSelf().inSingletonScope(); + container.bind(AiAssistantService).toSelf().inSingletonScope(); }; const configureStorage = (storageMode: string) => { diff --git a/meet-ce/backend/src/config/internal-config.ts b/meet-ce/backend/src/config/internal-config.ts index 2d840e1a..98462943 100644 --- a/meet-ce/backend/src/config/internal-config.ts +++ b/meet-ce/backend/src/config/internal-config.ts @@ -46,7 +46,7 @@ export const INTERNAL_CONFIG = { PARTICIPANT_MAX_CONCURRENT_NAME_REQUESTS: '20', // Maximum number of request by the same name at the same time allowed PARTICIPANT_NAME_RESERVATION_TTL: '12h' as StringValue, // Time-to-live for participant name reservations - CAPTIONS_AGENT_NAME: 'agent-speech-processing', + CAPTIONS_AGENT_NAME: 'speech-processing', // MongoDB Schema Versions // These define the current schema version for each collection diff --git a/meet-ce/backend/src/controllers/ai-assistant.controller.ts b/meet-ce/backend/src/controllers/ai-assistant.controller.ts new file mode 100644 index 00000000..f27d77a3 --- /dev/null +++ b/meet-ce/backend/src/controllers/ai-assistant.controller.ts @@ -0,0 +1,69 @@ +import { Request, Response } from 'express'; +import { container } from '../config/dependency-injector.config.js'; +import { handleError } from '../models/error.model.js'; +import { AiAssistantService } from '../services/ai-assistant.service.js'; +import { LoggerService } from '../services/logger.service.js'; +import { RequestSessionService } from '../services/request-session.service.js'; +import { TokenService } from '../services/token.service.js'; +import { getRoomMemberToken } from '../utils/token.utils.js'; + +const getRoomMemberIdentityFromRequest = async (req: Request): Promise => { + const tokenService = container.get(TokenService); + const token = getRoomMemberToken(req); + + if (!token) { + throw new Error('Room member token not found'); + } + + const claims = await tokenService.verifyToken(token); + + if (!claims.sub) { + throw new Error('Room member token does not include participant identity'); + } + + return claims.sub; +}; + +export const createAssistant = async (req: Request, res: Response) => { + const logger = container.get(LoggerService); + const requestSessionService = container.get(RequestSessionService); + const aiAssistantService = container.get(AiAssistantService); + // const payload: MeetCreateAssistantRequest = req.body; + const roomId = requestSessionService.getRoomIdFromToken(); + + if (!roomId) { + return handleError(res, new Error('Could not resolve room from token'), 'creating assistant'); + } + + try { + const participantIdentity = await getRoomMemberIdentityFromRequest(req); + logger.verbose(`Creating assistant for participant '${participantIdentity}' in room '${roomId}'`); + const assistant = await aiAssistantService.createLiveCaptionsAssistant(roomId, participantIdentity); + return res.status(200).json(assistant); + } catch (error) { + handleError(res, error, `creating assistant in room '${roomId}'`); + } +}; + +export const cancelAssistant = async (req: Request, res: Response) => { + const logger = container.get(LoggerService); + const requestSessionService = container.get(RequestSessionService); + const aiAssistantService = container.get(AiAssistantService); + const { assistantId } = req.params; + const roomId = requestSessionService.getRoomIdFromToken(); + + if (!roomId) { + return handleError(res, new Error('Could not resolve room from token'), 'canceling assistant'); + } + + try { + const participantIdentity = await getRoomMemberIdentityFromRequest(req); + logger.verbose( + `Canceling assistant '${assistantId}' for participant '${participantIdentity}' in room '${roomId}'` + ); + await aiAssistantService.cancelAssistant(assistantId, roomId, participantIdentity); + return res.status(204).send(); + } catch (error) { + handleError(res, error, `canceling assistant '${assistantId}' in room '${roomId}'`); + } +}; diff --git a/meet-ce/backend/src/helpers/redis.helper.ts b/meet-ce/backend/src/helpers/redis.helper.ts index 62a3b0f9..1b1ef9ea 100644 --- a/meet-ce/backend/src/helpers/redis.helper.ts +++ b/meet-ce/backend/src/helpers/redis.helper.ts @@ -45,4 +45,16 @@ export class MeetLock { return `${RedisLockPrefix.BASE}${RedisLockName.WEBHOOK}_${webhookEvent.event}_${webhookEvent.id}`; } + + static getAiAssistantLock(roomId: string, capabilityName: string): string { + if (!roomId) { + throw new Error('roomId must be a non-empty string'); + } + + if (!capabilityName) { + throw new Error('capabilityName must be a non-empty string'); + } + + return `${RedisLockPrefix.BASE}${RedisLockName.AI_ASSISTANT}_${roomId}_${capabilityName}`; + } } diff --git a/meet-ce/backend/src/middlewares/index.ts b/meet-ce/backend/src/middlewares/index.ts index 0b02c06d..cc4f5b98 100644 --- a/meet-ce/backend/src/middlewares/index.ts +++ b/meet-ce/backend/src/middlewares/index.ts @@ -9,6 +9,7 @@ export * from './room.middleware.js'; // Request validators export * from './request-validators/auth-validator.middleware.js'; +export * from './request-validators/ai-assistant-validator.middleware.js'; export * from './request-validators/config-validator.middleware.js'; export * from './request-validators/meeting-validator.middleware.js'; export * from './request-validators/recording-validator.middleware.js'; diff --git a/meet-ce/backend/src/middlewares/request-validators/ai-assistant-validator.middleware.ts b/meet-ce/backend/src/middlewares/request-validators/ai-assistant-validator.middleware.ts new file mode 100644 index 00000000..1a6bc0b3 --- /dev/null +++ b/meet-ce/backend/src/middlewares/request-validators/ai-assistant-validator.middleware.ts @@ -0,0 +1,26 @@ +import { NextFunction, Request, Response } from 'express'; +import { rejectUnprocessableRequest } from '../../models/error.model.js'; +import { AssistantIdSchema, CreateAssistantReqSchema } from '../../models/zod-schemas/ai-assistant.schema.js'; + +export const validateCreateAssistantReq = (req: Request, res: Response, next: NextFunction) => { + const { success, error, data } = CreateAssistantReqSchema.safeParse(req.body); + + if (!success) { + return rejectUnprocessableRequest(res, error); + } + + req.body = data; + next(); +}; + +export const validateAssistantIdPathParam = (req: Request, res: Response, next: NextFunction) => { + const { success, error, data } = AssistantIdSchema.safeParse(req.params.assistantId); + + if (!success) { + error.errors[0].path = ['assistantId']; + return rejectUnprocessableRequest(res, error); + } + + req.params.assistantId = data; + next(); +}; diff --git a/meet-ce/backend/src/models/redis.model.ts b/meet-ce/backend/src/models/redis.model.ts index 97977c00..58d82f9d 100644 --- a/meet-ce/backend/src/models/redis.model.ts +++ b/meet-ce/backend/src/models/redis.model.ts @@ -5,7 +5,9 @@ export const enum RedisKeyName { ROOM_PARTICIPANTS = `${REDIS_KEY_PREFIX}room_participants:`, // Stores released numeric suffixes (per base name) in a sorted set, so that freed numbers // can be reused efficiently instead of always incrementing to the next highest number. - PARTICIPANT_NAME_POOL = `${REDIS_KEY_PREFIX}participant_pool:` + PARTICIPANT_NAME_POOL = `${REDIS_KEY_PREFIX}participant_pool:`, + // Tracks participant-level assistant capability state in a room. + AI_ASSISTANT_PARTICIPANT_STATE = `${REDIS_KEY_PREFIX}ai_assistant:participant_state:` } export const enum RedisLockPrefix { @@ -18,5 +20,6 @@ export const enum RedisLockName { SCHEDULED_TASK = 'scheduled_task', STORAGE_INITIALIZATION = 'storage_initialization', MIGRATION = 'migration', - WEBHOOK = 'webhook' + WEBHOOK = 'webhook', + AI_ASSISTANT = 'ai_assistant' } diff --git a/meet-ce/backend/src/models/zod-schemas/ai-assistant.schema.ts b/meet-ce/backend/src/models/zod-schemas/ai-assistant.schema.ts new file mode 100644 index 00000000..f2e17049 --- /dev/null +++ b/meet-ce/backend/src/models/zod-schemas/ai-assistant.schema.ts @@ -0,0 +1,34 @@ +import { MeetAssistantCapabilityName } from '@openvidu-meet/typings'; +import { z } from 'zod'; + +export const CreateAssistantReqSchema = z.object({ + // scope: z.object({ + // resourceType: z.nativeEnum(MeetAssistantScopeResourceType), + // resourceIds: z.array(z.string().trim().min(1)).min(1) + // }), + capabilities: z + .array( + z.object({ + name: z.string() + }) + ) + .min(1) + .transform((capabilities) => { + const validValues = Object.values(MeetAssistantCapabilityName); + + // Filter out invalid capabilities + const filtered = capabilities.filter((cap) => + validValues.includes(cap.name as MeetAssistantCapabilityName) + ); + + // Remove duplicates based on capability name + const unique = Array.from(new Map(filtered.map((cap) => [cap.name, cap])).values()); + + return unique; + }) + .refine((caps) => caps.length > 0, { + message: 'At least one valid capability is required' + }) +}); + +export const AssistantIdSchema = z.string().trim().min(1); diff --git a/meet-ce/backend/src/routes/ai-assistant.routes.ts b/meet-ce/backend/src/routes/ai-assistant.routes.ts new file mode 100644 index 00000000..90c35cce --- /dev/null +++ b/meet-ce/backend/src/routes/ai-assistant.routes.ts @@ -0,0 +1,26 @@ +import bodyParser from 'body-parser'; +import { Router } from 'express'; +import * as aiAssistantCtrl from '../controllers/ai-assistant.controller.js'; +import { roomMemberTokenValidator, withAuth } from '../middlewares/auth.middleware.js'; +import { + validateAssistantIdPathParam, + validateCreateAssistantReq +} from '../middlewares/request-validators/ai-assistant-validator.middleware.js'; + +export const aiAssistantRouter: Router = Router(); +aiAssistantRouter.use(bodyParser.urlencoded({ extended: true })); +aiAssistantRouter.use(bodyParser.json()); + +aiAssistantRouter.post( + '/assistants', + withAuth(roomMemberTokenValidator), + validateCreateAssistantReq, + aiAssistantCtrl.createAssistant +); + +aiAssistantRouter.delete( + '/assistants/:assistantId', + withAuth(roomMemberTokenValidator), + validateAssistantIdPathParam, + aiAssistantCtrl.cancelAssistant +); diff --git a/meet-ce/backend/src/routes/index.ts b/meet-ce/backend/src/routes/index.ts index 226c3aa4..94d2a2aa 100644 --- a/meet-ce/backend/src/routes/index.ts +++ b/meet-ce/backend/src/routes/index.ts @@ -1,4 +1,5 @@ export * from './analytics.routes.js'; +export * from './ai-assistant.routes.js'; export * from './api-key.routes.js'; export * from './auth.routes.js'; export * from './global-config.routes.js'; diff --git a/meet-ce/backend/src/server.ts b/meet-ce/backend/src/server.ts index eb1e1c3e..243e0b79 100644 --- a/meet-ce/backend/src/server.ts +++ b/meet-ce/backend/src/server.ts @@ -9,6 +9,7 @@ import { setBaseUrlFromRequest } from './middlewares/base-url.middleware.js'; import { jsonSyntaxErrorHandler } from './middlewares/content-type.middleware.js'; import { initRequestContext } from './middlewares/request-context.middleware.js'; import { analyticsRouter } from './routes/analytics.routes.js'; +import { aiAssistantRouter } from './routes/ai-assistant.routes.js'; import { apiKeyRouter } from './routes/api-key.routes.js'; import { authRouter } from './routes/auth.routes.js'; import { configRouter } from './routes/global-config.routes.js'; @@ -99,6 +100,7 @@ const createApp = () => { // appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/recordings`, internalRecordingRouter); appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/config`, configRouter); appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/analytics`, analyticsRouter); + appRouter.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/ai`, aiAssistantRouter); appRouter.use('/health', (_req: Request, res: Response) => res.status(200).send('OK')); diff --git a/meet-ce/backend/src/services/ai-assistant.service.ts b/meet-ce/backend/src/services/ai-assistant.service.ts new file mode 100644 index 00000000..e4be37cf --- /dev/null +++ b/meet-ce/backend/src/services/ai-assistant.service.ts @@ -0,0 +1,252 @@ +import { MeetAssistantCapabilityName, MeetCreateAssistantResponse } from '@openvidu-meet/typings'; +import { inject, injectable } from 'inversify'; +import ms from 'ms'; +import { INTERNAL_CONFIG } from '../config/internal-config.js'; +import { MEET_ENV } from '../environment.js'; +import { MeetLock } from '../helpers/redis.helper.js'; +import { errorInsufficientPermissions } from '../models/error.model.js'; +import { RedisKeyName } from '../models/redis.model.js'; +import { LiveKitService } from './livekit.service.js'; +import { LoggerService } from './logger.service.js'; +import { MutexService } from './mutex.service.js'; +import { RedisService } from './redis.service.js'; +import { RoomService } from './room.service.js'; + +@injectable() +export class AiAssistantService { + private readonly ASSISTANT_STATE_LOCK_TTL = ms('15s'); + + constructor( + @inject(LoggerService) protected logger: LoggerService, + @inject(RoomService) protected roomService: RoomService, + @inject(LiveKitService) protected livekitService: LiveKitService, + @inject(MutexService) protected mutexService: MutexService, + @inject(RedisService) protected redisService: RedisService + ) {} + + /** + * Creates a live captions assistant for the specified room. + * If an assistant already exists for the room, it will be reused. + * @param roomId + * @param participantIdentity + * @returns + */ + async createLiveCaptionsAssistant( + roomId: string, + participantIdentity: string + ): Promise { + // ! For now, we are assuming that the only capability is live captions. + const capability = MeetAssistantCapabilityName.LIVE_CAPTIONS; + const lockName = MeetLock.getAiAssistantLock(roomId, capability); + + try { + await this.validateCreateConditions(roomId, capability); + + const lock = await this.mutexService.acquire(lockName, this.ASSISTANT_STATE_LOCK_TTL); + + if (!lock) { + this.logger.error(`Could not acquire lock '${lockName}' for creating assistant in room '${roomId}'`); + throw new Error('Could not acquire lock for creating assistant. Please try again.'); + } + + const existingAgent = await this.livekitService.getAgent(roomId, INTERNAL_CONFIG.CAPTIONS_AGENT_NAME); + + if (existingAgent) { + await this.setParticipantAssistantState(roomId, participantIdentity, capability, true); + return { id: existingAgent.id, status: 'active' }; + } + + const assistant = await this.livekitService.createAgent(roomId, INTERNAL_CONFIG.CAPTIONS_AGENT_NAME); + + await this.setParticipantAssistantState(roomId, participantIdentity, capability, true); + + return { + id: assistant.id, + status: 'active' + }; + } finally { + await this.mutexService.release(lockName); + } + } + + /** + * Stops the specified assistant for the given participant and room. + * If the assistant is not used by any other participants in the room, it will be stopped in LiveKit. + * @param assistantId + * @param roomId + * @param participantIdentity + * @returns + */ + async cancelAssistant(assistantId: string, roomId: string, participantIdentity: string): Promise { + const capability = MeetAssistantCapabilityName.LIVE_CAPTIONS; + // The lock only protects the atomic "count → stop dispatch" decision. + const lockName = MeetLock.getAiAssistantLock(roomId, capability); + + try { + await this.setParticipantAssistantState(roomId, participantIdentity, capability, false); + + const lock = await this.mutexService.acquire(lockName, this.ASSISTANT_STATE_LOCK_TTL); + + if (!lock) { + this.logger.warn( + `Could not acquire lock '${lockName}' for stopping assistant in room '${roomId}'. Participant state saved as disabled.` + ); + return; + } + + const enabledParticipants = await this.getEnabledParticipantsCount(roomId, capability); + + if (enabledParticipants > 0) { + this.logger.debug( + `Skipping assistant stop for room '${roomId}'. Remaining enabled participants: ${enabledParticipants}` + ); + return; + } + + const assistant = await this.livekitService.getAgent(roomId, assistantId); + + if (!assistant) { + this.logger.warn(`Captions assistant not found in room '${roomId}'. Skipping stop request.`); + return; + } + + await this.livekitService.stopAgent(assistantId, roomId); + } finally { + await this.mutexService.release(lockName); + } + } + + /** + * Cleanup assistant state in a room. + * - If participantIdentity is provided, removes only that participant state. + * - If participantIdentity is omitted, removes all assistant state in the room. + * + * If no enabled participants remain after cleanup, captions agent dispatch is stopped. + */ + async cleanupState(roomId: string, participantIdentity?: string): Promise { + const capability = MeetAssistantCapabilityName.LIVE_CAPTIONS; + const lockName = MeetLock.getAiAssistantLock(roomId, capability); + + try { + if (participantIdentity) { + await this.setParticipantAssistantState(roomId, participantIdentity, capability, false); + } + + // acquireWithRetry because this is called from webhooks (participantLeft / roomFinished). + // The agent may run indefinitely with no further opportunity to stop it. + const lock = await this.mutexService.acquireWithRetry(lockName, this.ASSISTANT_STATE_LOCK_TTL); + + if (!lock) { + const scope = participantIdentity ? `participant '${participantIdentity}'` : `room '${roomId}'`; + this.logger.error( + `Could not acquire lock '${lockName}' for dispatch cleanup (${scope}) after retries. ` + + (participantIdentity + ? 'Participant state was saved but dispatch stop may be skipped.' + : 'Room state cleanup and dispatch stop were skipped.') + ); + return; + } + + if (!participantIdentity) { + const pattern = `${RedisKeyName.AI_ASSISTANT_PARTICIPANT_STATE}${roomId}:${capability}:*`; + const keys = await this.redisService.getKeys(pattern); + + if (keys.length > 0) { + await this.redisService.delete(keys); + } + } + + const enabledParticipants = await this.getEnabledParticipantsCount(roomId, capability); + + if (enabledParticipants > 0) { + return; + } + + await this.stopCaptionsAssistantIfRunning(roomId); + } catch (error) { + this.logger.error(`Error occurred while cleaning up assistant state for room '${roomId}': ${error}`); + } finally { + await this.mutexService.release(lockName); + } + } + + protected async validateCreateConditions(roomId: string, capability: MeetAssistantCapabilityName): Promise { + if (capability === MeetAssistantCapabilityName.LIVE_CAPTIONS) { + if (MEET_ENV.CAPTIONS_ENABLED !== 'true') { + throw errorInsufficientPermissions(); + } + + const room = await this.roomService.getMeetRoom(roomId); + + if (!room.config.captions.enabled) { + throw errorInsufficientPermissions(); + } + } + } + + /** + * Sets or clears the assistant state for a participant in Redis. + * @param roomId + * @param participantIdentity + * @param capability + * @param enabled + */ + protected async setParticipantAssistantState( + roomId: string, + participantIdentity: string, + capability: MeetAssistantCapabilityName, + enabled: boolean + ): Promise { + const key = this.getParticipantAssistantStateKey(roomId, participantIdentity, capability); + + if (!enabled) { + await this.redisService.delete(key); + return; + } + + await this.redisService.setIfNotExists( + key, + JSON.stringify({ + enabled: true, + updatedAt: Date.now() + }) + ); + } + + /** + * Gets the count of participants that have the specified assistant capability enabled in the given room. + * @param roomId + * @param capability + * @returns + */ + protected async getEnabledParticipantsCount( + roomId: string, + capability: MeetAssistantCapabilityName + ): Promise { + const pattern = `${RedisKeyName.AI_ASSISTANT_PARTICIPANT_STATE}${roomId}:${capability}:*`; + const keys = await this.redisService.getKeys(pattern); + return keys.length; + } + + protected getParticipantAssistantStateKey( + roomId: string, + participantIdentity: string, + capability: MeetAssistantCapabilityName + ): string { + return `${RedisKeyName.AI_ASSISTANT_PARTICIPANT_STATE}${roomId}:${capability}:${participantIdentity}`; + } + + protected async stopCaptionsAssistantIfRunning(roomId: string): Promise { + const assistants = await this.livekitService.listAgents(roomId); + + if (assistants.length === 0) return; + + const captionsAssistant = assistants.find( + (assistant) => assistant.agentName === INTERNAL_CONFIG.CAPTIONS_AGENT_NAME + ); + + if (!captionsAssistant) return; + + await this.livekitService.stopAgent(captionsAssistant.id, roomId); + } +} diff --git a/meet-ce/backend/src/services/livekit-webhook.service.ts b/meet-ce/backend/src/services/livekit-webhook.service.ts index ecda2cf9..61216bef 100644 --- a/meet-ce/backend/src/services/livekit-webhook.service.ts +++ b/meet-ce/backend/src/services/livekit-webhook.service.ts @@ -10,6 +10,7 @@ import { DistributedEventType } from '../models/distributed-event.model.js'; import { RecordingRepository } from '../repositories/recording.repository.js'; import { RoomMemberRepository } from '../repositories/room-member.repository.js'; import { RoomRepository } from '../repositories/room.repository.js'; +import { AiAssistantService } from './ai-assistant.service.js'; import { DistributedEventService } from './distributed-event.service.js'; import { FrontendEventService } from './frontend-event.service.js'; import { LiveKitService } from './livekit.service.js'; @@ -35,6 +36,7 @@ export class LivekitWebhookService { @inject(FrontendEventService) protected frontendEventService: FrontendEventService, @inject(RoomMemberService) protected roomMemberService: RoomMemberService, @inject(RoomMemberRepository) protected roomMemberRepository: RoomMemberRepository, + @inject(AiAssistantService) protected aiAssistantService: AiAssistantService, @inject(LoggerService) protected logger: LoggerService ) { this.webhookReceiver = new WebhookReceiver(MEET_ENV.LIVEKIT_API_KEY, MEET_ENV.LIVEKIT_API_SECRET); @@ -190,8 +192,10 @@ export class LivekitWebhookService { if (!this.livekitService.isStandardParticipant(participant)) return; try { - // Release the participant's reserved name - await this.roomMemberService.releaseParticipantName(room.name, participant.name); + await Promise.all([ + this.roomMemberService.releaseParticipantName(room.name, participant.name), + this.aiAssistantService.cleanupState(room.name, participant.identity) + ]); this.logger.verbose(`Released name for participant '${participant.name}' in room '${room.name}'`); } catch (error) { this.logger.error('Error releasing participant name on participant left:', error); @@ -278,7 +282,8 @@ export class LivekitWebhookService { tasks.push( this.roomMemberService.cleanupParticipantNames(roomId), - this.recordingService.releaseRecordingLockIfNoEgress(roomId) + this.recordingService.releaseRecordingLockIfNoEgress(roomId), + this.aiAssistantService.cleanupState(roomId) ); await Promise.all(tasks); } catch (error) { diff --git a/meet-ce/backend/src/services/livekit.service.ts b/meet-ce/backend/src/services/livekit.service.ts index 72debf17..363ad9a1 100644 --- a/meet-ce/backend/src/services/livekit.service.ts +++ b/meet-ce/backend/src/services/livekit.service.ts @@ -1,6 +1,7 @@ -import { ParticipantInfo_Kind } from '@livekit/protocol'; +import { AgentDispatch, ParticipantInfo_Kind } from '@livekit/protocol'; import { inject, injectable } from 'inversify'; import { + AgentDispatchClient, CreateOptions, DataPacket_Kind, EgressClient, @@ -31,6 +32,7 @@ import { LoggerService } from './logger.service.js'; export class LiveKitService { private egressClient: EgressClient; private roomClient: RoomServiceClient; + private agentClient: AgentDispatchClient; constructor(@inject(LoggerService) protected logger: LoggerService) { const livekitUrlHostname = MEET_ENV.LIVEKIT_URL_PRIVATE.replace(/^ws:/, 'http:').replace(/^wss:/, 'https:'); @@ -40,6 +42,11 @@ export class LiveKitService { MEET_ENV.LIVEKIT_API_KEY, MEET_ENV.LIVEKIT_API_SECRET ); + this.agentClient = new AgentDispatchClient( + livekitUrlHostname, + MEET_ENV.LIVEKIT_API_KEY, + MEET_ENV.LIVEKIT_API_SECRET + ); } async createRoom(options: CreateOptions): Promise { @@ -270,6 +277,66 @@ export class LiveKitService { } } + /** + * Start an agent for a specific room. + * @param roomName + * @param agentName + * @returns The created AgentDispatch + */ + async createAgent( + roomName: string, + agentName: string /*, options: CreateDispatchOptions*/ + ): Promise { + try { + return await this.agentClient.createDispatch(roomName, agentName); + } catch (error) { + this.logger.error(`Error creating agent dispatch for room '${roomName}':`, error); + throw internalError(`creating agent dispatch for room '${roomName}'`); + } + } + + /** + * Lists all agents in a LiveKit room. + * @param roomName + * @returns An array of agents in the specified room + */ + async listAgents(roomName: string): Promise { + try { + return await this.agentClient.listDispatch(roomName); + } catch (error) { + this.logger.error(`Error listing agents for room '${roomName}':`, error); + return []; + } + } + + /** + * Gets an agent dispatch by its ID in a LiveKit room. + * @param roomName + * @param agentId + * @returns The agent if found, otherwise undefined + */ + async getAgent(roomName: string, agentId: string): Promise { + try { + return await this.agentClient.getDispatch(agentId, roomName); + } catch (error) { + this.logger.error(`Error getting agent dispatch '${agentId}' for room '${roomName}':`, error); + return undefined; + } + } + + /** + * Stops an agent in a LiveKit room. + * @param agentId + * @param roomName + */ + async stopAgent(agentId: string, roomName: string): Promise { + try { + await this.agentClient.deleteDispatch(agentId, roomName); + } catch (error) { + this.logger.error(`Error deleting agent dispatch '${agentId}' for room '${roomName}':`, error); + } + } + async startRoomComposite( roomName: string, output: EncodedFileOutput | StreamOutput, diff --git a/meet-ce/backend/src/services/mutex.service.ts b/meet-ce/backend/src/services/mutex.service.ts index 637596a0..c6ad4be1 100644 --- a/meet-ce/backend/src/services/mutex.service.ts +++ b/meet-ce/backend/src/services/mutex.service.ts @@ -105,6 +105,33 @@ export class MutexService { return locks; } + /** + * Attempts to acquire a lock, retrying up to `maxAttempts` times with a fixed delay between + * attempts. Intended for fire-and-forget flows (e.g. webhooks) where the caller has no + * opportunity to retry externally and a missed lock acquisition would leave the system in an + * inconsistent state. + * + * @param key - The resource to acquire a lock for. + * @param ttl - The time-to-live for the lock in milliseconds. + * @param maxAttempts - Maximum number of acquisition attempts. Defaults to 3. + * @param delayMs - Fixed delay in milliseconds between attempts. Defaults to 200. + * @returns A Promise that resolves to the acquired Lock, or null if all attempts fail. + */ + async acquireWithRetry(key: string, ttl: number = this.TTL_MS, maxAttempts = 3, delayMs = 200): Promise { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const lock = await this.acquire(key, ttl); + + if (lock) return lock; + + if (attempt < maxAttempts) { + this.logger.warn(`Lock '${key}' attempt ${attempt}/${maxAttempts} failed. Retrying in ${delayMs}ms...`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + return null; + } + lockExists(key: string): Promise { const registryKey = MeetLock.getRegistryLock(key); return this.redisService.exists(registryKey); diff --git a/meet-ce/backend/src/services/token.service.ts b/meet-ce/backend/src/services/token.service.ts index 72bf909a..8dc363ea 100644 --- a/meet-ce/backend/src/services/token.service.ts +++ b/meet-ce/backend/src/services/token.service.ts @@ -1,4 +1,3 @@ -import { RoomAgentDispatch, RoomConfiguration } from '@livekit/protocol'; import { MeetRoomMemberTokenMetadata, MeetUser } from '@openvidu-meet/typings'; import { inject, injectable } from 'inversify'; import { jwtDecode } from 'jwt-decode'; @@ -55,7 +54,7 @@ export class TokenService { } async generateRoomMemberToken(options: MeetRoomMemberTokenOptions): Promise { - const { tokenMetadata, livekitPermissions, participantName, participantIdentity, roomWithCaptions } = options; + const { tokenMetadata, livekitPermissions, participantName, participantIdentity } = options; const tokenOptions: AccessTokenOptions = { identity: participantIdentity, @@ -63,7 +62,7 @@ export class TokenService { ttl: INTERNAL_CONFIG.ROOM_MEMBER_TOKEN_EXPIRATION, metadata: JSON.stringify(tokenMetadata) }; - return await this.generateJwtToken(tokenOptions, livekitPermissions, roomWithCaptions); + return await this.generateJwtToken(tokenOptions, livekitPermissions); } parseRoomMemberTokenMetadata(metadata: string): MeetRoomMemberTokenMetadata { @@ -76,43 +75,13 @@ export class TokenService { } } - private async generateJwtToken( - tokenOptions: AccessTokenOptions, - grants?: VideoGrant, - roomWithCaptions = false - ): Promise { + private async generateJwtToken(tokenOptions: AccessTokenOptions, grants?: VideoGrant): Promise { const at = new AccessToken(MEET_ENV.LIVEKIT_API_KEY, MEET_ENV.LIVEKIT_API_SECRET, tokenOptions); if (grants) { at.addGrant(grants); } - const captionsEnabledGlobally = MEET_ENV.CAPTIONS_ENABLED === 'true'; - const captionsEnabledInRoom = Boolean(roomWithCaptions); - - // Warn if configuration is inconsistent - if (!captionsEnabledGlobally) { - if (captionsEnabledInRoom) { - this.logger.warn( - `Captions feature is disabled in environment but Room is created with captions enabled. ` + - `Please enable captions in environment by setting MEET_CAPTIONS_ENABLED=true to ensure proper functionality.` - ); - } - - return await at.toJwt(); - } - - if (captionsEnabledInRoom) { - this.logger.debug('Activating Captions Agent. Configuring Room Agent Dispatch.'); - at.roomConfig = new RoomConfiguration({ - agents: [ - new RoomAgentDispatch({ - agentName: INTERNAL_CONFIG.CAPTIONS_AGENT_NAME - }) - ] - }); - } - return await at.toJwt(); } diff --git a/meet-ce/backend/tests/helpers/request-helpers.ts b/meet-ce/backend/tests/helpers/request-helpers.ts index 39ab7f69..31f2c575 100644 --- a/meet-ce/backend/tests/helpers/request-helpers.ts +++ b/meet-ce/backend/tests/helpers/request-helpers.ts @@ -577,7 +577,7 @@ export const deleteRoom = async ( } const result = await req; - await sleep('1s'); + await sleep('5s'); // TODO - replace with a more robust solution to ensure webhook is processed before proceeding with the tests return result; }; @@ -619,7 +619,7 @@ export const bulkDeleteRooms = async ( } const result = await req; - await sleep('1s'); + await sleep('5s'); // TODO - replace with a more robust solution to ensure webhook is processed before proceeding with the tests return result; }; @@ -659,7 +659,7 @@ export const runExpiredRoomsGC = async () => { const roomTaskScheduler = container.get(RoomScheduledTasksService); await roomTaskScheduler['deleteExpiredRooms'](); - await sleep('1s'); + await sleep('5s'); // TODO - replace with a more robust solution to ensure webhook is processed before proceeding with the tests }; /** @@ -831,7 +831,7 @@ export const disconnectFakeParticipants = async () => { }); fakeParticipantsProcesses.clear(); - await sleep('1s'); + await sleep('1s'); // TODO - replace with a more robust solution to ensure webhook is processed before proceeding with the tests }; export const updateParticipant = async ( @@ -874,7 +874,7 @@ export const endMeeting = async (roomId: string, moderatorToken: string) => { .delete(getFullPath(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/meetings/${roomId}`)) .set(INTERNAL_CONFIG.ROOM_MEMBER_TOKEN_HEADER, moderatorToken) .send(); - await sleep('1s'); + await sleep('5s'); // TODO - replace with a more robust solution to ensure webhook is processed before proceeding with the tests return response; }; @@ -934,7 +934,7 @@ export const stopRecording = async ( } const response = await req; - await sleep('2.5s'); + await sleep('2.5s'); // TODO - replace with a more robust solution to ensure webhook is processed before proceeding with the tests return response; }; diff --git a/meet-ce/frontend/package.json b/meet-ce/frontend/package.json index 2f732ab4..51b5e088 100644 --- a/meet-ce/frontend/package.json +++ b/meet-ce/frontend/package.json @@ -28,7 +28,7 @@ "@angular/platform-browser": "20.3.15", "@angular/platform-browser-dynamic": "20.3.15", "@angular/router": "20.3.15", - "@livekit/track-processors": "0.7.0", + "@livekit/track-processors": "0.7.2", "@openvidu-meet/shared-components": "workspace:*", "@openvidu-meet/typings": "workspace:*", "autolinker": "4.1.5", diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.html index ad8ecd41..5b4836da 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.html @@ -11,8 +11,10 @@ > subtitles - @if (isCaptionsButtonDisabled()) { + @if (captionsStatus() === 'DISABLED_WITH_WARNING') { Live captions (disabled by admin) + } @else if (isCaptionsTogglePending()) { + {{ areCaptionsEnabledByUser() ? 'Disabling live captions...' : 'Enabling live captions...' }} } @else { {{ areCaptionsEnabledByUser() ? 'Disable live captions' : 'Enable live captions' }} } @@ -28,14 +30,16 @@ [disabledInteractive]="isCaptionsButtonDisabled()" [disableRipple]="true" [matTooltip]=" - isCaptionsButtonDisabled() + captionsStatus() === 'DISABLED_WITH_WARNING' ? 'Live captions are disabled by admin' - : areCaptionsEnabledByUser() - ? 'Disable live captions' - : 'Enable live captions' + : isCaptionsTogglePending() + ? (areCaptionsEnabledByUser() ? 'Disabling live captions...' : 'Enabling live captions...') + : areCaptionsEnabledByUser() + ? 'Disable live captions' + : 'Enable live captions' " > - @if (isCaptionsButtonDisabled()) { + @if (captionsStatus() === 'DISABLED_WITH_WARNING') { subtitles_off } @else { subtitles diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.ts index d1d40a79..172ec35f 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.ts @@ -34,6 +34,7 @@ export class MeetingToolbarExtraButtonsComponent { /** Whether to show the captions button (visible when not HIDDEN) */ showCaptionsButton = computed(() => this.meetingContextService.meetingUI().showCaptionsControls); /** Whether captions button is disabled (true when DISABLED_WITH_WARNING) */ + // TODO: Apply disabled while an enable/disable request is in flight to prevent concurrent calls isCaptionsButtonDisabled = computed(() => this.meetingContextService.meetingUI().showCaptionsControlsDisabled); /** Whether captions are currently enabled by the user */ areCaptionsEnabledByUser = this.captionService.areCaptionsEnabledByUser; @@ -51,12 +52,18 @@ export class MeetingToolbarExtraButtonsComponent { this.meetingService.copyMeetingSpeakerLink(room); } - onCaptionsClick(): void { - // Don't allow toggling if captions are disabled at system level - if (this.isCaptionsButtonDisabled()) { - this.log.w('Captions are disabled at system level (MEET_CAPTIONS_ENABLED=false)'); - return; + async onCaptionsClick(): Promise { + try { + // Don't allow toggling if captions are disabled at system level + if (this.isCaptionsButtonDisabled()) { + this.log.w('Captions are disabled at system level (MEET_CAPTIONS_ENABLED=false)'); + return; + } + this.captionService.areCaptionsEnabledByUser() + ? await this.captionService.disable() + : await this.captionService.enable(); + } catch (error) { + this.log.e('Error toggling captions:', error); } - this.areCaptionsEnabledByUser() ? this.captionService.disable() : this.captionService.enable(); } } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/services/meeting-captions.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/services/meeting-captions.service.ts index b104041a..bc7306c1 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/services/meeting-captions.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/services/meeting-captions.service.ts @@ -2,6 +2,7 @@ import { Injectable, inject, signal } from '@angular/core'; import { ILogger, LoggerService, ParticipantService, Room, TextStreamReader } from 'openvidu-components-angular'; import { Caption, CaptionsConfig } from '../models/captions.model'; import { CustomParticipantModel } from '../models/custom-participant.model'; +import { AiAssistantService } from '../../../shared/services/ai-assistant.service'; /** * Service responsible for managing live transcription captions. @@ -21,6 +22,7 @@ export class MeetingCaptionsService { private readonly participantService = inject(ParticipantService); private readonly loggerService = inject(LoggerService); private readonly logger: ILogger; + private readonly aiAssistantService = inject(AiAssistantService); // Configuration with defaults private readonly defaultConfig: Required = { @@ -38,6 +40,8 @@ export class MeetingCaptionsService { // Reactive state private readonly _captions = signal([]); private readonly _areCaptionsEnabledByUser = signal(false); + private readonly _captionsAgentId = signal(null); + private readonly _isCaptionsTogglePending = signal(false); /** * Current list of active captions @@ -47,6 +51,11 @@ export class MeetingCaptionsService { * Whether captions are enabled by the user */ readonly areCaptionsEnabledByUser = this._areCaptionsEnabledByUser.asReadonly(); + /** + * True while an enable() or disable() call is in flight. + * Use this to prevent concurrent toggle requests. + */ + readonly isCaptionsTogglePending = this._isCaptionsTogglePending.asReadonly(); // Map to track expiration timeouts private expirationTimeouts = new Map>(); @@ -82,7 +91,7 @@ export class MeetingCaptionsService { * Enables captions by registering the transcription handler. * This is called when the user activates captions. */ - enable(): void { + async enable(): Promise { if (!this.room) { this.logger.e('Cannot enable captions: room is not initialized'); return; @@ -93,29 +102,63 @@ export class MeetingCaptionsService { return; } - // Register the LiveKit transcription handler - this.room.registerTextStreamHandler('lk.transcription', this.handleTranscription.bind(this)); + if (this._isCaptionsTogglePending()) { + this.logger.d('Captions toggle already in progress'); + return; + } - this._areCaptionsEnabledByUser.set(true); - this.logger.d('Captions enabled'); + this._isCaptionsTogglePending.set(true); + + try { + // Register the LiveKit transcription handler + const agent = await this.aiAssistantService.createLiveCaptionsAssistant(); + this._captionsAgentId.set(agent.id); + this.room.registerTextStreamHandler('lk.transcription', this.handleTranscription.bind(this)); + this._areCaptionsEnabledByUser.set(true); + this.logger.d('Captions enabled'); + } finally { + // Add a small delay before allowing another toggle to prevent rapid concurrent calls + setTimeout(() => this._isCaptionsTogglePending.set(false), 500); + } } /** * Disables captions by clearing all captions and stopping transcription. * This is called when the user deactivates captions. */ - disable(): void { + async disable(): Promise { if (!this._areCaptionsEnabledByUser()) { this.logger.d('Captions already disabled'); return; } - // Clear all active captions - this.clearAllCaptions(); + if (this._isCaptionsTogglePending()) { + this.logger.d('Captions toggle already in progress'); + return; + } - this._areCaptionsEnabledByUser.set(false); - this.room?.unregisterTextStreamHandler('lk.transcription'); - this.logger.d('Captions disabled'); + this._isCaptionsTogglePending.set(true); + + try { + const agentId = this._captionsAgentId(); + + // Clear all active captions and unregister handler immediately so the UI + // reflects the disabled state before the async server call completes. + this.clearAllCaptions(); + this._areCaptionsEnabledByUser.set(false); + this.room?.unregisterTextStreamHandler('lk.transcription'); + + if (agentId) { + await this.aiAssistantService.cancelAssistant(agentId); + } + + this.logger.d('Captions disabled'); + } catch (error) { + this.logger.e('Error disabling captions:', error); + } finally { + // Add a small delay before allowing another toggle to prevent rapid concurrent calls + setTimeout(() => this._isCaptionsTogglePending.set(false), 500); + } } /** diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/services/meeting-event-handler.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/services/meeting-event-handler.service.ts index bd2d7180..ae63e3af 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/services/meeting-event-handler.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/services/meeting-event-handler.service.ts @@ -81,7 +81,6 @@ export class MeetingEventHandlerService { case MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED: const roleUpdateEvent = event as MeetParticipantRoleUpdatedPayload; await this.handleParticipantRoleUpdated(roleUpdateEvent); - this.showParticipantRoleUpdatedNotification(roleUpdateEvent.newRole); break; } } catch (error) { @@ -210,7 +209,7 @@ export class MeetingEventHandlerService { // Update local participant role local.meetRole = newRole; - console.log(`You have been assigned the role of ${newRole}`); + this.showParticipantRoleUpdatedNotification(newRole); } catch (error) { console.error('Error refreshing room member token:', error); } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-lists/recording-lists.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-lists/recording-lists.component.html index 78783aaf..6a289d07 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-lists/recording-lists.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/recordings/components/recording-lists/recording-lists.component.html @@ -450,17 +450,19 @@ class="action-button download-button" (click)="$event.stopPropagation(); downloadRecording(recording)" [disabled]="loading()" + id="download-recording-btn-{{ recording.recordingId }}" > download } + @if (isRecordingFailed(recording)) {