Merge branch 'main' into feat/room-members-users
This commit is contained in:
commit
7607f134a0
@ -0,0 +1,6 @@
|
||||
description: Create AI assistant activation request
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../../schemas/internal/ai-assistant-create-request.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'
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
56
meet-ce/backend/openapi/paths/internal/ai-assistant.yaml
Normal file
56
meet-ce/backend/openapi/paths/internal/ai-assistant.yaml
Normal file
@ -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'
|
||||
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
|
||||
69
meet-ce/backend/src/controllers/ai-assistant.controller.ts
Normal file
69
meet-ce/backend/src/controllers/ai-assistant.controller.ts
Normal file
@ -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<string> => {
|
||||
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}'`);
|
||||
}
|
||||
};
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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();
|
||||
};
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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);
|
||||
26
meet-ce/backend/src/routes/ai-assistant.routes.ts
Normal file
26
meet-ce/backend/src/routes/ai-assistant.routes.ts
Normal file
@ -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
|
||||
);
|
||||
@ -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';
|
||||
|
||||
@ -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'));
|
||||
|
||||
|
||||
252
meet-ce/backend/src/services/ai-assistant.service.ts
Normal file
252
meet-ce/backend/src/services/ai-assistant.service.ts
Normal file
@ -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<MeetCreateAssistantResponse> {
|
||||
// ! 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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<Room> {
|
||||
@ -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<AgentDispatch> {
|
||||
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<AgentDispatch[]> {
|
||||
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<AgentDispatch | undefined> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@ -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<Lock | null> {
|
||||
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<boolean> {
|
||||
const registryKey = MeetLock.getRegistryLock(key);
|
||||
return this.redisService.exists(registryKey);
|
||||
|
||||
@ -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<string> {
|
||||
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<string> {
|
||||
private async generateJwtToken(tokenOptions: AccessTokenOptions, grants?: VideoGrant): Promise<string> {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -11,8 +11,10 @@
|
||||
>
|
||||
<mat-icon class="material-symbols-outlined">subtitles</mat-icon>
|
||||
<span class="button-text">
|
||||
@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') {
|
||||
<mat-icon>subtitles_off</mat-icon>
|
||||
} @else {
|
||||
<mat-icon>subtitles</mat-icon>
|
||||
|
||||
@ -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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<CaptionsConfig> = {
|
||||
@ -38,6 +40,8 @@ export class MeetingCaptionsService {
|
||||
// Reactive state
|
||||
private readonly _captions = signal<Caption[]>([]);
|
||||
private readonly _areCaptionsEnabledByUser = signal<boolean>(false);
|
||||
private readonly _captionsAgentId = signal<string | null>(null);
|
||||
private readonly _isCaptionsTogglePending = signal<boolean>(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<string, ReturnType<typeof setTimeout>>();
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -450,17 +450,19 @@
|
||||
class="action-button download-button"
|
||||
(click)="$event.stopPropagation(); downloadRecording(recording)"
|
||||
[disabled]="loading()"
|
||||
id="download-recording-btn-{{ recording.recordingId }}"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (isRecordingFailed(recording)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
matTooltip="Delete Recording"
|
||||
class="action-button delete-button"
|
||||
(click)="$event.stopPropagation(); deleteRecording(recording)"
|
||||
[disabled]="loading()"
|
||||
class="action-button delete-button"
|
||||
matTooltip="Delete Recording"
|
||||
id="delete-recording-btn-{{ recording.recordingId }}"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
@ -471,7 +473,7 @@
|
||||
mat-icon-button
|
||||
class="action-button more-button"
|
||||
[matMenuTriggerFor]="actionsMenu"
|
||||
(click)="$event.stopPropagation();"
|
||||
(click)="$event.stopPropagation()"
|
||||
matTooltip="More Actions"
|
||||
[disabled]="loading()"
|
||||
id="more-actions-btn-{{ recording.recordingId }}"
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
MeetAssistantCapabilityName,
|
||||
MeetCreateAssistantRequest,
|
||||
MeetCreateAssistantResponse
|
||||
} from '@openvidu-meet/typings';
|
||||
import { HttpService } from './http.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AiAssistantService {
|
||||
protected readonly AI_ASSISTANT_API = `${HttpService.INTERNAL_API_PATH_PREFIX}/ai/assistants`;
|
||||
|
||||
constructor(protected httpService: HttpService) {}
|
||||
|
||||
async cancelAssistant(assistantId: string): Promise<void> {
|
||||
const path = `${this.AI_ASSISTANT_API}/${assistantId}`;
|
||||
await this.httpService.deleteRequest<void>(path);
|
||||
}
|
||||
|
||||
async createLiveCaptionsAssistant(): Promise<MeetCreateAssistantResponse> {
|
||||
const request: MeetCreateAssistantRequest = {
|
||||
capabilities: [{ name: MeetAssistantCapabilityName.LIVE_CAPTIONS }]
|
||||
};
|
||||
|
||||
return this.createAssistant(request);
|
||||
}
|
||||
|
||||
private async createAssistant(request: MeetCreateAssistantRequest): Promise<MeetCreateAssistantResponse> {
|
||||
return this.httpService.postRequest<MeetCreateAssistantResponse>(this.AI_ASSISTANT_API, request);
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './ai-assistant.service';
|
||||
export * from './analytics.service';
|
||||
export * from './api-key.service';
|
||||
export * from './app-context.service';
|
||||
|
||||
42
meet-ce/typings/src/ai-assistant.ts
Normal file
42
meet-ce/typings/src/ai-assistant.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Assistant creation options
|
||||
*/
|
||||
export interface MeetCreateAssistantRequest {
|
||||
// scope: MeetAssistantScope;
|
||||
capabilities: MeetAssistantCapability[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the scope of an assistant, i.e. the resource(s) it is associated with.
|
||||
*/
|
||||
// export interface MeetAssistantScope {
|
||||
// resourceType: MeetAssistantScopeResourceType;
|
||||
// resourceIds: string[];
|
||||
// }
|
||||
|
||||
/**
|
||||
* Defines a capability that an assistant can have, such as live captions.
|
||||
*/
|
||||
export interface MeetAssistantCapability {
|
||||
name: MeetAssistantCapabilityName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumeration of supported assistant capabilities.
|
||||
*/
|
||||
export enum MeetAssistantCapabilityName {
|
||||
LIVE_CAPTIONS = 'live_captions',
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumeration of supported resource types that an assistant can be associated with.
|
||||
*/
|
||||
// export enum MeetAssistantScopeResourceType {
|
||||
// MEETING = 'meeting',
|
||||
// }
|
||||
|
||||
|
||||
export interface MeetCreateAssistantResponse {
|
||||
id: string;
|
||||
status: 'active';
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './ai-assistant.js';
|
||||
export * from './analytics.js';
|
||||
export * from './frontend-signal.js';
|
||||
export * from './livekit-permissions.js';
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@ -48,8 +48,8 @@ importers:
|
||||
specifier: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0
|
||||
version: 20.2.14(2a2c27b5f9f3f8b334e74e3c717f4ace)
|
||||
'@livekit/track-processors':
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.16.1(@types/dom-mediacapture-record@1.0.22))
|
||||
specifier: ^0.7.2
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.16.1(@types/dom-mediacapture-record@1.0.22))
|
||||
autolinker:
|
||||
specifier: ^4.0.0
|
||||
version: 4.1.5
|
||||
@ -268,8 +268,8 @@ importers:
|
||||
specifier: 20.3.15
|
||||
version: 20.3.15(@angular/common@20.3.15(@angular/core@20.3.15(@angular/compiler@20.3.15)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.15(@angular/compiler@20.3.15)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.15(@angular/animations@20.3.15(@angular/core@20.3.15(@angular/compiler@20.3.15)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.15(@angular/core@20.3.15(@angular/compiler@20.3.15)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.15(@angular/compiler@20.3.15)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)
|
||||
'@livekit/track-processors':
|
||||
specifier: 0.7.0
|
||||
version: 0.7.0(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.16.1(@types/dom-mediacapture-record@1.0.22))
|
||||
specifier: 0.7.2
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.16.1(@types/dom-mediacapture-record@1.0.22))
|
||||
'@openvidu-meet/shared-components':
|
||||
specifier: workspace:*
|
||||
version: link:projects/shared-meet-components
|
||||
@ -703,8 +703,8 @@ importers:
|
||||
specifier: 20.3.15
|
||||
version: 20.3.15(@angular/common@20.3.15(@angular/core@20.3.15(@angular/compiler@20.3.15)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.15(@angular/compiler@20.3.15)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.15(@angular/animations@20.3.15(@angular/core@20.3.15(@angular/compiler@20.3.15)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.15(@angular/core@20.3.15(@angular/compiler@20.3.15)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.15(@angular/compiler@20.3.15)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)
|
||||
'@livekit/track-processors':
|
||||
specifier: 0.7.0
|
||||
version: 0.7.0(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.16.1(@types/dom-mediacapture-record@1.0.22))
|
||||
specifier: 0.7.2
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.16.1(@types/dom-mediacapture-record@1.0.22))
|
||||
'@openvidu-meet/shared-components':
|
||||
specifier: workspace:*
|
||||
version: link:../../meet-ce/frontend/projects/shared-meet-components
|
||||
@ -2968,8 +2968,8 @@ packages:
|
||||
'@livekit/protocol@1.43.4':
|
||||
resolution: {integrity: sha512-mJDFt/p+G2OKmIGizYiACK7Jb06wd42m9Pe7Y9cAOfdYpvwCqHlw4yul5Z7iRU3VKPsYJ27WL3oeHEoiu+HuAA==}
|
||||
|
||||
'@livekit/track-processors@0.7.0':
|
||||
resolution: {integrity: sha512-ERdByDrHPLA8xzZNFcqWiLt5ZJs4AZo1RjDlYplb//xeaWURaJdulqrds5EHSMHmELmTzlEOSgvSrzFlatlJQQ==}
|
||||
'@livekit/track-processors@0.7.2':
|
||||
resolution: {integrity: sha512-lzARBKTbBwqycdR/SwTu6//N0l20BzfDd7grxCXl07676SwRApNtZAK1GJjL1m3dCM3KBqH1aVxjMpNcbOw5uQ==}
|
||||
peerDependencies:
|
||||
'@types/dom-mediacapture-transform': ^0.1.9
|
||||
livekit-client: 2.16.1
|
||||
@ -13580,7 +13580,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 1.10.1
|
||||
|
||||
'@livekit/track-processors@0.7.0(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.16.1(@types/dom-mediacapture-record@1.0.22))':
|
||||
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.16.1(@types/dom-mediacapture-record@1.0.22))':
|
||||
dependencies:
|
||||
'@mediapipe/tasks-vision': 0.10.14
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user