diff --git a/backend/apps/config_app.py b/backend/apps/config_app.py index 88be72d55..67a5e934c 100644 --- a/backend/apps/config_app.py +++ b/backend/apps/config_app.py @@ -18,6 +18,9 @@ from apps.tool_config_app import router as tool_config_router from apps.user_management_app import router as user_management_router from apps.voice_app import voice_config_router as voice_router +from apps.tenant_app import router as tenant_router +from apps.group_app import router as group_router +from apps.invitation_app import router as invitation_router from consts.const import IS_SPEED_MODE # Import monitoring utilities @@ -57,6 +60,9 @@ app.include_router(prompt_router) app.include_router(tenant_config_router) app.include_router(remote_mcp_router) +app.include_router(tenant_router) +app.include_router(group_router) +app.include_router(invitation_router) # Initialize monitoring for the application monitoring_manager.setup_fastapi_app(app) diff --git a/backend/apps/group_app.py b/backend/apps/group_app.py new file mode 100644 index 000000000..3648f0476 --- /dev/null +++ b/backend/apps/group_app.py @@ -0,0 +1,651 @@ +""" +Group management API endpoints +""" +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException, Header +from http import HTTPStatus +from starlette.responses import JSONResponse + +from consts.model import ( + GroupCreateRequest, GroupUpdateRequest, + GroupUserRequest, GroupListRequest, SetDefaultGroupRequest +) +from consts.exceptions import NotFoundException, ValidationError, UnauthorizedError +from services.group_service import ( + create_group, get_group_info, update_group, delete_group, + add_user_to_single_group, remove_user_from_single_group, get_group_users, + add_user_to_groups, get_tenant_default_group_id, set_tenant_default_group_id, + get_groups_by_tenant +) +from services.tenant_service import get_tenant_info +from utils.auth_utils import get_current_user_id + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/groups", tags=["groups"]) + + +@router.post("", response_model=None) +async def create_group_endpoint( + request: GroupCreateRequest, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Create a new group + + Args: + request: Group creation request + authorization: Bearer token for authentication + + Returns: + JSONResponse: Created group information + """ + try: + # Get current user ID from token + user_id, _ = get_current_user_id(authorization) + + # Create group + group_info = create_group( + tenant_id=request.tenant_id, + group_name=request.group_name, + group_description=request.group_description, + user_id=user_id + ) + + logger.info(f"Created group '{request.group_name}' in tenant {request.tenant_id} by user {user_id}") + + return JSONResponse( + status_code=HTTPStatus.CREATED, + content={ + "message": "Group created successfully", + "data": group_info + } + ) + + except UnauthorizedError as exc: + logger.warning(f"Unauthorized group creation attempt: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except ValidationError as exc: + logger.warning(f"Group creation validation error: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error during group creation: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to create group" + ) + + +@router.get("/{group_id}") +async def get_group_endpoint(group_id: int) -> JSONResponse: + """ + Get group information by group ID + + Args: + group_id: Group identifier + authorization: Bearer token for authentication + + Returns: + JSONResponse: Group information + """ + try: + # Get group info + group_info = get_group_info(group_id) + + if not group_info: + raise NotFoundException(f"Group {group_id} not found") + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Group retrieved successfully", + "data": group_info + } + ) + + except NotFoundException as exc: + logger.warning(f"Group not found: {group_id}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error retrieving group {group_id}: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to retrieve group" + ) + + +@router.post("/list") +async def get_groups_endpoint( + request: GroupListRequest, +) -> JSONResponse: + """ + Search groups for a specific tenant with pagination + + Args: + request: Group search request with tenant_id, page, and page_size + + Returns: + JSONResponse: Paginated list of groups for the tenant + """ + try: + # Validate tenant exists + get_tenant_info(request.tenant_id) + # Get groups under given tenant with pagination + result = get_groups_by_tenant( + tenant_id=request.tenant_id, + page=request.page, + page_size=request.page_size + ) + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Groups retrieved successfully", + "data": result["groups"], + "pagination": { + "page": request.page, + "page_size": request.page_size, + "total": result["total"], + "total_pages": (result["total"] + request.page_size - 1) // request.page_size + } + } + ) + + except NotFoundException as exc: + logger.warning(f"Tenant not found: {request.tenant_id}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error retrieving groups: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to retrieve groups" + ) + + +@router.put("/{group_id}") +async def update_group_endpoint( + group_id: int, + request: GroupUpdateRequest, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Update group information + + Args: + group_id: Group identifier + request: Group update request + authorization: Bearer token for authentication + + Returns: + JSONResponse: Success status + """ + try: + # Get current user ID from token + user_id, _ = get_current_user_id(authorization) + + # Prepare updates dict + updates = {} + if request.group_name is not None: + updates["group_name"] = request.group_name + if request.group_description is not None: + updates["group_description"] = request.group_description + + if not updates: + raise ValidationError("No valid fields provided for update") + + # Update group + success = update_group( + group_id=group_id, + updates=updates, + user_id=user_id + ) + + if not success: + raise ValidationError("Failed to update group") + + logger.info(f"Updated group {group_id} by user {user_id}") + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Group updated successfully" + } + ) + + except NotFoundException as exc: + logger.warning(f"Group not found for update: {group_id}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except ValidationError as exc: + logger.warning(f"Group update validation error: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) + except UnauthorizedError as exc: + logger.warning(f"Unauthorized group update attempt: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error during group update: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update group" + ) + + +@router.delete("/{group_id}") +async def delete_group_endpoint( + group_id: int, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Delete group + + Args: + group_id: Group identifier + authorization: Bearer token for authentication + + Returns: + JSONResponse: Success status + """ + try: + # Get current user ID from token + user_id, _ = get_current_user_id(authorization) + + # Delete group + success = delete_group( + group_id=group_id, + user_id=user_id + ) + + if not success: + raise ValidationError("Failed to delete group") + + logger.info(f"Deleted group {group_id} by user {user_id}") + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Group deleted successfully" + } + ) + + except NotFoundException as exc: + logger.warning(f"Group not found for deletion: {group_id}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except ValidationError as exc: + logger.warning(f"Group deletion validation error: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) + except UnauthorizedError as exc: + logger.warning(f"Unauthorized group deletion attempt: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error during group deletion: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to delete group" + ) + + +@router.post("/{group_id}/members") +async def add_user_to_group_endpoint( + group_id: int, + request: GroupUserRequest, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Add user to group + + Args: + group_id: Group identifier + request: User addition request containing user_id + authorization: Bearer token for authentication + + Returns: + JSONResponse: Group membership result + """ + try: + # Validate request - only user_id should be provided in body + if request.group_ids is not None: + raise ValidationError("group_ids should not be provided for single group operation") + + # Get current user ID from token + current_user_id, _ = get_current_user_id(authorization) + + # Add user to group + result = add_user_to_single_group( + group_id=group_id, + user_id=request.user_id, + current_user_id=current_user_id + ) + + logger.info(f"Added user {request.user_id} to group {group_id} by user {current_user_id}") + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "User added to group successfully", + "data": result + } + ) + + except NotFoundException as exc: + logger.warning(f"Group or user not found: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except ValidationError as exc: + logger.warning(f"Group membership validation error: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) + except UnauthorizedError as exc: + logger.warning(f"Unauthorized group membership modification: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error adding user to group: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to add user to group" + ) + + +@router.delete("/{group_id}/members/{user_id}") +async def remove_user_from_group_endpoint( + group_id: int, + user_id: str, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Remove user from group + + Args: + group_id: Group identifier + user_id: User identifier + authorization: Bearer token for authentication + + Returns: + JSONResponse: Success status + """ + try: + # Get current user ID from token + current_user_id, _ = get_current_user_id(authorization) + + # Remove user from group + success = remove_user_from_single_group( + group_id=group_id, + user_id=user_id, + current_user_id=current_user_id + ) + + if not success: + raise ValidationError("Failed to remove user from group") + + logger.info(f"Removed user {user_id} from group {group_id} by user {current_user_id}") + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "User removed from group successfully" + } + ) + + except NotFoundException as exc: + logger.warning(f"Group or user not found: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except ValidationError as exc: + logger.warning(f"Group membership removal validation error: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) + except UnauthorizedError as exc: + logger.warning(f"Unauthorized group membership modification: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error removing user from group: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to remove user from group" + ) + + +@router.get("/{group_id}/members") +async def get_group_users_endpoint(group_id: int) -> JSONResponse: + """ + Get all users in a group + + Args: + group_id: Group identifier + authorization: Bearer token for authentication + + Returns: + JSONResponse: List of group users + """ + try: + # Get group users + users = get_group_users(group_id) + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Group users retrieved successfully", + "data": users + } + ) + + except NotFoundException as exc: + logger.warning(f"Group not found: {group_id}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error retrieving group users: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to retrieve group users" + ) + + +@router.post("/members/batch") +async def add_user_to_groups_endpoint( + request: GroupUserRequest, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Add user to multiple groups (batch operation) + + Args: + request: Batch user addition request containing user_id and group_ids + authorization: Bearer token for authentication + + Returns: + JSONResponse: Batch operation results + """ + try: + # Validate request for batch operation + if request.group_ids is None or len(request.group_ids) == 0: + raise ValidationError("group_ids is required for batch operations") + + # Get current user ID from token + current_user_id, _ = get_current_user_id(authorization) + + # Add user to multiple groups + results = add_user_to_groups( + user_id=request.user_id, + group_ids=request.group_ids, + current_user_id=current_user_id + ) + + logger.info(f"Batch added user {request.user_id} to {len(request.group_ids)} groups by user {current_user_id}") + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Batch user addition completed", + "data": results + } + ) + + except ValidationError as exc: + logger.warning(f"Batch user addition validation error: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) + except UnauthorizedError as exc: + logger.warning(f"Unauthorized batch group membership modification: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error in batch user addition: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to add user to groups" + ) + + +@router.get("/tenants/{tenant_id}/default") +async def get_tenant_default_group_endpoint(tenant_id: str) -> JSONResponse: + """ + Get tenant's default group ID + + Args: + tenant_id: Tenant identifier + authorization: Bearer token for authentication + + Returns: + JSONResponse: Default group ID + """ + try: + # Get default group ID + default_group_id = get_tenant_default_group_id(tenant_id) + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Default group ID retrieved successfully", + "data": { + "tenant_id": tenant_id, + "default_group_id": default_group_id + } + } + ) + + except Exception as exc: + logger.error(f"Unexpected error retrieving default group for tenant {tenant_id}: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to retrieve default group" + ) + + +@router.put("/tenants/{tenant_id}/default") +async def set_tenant_default_group_endpoint( + tenant_id: str, + request: SetDefaultGroupRequest, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Set tenant's default group ID + + Args: + tenant_id: Tenant identifier + request: Request containing the default group ID to set + authorization: Bearer token for authentication + + Returns: + JSONResponse: Success status + """ + try: + # Get current user ID from token + user_id, _ = get_current_user_id(authorization) + + # Set default group ID + success = set_tenant_default_group_id( + tenant_id=tenant_id, + group_id=request.default_group_id, + updated_by=user_id + ) + + if not success: + raise ValidationError("Failed to set default group") + + logger.info(f"Set default group {request.default_group_id} for tenant {tenant_id} by user {user_id}") + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Default group set successfully", + "data": { + "tenant_id": tenant_id, + "default_group_id": request.default_group_id + } + } + ) + + except NotFoundException as exc: + logger.warning(f"Tenant or group not found: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except ValidationError as exc: + logger.warning(f"Validation error setting default group: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) + except UnauthorizedError as exc: + logger.warning(f"Unauthorized attempt to set default group: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error setting default group for tenant {tenant_id}: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to set default group" + ) diff --git a/backend/apps/invitation_app.py b/backend/apps/invitation_app.py new file mode 100644 index 000000000..1512e3274 --- /dev/null +++ b/backend/apps/invitation_app.py @@ -0,0 +1,488 @@ +""" +Invitation management API endpoints +""" +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException, Header +from http import HTTPStatus +from starlette.responses import JSONResponse + +from consts.model import ( + InvitationCreateRequest, InvitationUpdateRequest, InvitationListRequest +) +from consts.exceptions import NotFoundException, ValidationError, UnauthorizedError +from services.invitation_service import ( + create_invitation_code, update_invitation_code, get_invitation_by_code, + check_invitation_available, use_invitation_code, update_invitation_code_status, + get_invitations_list, delete_invitation_code +) +from database.user_tenant_db import get_user_tenant_by_user_id +from utils.auth_utils import get_current_user_id + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/invitations", tags=["invitations"]) + + +@router.post("/list") +async def list_invitations_endpoint( + request: InvitationListRequest, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + List invitation codes with pagination + + Args: + request: Invitation list request with pagination parameters + authorization: Bearer token for authentication + + Returns: + JSONResponse: Paginated list of invitation codes + """ + try: + # Get current user ID from token + user_id, _ = get_current_user_id(authorization) + + # Get invitations list + result = get_invitations_list( + tenant_id=request.tenant_id, + page=request.page, + page_size=request.page_size, + user_id=user_id + ) + + logger.info(f"User {user_id} retrieved invitation list (tenant: {request.tenant_id or 'all'}, page: {request.page}, size: {request.page_size})") + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Invitation codes retrieved successfully", + "data": result + } + ) + + except UnauthorizedError as exc: + logger.warning(f"Unauthorized invitation list access attempt: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error retrieving invitation list: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to retrieve invitation codes" + ) + + +@router.post("") +async def create_invitation_endpoint( + request: InvitationCreateRequest, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Create a new invitation code + + Args: + request: Invitation creation request + authorization: Bearer token for authentication + + Returns: + JSONResponse: Created invitation information + """ + try: + # Get current user ID from token + user_id, _ = get_current_user_id(authorization) + + # Validate tenant_id from request + tenant_id = request.tenant_id + + # Preprocess request parameters to handle empty values + invitation_code = request.invitation_code if request.invitation_code else None + group_ids = request.group_ids if request.group_ids else None + expiry_date = request.expiry_date if request.expiry_date else None + + # Create invitation code + invitation_info = create_invitation_code( + tenant_id=tenant_id, + code_type=request.code_type, + invitation_code=invitation_code, + group_ids=group_ids, + capacity=request.capacity, + expiry_date=expiry_date, + user_id=user_id + ) + + logger.info(f"Created invitation code {invitation_info['invitation_code']} (type: {request.code_type}) for tenant {tenant_id} by user {user_id}") + + return JSONResponse( + status_code=HTTPStatus.CREATED, + content={ + "message": "Invitation code created successfully", + "data": invitation_info + } + ) + + except ValueError as exc: + logger.warning(f"Invalid invitation creation parameters: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) + except NotFoundException as exc: + logger.warning(f"User not found during invitation creation: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except UnauthorizedError as exc: + logger.warning(f"Unauthorized invitation creation attempt: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error during invitation creation: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to create invitation code" + ) + + +@router.put("/{invitation_code}") +async def update_invitation_endpoint( + invitation_code: str, + request: InvitationUpdateRequest, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Update invitation code information + + Args: + invitation_code: Invitation code + request: Invitation update request + authorization: Bearer token for authentication + + Returns: + JSONResponse: Success status + """ + try: + # Get current user ID from token + user_id, _ = get_current_user_id(authorization) + + # Get invitation info to find invitation_id + invitation_info = get_invitation_by_code(invitation_code) + if not invitation_info: + raise NotFoundException(f"Invitation code {invitation_code} not found") + + invitation_id = invitation_info["invitation_id"] + + # Prepare updates dict + updates = {} + if request.capacity is not None: + updates["capacity"] = request.capacity + if request.expiry_date is not None: + updates["expiry_date"] = request.expiry_date + if request.group_ids is not None: + updates["group_ids"] = request.group_ids + + if not updates: + raise ValidationError("No valid fields provided for update") + + # Update invitation + success = update_invitation_code( + invitation_id=invitation_id, + updates=updates, + user_id=user_id + ) + + if not success: + raise ValidationError("Failed to update invitation code") + + logger.info(f"Updated invitation code {invitation_code} by user {user_id}") + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Invitation code updated successfully" + } + ) + + except NotFoundException as exc: + logger.warning(f"Invitation not found for update: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except ValidationError as exc: + logger.warning(f"Invitation update validation error: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) + except UnauthorizedError as exc: + logger.warning(f"Unauthorized invitation update attempt: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except Exception as exc: + import traceback + logger.error(f"Unexpected error during invitation update: {str(exc)}") + logger.error(f"Exception type: {type(exc).__name__}") + logger.error(f"Full traceback: {traceback.format_exc()}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update invitation code" + ) + + +@router.get("/{invitation_code}") +async def get_invitation_endpoint(invitation_code: str) -> JSONResponse: + """ + Get invitation information by code + + Args: + invitation_code: Invitation code + + Returns: + JSONResponse: Invitation information + """ + try: + # Get invitation info + invitation_info = get_invitation_by_code(invitation_code) + + if not invitation_info: + raise NotFoundException(f"Invitation code {invitation_code} not found") + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Invitation code retrieved successfully", + "data": invitation_info + } + ) + + except NotFoundException as exc: + logger.warning(f"Invitation code not found: {invitation_code}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error retrieving invitation code {invitation_code}: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to retrieve invitation code" + ) + + +@router.delete("/{invitation_code}") +async def delete_invitation_endpoint( + invitation_code: str, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Delete invitation code + + Args: + invitation_code: Invitation code to delete + authorization: Bearer token for authentication + + Returns: + JSONResponse: Success status + """ + try: + # Get current user ID from token + user_id, _ = get_current_user_id(authorization) + + # Get invitation info to find invitation_id + invitation_info = get_invitation_by_code(invitation_code) + if not invitation_info: + raise NotFoundException(f"Invitation code {invitation_code} not found") + + invitation_id = invitation_info["invitation_id"] + + # Delete invitation code + success = delete_invitation_code( + invitation_id=invitation_id, + user_id=user_id + ) + + if not success: + raise ValidationError("Failed to delete invitation code") + + logger.info(f"Deleted invitation code {invitation_code} by user {user_id}") + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Invitation code deleted successfully" + } + ) + + except NotFoundException as exc: + logger.warning(f"Invitation not found for deletion: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except ValidationError as exc: + logger.warning(f"Invitation deletion validation error: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) + except UnauthorizedError as exc: + logger.warning(f"Unauthorized invitation deletion attempt: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error during invitation deletion: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to delete invitation code" + ) + + +@router.get("/{invitation_code}/available") +async def check_invitation_available_endpoint(invitation_code: str) -> JSONResponse: + """ + Check if invitation code is available for use + + Args: + invitation_code: Invitation code to check + + Returns: + JSONResponse: Availability status + """ + try: + # Check availability + is_available = check_invitation_available(invitation_code) + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Invitation availability checked successfully", + "data": { + "invitation_code": invitation_code, + "available": is_available + } + } + ) + + except Exception as exc: + logger.error(f"Unexpected error checking invitation availability: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to check invitation availability" + ) + + +@router.post("/{invitation_code}/use") +async def use_invitation_endpoint( + invitation_code: str, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Use an invitation code + + Args: + invitation_code: Invitation code to use + authorization: Bearer token for authentication + + Returns: + JSONResponse: Usage result + """ + try: + # Get current user ID from token + current_user_id, _ = get_current_user_id(authorization) + + # Users can use invitation codes for themselves + + # Use invitation code + usage_result = use_invitation_code( + invitation_code=invitation_code, + user_id=current_user_id + ) + + logger.info(f"User {current_user_id} used invitation code {invitation_code}") + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Invitation code used successfully", + "data": usage_result + } + ) + + except NotFoundException as exc: + logger.warning(f"Invitation code not available: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except UnauthorizedError as exc: + logger.warning(f"Unauthorized invitation usage attempt: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error using invitation code: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to use invitation code" + ) + + +@router.post("/{invitation_code}/update-status") +async def update_invitation_status_endpoint(invitation_code: str) -> JSONResponse: + """ + Update invitation code status based on expiry and usage + + Args: + invitation_code: Invitation code + authorization: Bearer token for authentication + + Returns: + JSONResponse: Status update result + """ + try: + # Get invitation info to find invitation_id + invitation_info = get_invitation_by_code(invitation_code) + if not invitation_info: + raise NotFoundException(f"Invitation code {invitation_code} not found") + + invitation_id = invitation_info["invitation_id"] + + # Update status + status_updated = update_invitation_code_status(invitation_id) + + message = "Invitation status updated" if status_updated else "Invitation status unchanged" + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": message, + "data": { + "invitation_code": invitation_code, + "status_updated": status_updated + } + } + ) + + except NotFoundException as exc: + logger.warning(f"Invitation not found for status update: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error updating invitation status: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update invitation status" + ) diff --git a/backend/apps/tenant_app.py b/backend/apps/tenant_app.py new file mode 100644 index 000000000..07215b5b2 --- /dev/null +++ b/backend/apps/tenant_app.py @@ -0,0 +1,244 @@ +""" +Tenant management API endpoints +""" +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException, Header +from http import HTTPStatus +from starlette.responses import JSONResponse + +from consts.model import TenantCreateRequest, TenantUpdateRequest +from consts.exceptions import NotFoundException, ValidationError, UnauthorizedError +from services.tenant_service import create_tenant, get_tenant_info, get_all_tenants, update_tenant_info +from utils.auth_utils import get_current_user_id + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/tenants", tags=["tenants"]) + + +@router.post("", response_model=None) +async def create_tenant_endpoint( + request: TenantCreateRequest, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Create a new tenant + + Args: + request: Tenant creation request + authorization: Bearer token for authentication + + Returns: + JSONResponse: Created tenant information + """ + try: + # Get current user ID from token + user_id, _ = get_current_user_id(authorization) + + # Create tenant + tenant_info = create_tenant( + tenant_name=request.tenant_name, + created_by=user_id + ) + + logger.info(f"Created tenant {tenant_info['tenant_id']} by user {user_id}") + + return JSONResponse( + status_code=HTTPStatus.CREATED, + content={ + "message": "Tenant created successfully", + "data": tenant_info + } + ) + + except UnauthorizedError as exc: + logger.warning(f"Unauthorized tenant creation attempt: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except ValidationError as exc: + logger.warning(f"Tenant creation validation error: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error during tenant creation: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to create tenant" + ) + + +@router.get("/{tenant_id}") +async def get_tenant_endpoint(tenant_id: str) -> JSONResponse: + """ + Get tenant information by tenant ID + + Args: + tenant_id: Tenant identifier + + Returns: + JSONResponse: Tenant information + """ + try: + # Get tenant info + tenant_info = get_tenant_info(tenant_id) + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Tenant retrieved successfully", + "data": tenant_info + } + ) + + except NotFoundException as exc: + logger.warning(f"Tenant not found: {tenant_id}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error retrieving tenant {tenant_id}: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to retrieve tenant" + ) + + +@router.get("") +async def get_all_tenants_endpoint() -> JSONResponse: + """ + Get all tenants + + Returns: + JSONResponse: List of all tenants + """ + try: + # Get all tenants + tenants = get_all_tenants() + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Tenants retrieved successfully", + "data": tenants + } + ) + + except Exception as exc: + logger.error(f"Unexpected error retrieving tenants: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to retrieve tenants" + ) + + +@router.put("/{tenant_id}") +async def update_tenant_endpoint( + tenant_id: str, + request: TenantUpdateRequest, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Update tenant information + + Args: + tenant_id: Tenant identifier + request: Tenant update request + authorization: Bearer token for authentication + + Returns: + JSONResponse: Updated tenant information + """ + try: + # Get current user ID from token + user_id, _ = get_current_user_id(authorization) + + # Update tenant + updated_tenant = update_tenant_info( + tenant_id=tenant_id, + tenant_name=request.tenant_name, + updated_by=user_id + ) + + logger.info(f"Updated tenant {tenant_id} by user {user_id}") + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Tenant updated successfully", + "data": updated_tenant + } + ) + + except NotFoundException as exc: + logger.warning(f"Tenant not found for update: {tenant_id}") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(exc) + ) + except ValidationError as exc: + logger.warning(f"Tenant update validation error: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) + except UnauthorizedError as exc: + logger.warning(f"Unauthorized tenant update attempt: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error during tenant update: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update tenant" + ) + + +@router.delete("/{tenant_id}") +async def delete_tenant_endpoint( + tenant_id: str, + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """ + Delete tenant (placeholder - not yet implemented) + + Args: + tenant_id: Tenant identifier + authorization: Bearer token for authentication + + Returns: + JSONResponse: Deletion result + """ + try: + # Get current user ID from token + user_id, _ = get_current_user_id(authorization) + + # Note: Delete functionality is not yet implemented in the service layer + # This will raise ValidationError as per current implementation + raise ValidationError("Tenant deletion is not yet implemented due to complex dependencies") + + except ValidationError as exc: + logger.warning(f"Tenant deletion not supported: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.NOT_IMPLEMENTED, + detail=str(exc) + ) + except UnauthorizedError as exc: + logger.warning(f"Unauthorized tenant deletion attempt: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=str(exc) + ) + except Exception as exc: + logger.error(f"Unexpected error during tenant deletion: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to delete tenant" + ) diff --git a/backend/apps/user_management_app.py b/backend/apps/user_management_app.py index 1d26aa10d..8265aed9c 100644 --- a/backend/apps/user_management_app.py +++ b/backend/apps/user_management_app.py @@ -10,8 +10,8 @@ from consts.model import UserSignInRequest, UserSignUpRequest from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException from services.user_management_service import get_authorized_client, validate_token, \ - check_auth_service_health, signup_user, signin_user, refresh_user_token, \ - get_session_by_authorization, revoke_regular_user + check_auth_service_health, signup_user, signup_user_with_invitation, signin_user, refresh_user_token, \ + get_session_by_authorization, revoke_regular_user, get_user_info, get_permissions_by_role from consts.exceptions import UnauthorizedError from utils.auth_utils import get_current_user_id @@ -41,10 +41,15 @@ async def service_health(): async def signup(request: UserSignUpRequest): """User registration""" try: - user_data = await signup_user(email=request.email, - password=request.password, - is_admin=request.is_admin, - invite_code=request.invite_code) + if request.with_new_invitation: + user_data = await signup_user_with_invitation(email=request.email, + password=request.password, + invite_code=request.invite_code) + else: + user_data = await signup_user(email=request.email, + password=request.password, + is_admin=request.is_admin, + invite_code=request.invite_code) if request.is_admin: success_message = "🎉 Admin account registered successfully! You now have system management permissions." else: @@ -167,6 +172,43 @@ async def get_session(request: Request): detail="Get user session failed") +@router.get("/current_user_info") +async def get_user_information(request: Request): + """Get current user information including user ID, group IDs, tenant ID, and role""" + authorization = request.headers.get("Authorization") + if not authorization: + # Treat as not logged in when missing token + return JSONResponse(status_code=HTTPStatus.OK, + content={"message": "User not logged in", + "data": None}) + + try: + # Use the unified token validation function to get user ID + is_valid, user = validate_token(authorization) + if not is_valid or not user: + raise UnauthorizedError("User not logged in or session invalid") + + user_id = user.id + + # Get user information + user_info = await get_user_info(user_id) + if not user_info: + raise UnauthorizedError("User information not found") + + return JSONResponse(status_code=HTTPStatus.OK, + content={"message": "Success", + "data": user_info}) + + except UnauthorizedError as e: + logging.error(f"Get user information unauthorized: {str(e)}") + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, + detail="User not logged in or session invalid") + except Exception as e: + logging.error(f"Get user information failed: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Get user information failed") + + @router.get("/current_user_id") async def get_user_id(request: Request): """Get current user ID, return None if not logged in""" @@ -195,7 +237,7 @@ async def get_user_id(request: Request): except ValueError as e: logging.error(f"Get user ID failed: {str(e)}") raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - detail="User not logged in or session invalid") + detail="User not logged in or session invalid") except Exception as e: logging.error(f"Get user ID failed: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, @@ -245,3 +287,32 @@ async def revoke_user_account(request: Request): logging.error(f"User revoke failed: {str(e)}") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="User revoke failed") + + +@router.get("/role_permissions/{user_role}") +async def get_role_permissions(user_role: str): + """ + Get all permissions for a specific user role. + + Args: + user_role (str): User role to query permissions for (SU, ADMIN, DEV, USER) + + Returns: + JSONResponse: Permissions data with success message + """ + try: + permissions_data = await get_permissions_by_role(user_role) + + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": permissions_data["message"], + "data": { + "user_role": permissions_data["user_role"], + "permissions": permissions_data["permissions"], + "total_permissions": permissions_data["total_permissions"] + } + }) + except Exception as e: + logging.error( + f"Failed to get role permissions for role {user_role}: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve permissions for role {user_role}") diff --git a/backend/apps/vectordatabase_app.py b/backend/apps/vectordatabase_app.py index 39b94fbd0..7d01c10d7 100644 --- a/backend/apps/vectordatabase_app.py +++ b/backend/apps/vectordatabase_app.py @@ -25,21 +25,27 @@ logger = logging.getLogger("vectordatabase_app") -@router.get("/check_exist/{index_name}") +@router.post("/check_exist") async def check_knowledge_base_exist( - index_name: str = Path(..., description="Name of the index to check"), + request: Dict[str, str] = Body( + ..., description="Request body containing knowledge base name"), vdb_core: VectorDatabaseCore = Depends(get_vector_db_core), authorization: Optional[str] = Header(None) ): - """Check if a knowledge base name exists and in which scope.""" + """Check if a knowledge base name exists in the current tenant.""" try: + knowledge_name = request.get("knowledge_name", "") + if not knowledge_name: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Knowledge base name is required") + user_id, tenant_id = get_current_user_id(authorization) - return check_knowledge_base_exist_impl(index_name=index_name, vdb_core=vdb_core, user_id=user_id, tenant_id=tenant_id) + return check_knowledge_base_exist_impl(knowledge_name=knowledge_name, vdb_core=vdb_core, user_id=user_id, tenant_id=tenant_id) except Exception as e: logger.error( - f"Error checking knowledge base existence for '{index_name}': {str(e)}", exc_info=True) + f"Error checking knowledge base existence for '{knowledge_name}': {str(e)}", exc_info=True) raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Error checking existence for index: {str(e)}") + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Error checking existence for knowledge base: {str(e)}") @router.post("/{index_name}") diff --git a/backend/consts/const.py b/backend/consts/const.py index 7fd0e0098..a76227614 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -16,11 +16,6 @@ class VectorDatabaseType(str, Enum): ELASTICSEARCH = "elasticsearch" -# ModelEngine Configuration -MODEL_ENGINE_HOST = os.getenv('MODEL_ENGINE_HOST') -MODEL_ENGINE_APIKEY = os.getenv('MODEL_ENGINE_APIKEY') - - # Elasticsearch Configuration ES_HOST = os.getenv("ELASTICSEARCH_HOST") ES_API_KEY = os.getenv("ELASTICSEARCH_API_KEY") @@ -129,8 +124,10 @@ class VectorDatabaseType(str, Enum): DISABLE_CELERY_FLOWER = os.getenv( "DISABLE_CELERY_FLOWER", "false").lower() == "true" DOCKER_ENVIRONMENT = os.getenv("DOCKER_ENVIRONMENT", "false").lower() == "true" -NEXENT_MCP_DOCKER_IMAGE = os.getenv("NEXENT_MCP_DOCKER_IMAGE", "nexent/nexent-mcp:latest") -ENABLE_UPLOAD_IMAGE = os.getenv("ENABLE_UPLOAD_IMAGE", "false").lower() == "true" +NEXENT_MCP_DOCKER_IMAGE = os.getenv( + "NEXENT_MCP_DOCKER_IMAGE", "nexent/nexent-mcp:latest") +ENABLE_UPLOAD_IMAGE = os.getenv( + "ENABLE_UPLOAD_IMAGE", "false").lower() == "true" # Celery Configuration @@ -186,6 +183,9 @@ class VectorDatabaseType(str, Enum): # Debug JWT expiration time (seconds), not set or 0 means not effective DEBUG_JWT_EXPIRE_SECONDS = int(os.getenv('DEBUG_JWT_EXPIRE_SECONDS', '0') or 0) +# User info query source control: "supabase" or "pg" (default: "supabase" for backward compatibility) +USER_INFO_QUERY_SOURCE = os.getenv('USER_INFO_QUERY_SOURCE', 'supabase').lower() + # Memory Search Status Messages (for i18n placeholders) MEMORY_SEARCH_START_MSG = "" MEMORY_SEARCH_DONE_MSG = "" @@ -250,6 +250,9 @@ class VectorDatabaseType(str, Enum): ICON_TYPE = "ICON_TYPE" AVATAR_URI = "AVATAR_URI" CUSTOM_ICON_URL = "CUSTOM_ICON_URL" +TENANT_NAME = "TENANT_NAME" +TENANT_ID = "TENANT_ID" +DEFAULT_GROUP_ID = "DEFAULT_GROUP_ID" # Task Status Constants TASK_STATUS = { @@ -286,5 +289,8 @@ class VectorDatabaseType(str, Enum): DEFAULT_EN_TITLE = "New Conversation" +# Model Engine Configuration +MODEL_ENGINE_ENABLED = os.getenv("MODEL_ENGINE_ENABLED") + # APP Version -APP_VERSION = "v1.7.9.1" +APP_VERSION = "v1.7.9.2" diff --git a/backend/consts/exceptions.py b/backend/consts/exceptions.py index 068249998..815ed0eef 100644 --- a/backend/consts/exceptions.py +++ b/backend/consts/exceptions.py @@ -58,6 +58,12 @@ class TimeoutException(Exception): pass + +class ValidationError(Exception): + """Raised when validation fails.""" + pass + + class NotFoundException(Exception): """Raised when not found exception occurs.""" pass diff --git a/backend/consts/model.py b/backend/consts/model.py index b7a377077..633a1fc82 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -32,6 +32,7 @@ class UserSignUpRequest(BaseModel): password: str = Field(..., min_length=6) is_admin: Optional[bool] = False invite_code: Optional[str] = None + with_new_invitation: Optional[bool] = False class UserSignInRequest(BaseModel): @@ -105,6 +106,7 @@ class AppConfig(BaseModel): iconType: str customIconUrl: Optional[str] = None avatarUri: Optional[str] = None + modelEngineEnabled: bool = True class GlobalConfig(BaseModel): @@ -456,3 +458,126 @@ class MCPConfigRequest(BaseModel): """Request model for adding MCP servers from configuration""" mcpServers: Dict[str, MCPServerConfig] = Field( ..., description="Dictionary of MCP server configurations") + + +# Tenant Management Data Models +# --------------------------------------------------------------------------- +class TenantCreateRequest(BaseModel): + """Request model for creating a tenant""" + tenant_name: str = Field(..., min_length=1, + description="Tenant display name") + + +class TenantUpdateRequest(BaseModel): + """Request model for updating tenant information""" + tenant_name: str = Field(..., min_length=1, + description="New tenant display name") + + +class TenantResponse(BaseModel): + """Response model for tenant information""" + tenant_id: str = Field(..., description="Tenant identifier") + tenant_name: str = Field(..., description="Tenant display name") + default_group_id: Optional[int] = Field( + None, description="Default group ID for the tenant") + + +# Group Management Data Models +# --------------------------------------------------------------------------- +class GroupCreateRequest(BaseModel): + """Request model for creating a group""" + tenant_id: str = Field(..., min_length=1, + description="Tenant ID where the group belongs") + group_name: str = Field(..., min_length=1, + description="Group display name") + group_description: Optional[str] = Field( + None, description="Optional group description") + + +class GroupUpdateRequest(BaseModel): + """Request model for updating group information""" + group_name: Optional[str] = Field(None, description="New group name") + group_description: Optional[str] = Field( + None, description="New group description") + + +class GroupListRequest(BaseModel): + """Request model for listing groups""" + tenant_id: str = Field(..., description="Tenant ID to filter groups") + page: int = Field(1, ge=1, description="Page number for pagination") + page_size: int = Field( + 20, ge=1, le=100, description="Number of items per page") + + +class GroupUserRequest(BaseModel): + """Request model for adding/removing user from group""" + user_id: str = Field(..., min_length=1, + description="User ID to add/remove") + group_ids: Optional[List[int]] = Field( + None, description="List of group IDs (for batch operations)") + + +class SetDefaultGroupRequest(BaseModel): + """Request model for setting tenant's default group""" + default_group_id: int = Field(..., ge=1, + description="Group ID to set as default for the tenant") + + +# Invitation Management Data Models +# --------------------------------------------------------------------------- +class InvitationCreateRequest(BaseModel): + """Request model for creating invitation code""" + tenant_id: str = Field( + ..., min_length=1, description="Tenant ID where the invitation belongs") + code_type: str = Field( + ..., description="Invitation code type (ADMIN_INVITE, DEV_INVITE, USER_INVITE)") + invitation_code: Optional[str] = Field( + None, description="Custom invitation code (auto-generated if not provided)") + group_ids: Optional[List[int]] = Field( + None, description="Associated group IDs") + capacity: int = Field( + default=1, ge=1, description="Maximum usage capacity") + expiry_date: Optional[str] = Field( + None, description="Expiry date in ISO format") + + +class InvitationUpdateRequest(BaseModel): + """Request model for updating invitation code""" + capacity: Optional[int] = Field(None, ge=1, description="New capacity") + expiry_date: Optional[str] = Field(None, description="New expiry date") + group_ids: Optional[List[int]] = Field(None, description="New group IDs") + + +class InvitationResponse(BaseModel): + """Response model for invitation information""" + invitation_id: int = Field(..., description="Invitation ID") + invitation_code: str = Field(..., description="Invitation code") + code_type: str = Field(..., description="Code type") + group_ids: Optional[List[int]] = Field( + None, description="Associated group IDs") + capacity: int = Field(..., description="Usage capacity") + expiry_date: Optional[str] = Field(None, description="Expiry date") + status: str = Field(..., description="Current status") + created_at: Optional[str] = Field(None, description="Creation timestamp") + updated_at: Optional[str] = Field( + None, description="Last update timestamp") + + +class InvitationListRequest(BaseModel): + """Request model for listing invitation codes""" + tenant_id: Optional[str] = Field( + None, description="Tenant ID to filter by (optional)") + page: int = Field(1, ge=1, description="Page number for pagination") + page_size: int = Field( + 20, ge=1, le=100, description="Number of items per page") + + +class InvitationUseResponse(BaseModel): + """Response model for invitation usage""" + invitation_record_id: int = Field(..., description="Usage record ID") + invitation_code: str = Field(..., description="Used invitation code") + user_id: str = Field(..., description="User who used the code") + invitation_id: int = Field(..., description="Invitation ID") + code_type: str = Field(..., description="Code type") + group_ids: Optional[List[int]] = Field( + None, description="Associated group IDs") diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 301dd64aa..3f1875de3 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -217,6 +217,7 @@ class AgentInfo(TableBase): Text, doc="Manually entered by the user to describe the entire business process") business_logic_model_name = Column(String(100), doc="Model name used for business logic prompt generation") business_logic_model_id = Column(Integer, doc="Model ID used for business logic prompt generation, foreign key reference to model_record_t.model_id") + group_ids = Column(String, doc="Agent group IDs list") class ToolInstance(TableBase): @@ -251,6 +252,9 @@ class KnowledgeRecord(TableBase): knowledge_sources = Column(String(300), doc="Knowledge base sources") embedding_model_name = Column(String(200), doc="Embedding model name, used to record the embedding model used by the knowledge base") tenant_id = Column(String(100), doc="Tenant ID") + group_ids = Column(String, doc="Knowledge base group IDs list") + ingroup_permission = Column( + String(30), doc="In-group permission: EDIT, READ_ONLY, PRIVATE") class TenantConfig(TableBase): @@ -322,6 +326,7 @@ class UserTenant(TableBase): primary_key=True, nullable=False, doc="User tenant relationship ID, unique primary key") user_id = Column(String(100), nullable=False, doc="User ID") tenant_id = Column(String(100), nullable=False, doc="Tenant ID") + user_role = Column(String(30), doc="User role: SU, ADMIN, DEV, USER") class AgentRelation(TableBase): @@ -355,3 +360,85 @@ class PartnerMappingId(TableBase): 30), doc="Type of the external - internal mapping, value set: CONVERSATION") tenant_id = Column(String(100), doc="Tenant ID") user_id = Column(String(100), doc="User ID") + + +class TenantInvitationCode(TableBase): + """ + Tenant invitation code information table + """ + __tablename__ = "tenant_invitation_code_t" + __table_args__ = {"schema": SCHEMA} + + invitation_id = Column(Integer, Sequence("tenant_invitation_code_t_invitation_id_seq", schema=SCHEMA), + primary_key=True, nullable=False, doc="Invitation ID, primary key") + tenant_id = Column(String(100), nullable=False, + doc="Tenant ID, foreign key") + invitation_code = Column(String(100), nullable=False, + unique=True, doc="Invitation code") + group_ids = Column(String, doc="Associated group IDs list") + capacity = Column(Integer, nullable=False, default=1, + doc="Invitation code capacity") + expiry_date = Column(TIMESTAMP(timezone=False), + doc="Invitation code expiry date") + status = Column(String(30), nullable=False, + doc="Invitation code status: IN_USE, EXPIRE, DISABLE, RUN_OUT") + code_type = Column(String(30), nullable=False, + doc="Invitation code type: ADMIN_INVITE, DEV_INVITE, USER_INVITE") + + +class TenantInvitationRecord(TableBase): + """ + Tenant invitation record table + """ + __tablename__ = "tenant_invitation_record_t" + __table_args__ = {"schema": SCHEMA} + + invitation_record_id = Column(Integer, Sequence("tenant_invitation_record_t_invitation_record_id_seq", schema=SCHEMA), + primary_key=True, nullable=False, doc="Invitation record ID, primary key") + invitation_id = Column(Integer, nullable=False, + doc="Invitation ID, foreign key") + user_id = Column(String(100), nullable=False, doc="User ID") + + +class TenantGroupInfo(TableBase): + """ + Tenant group information table + """ + __tablename__ = "tenant_group_info_t" + __table_args__ = {"schema": SCHEMA} + + group_id = Column(Integer, Sequence("tenant_group_info_t_group_id_seq", schema=SCHEMA), + primary_key=True, nullable=False, doc="Group ID, primary key") + tenant_id = Column(String(100), nullable=False, + doc="Tenant ID, foreign key") + group_name = Column(String(100), nullable=False, doc="Group name") + group_description = Column(String(500), doc="Group description") + + +class TenantGroupUser(TableBase): + """ + Tenant group user membership table + """ + __tablename__ = "tenant_group_user_t" + __table_args__ = {"schema": SCHEMA} + + group_user_id = Column(Integer, Sequence("tenant_group_user_t_group_user_id_seq", schema=SCHEMA), + primary_key=True, nullable=False, doc="Group user ID, primary key") + group_id = Column(Integer, nullable=False, doc="Group ID, foreign key") + user_id = Column(String(100), nullable=False, doc="User ID, foreign key") + + +class RolePermission(TableBase): + """ + Role permission configuration table + """ + __tablename__ = "role_permission_t" + __table_args__ = {"schema": SCHEMA} + + role_permission_id = Column(Integer, Sequence("role_permission_t_role_permission_id_seq", schema=SCHEMA), + primary_key=True, nullable=False, doc="Role permission ID, primary key") + user_role = Column(String(30), nullable=False, + doc="User role: SU, ADMIN, DEV, USER") + permission_category = Column(String(30), doc="Permission category") + permission_type = Column(String(30), doc="Permission type") + permission_subtype = Column(String(30), doc="Permission subtype") diff --git a/backend/database/group_db.py b/backend/database/group_db.py new file mode 100644 index 000000000..346a29dd7 --- /dev/null +++ b/backend/database/group_db.py @@ -0,0 +1,309 @@ +""" +Database operations for group management +""" +from typing import Any, Dict, List, Optional, Union + +from database.client import as_dict, get_db_session +from database.db_models import TenantGroupInfo, TenantGroupUser +from utils.str_utils import convert_string_to_list + + +def query_groups(group_id: Union[int, str, List[int]]) -> Union[Optional[Dict[str, Any]], List[Dict[str, Any]]]: + """ + Query group(s) by group ID(s) + + Args: + group_id: Group ID(s) - can be int, comma-separated string, or list of ints + + Returns: + Single group dict if int provided, list of group dicts if string/list provided + """ + # Convert input to list of integers + if isinstance(group_id, int): + group_ids = [group_id] + return_single = True + elif isinstance(group_id, str): + group_ids = convert_string_to_list(group_id) + return_single = False + elif isinstance(group_id, list): + group_ids = group_id + return_single = False + else: + raise ValueError("group_id must be int, str, or List[int]") + + if not group_ids: + return [] if not return_single else None + + with get_db_session() as session: + result = session.query(TenantGroupInfo).filter( + TenantGroupInfo.group_id.in_(group_ids), + TenantGroupInfo.delete_flag == "N" + ).all() + + groups = [as_dict(record) for record in result] + + # Return single result if single ID was provided + if return_single: + return groups[0] if groups else None + else: + return groups + + +def query_groups_by_tenant(tenant_id: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]: + """ + Query groups for a tenant with pagination + + Args: + tenant_id (str): Tenant ID + page (int): Page number (1-based) + page_size (int): Number of items per page + + Returns: + Dict[str, Any]: Dictionary containing groups list and total count + """ + offset = (page - 1) * page_size + + with get_db_session() as session: + # Get total count + total = session.query(TenantGroupInfo).filter( + TenantGroupInfo.tenant_id == tenant_id, + TenantGroupInfo.delete_flag == "N" + ).count() + + # Get paginated results + result = session.query(TenantGroupInfo).filter( + TenantGroupInfo.tenant_id == tenant_id, + TenantGroupInfo.delete_flag == "N" + ).offset(offset).limit(page_size).all() + + return { + "groups": [as_dict(record) for record in result], + "total": total + } + + +def add_group(tenant_id: str, group_name: str, group_description: Optional[str] = None, + created_by: Optional[str] = None) -> int: + """ + Add a new group + + Args: + tenant_id (str): Tenant ID + group_name (str): Group name + group_description (Optional[str]): Group description + created_by (Optional[str]): Created by user + + Returns: + int: Created group ID + """ + with get_db_session() as session: + group = TenantGroupInfo( + tenant_id=tenant_id, + group_name=group_name, + group_description=group_description, + created_by=created_by, + updated_by=created_by + ) + session.add(group) + session.flush() # To get the ID + return group.group_id + + +def modify_group(group_id: int, updates: Dict[str, Any], updated_by: Optional[str] = None) -> bool: + """ + Modify group information + + Args: + group_id (int): Group ID + updates (Dict[str, Any]): Fields to update + updated_by (Optional[str]): Updated by user + + Returns: + bool: Whether update was successful + """ + with get_db_session() as session: + update_data = updates.copy() + if updated_by: + update_data["updated_by"] = updated_by + + result = session.query(TenantGroupInfo).filter( + TenantGroupInfo.group_id == group_id, + TenantGroupInfo.delete_flag == "N" + ).update(update_data, synchronize_session=False) + + return result > 0 + + +def remove_group(group_id: int, updated_by: Optional[str] = None) -> bool: + """ + Remove group (soft delete) + + Args: + group_id (int): Group ID + updated_by (Optional[str]): Updated by user + + Returns: + bool: Whether removal was successful + """ + with get_db_session() as session: + update_data: Dict[str, Any] = {"delete_flag": "Y"} + if updated_by: + update_data["updated_by"] = updated_by + + result = session.query(TenantGroupInfo).filter( + TenantGroupInfo.group_id == group_id, + TenantGroupInfo.delete_flag == "N" + ).update(update_data, synchronize_session=False) + + return result > 0 + + +def add_user_to_group(group_id: int, user_id: str, created_by: Optional[str] = None) -> int: + """ + Add user to group + + Args: + group_id (int): Group ID + user_id (str): User ID + created_by (Optional[str]): Created by user + + Returns: + int: Created group user ID + """ + with get_db_session() as session: + group_user = TenantGroupUser( + group_id=group_id, + user_id=user_id, + created_by=created_by, + updated_by=created_by + ) + session.add(group_user) + session.flush() # To get the ID + return group_user.group_user_id + + +def remove_user_from_group(group_id: int, user_id: str, updated_by: Optional[str] = None) -> bool: + """ + Remove user from group + + Args: + group_id (int): Group ID + user_id (str): User ID + updated_by (Optional[str]): Updated by user + + Returns: + bool: Whether removal was successful + """ + with get_db_session() as session: + update_data: Dict[str, Any] = {"delete_flag": "Y"} + if updated_by: + update_data["updated_by"] = updated_by + + result = session.query(TenantGroupUser).filter( + TenantGroupUser.group_id == group_id, + TenantGroupUser.user_id == user_id, + TenantGroupUser.delete_flag == "N" + ).update(update_data, synchronize_session=False) + + return result > 0 + + +def query_group_users(group_id: int) -> List[Dict[str, Any]]: + """ + Query all users in a group + + Args: + group_id (int): Group ID + + Returns: + List[Dict[str, Any]]: List of group user records + """ + with get_db_session() as session: + result = session.query(TenantGroupUser).filter( + TenantGroupUser.group_id == group_id, + TenantGroupUser.delete_flag == "N" + ).all() + + return [as_dict(record) for record in result] + + +def query_group_ids_by_user(user_id: str) -> List[int]: + """ + Query all group IDs for a user + + Args: + user_id (str): User ID + + Returns: + List[int]: List of group IDs + """ + with get_db_session() as session: + result = session.query(TenantGroupUser.group_id).filter( + TenantGroupUser.user_id == user_id, + TenantGroupUser.delete_flag == "N" + ).all() + + return [record[0] for record in result] + + +def query_groups_by_user(user_id: str) -> List[Dict[str, Any]]: + """ + Query all groups for a user + + Args: + user_id (str): User ID + + Returns: + List[Dict[str, Any]]: List of group records + """ + with get_db_session() as session: + result = session.query(TenantGroupInfo).join( + TenantGroupUser, + TenantGroupInfo.group_id == TenantGroupUser.group_id + ).filter( + TenantGroupUser.user_id == user_id, + TenantGroupUser.delete_flag == "N", + TenantGroupInfo.delete_flag == "N" + ).all() + + return [as_dict(record) for record in result] + + +def check_user_in_group(user_id: str, group_id: int) -> bool: + """ + Check if user is in a specific group + + Args: + user_id (str): User ID + group_id (int): Group ID + + Returns: + bool: Whether user is in the group + """ + with get_db_session() as session: + result = session.query(TenantGroupUser).filter( + TenantGroupUser.group_id == group_id, + TenantGroupUser.user_id == user_id, + TenantGroupUser.delete_flag == "N" + ).first() + + return result is not None + + +def count_group_users(group_id: int) -> int: + """ + Count users in a group + + Args: + group_id (int): Group ID + + Returns: + int: Number of users in the group + """ + with get_db_session() as session: + result = session.query(TenantGroupUser).filter( + TenantGroupUser.group_id == group_id, + TenantGroupUser.delete_flag == "N" + ).count() + + return result diff --git a/backend/database/invitation_db.py b/backend/database/invitation_db.py new file mode 100644 index 000000000..e3d494976 --- /dev/null +++ b/backend/database/invitation_db.py @@ -0,0 +1,303 @@ +""" +Database operations for invitation code management +""" +from typing import Any, Dict, List, Optional + +from database.client import as_dict, get_db_session +from database.db_models import TenantInvitationCode, TenantInvitationRecord +from utils.str_utils import convert_list_to_string + + +def query_invitation_by_code(invitation_code: str) -> Optional[Dict[str, Any]]: + """ + Query invitation by invitation code + + Args: + invitation_code (str): Invitation code + + Returns: + Optional[Dict[str, Any]]: Invitation record + """ + with get_db_session() as session: + result = session.query(TenantInvitationCode).filter( + TenantInvitationCode.invitation_code == invitation_code, + TenantInvitationCode.delete_flag == "N" + ).first() + + if result: + return as_dict(result) + return None + + +def query_invitation_by_id(invitation_id: int) -> Optional[Dict[str, Any]]: + """ + Query invitation by ID + + Args: + invitation_id (int): Invitation ID + + Returns: + Optional[Dict[str, Any]]: Invitation record + """ + with get_db_session() as session: + result = session.query(TenantInvitationCode).filter( + TenantInvitationCode.invitation_id == invitation_id, + TenantInvitationCode.delete_flag == "N" + ).first() + + if result: + return as_dict(result) + return None + + +def query_invitations_by_tenant(tenant_id: str) -> List[Dict[str, Any]]: + """ + Query all invitations for a tenant + + Args: + tenant_id (str): Tenant ID + + Returns: + List[Dict[str, Any]]: List of invitation records + """ + with get_db_session() as session: + result = session.query(TenantInvitationCode).filter( + TenantInvitationCode.tenant_id == tenant_id, + TenantInvitationCode.delete_flag == "N" + ).all() + + return [as_dict(record) for record in result] + + +def add_invitation(tenant_id: str, invitation_code: str, code_type: str, group_ids: Optional[List[int]] = None, + capacity: int = 1, expiry_date: Optional[str] = None, + status: str = "IN_USE", created_by: Optional[str] = None) -> int: + """ + Add a new invitation + + Args: + tenant_id (str): Tenant ID + invitation_code (str): Invitation code + code_type (str): Invitation code type (ADMIN_INVITE, DEV_INVITE, USER_INVITE) + group_ids (Optional[List[int]]): Associated group IDs + capacity (int): Invitation capacity + expiry_date (Optional[str]): Expiry date + status (str): Status + created_by (Optional[str]): Created by user + + Returns: + int: Created invitation ID + """ + with get_db_session() as session: + invitation = TenantInvitationCode( + tenant_id=tenant_id, + invitation_code=invitation_code, + code_type=code_type, + group_ids=convert_list_to_string(group_ids), + capacity=capacity, + expiry_date=expiry_date, + status=status, + created_by=created_by, + updated_by=created_by + ) + session.add(invitation) + session.flush() # To get the ID + return invitation.invitation_id + + +def modify_invitation(invitation_id: int, updates: Dict[str, Any], updated_by: Optional[str] = None) -> bool: + """ + Modify invitation + + Args: + invitation_id (int): Invitation ID + updates (Dict[str, Any]): Fields to update + updated_by (Optional[str]): Updated by user + + Returns: + bool: Whether update was successful + """ + with get_db_session() as session: + update_data = updates.copy() + if updated_by: + update_data["updated_by"] = updated_by + + # Convert group_ids list to string if present + if "group_ids" in update_data and isinstance(update_data["group_ids"], list): + update_data["group_ids"] = convert_list_to_string(update_data["group_ids"]) + + result = session.query(TenantInvitationCode).filter( + TenantInvitationCode.invitation_id == invitation_id, + TenantInvitationCode.delete_flag == "N" + ).update(update_data, synchronize_session=False) + + return result > 0 + + +def remove_invitation(invitation_id: int, updated_by: Optional[str] = None) -> bool: + """ + Remove invitation (soft delete) + + Args: + invitation_id (int): Invitation ID + updated_by (Optional[str]): Updated by user + + Returns: + bool: Whether removal was successful + """ + with get_db_session() as session: + update_data: Dict[str, Any] = {"delete_flag": "Y"} + if updated_by: + update_data["updated_by"] = updated_by + + result = session.query(TenantInvitationCode).filter( + TenantInvitationCode.invitation_id == invitation_id, + TenantInvitationCode.delete_flag == "N" + ).update(update_data, synchronize_session=False) + + return result > 0 + + +def query_invitation_records(invitation_id: int) -> List[Dict[str, Any]]: + """ + Query invitation records by invitation ID + + Args: + invitation_id (int): Invitation ID + + Returns: + List[Dict[str, Any]]: List of invitation records + """ + with get_db_session() as session: + result = session.query(TenantInvitationRecord).filter( + TenantInvitationRecord.invitation_id == invitation_id, + TenantInvitationRecord.delete_flag == "N" + ).all() + + return [as_dict(record) for record in result] + + +def add_invitation_record(invitation_id: int, user_id: str, created_by: Optional[str] = None) -> int: + """ + Add invitation usage record + + Args: + invitation_id (int): Invitation ID + user_id (str): User ID + created_by (Optional[str]): Created by user + + Returns: + int: Created invitation record ID + """ + with get_db_session() as session: + record = TenantInvitationRecord( + invitation_id=invitation_id, + user_id=user_id, + created_by=created_by, + updated_by=created_by + ) + session.add(record) + session.flush() # To get the ID + return record.invitation_record_id + + +def query_invitation_records_by_user(user_id: str) -> List[Dict[str, Any]]: + """ + Query invitation records by user ID + + Args: + user_id (str): User ID + + Returns: + List[Dict[str, Any]]: List of invitation records + """ + with get_db_session() as session: + result = session.query(TenantInvitationRecord).filter( + TenantInvitationRecord.user_id == user_id, + TenantInvitationRecord.delete_flag == "N" + ).all() + + return [as_dict(record) for record in result] + + +def count_invitation_usage(invitation_id: int) -> int: + """ + Count usage for an invitation code + + Args: + invitation_id (int): Invitation ID + + Returns: + int: Number of times the invitation has been used + """ + with get_db_session() as session: + result = session.query(TenantInvitationRecord).filter( + TenantInvitationRecord.invitation_id == invitation_id, + TenantInvitationRecord.delete_flag == "N" + ).count() + + return result + + +def query_invitation_status(invitation_code: str) -> Optional[str]: + """ + Query invitation status + + Args: + invitation_code (str): Invitation code + + Returns: + Optional[str]: Invitation status if exists, None otherwise + """ + with get_db_session() as session: + invitation = session.query(TenantInvitationCode).filter( + TenantInvitationCode.invitation_code == invitation_code, + TenantInvitationCode.delete_flag == "N" + ).first() + + return invitation.status if invitation else None + + +def query_invitations_with_pagination( + tenant_id: Optional[str] = None, + page: int = 1, + page_size: int = 20 +) -> Dict[str, Any]: + """ + Query invitations with pagination support + + Args: + tenant_id (Optional[str]): Tenant ID to filter by, None for all tenants + page (int): Page number (1-based) + page_size (int): Number of items per page + + Returns: + Dict[str, Any]: Dictionary containing items list and total count + """ + with get_db_session() as session: + query = session.query(TenantInvitationCode).filter( + TenantInvitationCode.delete_flag == "N" + ) + + # Apply tenant filter if provided + if tenant_id: + query = query.filter(TenantInvitationCode.tenant_id == tenant_id) + + # Get total count + total = query.count() + + # Apply pagination + offset = (page - 1) * page_size + results = query.offset(offset).limit(page_size).all() + + # Convert to dict format + items = [as_dict(record) for record in results] + + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + # Ceiling division + "total_pages": (total + page_size - 1) // page_size + } diff --git a/backend/database/knowledge_db.py b/backend/database/knowledge_db.py index 6faccdafa..7f60f873c 100644 --- a/backend/database/knowledge_db.py +++ b/backend/database/knowledge_db.py @@ -6,6 +6,7 @@ from database.client import as_dict, get_db_session from database.db_models import KnowledgeRecord +from utils.str_utils import convert_list_to_string def _generate_index_name(knowledge_id: int) -> str: @@ -40,6 +41,7 @@ def create_knowledge_record(query: Dict[str, Any]) -> Dict[str, Any]: "knowledge_name") or query.get("index_name") # Prepare data dictionary + group_ids = query.get("group_ids") data: Dict[str, Any] = { "knowledge_describe": query.get("knowledge_describe", ""), "created_by": query.get("user_id"), @@ -48,6 +50,8 @@ def create_knowledge_record(query: Dict[str, Any]) -> Dict[str, Any]: "tenant_id": query.get("tenant_id"), "embedding_model_name": query.get("embedding_model_name"), "knowledge_name": knowledge_name, + "group_ids": convert_list_to_string(group_ids) if isinstance(group_ids, list) else group_ids, + "ingroup_permission": query.get("ingroup_permission"), } # For backward compatibility: if caller explicitly provides index_name, @@ -168,9 +172,14 @@ def get_knowledge_record(query: Optional[Dict[str, Any]] = None) -> Dict[str, An with get_db_session() as session: db_query = session.query(KnowledgeRecord).filter( KnowledgeRecord.delete_flag != 'Y', - KnowledgeRecord.index_name == query['index_name'], ) + # Support both index_name and knowledge_name queries + if 'index_name' in query: + db_query = db_query.filter(KnowledgeRecord.index_name == query['index_name']) + elif 'knowledge_name' in query: + db_query = db_query.filter(KnowledgeRecord.knowledge_name == query['knowledge_name']) + # Add tenant_id filter only if it is provided in the query if 'tenant_id' in query and query['tenant_id'] is not None: db_query = db_query.filter( diff --git a/backend/database/role_permission_db.py b/backend/database/role_permission_db.py new file mode 100644 index 000000000..f2155a557 --- /dev/null +++ b/backend/database/role_permission_db.py @@ -0,0 +1,91 @@ +""" +Database operations for role permission management +""" +from typing import Any, Dict, List, Optional + +from database.client import as_dict, get_db_session +from database.db_models import RolePermission + + +def get_role_permissions(user_role: str) -> List[Dict[str, Any]]: + """ + Get all permissions for a user role + + Args: + user_role (str): User role (SU, ADMIN, DEV, USER) + + Returns: + List[Dict[str, Any]]: List of role permission records + """ + with get_db_session() as session: + result = session.query(RolePermission).filter( + RolePermission.user_role == user_role, + RolePermission.delete_flag == "N" + ).all() + + return [as_dict(record) for record in result] + + +def get_all_role_permissions() -> List[Dict[str, Any]]: + """ + Get all role permissions + + Returns: + List[Dict[str, Any]]: List of all role permission records + """ + with get_db_session() as session: + result = session.query(RolePermission).filter( + RolePermission.delete_flag == "N" + ).all() + + return [as_dict(record) for record in result] + + +def check_role_permission(user_role: str, permission_category: Optional[str] = None, + permission_type: Optional[str] = None, permission_subtype: Optional[str] = None) -> bool: + """ + Check if a role has specific permission + + Args: + user_role (str): User role + permission_category (Optional[str]): Permission category + permission_type (Optional[str]): Permission type + permission_subtype (Optional[str]): Permission subtype + + Returns: + bool: Whether the role has the permission + """ + with get_db_session() as session: + query = session.query(RolePermission).filter( + RolePermission.user_role == user_role, + RolePermission.delete_flag == "N" + ) + + if permission_category: + query = query.filter(RolePermission.permission_category == permission_category) + if permission_type: + query = query.filter(RolePermission.permission_type == permission_type) + if permission_subtype: + query = query.filter(RolePermission.permission_subtype == permission_subtype) + + result = query.first() + return result is not None + + +def get_permissions_by_category(permission_category: str) -> List[Dict[str, Any]]: + """ + Get all permissions for a specific category + + Args: + permission_category (str): Permission category + + Returns: + List[Dict[str, Any]]: List of role permission records + """ + with get_db_session() as session: + result = session.query(RolePermission).filter( + RolePermission.permission_category == permission_category, + RolePermission.delete_flag == "N" + ).all() + + return [as_dict(record) for record in result] diff --git a/backend/database/tenant_config_db.py b/backend/database/tenant_config_db.py index 2c97df457..6ac4e08b6 100644 --- a/backend/database/tenant_config_db.py +++ b/backend/database/tenant_config_db.py @@ -3,6 +3,7 @@ from sqlalchemy.exc import SQLAlchemyError +from consts.const import TENANT_ID from database.client import get_db_session from database.db_models import TenantConfig @@ -137,3 +138,19 @@ def update_config_by_tenant_config_id_and_data(tenant_config_id: int, insert_dat session.rollback() logger.error(f"update config by tenant config id and data failed, error: {e}") return False + + +def get_all_tenant_ids(): + """ + Get all tenant IDs that have tenant configurations + + Returns: + List[str]: List of tenant IDs + """ + with get_db_session() as session: + result = session.query(TenantConfig.tenant_id).filter( + TenantConfig.config_key == TENANT_ID, + TenantConfig.delete_flag == "N" + ).distinct().all() + + return [row[0] for row in result] diff --git a/backend/database/tool_db.py b/backend/database/tool_db.py index edc256fde..ff9c1488c 100644 --- a/backend/database/tool_db.py +++ b/backend/database/tool_db.py @@ -182,7 +182,7 @@ def search_tools_for_sub_agent(agent_id, tenant_id): ToolInstance.agent_id == agent_id, ToolInstance.tenant_id == tenant_id, ToolInstance.delete_flag != 'Y', - ToolInstance.enabled == True + ToolInstance.enabled ) tool_instances = query.all() diff --git a/backend/database/user_tenant_db.py b/backend/database/user_tenant_db.py index 16c8c0928..960b38855 100644 --- a/backend/database/user_tenant_db.py +++ b/backend/database/user_tenant_db.py @@ -1,11 +1,12 @@ """ Database operations for user tenant relationship management """ -from typing import Any, Dict, Optional +from typing import Any, List, Dict, Optional from consts.const import DEFAULT_TENANT_ID from database.client import as_dict, get_db_session from database.db_models import UserTenant +from utils.str_utils import convert_list_to_string def get_user_tenant_by_user_id(user_id: str) -> Optional[Dict[str, Any]]: @@ -32,7 +33,7 @@ def get_user_tenant_by_user_id(user_id: str) -> Optional[Dict[str, Any]]: def get_all_tenant_ids() -> list[str]: """ Get all unique tenant IDs from the database - + Returns: list[str]: List of unique tenant IDs """ @@ -40,28 +41,30 @@ def get_all_tenant_ids() -> list[str]: result = session.query(UserTenant.tenant_id).filter( UserTenant.delete_flag == "N" ).distinct().all() - + tenant_ids = [row[0] for row in result] - + # Add default tenant_id if not already in the list if DEFAULT_TENANT_ID not in tenant_ids: tenant_ids.append(DEFAULT_TENANT_ID) - + return tenant_ids -def insert_user_tenant(user_id: str, tenant_id: str): +def insert_user_tenant(user_id: str, tenant_id: str, user_role: str = "USER"): """ Insert user tenant relationship Args: user_id (str): User ID tenant_id (str): Tenant ID + user_role (str): User role (SUPER_ADMIN, ADMIN, DEV, USER) """ with get_db_session() as session: user_tenant = UserTenant( user_id=user_id, tenant_id=tenant_id, + user_role=user_role, created_by=user_id, updated_by=user_id ) diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py index 591da0064..ab8a4284a 100644 --- a/backend/services/agent_service.py +++ b/backend/services/agent_service.py @@ -53,6 +53,8 @@ query_tool_instances_by_id, search_tools_for_sub_agent ) +from database.group_db import query_group_ids_by_user +from utils.str_utils import convert_list_to_string, convert_string_to_list from services.conversation_management_service import save_conversation_assistant, save_conversation_user from services.memory_config_service import build_memory_context from utils.auth_utils import get_current_user_info, get_user_language @@ -89,6 +91,26 @@ def _resolve_user_tenant_language( return user_id, tenant_id, get_user_language(http_request) +def _get_user_group_ids(user_id: str, tenant_id: str) -> str: + """ + Get user's group IDs as a comma-separated string. + + Args: + user_id: User ID + tenant_id: Tenant ID + + Returns: + Comma-separated string of group IDs + """ + try: + group_ids = query_group_ids_by_user(user_id) + return convert_list_to_string(group_ids) + except Exception as e: + logger.warning( + f"Failed to get user groups for user {user_id}: {str(e)}") + return "" + + def _resolve_model_with_fallback( model_display_name: str | None, exported_model_id: str | None, @@ -97,45 +119,45 @@ def _resolve_model_with_fallback( ) -> str | None: """ Resolve model_id from model_display_name with fallback to quick config LLM model. - + Args: model_display_name: Display name of the model to lookup exported_model_id: Original model_id from export (for logging only) model_label: Label for logging (e.g., "Model", "Business logic model") tenant_id: Tenant ID for model lookup - + Returns: Resolved model_id or None if not found and no fallback available """ if not model_display_name: return None - + # Try to find model by display name in current tenant resolved_id = get_model_id_by_display_name(model_display_name, tenant_id) - + if resolved_id: logger.info( f"{model_label} '{model_display_name}' found in tenant {tenant_id}, " f"mapped to model_id: {resolved_id} (exported model_id was: {exported_model_id})") return resolved_id - + # Model not found, try fallback to quick config LLM model logger.warning( f"{model_label} '{model_display_name}' (exported model_id: {exported_model_id}) " f"not found in tenant {tenant_id}, falling back to quick config LLM model.") - + quick_config_model = tenant_config_manager.get_model_config( key=MODEL_CONFIG_MAPPING["llm"], tenant_id=tenant_id ) - + if quick_config_model: fallback_id = quick_config_model.get("model_id") logger.info( f"Using quick config LLM model for {model_label.lower()}: " f"{quick_config_model.get('display_name')} (model_id: {fallback_id})") return fallback_id - + logger.warning(f"No quick config LLM model found for tenant {tenant_id}") return None @@ -775,7 +797,8 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str = agent_id: Optional[int] = request.agent_id try: if agent_id is None: - # Create agent + # Create agent - automatically set group_ids to current user's groups + user_group_ids = _get_user_group_ids(user_id, tenant_id) created = create_agent(agent_info={ "name": request.name, "display_name": request.display_name, @@ -791,7 +814,8 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str = "duty_prompt": request.duty_prompt, "constraint_prompt": request.constraint_prompt, "few_shots_prompt": request.few_shots_prompt, - "enabled": request.enabled if request.enabled is not None else True + "enabled": request.enabled if request.enabled is not None else True, + "group_ids": user_group_ids }, tenant_id=tenant_id, user_id=user_id) agent_id = created["agent_id"] else: @@ -1132,7 +1156,7 @@ async def import_agent_by_agent_id( if not import_agent_info.name.isidentifier(): raise ValueError( f"Invalid agent name: {import_agent_info.name}. agent name must be a valid python variable name.") - + # Resolve model IDs with fallback # Note: We use model_display_name for cross-tenant compatibility # The exported model_id is kept for reference/debugging only @@ -1142,7 +1166,7 @@ async def import_agent_by_agent_id( model_label="Model", tenant_id=tenant_id ) - + business_logic_model_id = _resolve_model_with_fallback( model_display_name=import_agent_info.business_logic_model_name, exported_model_id=import_agent_info.business_logic_model_id, @@ -1153,7 +1177,8 @@ async def import_agent_by_agent_id( agent_name = import_agent_info.name agent_display_name = import_agent_info.display_name - # create a new agent + # create a new agent - use current user's groups instead of imported group_ids + user_group_ids = _get_user_group_ids(user_id, tenant_id) new_agent = create_agent(agent_info={"name": agent_name, "display_name": agent_display_name, "description": import_agent_info.description, @@ -1168,7 +1193,8 @@ async def import_agent_by_agent_id( "duty_prompt": import_agent_info.duty_prompt, "constraint_prompt": import_agent_info.constraint_prompt, "few_shots_prompt": import_agent_info.few_shots_prompt, - "enabled": import_agent_info.enabled}, + "enabled": import_agent_info.enabled, + "group_ids": user_group_ids}, tenant_id=tenant_id, user_id=user_id) new_agent_id = new_agent["agent_id"] @@ -1248,7 +1274,8 @@ async def list_all_agent_info_impl(tenant_id: str) -> list[dict]: "description": agent["description"], "author": agent.get("author"), "is_available": len(unavailable_reasons) == 0, - "unavailable_reasons": unavailable_reasons + "unavailable_reasons": unavailable_reasons, + "group_ids": convert_string_to_list(agent.get("group_ids")) }) return simple_agent_list @@ -1344,28 +1371,28 @@ def check_agent_availability( ) -> tuple[bool, list[str]]: """ Check if an agent is available based on its tools and model configuration. - + Args: agent_id: The agent ID to check tenant_id: The tenant ID agent_info: Optional pre-fetched agent info (to avoid duplicate DB queries) model_cache: Optional model cache for performance optimization - + Returns: tuple: (is_available: bool, unavailable_reasons: list[str]) """ unavailable_reasons: list[str] = [] - + if model_cache is None: model_cache = {} - + # Fetch agent info if not provided if agent_info is None: agent_info = search_agent_info_by_agent_id(agent_id, tenant_id) - + if not agent_info: return False, ["agent_not_found"] - + # Check tool availability tool_info = search_tools_for_sub_agent(agent_id=agent_id, tenant_id=tenant_id) tool_id_list = [tool["tool_id"] for tool in tool_info if tool.get("tool_id") is not None] @@ -1373,7 +1400,7 @@ def check_agent_availability( tool_statuses = check_tool_is_available(tool_id_list) if not all(tool_statuses): unavailable_reasons.append("tool_unavailable") - + # Check model availability model_reasons = _collect_model_availability_reasons( agent=agent_info, @@ -1381,7 +1408,7 @@ def check_agent_availability( model_cache=model_cache ) unavailable_reasons.extend(model_reasons) - + is_available = len(unavailable_reasons) == 0 return is_available, unavailable_reasons @@ -1936,4 +1963,4 @@ def get_sub_agents_recursive(parent_agent_id: int, depth: int = 0, max_depth: in except Exception as e: logger.exception( f"Failed to get agent call relationship for agent {agent_id}: {str(e)}") - raise ValueError(f"Failed to get agent call relationship: {str(e)}") \ No newline at end of file + raise ValueError(f"Failed to get agent call relationship: {str(e)}") diff --git a/backend/services/config_sync_service.py b/backend/services/config_sync_service.py index 54b477a0e..2621557b2 100644 --- a/backend/services/config_sync_service.py +++ b/backend/services/config_sync_service.py @@ -10,9 +10,13 @@ DEFAULT_APP_DESCRIPTION_ZH, DEFAULT_APP_NAME_EN, DEFAULT_APP_NAME_ZH, + DEFAULT_GROUP_ID, ICON_TYPE, + LANGUAGE, MODEL_CONFIG_MAPPING, - LANGUAGE + LANGUAGE, + MODEL_ENGINE_ENABLED, + TENANT_NAME ) from database.model_management_db import get_model_id_by_display_name from utils.config_utils import ( @@ -49,7 +53,8 @@ def handle_model_config(tenant_id: str, user_id: str, config_key: str, model_id: return current_model_id = tenant_config_dict.get(config_key) - current_model_id = int(current_model_id) if str(current_model_id).isdigit() else None + current_model_id = int(current_model_id) if str( + current_model_id).isdigit() else None if current_model_id == model_id: tenant_config_manager.update_single_config(tenant_id, config_key) @@ -130,11 +135,14 @@ def build_app_config(language: str, tenant_id: str) -> dict: "name": tenant_config_manager.get_app_config(APP_NAME, tenant_id=tenant_id) or default_app_name, "description": tenant_config_manager.get_app_config(APP_DESCRIPTION, tenant_id=tenant_id) or default_app_description, + "tenantName": tenant_config_manager.get_app_config(TENANT_NAME, tenant_id=tenant_id) or "", + "defaultGroupId": tenant_config_manager.get_app_config(DEFAULT_GROUP_ID, tenant_id=tenant_id) or "", "icon": { "type": tenant_config_manager.get_app_config(ICON_TYPE, tenant_id=tenant_id) or "preset", "avatarUri": tenant_config_manager.get_app_config(AVATAR_URI, tenant_id=tenant_id) or "", "customUrl": tenant_config_manager.get_app_config(CUSTOM_ICON_URL, tenant_id=tenant_id) or "" - } + }, + "modelEngineEnabled": str(MODEL_ENGINE_ENABLED).lower() == "true" } diff --git a/backend/services/group_service.py b/backend/services/group_service.py new file mode 100644 index 000000000..bd838e1da --- /dev/null +++ b/backend/services/group_service.py @@ -0,0 +1,492 @@ +""" +Group service for managing groups and group memberships. +""" +import logging +from typing import Any, Dict, List, Optional, Union + +from database.group_db import ( + query_groups, + query_groups_by_tenant, + add_group, + modify_group, + remove_group, + add_user_to_group, + remove_user_from_group, + query_group_users, + query_groups_by_user, + query_group_ids_by_user, + check_user_in_group, + count_group_users +) +from database.user_tenant_db import get_user_tenant_by_user_id +from database.tenant_config_db import get_single_config_info, insert_config, update_config_by_tenant_config_id +from consts.exceptions import NotFoundException, UnauthorizedError, ValidationError +from consts.const import DEFAULT_GROUP_ID +from services.tenant_service import get_tenant_info + +logger = logging.getLogger(__name__) + + +def get_group_info(group_id: Union[int, str, List[int]]) -> Union[Optional[Dict[str, Any]], List[Dict[str, Any]]]: + """ + Get group(s) by group ID(s). + + Args: + group_id: Group ID(s) - can be int, comma-separated string, or list of ints + + Returns: + Single group dict with group_id, group_name, group_description if int provided, + list of group dicts if string/list provided + + Raises: + NotFoundException: When group not found + """ + result = query_groups(group_id) + + if isinstance(group_id, int) and result is None: + raise NotFoundException(f"Group {group_id} not found") + + # Extract only the required fields: group_id, group_name, group_description + if isinstance(group_id, int) and result is not None: + # Single group result + return { + "group_id": result.get("group_id"), + "group_name": result.get("group_name"), + "group_description": result.get("group_description") + } + elif isinstance(group_id, (str, list)) and result is not None: + # List of groups result + filtered_groups = [] + for group in result: + filtered_groups.append({ + "group_id": group.get("group_id"), + "group_name": group.get("group_name"), + "group_description": group.get("group_description") + }) + return filtered_groups + + return result + + +def get_groups_by_tenant(tenant_id: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]: + """ + Get groups for a specific tenant with pagination. + + Args: + tenant_id (str): Tenant ID + page (int): Page number (1-based) + page_size (int): Number of items per page + + Returns: + Dict[str, Any]: Dictionary containing groups list and total count + """ + # Get paginated results and total count + result = query_groups_by_tenant(tenant_id, page, page_size) + + # Filter to only return required fields for each group + filtered_groups = [] + for group in result["groups"]: + filtered_groups.append({ + "group_id": group.get("group_id"), + "group_name": group.get("group_name"), + "group_description": group.get("group_description") + }) + + return { + "groups": filtered_groups, + "total": result["total"] + } + + +def get_tenant_default_group_id(tenant_id: str) -> Optional[int]: + """ + Get the default group ID for a tenant. + + Args: + tenant_id (str): Tenant ID + + Returns: + Optional[int]: Default group ID if exists, None otherwise + """ + try: + tenant_info = get_tenant_info(tenant_id) + default_group_id = tenant_info.get("default_group_id") + return int(default_group_id) if default_group_id else None + except Exception as e: + logger.warning(f"Failed to get default group ID for tenant {tenant_id}: {str(e)}") + return None + + +def set_tenant_default_group_id(tenant_id: str, group_id: int, updated_by: Optional[str] = None) -> bool: + """ + Set the default group ID for a tenant. + + Args: + tenant_id (str): Tenant ID + group_id (int): Group ID to set as default + updated_by (Optional[str]): User ID performing the update + + Returns: + bool: Whether the operation was successful + + Raises: + NotFoundException: When tenant or group not found + ValidationError: When group doesn't belong to the tenant + """ + # Verify tenant exists + try: + tenant_info = get_tenant_info(tenant_id) + if not tenant_info: + raise NotFoundException(f"Tenant {tenant_id} not found") + except NotFoundException: + raise + + # Verify group exists and belongs to the tenant + group = query_groups(group_id) + if not group: + raise NotFoundException(f"Group {group_id} not found") + + # Check if group belongs to the tenant (groups are tenant-specific) + if str(group.get("tenant_id")) != tenant_id: + raise ValidationError( + f"Group {group_id} does not belong to tenant {tenant_id}") + + try: + # Try to update existing default group config + existing_config = get_single_config_info(tenant_id, DEFAULT_GROUP_ID) + if existing_config: + success = update_config_by_tenant_config_id( + existing_config["tenant_config_id"], + str(group_id) + ) + if success: + logger.info( + f"Updated default group ID to {group_id} for tenant {tenant_id} by user {updated_by}") + else: + # Create new default group config + config_data = { + "tenant_id": tenant_id, + "config_key": DEFAULT_GROUP_ID, + "config_value": str(group_id), + "created_by": updated_by, + "updated_by": updated_by + } + success = insert_config(config_data) + if success: + logger.info( + f"Set default group ID to {group_id} for tenant {tenant_id} by user {updated_by}") + + return success + + except Exception as e: + logger.error( + f"Failed to set default group ID to {group_id} for tenant {tenant_id}: {str(e)}") + raise ValidationError(f"Failed to set default group: {str(e)}") + + +def create_group(tenant_id: str, group_name: str, group_description: Optional[str] = None, + user_id: str = None) -> Dict[str, Any]: + """ + Create a new group. + + Args: + tenant_id (str): Tenant ID + group_name (str): Group name + group_description (Optional[str]): Group description + user_id (str): Current user ID + + Returns: + Dict[str, Any]: Created group information + + Raises: + NotFoundException: When user not found + UnauthorizedError: When user doesn't have permission + """ + # Check user permission + if user_id: + user_info = get_user_tenant_by_user_id(user_id) + if not user_info: + raise NotFoundException(f"User {user_id} not found") + + user_role = user_info.get("user_role", "USER") + if user_role not in ["SU", "ADMIN"]: + raise UnauthorizedError(f"User role {user_role} not authorized to create groups") + + # Create group + group_id = add_group( + tenant_id=tenant_id, + group_name=group_name, + group_description=group_description, + created_by=user_id + ) + + logger.info(f"Created group {group_name} for tenant {tenant_id} by user {user_id}") + + return { + "group_id": group_id, + "group_name": group_name, + "group_description": group_description + } + + +def update_group(group_id: int, updates: Dict[str, Any], user_id: str) -> bool: + """ + Update group information. + + Args: + group_id (int): Group ID + updates (Dict[str, Any]): Fields to update + user_id (str): Current user ID + + Returns: + bool: Whether update was successful + + Raises: + NotFoundException: When user or group not found + UnauthorizedError: When user doesn't have permission + """ + # Check user permission + user_info = get_user_tenant_by_user_id(user_id) + if not user_info: + raise NotFoundException(f"User {user_id} not found") + + user_role = user_info.get("user_role", "USER") + if user_role not in ["SU", "ADMIN"]: + raise UnauthorizedError(f"User role {user_role} not authorized to update groups") + + # Check if group exists + group = query_groups(group_id) + if not group: + raise NotFoundException(f"Group {group_id} not found") + + # Update group + success = modify_group( + group_id=group_id, + updates=updates, + updated_by=user_id + ) + + if success: + logger.info(f"Updated group {group_id} by user {user_id}") + + return success + + +def delete_group(group_id: int, user_id: str) -> bool: + """ + Delete group. + TODO: Clear user-group relationship, knowledgebases, agents, invitation codes under the group + + Args: + group_id (int): Group ID + user_id (str): Current user ID + + Returns: + bool: Whether deletion was successful + + Raises: + NotFoundException: When user or group not found + UnauthorizedError: When user doesn't have permission + """ + # Check user permission + user_info = get_user_tenant_by_user_id(user_id) + if not user_info: + raise NotFoundException(f"User {user_id} not found") + + user_role = user_info.get("user_role", "USER") + if user_role not in ["SU", "ADMIN"]: + raise UnauthorizedError(f"User role {user_role} not authorized to delete groups") + + # Check if group exists + group = query_groups(group_id) + if not group: + raise NotFoundException(f"Group {group_id} not found") + + # Delete group + success = remove_group( + group_id=group_id, + updated_by=user_id + ) + + if success: + logger.info(f"Deleted group {group_id} by user {user_id}") + + return success + + +def add_user_to_single_group(group_id: int, user_id: str, current_user_id: str) -> Dict[str, Any]: + """ + Add user to group. + + Args: + group_id (int): Group ID + user_id (str): User ID to add + current_user_id (str): Current user ID performing the action + + Returns: + Dict[str, Any]: Group membership information + + Raises: + NotFoundException: When user or group not found + UnauthorizedError: When user doesn't have permission + """ + # Check current user permission + user_info = get_user_tenant_by_user_id(current_user_id) + if not user_info: + raise UnauthorizedError(f"User {current_user_id} not found") + + # Check if group exists + group = query_groups(group_id) + if not group: + raise NotFoundException(f"Group {group_id} not found") + + # Check if user is already in group + if check_user_in_group(user_id, group_id): + return { + "group_id": group_id, + "user_id": user_id, + "already_member": True + } + + # Add user to group + group_user_id = add_user_to_group( + group_id=group_id, + user_id=user_id, + created_by=current_user_id + ) + + logger.info(f"Added user {user_id} to group {group_id} by user {current_user_id}") + + return { + "group_user_id": group_user_id, + "group_id": group_id, + "user_id": user_id, + "already_member": False + } + + +def remove_user_from_single_group(group_id: int, user_id: str, current_user_id: str) -> bool: + """ + Remove user from group. + + Args: + group_id (int): Group ID + user_id (str): User ID to remove + current_user_id (str): Current user ID performing the action + + Returns: + bool: Whether removal was successful + + Raises: + NotFoundException: When user or group not found + UnauthorizedError: When user doesn't have permission + """ + # Check current user permission + user_info = get_user_tenant_by_user_id(current_user_id) + if not user_info: + raise UnauthorizedError(f"User {current_user_id} not found") + + user_role = user_info.get("user_role", "USER") + if user_role not in ["SU", "ADMIN"]: + raise UnauthorizedError(f"User role {user_role} not authorized to manage group memberships") + + # Check if group exists + group = query_groups(group_id) + if not group: + raise NotFoundException(f"Group {group_id} not found") + + # Remove user from group + success = remove_user_from_group( + group_id=group_id, + user_id=user_id, + updated_by=current_user_id + ) + + if success: + logger.info(f"Removed user {user_id} from group {group_id} by user {current_user_id}") + + return success + + +def get_group_users(group_id: int) -> List[Dict[str, Any]]: + """ + Get all users in a group. + + Args: + group_id (int): Group ID + + Returns: + List[Dict[str, Any]]: List of group user records + + Raises: + NotFoundException: When group not found + """ + # Check if group exists + group = query_groups(group_id) + if not group: + raise NotFoundException(f"Group {group_id} not found") + + users = query_group_users(group_id) + + filtered_users = [] + for user in users: + filtered_users.append({ + "group_user_id": user.get("group_user_id"), + "group_id": user.get("group_id"), + "user_id": user.get("user_id") + }) + + return filtered_users + + +def get_group_user_count(group_id: int) -> int: + """ + Get user count in a group. + + Args: + group_id (int): Group ID + + Returns: + int: Number of users in the group + + Raises: + NotFoundException: When group not found + """ + # Check if group exists + group = query_groups(group_id) + if not group: + raise NotFoundException(f"Group {group_id} not found") + + return count_group_users(group_id) + + +def add_user_to_groups(user_id: str, group_ids: List[int], current_user_id: str) -> List[Dict[str, Any]]: + """ + Add user to multiple groups. + + Args: + user_id (str): User ID to add + group_ids (List[int]): List of group IDs + current_user_id (str): Current user ID performing the action + + Returns: + List[Dict[str, Any]]: List of group membership results + + Raises: + UnauthorizedError: When user doesn't have permission + """ + results = [] + for group_id in group_ids: + try: + result = add_user_to_single_group( + group_id, user_id, current_user_id) + results.append(result) + except Exception as e: + logger.error(f"Failed to add user {user_id} to group {group_id}: {str(e)}") + results.append({ + "group_id": group_id, + "user_id": user_id, + "error": str(e) + }) + + return results \ No newline at end of file diff --git a/backend/services/invitation_service.py b/backend/services/invitation_service.py new file mode 100644 index 000000000..f4ba727c3 --- /dev/null +++ b/backend/services/invitation_service.py @@ -0,0 +1,522 @@ +""" +Invitation service for managing invitation codes and records. +""" +import logging +import random +import string +from datetime import datetime +from typing import Optional, Dict, Any, List + +from database.invitation_db import ( + query_invitation_by_code, + query_invitation_by_id, + add_invitation, + modify_invitation, + add_invitation_record, + count_invitation_usage, + query_invitations_with_pagination, + remove_invitation +) +from database.user_tenant_db import get_user_tenant_by_user_id +from database.group_db import query_group_ids_by_user +from consts.exceptions import NotFoundException, UnauthorizedError +from services.group_service import get_tenant_default_group_id +from utils.str_utils import convert_string_to_list + +logger = logging.getLogger(__name__) + + +def create_invitation_code( + tenant_id: str, + code_type: str, + invitation_code: Optional[str] = None, + group_ids: Optional[List[int]] = None, + capacity: int = 1, + expiry_date: Optional[str] = None, + status: str = "IN_USE", + user_id: str = None +) -> Dict[str, Any]: + """ + Create a new invitation code with business logic. + + Args: + tenant_id (str): Tenant ID + code_type (str): Invitation code type (ADMIN_INVITE, DEV_INVITE, USER_INVITE) + invitation_code (Optional[str]): Invitation code, auto-generated if None + group_ids (Optional[List[int]]): Associated group IDs + capacity (int): Invitation code capacity + expiry_date (Optional[str]): Expiry date + status (str): Status + user_id (str): Current user ID + + Returns: + Dict[str, Any]: Created invitation code information + + Raises: + NotFoundException: When user not found + UnauthorizedError: When user doesn't have permission + ValueError: When code_type is invalid + """ + # Validate code_type + valid_code_types = ["ADMIN_INVITE", "DEV_INVITE", "USER_INVITE"] + if code_type not in valid_code_types: + raise ValueError(f"Invalid code_type: {code_type}. Must be one of {valid_code_types}") + + # Get user information + user_info = get_user_tenant_by_user_id(user_id) + if not user_info: + raise NotFoundException(f"User {user_id} not found") + + user_role = user_info.get("user_role", "USER") + + # Check permission based on code_type + if code_type == "ADMIN_INVITE" and user_role not in ["SU"]: + raise UnauthorizedError(f"User role {user_role} not authorized to create ADMIN_INVITE codes") + elif code_type in ["DEV_INVITE", "USER_INVITE"] and user_role not in ["SU", "ADMIN"]: + raise UnauthorizedError(f"User role {user_role} not authorized to create {code_type} codes") + + # Set default group_ids based on code_type if not provided + if group_ids is None: + if code_type == "ADMIN_INVITE": + # For admin invites, try to use tenant default group, fallback to empty list + default_group_id = get_tenant_default_group_id(tenant_id) + group_ids = [default_group_id] if default_group_id else [] + elif code_type in ["DEV_INVITE", "USER_INVITE"]: + group_ids = query_group_ids_by_user(user_id) + else: + group_ids = [] + + # Generate invitation code if not provided + if not invitation_code: + invitation_code = _generate_unique_invitation_code() + else: + # Change to upper case by default + invitation_code = invitation_code.upper() + + # Create invitation (status will be set automatically) + invitation_id = add_invitation( + tenant_id=tenant_id, + invitation_code=invitation_code, + code_type=code_type, + group_ids=group_ids, + capacity=capacity, + expiry_date=expiry_date, + status=status, + created_by=user_id + ) + + # Automatically update status based on expiry date and capacity + update_invitation_code_status(invitation_id) + + logger.info(f"Created invitation code {invitation_code} (type: {code_type}) for tenant {tenant_id} by user {user_id}") + + # Get the final invitation info with correct status + invitation_info = query_invitation_by_id(invitation_id) + normalized_info = _normalize_invitation_data(invitation_info) if invitation_info else None + + return { + "invitation_id": invitation_id, + "invitation_code": invitation_code, + "code_type": code_type, + "group_ids": group_ids, + "capacity": capacity, + "expiry_date": expiry_date, + "status": normalized_info.get("status", "IN_USE") if normalized_info else "IN_USE" + } + + +def update_invitation_code( + invitation_id: int, + updates: Dict[str, Any], + user_id: str +) -> bool: + """ + Update invitation code information. + + Args: + invitation_id (int): Invitation ID + updates (Dict[str, Any]): Fields to update + user_id (str): Current user ID + + Returns: + bool: Whether update was successful + + Raises: + UnauthorizedError: When user doesn't have permission + """ + # Check user permission + user_info = get_user_tenant_by_user_id(user_id) + if not user_info: + raise UnauthorizedError(f"User {user_id} not found") + + user_role = user_info.get("user_role", "USER") + if user_role not in ["SU", "ADMIN"]: + raise UnauthorizedError(f"User role {user_role} not authorized to update invitation codes") + + # Update invitation code + success = modify_invitation( + invitation_id=invitation_id, + updates=updates, + updated_by=user_id + ) + + if success: + logger.info(f"Updated invitation code {invitation_id} by user {user_id}") + # Automatically update status after successful update + update_invitation_code_status(invitation_id) + + return success + + +def delete_invitation_code(invitation_id: int, user_id: str) -> bool: + """ + Delete invitation code (soft delete). + + Args: + invitation_id (int): Invitation ID to delete + user_id (str): Current user ID for permission checks + + Returns: + bool: Whether deletion was successful + + Raises: + UnauthorizedError: When user doesn't have permission to delete + NotFoundException: When invitation not found + """ + # Check user permission + user_info = get_user_tenant_by_user_id(user_id) + if not user_info: + raise UnauthorizedError(f"User {user_id} not found") + + user_role = user_info.get("user_role", "USER") + if user_role not in ["SU", "ADMIN"]: + raise UnauthorizedError( + f"User role {user_role} not authorized to delete invitation codes") + + # Check if invitation exists + invitation_info = query_invitation_by_id(invitation_id) + if not invitation_info: + raise NotFoundException(f"Invitation {invitation_id} not found") + + # Delete invitation code + success = remove_invitation( + invitation_id=invitation_id, updated_by=user_id) + + if success: + logger.info( + f"Deleted invitation code {invitation_id} by user {user_id}") + + return success + + +def _normalize_invitation_data(invitation_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Normalize invitation data types for consistent API responses. + + Args: + invitation_data: Raw invitation data from database + + Returns: + Normalized invitation data with correct types + """ + if not invitation_data: + return invitation_data + + # Create a copy to avoid modifying the original + normalized = invitation_data.copy() + + # Convert datetime objects to ISO format strings + for key, value in normalized.items(): + if isinstance(value, datetime): + normalized[key] = value.isoformat() + + # Ensure correct data types + if "invitation_id" in normalized: + normalized["invitation_id"] = int(normalized["invitation_id"]) + if "capacity" in normalized: + normalized["capacity"] = int(normalized["capacity"]) + if "group_ids" in normalized: + # Convert group_ids string back to list + group_ids_value = normalized["group_ids"] + if isinstance(group_ids_value, str): + normalized["group_ids"] = convert_string_to_list(group_ids_value) + elif group_ids_value is None: + normalized["group_ids"] = [] + + return normalized + + +def get_invitation_by_code(invitation_code: str) -> Optional[Dict[str, Any]]: + """ + Get invitation code information by code. + + Args: + invitation_code (str): Invitation code + + Returns: + Optional[Dict[str, Any]]: Invitation code information or None if not found + """ + invitation_data = query_invitation_by_code(invitation_code) + if invitation_data: + # Calculate current status to ensure expiry and capacity checks are current + invitation_data = _calculate_current_status(invitation_data) + return _normalize_invitation_data(invitation_data) if invitation_data else None + + +def _calculate_current_status(invitation_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Calculate the current status of an invitation based on expiry and usage. + + Args: + invitation_data: Raw invitation data from database + + Returns: + Updated invitation data with current status + """ + if not invitation_data: + return invitation_data + + invitation_id = invitation_data.get("invitation_id") + if not invitation_id: + return invitation_data + + current_time = datetime.now() + expiry_date = invitation_data.get("expiry_date") + capacity = int(invitation_data.get("capacity", 1)) + + # Get usage count + usage_count = count_invitation_usage(invitation_id) + current_status = invitation_data.get("status", "IN_USE") + + new_status = current_status + + # Check expiry + if expiry_date: + try: + if isinstance(expiry_date, datetime): + expiry_datetime = expiry_date + else: + expiry_datetime = datetime.fromisoformat( + str(expiry_date).replace('Z', '+00:00')) + if current_time > expiry_datetime: + new_status = "EXPIRE" + except (ValueError, AttributeError, TypeError): + logger.warning(f"Invalid expiry_date format for invitation {invitation_id}: {expiry_date}") + + # Check capacity + if usage_count >= capacity: + new_status = "RUN_OUT" + + # Update status in the data dict + invitation_data["status"] = new_status + return invitation_data + + +def check_invitation_available(invitation_code: str) -> bool: + """ + Check if invitation is available for use. + + Args: + invitation_code (str): Invitation code + + Returns: + bool: Whether the code is available + """ + invitation = query_invitation_by_code(invitation_code) + if not invitation: + return False + + # Check status + if invitation.get("status") != "IN_USE": + return False + + # Check capacity + usage_count = count_invitation_usage(invitation["invitation_id"]) + return usage_count < invitation["capacity"] + + +def use_invitation_code( + invitation_code: str, + user_id: str +) -> Dict[str, Any]: + """ + Use an invitation code by creating a usage record. + + Args: + invitation_code (str): Invitation code to use + user_id (str): User ID using the code + + Returns: + Dict[str, Any]: Invitation usage result including code_type + + Raises: + NotFoundException: When invitation code not found or not available + """ + # Check if invitation is available + if not check_invitation_available(invitation_code): + raise NotFoundException(f"Invitation code {invitation_code} is not available") + + # Get invitation code details + invitation_info = query_invitation_by_code(invitation_code) + if not invitation_info: + raise NotFoundException(f"Invitation code {invitation_code} not found") + + # Create usage record + record_id = add_invitation_record( + invitation_id=invitation_info["invitation_id"], + user_id=user_id, + created_by=user_id + ) + + # Update invitation status + update_invitation_code_status(invitation_info["invitation_id"]) + + logger.info(f"User {user_id} used invitation code {invitation_code}") + + return { + "invitation_record_id": record_id, + "invitation_code": invitation_code, + "user_id": user_id, + "invitation_id": invitation_info["invitation_id"], + "code_type": invitation_info["code_type"], + "group_ids": invitation_info["group_ids"] + } + + +def update_invitation_code_status(invitation_id: int) -> bool: + """ + Update invitation code status based on expiry date and usage count. + + Args: + invitation_id (int): Invitation ID + + Returns: + bool: Whether status was updated + """ + # Get invitation code details + invitation_info = query_invitation_by_id(invitation_id) + if not invitation_info: + return False + + current_time = datetime.now() + expiry_date = invitation_info.get("expiry_date") + capacity = int(invitation_info["capacity"]) + + usage_count = count_invitation_usage(invitation_id) + current_status = invitation_info["status"] + + new_status = current_status + + # Check expiry + if expiry_date: + try: + if isinstance(expiry_date, datetime): + expiry_datetime = expiry_date + else: + expiry_datetime = datetime.fromisoformat( + str(expiry_date).replace('Z', '+00:00')) + if current_time > expiry_datetime: + new_status = "EXPIRE" + except (ValueError, AttributeError, TypeError): + logger.warning(f"Invalid expiry_date format for invitation {invitation_id}: {expiry_date}") + + # Check capacity + if usage_count >= capacity: + new_status = "RUN_OUT" + + # Update status if changed + if new_status != current_status: + modify_invitation( + invitation_id=invitation_id, + updates={"status": new_status}, + updated_by="system" + ) + logger.info(f"Updated invitation code {invitation_id} status to {new_status}") + return True + + return False + + +def _generate_unique_invitation_code(length: int = 6) -> str: + """ + Generate a unique invitation code. + + Args: + length (int): Code length + + Returns: + str: Unique invitation code + """ + max_attempts = 100 # Prevent infinite loop + attempts = 0 + + while attempts < max_attempts: + # Generate random code with letters and digits + code = ''.join(random.choices(string.ascii_letters + string.digits, k=length)) + + # Check uniqueness + if not query_invitation_by_code(code): + return code.upper() + + attempts += 1 + + raise RuntimeError(f"Failed to generate unique invitation code after {max_attempts} attempts") + + +def get_invitations_list( + tenant_id: Optional[str], + page: int, + page_size: int, + user_id: str +) -> Dict[str, Any]: + """ + Get invitations list with pagination and permission checks. + + Args: + tenant_id (Optional[str]): Tenant ID to filter by, None for all tenants + page (int): Page number + page_size (int): Number of items per page + user_id (str): Current user ID for permission checks + + Returns: + Dict[str, Any]: Paginated invitation list result + + Raises: + UnauthorizedError: When user doesn't have permission to view the requested data + """ + # Get user information for permission checks + user_info = get_user_tenant_by_user_id(user_id) + if not user_info: + raise UnauthorizedError(f"User {user_id} not found") + + user_role = user_info.get("user_role", "USER") + + # Permission logic: + # - If tenant_id is provided: ADMIN or SU can view that tenant's invitations + # - If tenant_id is not provided: Only SU can view all invitations + if tenant_id: + # If tenant_id is specified, user must be ADMIN/SU + if user_role not in ["SU", "ADMIN"]: + raise UnauthorizedError( + f"User role {user_role} not authorized to view invitation lists") + else: + # If no tenant_id specified, only SU can view all invitations + if user_role not in ["SU"]: + raise UnauthorizedError( + f"User role {user_role} not authorized to view all tenant invitations") + + # Query invitations with pagination + result = query_invitations_with_pagination( + tenant_id=tenant_id, + page=page, + page_size=page_size + ) + + logger.info( + f"User {user_id} queried invitations list (tenant: {tenant_id or 'all'}, page: {page}, size: {page_size})") + + # Normalize each invitation item in the list + if result and "items" in result: + result["items"] = [_normalize_invitation_data(item) for item in result["items"]] + + return result diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index 24fb5bc16..3e67a804f 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -9,12 +9,9 @@ DEFAULT_LLM_MAX_TOKENS, DEFAULT_EXPECTED_CHUNK_SIZE, DEFAULT_MAXIMUM_CHUNK_SIZE, - MODEL_ENGINE_HOST, - MODEL_ENGINE_APIKEY, ) from consts.model import ModelConnectStatusEnum, ModelRequest from consts.provider import SILICON_GET_URL, ProviderEnum -from consts.exceptions import TimeoutException from database.model_management_db import get_models_by_tenant_factory_type from services.model_health_service import embedding_dimension_check from utils.model_name_utils import split_repo_name, add_repo_to_name diff --git a/backend/services/tenant_service.py b/backend/services/tenant_service.py new file mode 100644 index 000000000..0519b8fa8 --- /dev/null +++ b/backend/services/tenant_service.py @@ -0,0 +1,240 @@ +""" +Tenant service for managing tenant operations +""" +import logging +import uuid +from typing import Any, Dict, List, Optional + +from database.tenant_config_db import ( + get_single_config_info, + insert_config, + update_config_by_tenant_config_id, + get_all_tenant_ids +) +from database.group_db import add_group +from consts.const import TENANT_NAME, TENANT_ID, DEFAULT_GROUP_ID +from consts.exceptions import NotFoundException, ValidationError + +logger = logging.getLogger(__name__) + + +def get_tenant_info(tenant_id: str) -> Dict[str, Any]: + """ + Get tenant information by tenant ID + + Args: + tenant_id (str): Tenant ID + + Returns: + Dict[str, Any]: Tenant information + + Raises: + NotFoundException: When tenant not found + """ + # Get tenant name + name_config = get_single_config_info(tenant_id, TENANT_NAME) + if not name_config: + raise NotFoundException("The name of tenant not found.") + + group_config = get_single_config_info(tenant_id, DEFAULT_GROUP_ID) + + tenant_info = { + "tenant_id": tenant_id, + "tenant_name": name_config.get("config_value") if name_config else "", + "default_group_id": group_config.get("config_value") if group_config else "" + } + + return tenant_info + + +def get_all_tenants() -> List[Dict[str, Any]]: + """ + Get all tenants + + Returns: + List[Dict[str, Any]]: List of all tenant information + """ + tenant_ids = get_all_tenant_ids() + tenants = [] + + for tenant_id in tenant_ids: + try: + tenant_info = get_tenant_info(tenant_id) + tenants.append(tenant_info) + except NotFoundException: + # Skip tenants that can't be found (shouldn't happen but being defensive) + logging.warning(f"Tenant info of {tenant_id} not found. Which is not expected to happend. Continue anyway.") + continue + + return tenants + + +def create_tenant(tenant_name: str, created_by: Optional[str] = None) -> Dict[str, Any]: + """ + Create a new tenant with default group + + Args: + tenant_name (str): Tenant name + created_by (Optional[str]): Created by user ID + + Returns: + Dict[str, Any]: Created tenant information + + Raises: + ValidationError: When tenant creation fails + """ + # Generate a random UUID for tenant_id + tenant_id = str(uuid.uuid4()) + + # Check if tenant already exists (extremely unlikely with UUID, but good practice) + try: + existing_tenant = get_tenant_info(tenant_id) + if existing_tenant: + raise ValidationError(f"Tenant {tenant_id} already exists") + except NotFoundException: + # Tenant doesn't exist, which is what we want + pass + + # Validate tenant name + if not tenant_name or not tenant_name.strip(): + raise ValidationError("Tenant name cannot be empty") + + try: + # Create default group first + default_group_id = _create_default_group_for_tenant(tenant_id, created_by) + + # Create tenant ID configuration + tenant_id_data = { + "tenant_id": tenant_id, + "config_key": TENANT_ID, + "config_value": tenant_id, + "created_by": created_by, + "updated_by": created_by + } + id_success = insert_config(tenant_id_data) + if not id_success: + raise ValidationError("Failed to create tenant ID configuration") + + # Create tenant name configuration + tenant_name_data = { + "tenant_id": tenant_id, + "config_key": TENANT_NAME, + "config_value": tenant_name.strip(), + "created_by": created_by, + "updated_by": created_by + } + name_success = insert_config(tenant_name_data) + if not name_success: + raise ValidationError("Failed to create tenant name configuration") + + # Create default group ID configuration + group_config_data = { + "tenant_id": tenant_id, + "config_key": DEFAULT_GROUP_ID, + "config_value": str(default_group_id), + "created_by": created_by, + "updated_by": created_by + } + group_success = insert_config(group_config_data) + if not group_success: + raise ValidationError("Failed to create tenant default group configuration") + + tenant_info = { + "tenant_id": tenant_id, + "tenant_name": tenant_name.strip(), + "default_group_id": str(default_group_id) + } + + logger.info(f"Created tenant {tenant_id} with name '{tenant_name}' and default group {default_group_id}") + return tenant_info + + except Exception as e: + logger.error(f"Failed to create tenant {tenant_id}: {str(e)}") + raise ValidationError(f"Failed to create tenant: {str(e)}") + + +def update_tenant_info(tenant_id: str, tenant_name: str, updated_by: Optional[str] = None) -> Dict[str, Any]: + """ + Update tenant information + + Args: + tenant_id (str): Tenant ID + tenant_name (str): New tenant name + updated_by (Optional[str]): Updated by user ID + + Returns: + Dict[str, Any]: Updated tenant information + + Raises: + NotFoundException: When tenant not found + ValidationError: When tenant name is invalid + """ + # Check if tenant exists and get current name config + name_config = get_single_config_info(tenant_id, TENANT_NAME) + if not name_config: + raise NotFoundException(f"Tenant {tenant_id} not found") + + # Validate tenant name + if not tenant_name or not tenant_name.strip(): + raise ValidationError("Tenant name cannot be empty") + + # Update tenant name + success = update_config_by_tenant_config_id( + name_config["tenant_config_id"], + tenant_name.strip() + ) + if not success: + raise ValidationError("Failed to update tenant name") + + # Return updated tenant information + updated_tenant = get_tenant_info(tenant_id) + logger.info(f"Updated tenant {tenant_id} name to '{tenant_name}'") + return updated_tenant + + +def delete_tenant(tenant_id: str, deleted_by: Optional[str] = None) -> bool: + """ + Delete tenant (placeholder for future implementation) + NOTE: Deletion logic is complex and not yet implemented + + Args: + tenant_id (str): Tenant ID + deleted_by (Optional[str]): Deleted by user ID + + Returns: + bool: Always returns False as this is not yet implemented + + Raises: + ValidationError: Always raised as this is not yet implemented + """ + raise NotImplementedError("Tenant deletion is not yet implemented due to complex dependencies") + + +def _create_default_group_for_tenant(tenant_id: str, created_by: Optional[str] = None) -> int: + """ + Create a default group for a new tenant + + Args: + tenant_id (str): Tenant ID + created_by (Optional[str]): Created by user ID + + Returns: + int: Created default group ID + + Raises: + ValidationError: When default group creation fails + """ + try: + default_group_name = "Default Group" + group_id = add_group( + tenant_id=tenant_id, + group_name=default_group_name, + group_description="Default group created automatically for new tenant", + created_by=created_by + ) + + return group_id + + except Exception as e: + logger.error(f"Failed to create default group for tenant {tenant_id}: {str(e)}") + raise ValidationError(f"Failed to create default group: {str(e)}") diff --git a/backend/services/user_management_service.py b/backend/services/user_management_service.py index 9a5cd60a2..b565e8aad 100644 --- a/backend/services/user_management_service.py +++ b/backend/services/user_management_service.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, Any, Tuple +from typing import Optional, Any, Tuple, Dict import aiohttp from fastapi import Header @@ -12,15 +12,19 @@ calculate_expires_at, get_jwt_expiry_seconds, ) -from consts.const import INVITE_CODE, SUPABASE_URL, SUPABASE_KEY +from consts.const import INVITE_CODE, SUPABASE_URL, SUPABASE_KEY, DEFAULT_TENANT_ID from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException, UnauthorizedError from database.model_management_db import create_model_record -from database.user_tenant_db import insert_user_tenant, soft_delete_user_tenant_by_user_id +from database.user_tenant_db import insert_user_tenant, soft_delete_user_tenant_by_user_id, get_user_tenant_by_user_id from database.memory_config_db import soft_delete_all_configs_by_user_id from database.conversation_db import soft_delete_all_conversations_by_user +from database.group_db import query_group_ids_by_user +from database.role_permission_db import get_role_permissions from utils.memory_utils import build_memory_config from nexent.memory.memory_service import clear_memory +from services.invitation_service import use_invitation_code, check_invitation_available, get_invitation_by_code +from services.group_service import add_user_to_groups logging.getLogger("user_management_service").setLevel(logging.DEBUG) @@ -160,6 +164,120 @@ async def signup_user(email: EmailStr, "Registration service is temporarily unavailable, please try again later") +async def signup_user_with_invitation(email: EmailStr, + password: str, + invite_code: Optional[str] = None): + """User registration with invitation code support""" + client = get_supabase_client() + logging.info( + f"Receive registration request: email={email}, invite_code={'provided' if invite_code else 'not provided'}") + + # Default user role is USER + user_role = "USER" + invitation_info = None + + # Validate invitation code if provided (without using it yet) + if invite_code: + try: + # Convert invite code to upper case for consistency + invite_code = invite_code.upper() + + # Check if invitation is available + if not check_invitation_available(invite_code): + raise IncorrectInviteCodeException( + f"Invitation code {invite_code} is not available") + + # Get invitation code details + invitation_info = get_invitation_by_code(invite_code) + if not invitation_info: + raise IncorrectInviteCodeException( + f"Invitation code {invite_code} not found") + + # Determine user role based on invitation code type + code_type = invitation_info["code_type"] + if code_type == "ADMIN_INVITE": + user_role = "ADMIN" + elif code_type == "DEV_INVITE": + user_role = "DEV" + + logging.info( + f"Invitation code {invite_code} validated successfully, will assign role: {user_role}") + + except IncorrectInviteCodeException: + raise + except Exception as e: + logging.error( + f"Invitation code {invite_code} validation failed: {str(e)}") + raise IncorrectInviteCodeException( + f"Invalid invitation code: {str(e)}") + + # Set user metadata, including role information + response = client.auth.sign_up({ + "email": email, + "password": password + }) + + if response.user: + user_id = response.user.id + + # Determine tenant_id based on invitation code + if invitation_info: + tenant_id = invitation_info["tenant_id"] + else: + tenant_id = DEFAULT_TENANT_ID + + # Create user tenant relationship + logging.debug(f"Creating user tenant relationship: user_id={user_id}, tenant_id={tenant_id}, user_role={user_role}") + insert_user_tenant( + user_id=user_id, tenant_id=tenant_id, user_role=user_role) + logging.debug(f"User tenant relationship created successfully for user {user_id}") + + # Use invitation code now that we have the real user_id + if invitation_info: + try: + invitation_result = use_invitation_code(invite_code, user_id) + logging.debug( + f"Invitation code {invite_code} used successfully for user {user_id}") + + # Add user to groups specified in invitation code + group_ids = invitation_result.get("group_ids", []) + if group_ids: + try: + # Convert group_ids from string to list if needed + if isinstance(group_ids, str): + from utils.str_utils import convert_string_to_list + group_ids = convert_string_to_list(group_ids) + + if group_ids: + group_results = add_user_to_groups(user_id, group_ids, user_id) + successful_adds = [ + r for r in group_results if not r.get("error")] + logging.info( + f"User {user_id} added to {len(successful_adds)} groups from invitation code") + + except Exception as e: + logging.error( + f"Failed to add user {user_id} to invitation groups: {str(e)}") + + except Exception as e: + # If using invitation code fails after registration, log error but don't fail registration + logging.error( + f"Failed to use invitation code {invite_code} for user {user_id}: {str(e)}") + + logging.info( + f"User {email} registered successfully, role: {user_role}, tenant: {tenant_id}") + + if user_role == "ADMIN": + await generate_tts_stt_4_admin(tenant_id, user_id) + + return await parse_supabase_response(False, response, user_role) + else: + logging.error( + "Supabase registration request returned no user object") + raise UserRegistrationException( + "Registration service is temporarily unavailable, please try again later") + + async def parse_supabase_response(is_admin, response, user_role): """Parse Supabase response and build standardized user registration response""" user_data = { @@ -379,3 +497,74 @@ async def revoke_regular_user(user_id: str, tenant_id: str) -> None: logging.error( f"Unexpected error in revoke_regular_user for {user_id}: {e}") # swallow to keep idempotent behavior + + +async def get_user_info(user_id: str) -> Optional[Dict[str, Any]]: + """ + Get user information including user ID, group IDs list, tenant ID, and user role. + All information is retrieved from PostgreSQL database. + + Args: + user_id (str): User ID to query + + Returns: + Optional[Dict[str, Any]]: User information dictionary containing: + - user_id: User ID + - group_ids: List of group IDs the user belongs to + - tenant_id: Tenant ID + - user_role: User role (USER, ADMIN, DEV, etc.) + Returns None if user not found + """ + try: + + + # Get user tenant relationship + user_tenant = get_user_tenant_by_user_id(user_id) + if not user_tenant: + return None + + tenant_id = user_tenant["tenant_id"] + user_role = user_tenant["user_role"] + + # Get group IDs + group_ids = query_group_ids_by_user(user_id) + + return { + "user_id": user_id, + "group_ids": group_ids, + "tenant_id": tenant_id, + "user_role": user_role + } + + except Exception as e: + logging.error( + f"Failed to get user info for user {user_id}: {str(e)}") + return None + + +async def get_permissions_by_role(user_role: str) -> Dict[str, Any]: + """ + Get all permissions for a specific user role. + + This method retrieves role permissions from the database and returns them + in a structured format suitable for API responses. + + Args: + user_role (str): User role to query permissions for (SU, ADMIN, DEV, USER) + + Returns: + Dict[str, Any]: Response containing permissions data and metadata + """ + try: + permissions = get_role_permissions(user_role) + + return { + "user_role": user_role, + "permissions": permissions, + "total_permissions": len(permissions), + "message": f"Successfully retrieved {len(permissions)} permissions for role {user_role}" + } + except Exception as e: + logging.error( + f"Failed to get role permissions for role {user_role}: {str(e)}") + raise Exception(f"Failed to retrieve permissions for role {user_role}") diff --git a/backend/services/vectordatabase_service.py b/backend/services/vectordatabase_service.py index 18203847e..497aebfe7 100644 --- a/backend/services/vectordatabase_service.py +++ b/backend/services/vectordatabase_service.py @@ -24,7 +24,7 @@ from nexent.vector_database.base import VectorDatabaseCore from nexent.vector_database.elasticsearch_core import ElasticSearchCore -from consts.const import ES_API_KEY, ES_HOST, LANGUAGE, VectorDatabaseType +from consts.const import DEFAULT_TENANT_ID, DEFAULT_USER_ID, ES_API_KEY, ES_HOST, LANGUAGE, VectorDatabaseType from consts.model import ChunkCreateRequest, ChunkUpdateRequest from database.attachment_db import delete_file from database.knowledge_db import ( @@ -35,9 +35,13 @@ get_knowledge_info_by_tenant_id, update_model_name_by_index_name, ) +from database.user_tenant_db import get_user_tenant_by_user_id +from database.group_db import query_group_ids_by_user from services.redis_service import get_redis_service +from services.group_service import get_tenant_default_group_id from utils.config_utils import tenant_config_manager, get_model_name_from_config from utils.file_management_utils import get_all_files_status, get_file_size +from utils.str_utils import convert_string_to_list def _update_progress(task_id: str, processed: int, total: int): @@ -127,12 +131,12 @@ def _rethrow_or_plain(exc: Exception) -> None: raise Exception(msg) -def check_knowledge_base_exist_impl(index_name: str, vdb_core: VectorDatabaseCore, user_id: str, tenant_id: str) -> dict: +def check_knowledge_base_exist_impl(knowledge_name: str, vdb_core: VectorDatabaseCore, user_id: str, tenant_id: str) -> dict: """ Check knowledge base existence and handle orphan cases Args: - index_name: Name of the index to check + knowledge_name: Name of the knowledge base to check vdb_core: Elasticsearch core instance user_id: Current user ID tenant_id: Current tenant ID @@ -140,59 +144,16 @@ def check_knowledge_base_exist_impl(index_name: str, vdb_core: VectorDatabaseCor Returns: dict: Status information about the knowledge base """ - # 1. Check index existence in ES and corresponding record in PG - es_exists = vdb_core.check_index_exists(index_name) - pg_record = get_knowledge_record({"index_name": index_name}) + # 1. Check if knowledge_name exists in PG for the current tenant + pg_record = get_knowledge_record( + {"knowledge_name": knowledge_name, "tenant_id": tenant_id}) - # Case A: Orphan in ES only (exists in ES, missing in PG) - if es_exists and not pg_record: - logger.warning( - f"Detected orphan knowledge base '{index_name}' – present in ES, absent in PG. Deleting ES index only.") - try: - vdb_core.delete_index(index_name) - # Clean up Redis records related to this index to avoid stale tasks - try: - redis_service = get_redis_service() - redis_cleanup = redis_service.delete_knowledgebase_records( - index_name) - logger.debug( - f"Redis cleanup for orphan index '{index_name}': {redis_cleanup['total_deleted']} records removed") - except Exception as redis_error: - logger.warning( - f"Redis cleanup failed for orphan index '{index_name}': {str(redis_error)}") - return { - "status": "error_cleaning_orphans", - "action": "cleaned_es" - } - except Exception as e: - logger.error( - f"Failed to delete orphan ES index '{index_name}': {str(e)}") - # Still return orphan status so frontend knows it requires attention - return {"status": "error_cleaning_orphans", "error": True} - - # Case B: Orphan in PG only (missing in ES, present in PG) - if not es_exists and pg_record: - logger.warning( - f"Detected orphan knowledge base '{index_name}' – present in PG, absent in ES. Deleting PG record only.") - try: - delete_knowledge_record( - {"index_name": index_name, "user_id": user_id}) - return {"status": "error_cleaning_orphans", "action": "cleaned_pg"} - except Exception as e: - logger.error( - f"Failed to delete orphan PG record for '{index_name}': {str(e)}") - return {"status": "error_cleaning_orphans", "error": True} - - # Case C: Index/record both absent -> name is available - if not es_exists and not pg_record: - return {"status": "available"} - - # Case D: Index and record both exist – check tenant ownership - record_tenant_id = pg_record.get('tenant_id') if pg_record else None - if str(record_tenant_id) == str(tenant_id): + # Case A: Knowledge base name already exists in the same tenant + if pg_record: return {"status": "exists_in_tenant"} - else: - return {"status": "exists_in_other_tenant"} + + # Case B: Name is available in this tenant + return {"status": "available"} def get_embedding_model(tenant_id: str): @@ -480,8 +441,18 @@ def list_indices( vdb_core: VectorDatabaseCore = Depends(get_vector_db_core) ): """ - List all indices that the current user has permissions to access. - async PG database to sync ES, remove the data that is not in ES + List all indices that the current user has permissions to access based on role and group permissions. + + Permission logic: + - SU: All knowledgebases visible, all editable + - ADMIN: Knowledgebases from same tenant visible, all editable + - USER/DEV: Knowledgebases where user belongs to intersecting groups, permission determined by: + * If user is creator: editable + * If ingroup_permission=EDIT: editable + * If ingroup_permission=READ_ONLY: read-only + * If ingroup_permission=PRIVATE: not visible + + Also syncs PG database with ES, removing data that is not in ES. Args: pattern: Pattern to match index names @@ -491,33 +462,111 @@ def list_indices( vdb_core: VectorDatabaseCore instance Returns: - Dict[str, Any]: A dictionary containing the list of indices and the count. + Dict[str, Any]: A dictionary containing the list of visible knowledgebases with permissions. """ - all_indices_list = vdb_core.get_user_indices(pattern) + # Get user tenant information for permission checking + user_tenant = get_user_tenant_by_user_id(user_id) + if not user_tenant: + return {"indices": [], "count": 0} - db_record = get_knowledge_info_by_tenant_id(tenant_id=tenant_id) + user_role = user_tenant.get("user_role") + user_tenant_id = user_tenant.get("tenant_id") + # Get user group IDs from tenant_group_user_t table + user_group_ids = query_group_ids_by_user(user_id) - # Build mapping from index_name to user-facing knowledge_name (fallback to index_name) - index_to_display_name = { - record["index_name"]: record.get( - "knowledge_name") or record["index_name"] - for record in db_record - } + # Get all indices from Elasticsearch + es_indices_list = vdb_core.get_user_indices(pattern) + + # Get all knowledgebase records from database (for cleanup and permission checking) + all_db_records = get_knowledge_info_by_tenant_id(user_tenant_id) - filtered_indices_list = [] + # Filter visible knowledgebases based on user role and permissions + visible_knowledgebases = [] model_name_is_none_list = [] - for record in db_record: - # async PG database to sync ES, remove the data that is not in ES - if record["index_name"] not in all_indices_list: - delete_knowledge_record( - {"index_name": record["index_name"], "user_id": user_id}) + + for record in all_db_records: + index_name = record["index_name"] + + # Check if index exists in Elasticsearch (skip if not found) + if index_name not in es_indices_list: continue - if record["embedding_model_name"] is None: - model_name_is_none_list.append(record["index_name"]) - filtered_indices_list.append(record["index_name"]) - indices = [info.get("index") if isinstance( - info, dict) else info for info in filtered_indices_list] + # Check permission based on user role + permission = None + + # Fallback logic: if user_id equals tenant_id, treat as legacy admin user + # even if user_role is None or empty + effective_user_role = user_role + if user_id == tenant_id: + effective_user_role = "ADMIN" + logger.info(f"User {user_id} identified as legacy admin") + elif user_id == DEFAULT_USER_ID and tenant_id == DEFAULT_TENANT_ID: + effective_user_role = "ADMIN" + logger.info("User under SPEED version is treated as admin") + + if effective_user_role in ["SU", "ADMIN"] : + # SU can see all knowledgebases + permission = "EDIT" + elif effective_user_role in ["USER", "DEV"]: + # USER/DEV need group-based permission checking + kb_group_ids_str = record.get("group_ids") + kb_group_ids = convert_string_to_list(kb_group_ids_str or "") + kb_created_by = record.get("created_by") + kb_ingroup_permission = record.get("ingroup_permission") or "READ_ONLY" + + # Check if user belongs to any of the knowledgebase groups + # Compatibility logic for legacy data: + # - If both kb_group_ids and user_group_ids are effectively empty (None or empty lists), + # consider them intersecting (backward compatibility) + # - If either side has groups but they don't intersect, no intersection + kb_groups_empty = kb_group_ids_str is None or (isinstance( + kb_group_ids_str, str) and kb_group_ids_str.strip() == "") or len(kb_group_ids) == 0 + user_groups_empty = len(user_group_ids) == 0 + + if kb_groups_empty and user_groups_empty: + # Both are empty/None - consider intersecting for backward compatibility + has_group_intersection = True + else: + # Normal intersection check + has_group_intersection = bool(set(user_group_ids) & set(kb_group_ids)) + + if has_group_intersection: + # Determine permission level + permission = "READ_ONLY" # Default + + # User is creator: creator permission + if kb_created_by == user_id: + permission = "CREATOR" + # Group permission allows editing + elif kb_ingroup_permission == "EDIT": + permission = "EDIT" + # Group permission is read-only: already set + elif kb_ingroup_permission == "READ_ONLY": + permission = "READ_ONLY" + # Group permission is private: not visible + elif kb_ingroup_permission == "PRIVATE": + permission = None + + # Add to visible list if permission is granted + if permission: + record_with_permission = dict(record) + record_with_permission["permission"] = permission + # Convert group_ids string to list for easier client consumption + if record.get("group_ids"): + record_with_permission["group_ids"] = convert_string_to_list( + record["group_ids"]) + else: + # If no group_ids specified, use tenant default group + default_group_id = get_tenant_default_group_id(record.get("tenant_id")) + record_with_permission["group_ids"] = [default_group_id] if default_group_id else [] + visible_knowledgebases.append(record_with_permission) + + # Track records with missing embedding model for stats update + if record.get("embedding_model_name") is None: + model_name_is_none_list.append(index_name) + + # Build response + indices = [record["index_name"] for record in visible_knowledgebases] response = { "indices": indices, @@ -526,22 +575,34 @@ def list_indices( if include_stats: stats_info = [] - if filtered_indices_list: - indice_stats = vdb_core.get_indices_detail(filtered_indices_list) - for index_name in filtered_indices_list: + if visible_knowledgebases: + index_names = [record["index_name"] + for record in visible_knowledgebases] + indice_stats = vdb_core.get_indices_detail(index_names) + + for record in visible_knowledgebases: + index_name = record["index_name"] index_stats = indice_stats.get(index_name, {}) stats_info.append({ # Internal index name (used as ID) "name": index_name, # User-facing knowledge base name from PostgreSQL (fallback to index_name) - "display_name": index_to_display_name.get(index_name, index_name), + "display_name": record.get("knowledge_name", index_name), + "permission": record["permission"], + "group_ids": record["group_ids"], "stats": index_stats, }) + + # Update model name if missing if index_name in model_name_is_none_list: - update_model_name_by_index_name(index_name, - index_stats.get("base_info", {}).get( - "embedding_model", ""), - tenant_id, user_id) + update_model_name_by_index_name( + index_name, + index_stats.get("base_info", {}).get( + "embedding_model", ""), + record.get("tenant_id", tenant_id), + user_id + ) + response["indices_info"] = stats_info return response diff --git a/backend/utils/config_utils.py b/backend/utils/config_utils.py index 67b78283c..a9bd1566f 100644 --- a/backend/utils/config_utils.py +++ b/backend/utils/config_utils.py @@ -51,17 +51,7 @@ def get_model_name_from_config(model_config: Dict[str, Any]) -> str: class TenantConfigManager: - """Tenant configuration manager for dynamic loading and caching configurations from database""" - - def __init__(self): - self.config_cache = {} - self.cache_expiry = {} # Store expiration timestamps for each cache entry - self.CACHE_DURATION = 86400 # 1 day in seconds - self.last_modified_times = {} # Store last modified times for each tenant - - def _get_cache_key(self, tenant_id: str, key: str) -> str: - """Generate a unique cache key combining tenant_id and key""" - return f"{tenant_id}:{key}" + """Tenant configuration manager that reads configurations from the database on demand.""" def load_config(self, tenant_id: str, force_reload: bool = False): """Load configuration from database and update cache @@ -78,61 +68,22 @@ def load_config(self, tenant_id: str, force_reload: bool = False): logger.warning("Invalid tenant ID provided") return {} - complete_cache_key = self._get_cache_key(tenant_id, "*") - current_time = time.time() - - # Check if we have a valid cache entry - if not force_reload and complete_cache_key in self.config_cache: - # Check if cache is still valid - if complete_cache_key in self.cache_expiry and current_time < self.cache_expiry[complete_cache_key]: - return self.config_cache[complete_cache_key] - - # Cache miss or forced reload - Get configurations from database + # Always load latest configurations directly from DB (no in-process cache). configs = get_all_configs_by_tenant_id(tenant_id) if not configs: logger.info(f"No configurations found for tenant {tenant_id}") return {} - # Update cache with new configurations - cache_updates = 0 tenant_configs = {} - for config in configs: - cache_key = self._get_cache_key(tenant_id, config["config_key"]) - self.config_cache[cache_key] = config["config_value"] tenant_configs[config["config_key"]] = config["config_value"] - self.cache_expiry[cache_key] = current_time + self.CACHE_DURATION - cache_updates += 1 - - # Store the complete tenant config - self.config_cache[complete_cache_key] = tenant_configs - self.cache_expiry[complete_cache_key] = current_time + \ - self.CACHE_DURATION - - # Store the last modified time from database - self.last_modified_times[tenant_id] = self._get_tenant_config_modified_time( - tenant_id) logger.info( - f"Configuration reloaded for tenant {tenant_id} at: {time.strftime('%Y-%m-%d %H:%M:%S')}") + f"Configuration loaded for tenant {tenant_id} at: {time.strftime('%Y-%m-%d %H:%M:%S')}") return tenant_configs - def _get_tenant_config_modified_time(self, tenant_id: str) -> float: - """Get the last modification time of tenant configurations - - Args: - tenant_id (str): The tenant ID to check - - Returns: - float: The last modification timestamp - """ - # This is a placeholder - implement actual database query - # to get the last modification time of tenant configurations - # Example: return db.query("SELECT MAX(modified_at) FROM tenant_configs WHERE tenant_id = %s", tenant_id) - return time.time() # Temporary implementation - def get_model_config(self, key: str, default=None, tenant_id: str | None = None): if default is None: default = {} @@ -186,8 +137,6 @@ def set_single_config(self, user_id: str | None = None, tenant_id: str | None = } insert_config(insert_data) - # Clear cache for this tenant after setting new config - self.clear_cache(tenant_id) def delete_single_config(self, tenant_id: str | None = None, key: str | None = None, ): """Delete configuration value in database""" @@ -200,8 +149,6 @@ def delete_single_config(self, tenant_id: str | None = None, key: str | None = N if existing_config: delete_config_by_tenant_config_id( existing_config["tenant_config_id"]) - # Clear cache for this tenant after deleting config - self.clear_cache(tenant_id) return def update_single_config(self, tenant_id: str | None = None, key: str | None = None): @@ -219,26 +166,7 @@ def update_single_config(self, tenant_id: str | None = None, key: str | None = N } update_config_by_tenant_config_id_and_data( existing_config["tenant_config_id"], update_data) - - # Clear cache for this tenant after updating config so that - # subsequent reads immediately see the latest configuration - self.clear_cache(tenant_id) return - def clear_cache(self, tenant_id: str | None = None): - """Clear the cache for a specific tenant or all tenants""" - if tenant_id: - # Clear cache for specific tenant - keys_to_remove = [ - k for k in self.config_cache.keys() if k.startswith(f"{tenant_id}:")] - for key in keys_to_remove: - del self.config_cache[key] - if key in self.cache_expiry: - del self.cache_expiry[key] - else: - # Clear all cache - self.config_cache.clear() - self.cache_expiry.clear() - tenant_config_manager = TenantConfigManager() diff --git a/backend/utils/str_utils.py b/backend/utils/str_utils.py index a20b4e4b7..dc7887595 100644 --- a/backend/utils/str_utils.py +++ b/backend/utils/str_utils.py @@ -1,4 +1,5 @@ import re +from typing import List, Optional def remove_think_blocks(text: str) -> str: @@ -6,3 +7,33 @@ def remove_think_blocks(text: str) -> str: if not text: return text return re.sub(r"(?:)?.*?", "", text, flags=re.DOTALL | re.IGNORECASE) + + +def convert_list_to_string(items: Optional[List[int]]) -> str: + """ + Convert list of integers to comma-separated string for database storage + + Args: + items: List of integers or None + + Returns: + Comma-separated string, empty string if None + """ + if items is None: + return "" + return ",".join(str(item) for item in items) + + +def convert_string_to_list(items_str: Optional[str]) -> List[int]: + """ + Convert comma-separated string to list of integers for processing + + Args: + items_str: Comma-separated string or None + + Returns: + List of integers, empty list if None or empty string + """ + if not items_str or items_str.strip() == "": + return [] + return [int(item.strip()) for item in items_str.split(",") if item.strip().isdigit()] diff --git a/docker/.env.example b/docker/.env.example index 04a9cfa5a..bd1ad2ee5 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -71,8 +71,6 @@ REDIS_BACKEND_URL=redis://redis:6379/1 # Model Engine Config MODEL_ENGINE_ENABLED=false -MODEL_ENGINE_HOST="" -MODEL_ENGINE_API_KEY="" # Supabase Config DASHBOARD_USERNAME=supabase diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index bfe707a04..36f1129b2 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -35,21 +35,25 @@ services: volumes: - ${ROOT_DIR}/elasticsearch:/usr/share/elasticsearch/data ports: - - "9210:9200" # HTTP API - - "9310:9300" # Cluster communication port + - "9210:9200" # HTTP API + - "9310:9300" # Cluster communication port networks: - nexent restart: always healthcheck: - test: ["CMD-SHELL", "curl -sf -u elastic:${ELASTIC_PASSWORD} http://localhost:9200/_cluster/health | grep -qE '\"status\":\"(green|yellow)\"' || exit 1"] + test: + [ + "CMD-SHELL", + 'curl -sf -u elastic:${ELASTIC_PASSWORD} http://localhost:9200/_cluster/health | grep -qE ''"status":"(green|yellow)"'' || exit 1', + ] interval: 5s timeout: 10s retries: 20 logging: driver: "json-file" options: - max-size: "100m" # Maximum size of a single log file - max-file: "3" # Maximum number of log files to keep + max-size: "100m" # Maximum size of a single log file + max-file: "3" # Maximum number of log files to keep nexent-postgresql: image: ${POSTGRESQL_IMAGE} @@ -69,8 +73,8 @@ services: logging: driver: "json-file" options: - max-size: "100m" # Maximum size of a single log file - max-file: "3" # Maximum number of log files to keep + max-size: "100m" # Maximum size of a single log file + max-file: "3" # Maximum number of log files to keep networks: - nexent @@ -79,11 +83,11 @@ services: container_name: nexent-config restart: always ports: - - "5010:5010" # Config service port + - "5010:5010" # Config service port volumes: - ${NEXENT_USER_DIR:-$HOME/nexent}:/mnt/nexent - ${ROOT_DIR}/openssh-server/ssh-keys:/opt/ssh-keys:ro - - /var/run/docker.sock:/var/run/docker.sock:ro # Docker socket for MCP container management + - /var/run/docker.sock:/var/run/docker.sock:ro # Docker socket for MCP container management environment: <<: [*minio-vars, *es-vars] skip_proxy: "true" @@ -97,8 +101,8 @@ services: logging: driver: "json-file" options: - max-size: "10m" # Maximum size of a single log file - max-file: "3" # Maximum number of log files to keep + max-size: "10m" # Maximum size of a single log file + max-file: "3" # Maximum number of log files to keep networks: - nexent entrypoint: ["/bin/bash", "-c", "python backend/config_service.py"] @@ -108,7 +112,7 @@ services: container_name: nexent-runtime restart: always ports: - - "5014:5014" # Runtime service port + - "5014:5014" # Runtime service port volumes: - ${NEXENT_USER_DIR:-$HOME/nexent}:/mnt/nexent - ${ROOT_DIR}/openssh-server/ssh-keys:/opt/ssh-keys:ro @@ -125,8 +129,8 @@ services: logging: driver: "json-file" options: - max-size: "10m" # Maximum size of a single log file - max-file: "3" # Maximum number of log files to keep + max-size: "10m" # Maximum size of a single log file + max-file: "3" # Maximum number of log files to keep networks: - nexent entrypoint: ["/bin/bash", "-c", "python backend/runtime_service.py"] @@ -136,7 +140,7 @@ services: container_name: nexent-mcp restart: always ports: - - "5011:5011" # MCP service port + - "5011:5011" # MCP service port volumes: - ${NEXENT_USER_DIR:-$HOME/nexent}:/mnt/nexent - ${ROOT_DIR}/openssh-server/ssh-keys:/opt/ssh-keys:ro @@ -153,8 +157,8 @@ services: logging: driver: "json-file" options: - max-size: "10m" # Maximum size of a single log file - max-file: "3" # Maximum number of log files to keep + max-size: "10m" # Maximum size of a single log file + max-file: "3" # Maximum number of log files to keep networks: - nexent entrypoint: ["/bin/bash", "-c", "python backend/mcp_service.py"] @@ -164,7 +168,7 @@ services: container_name: nexent-northbound restart: always ports: - - "5013:5013" # Northbound service port + - "5013:5013" # Northbound service port volumes: - ${NEXENT_USER_DIR:-$HOME/nexent}:/mnt/nexent - ${ROOT_DIR}/openssh-server/ssh-keys:/opt/ssh-keys:ro @@ -181,8 +185,8 @@ services: logging: driver: "json-file" options: - max-size: "10m" # Maximum size of a single log file - max-file: "3" # Maximum number of log files to keep + max-size: "10m" # Maximum size of a single log file + max-file: "3" # Maximum number of log files to keep networks: - nexent entrypoint: ["/bin/bash", "-c", "python backend/northbound_service.py"] @@ -201,11 +205,12 @@ services: - RUNTIME_HTTP_BACKEND=http://nexent-runtime:5014 - MINIO_ENDPOINT=http://nexent-minio:9000 - MARKET_BACKEND=https://market.nexent.tech + - MODEL_ENGINE_ENABLED=${MODEL_ENGINE_ENABLED:-false} logging: driver: "json-file" options: - max-size: "10m" # Maximum size of a single log file - max-file: "3" # Maximum number of log files to keep + max-size: "10m" # Maximum size of a single log file + max-file: "3" # Maximum number of log files to keep nexent-data-process: image: ${NEXENT_DATA_PROCESS_IMAGE} @@ -215,8 +220,8 @@ services: privileged: true ports: - "5012:5012" - - "5555:5555" # Celery Flower port - - "8265:8265" # Ray Dashboardport + - "5555:5555" # Celery Flower port + - "8265:8265" # Ray Dashboardport volumes: - ${NEXENT_USER_DIR:-$HOME/nexent}:/mnt/nexent environment: @@ -253,7 +258,7 @@ services: volumes: - ${ROOT_DIR}/redis:/data healthcheck: - test: [ "CMD", "redis-cli", "ping" ] + test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 5s retries: 5 @@ -266,8 +271,8 @@ services: container_name: nexent-minio command: server /data ports: - - "9010:9000" # MinIO API port - - "9011:9001" # MinIO Console port + - "9010:9000" # MinIO API port + - "9011:9001" # MinIO Console port environment: <<: [*minio-vars, *proxy-vars] MINIO_ROOT_USER: ${MINIO_ROOT_USER} @@ -280,8 +285,8 @@ services: logging: driver: "json-file" options: - max-size: "100m" # Maximum size of a single log file - max-file: "3" # Maximum number of log files to keep + max-size: "100m" # Maximum size of a single log file + max-file: "3" # Maximum number of log files to keep entrypoint: > /bin/sh -c " minio server /etc/minio/data --address ':9000' --console-address ':9001' & @@ -303,7 +308,7 @@ services: - DEV_USER=${SSH_USERNAME:-linuxserver.io} - DEV_PASSWORD=${SSH_PASSWORD:-nexent123} ports: - - "2222:22" # SSH port + - "2222:22" # SSH port volumes: - ${TERMINAL_MOUNT_DIR:-./workspace}:/opt/terminal networks: @@ -312,8 +317,8 @@ services: logging: driver: "json-file" options: - max-size: "10m" # Maximum size of a single log file - max-file: "3" # Maximum number of log files to keep + max-size: "10m" # Maximum size of a single log file + max-file: "3" # Maximum number of log files to keep profiles: - terminal @@ -322,4 +327,4 @@ networks: driver: bridge volumes: - redis_data: \ No newline at end of file + redis_data: diff --git a/docker/sql/v1.8.0_1226_add_invitation_and_group_system.sql b/docker/sql/v1.8.0_1226_add_invitation_and_group_system.sql new file mode 100644 index 000000000..a8376162c --- /dev/null +++ b/docker/sql/v1.8.0_1226_add_invitation_and_group_system.sql @@ -0,0 +1,146 @@ +-- Add invitation code and group management system +-- This migration adds invitation codes, groups, and permission management features + +-- 1. Create tenant_invitation_code_t table for invitation codes +CREATE TABLE IF NOT EXISTS nexent.tenant_invitation_code_t ( + invitation_id SERIAL PRIMARY KEY, + tenant_id VARCHAR(100) NOT NULL, + invitation_code VARCHAR(100) NOT NULL, + group_ids VARCHAR, -- int4 list + capacity INT4 NOT NULL DEFAULT 1, + expiry_date TIMESTAMP(6) WITHOUT TIME ZONE, + status VARCHAR(30) NOT NULL, + code_type VARCHAR(30) NOT NULL, + create_time TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW(), + update_time TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW(), + created_by VARCHAR(100), + updated_by VARCHAR(100), + delete_flag VARCHAR(1) DEFAULT 'N' +); + +-- Add comments for tenant_invitation_code_t table +COMMENT ON TABLE nexent.tenant_invitation_code_t IS 'Tenant invitation code information table'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.invitation_id IS 'Invitation ID, primary key'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.tenant_id IS 'Tenant ID, foreign key'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.invitation_code IS 'Invitation code'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.group_ids IS 'Associated group IDs list'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.capacity IS 'Invitation code capacity'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.expiry_date IS 'Invitation code expiry date'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.status IS 'Invitation code status: IN_USE, EXPIRE, DISABLE, RUN_OUT'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.code_type IS 'Invitation code type: ADMIN_INVITE, DEV_INVITE, USER_INVITE'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.create_time IS 'Create time'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.update_time IS 'Update time'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.created_by IS 'Created by'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.updated_by IS 'Updated by'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.delete_flag IS 'Delete flag, Y/N'; + +-- 2. Create tenant_invitation_record_t table for invitation usage records +CREATE TABLE IF NOT EXISTS nexent.tenant_invitation_record_t ( + invitation_record_id SERIAL PRIMARY KEY, + invitation_id INT4 NOT NULL, + user_id VARCHAR(100) NOT NULL, + create_time TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW(), + update_time TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW(), + created_by VARCHAR(100), + updated_by VARCHAR(100), + delete_flag VARCHAR(1) DEFAULT 'N' +); + +-- Add comments for tenant_invitation_record_t table +COMMENT ON TABLE nexent.tenant_invitation_record_t IS 'Tenant invitation record table'; +COMMENT ON COLUMN nexent.tenant_invitation_record_t.invitation_record_id IS 'Invitation record ID, primary key'; +COMMENT ON COLUMN nexent.tenant_invitation_record_t.invitation_id IS 'Invitation ID, foreign key'; +COMMENT ON COLUMN nexent.tenant_invitation_record_t.user_id IS 'User ID'; +COMMENT ON COLUMN nexent.tenant_invitation_record_t.create_time IS 'Create time'; +COMMENT ON COLUMN nexent.tenant_invitation_record_t.update_time IS 'Update time'; +COMMENT ON COLUMN nexent.tenant_invitation_record_t.created_by IS 'Created by'; +COMMENT ON COLUMN nexent.tenant_invitation_record_t.updated_by IS 'Updated by'; +COMMENT ON COLUMN nexent.tenant_invitation_record_t.delete_flag IS 'Delete flag, Y/N'; + +-- 3. Create tenant_group_info_t table for group information +CREATE TABLE IF NOT EXISTS nexent.tenant_group_info_t ( + group_id SERIAL PRIMARY KEY, + tenant_id VARCHAR(100) NOT NULL, + group_name VARCHAR(100) NOT NULL, + group_description VARCHAR(500), + create_time TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW(), + update_time TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW(), + created_by VARCHAR(100), + updated_by VARCHAR(100), + delete_flag VARCHAR(1) DEFAULT 'N' +); + +-- Add comments for tenant_group_info_t table +COMMENT ON TABLE nexent.tenant_group_info_t IS 'Tenant group information table'; +COMMENT ON COLUMN nexent.tenant_group_info_t.group_id IS 'Group ID, primary key'; +COMMENT ON COLUMN nexent.tenant_group_info_t.tenant_id IS 'Tenant ID, foreign key'; +COMMENT ON COLUMN nexent.tenant_group_info_t.group_name IS 'Group name'; +COMMENT ON COLUMN nexent.tenant_group_info_t.group_description IS 'Group description'; +COMMENT ON COLUMN nexent.tenant_group_info_t.create_time IS 'Create time'; +COMMENT ON COLUMN nexent.tenant_group_info_t.update_time IS 'Update time'; +COMMENT ON COLUMN nexent.tenant_group_info_t.created_by IS 'Created by'; +COMMENT ON COLUMN nexent.tenant_group_info_t.updated_by IS 'Updated by'; +COMMENT ON COLUMN nexent.tenant_group_info_t.delete_flag IS 'Delete flag, Y/N'; + +-- 4. Create tenant_group_user_t table for group user membership +CREATE TABLE IF NOT EXISTS nexent.tenant_group_user_t ( + group_user_id SERIAL PRIMARY KEY, + group_id INT4 NOT NULL, + user_id VARCHAR(100) NOT NULL, + create_time TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW(), + update_time TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW(), + created_by VARCHAR(100), + updated_by VARCHAR(100), + delete_flag VARCHAR(1) DEFAULT 'N' +); + +-- Add comments for tenant_group_user_t table +COMMENT ON TABLE nexent.tenant_group_user_t IS 'Tenant group user membership table'; +COMMENT ON COLUMN nexent.tenant_group_user_t.group_user_id IS 'Group user ID, primary key'; +COMMENT ON COLUMN nexent.tenant_group_user_t.group_id IS 'Group ID, foreign key'; +COMMENT ON COLUMN nexent.tenant_group_user_t.user_id IS 'User ID, foreign key'; +COMMENT ON COLUMN nexent.tenant_group_user_t.create_time IS 'Create time'; +COMMENT ON COLUMN nexent.tenant_group_user_t.update_time IS 'Update time'; +COMMENT ON COLUMN nexent.tenant_group_user_t.created_by IS 'Created by'; +COMMENT ON COLUMN nexent.tenant_group_user_t.updated_by IS 'Updated by'; +COMMENT ON COLUMN nexent.tenant_group_user_t.delete_flag IS 'Delete flag, Y/N'; + +-- 5. Add fields to user_tenant_t table +ALTER TABLE nexent.user_tenant_t +ADD COLUMN IF NOT EXISTS user_role VARCHAR(30); + +-- Add comments for new fields in user_tenant_t table +COMMENT ON COLUMN nexent.user_tenant_t.user_role IS 'User role: SU, ADMIN, DEV, USER'; + +-- 6. Create role_permission_t table for role permissions +CREATE TABLE IF NOT EXISTS nexent.role_permission_t ( + role_permission_id SERIAL PRIMARY KEY, + user_role VARCHAR(30) NOT NULL, + permission_category VARCHAR(30), + permission_type VARCHAR(30), + permission_subtype VARCHAR(30) +); + +-- Add comments for role_permission_t table +COMMENT ON TABLE nexent.role_permission_t IS 'Role permission configuration table'; +COMMENT ON COLUMN nexent.role_permission_t.role_permission_id IS 'Role permission ID, primary key'; +COMMENT ON COLUMN nexent.role_permission_t.user_role IS 'User role: SU, ADMIN, DEV, USER'; +COMMENT ON COLUMN nexent.role_permission_t.permission_category IS 'Permission category'; +COMMENT ON COLUMN nexent.role_permission_t.permission_type IS 'Permission type'; +COMMENT ON COLUMN nexent.role_permission_t.permission_subtype IS 'Permission subtype'; + +-- 7. Add fields to knowledge_record_t table +ALTER TABLE nexent.knowledge_record_t +ADD COLUMN IF NOT EXISTS group_ids VARCHAR, -- int4 list +ADD COLUMN IF NOT EXISTS ingroup_permission VARCHAR(30); + +-- Add comments for new fields in knowledge_record_t table +COMMENT ON COLUMN nexent.knowledge_record_t.group_ids IS 'Knowledge base group IDs list'; +COMMENT ON COLUMN nexent.knowledge_record_t.ingroup_permission IS 'In-group permission: EDIT, READ_ONLY, PRIVATE'; + +-- 8. Add fields to ag_tenant_agent_t table +ALTER TABLE nexent.ag_tenant_agent_t +ADD COLUMN IF NOT EXISTS group_ids VARCHAR; -- int4 list + +-- Add comments for new fields in ag_tenant_agent_t table +COMMENT ON COLUMN nexent.ag_tenant_agent_t.group_ids IS 'Agent group IDs list'; diff --git a/frontend/app/[locale]/agents/AgentSetupOrchestrator.tsx b/frontend/app/[locale]/agents/AgentSetupOrchestrator.tsx deleted file mode 100644 index 99f3c7d3b..000000000 --- a/frontend/app/[locale]/agents/AgentSetupOrchestrator.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { Card, Row, Col } from "antd"; - -import AgentManageComp from "./components/AgentManageComp"; -import AgentConfigComp from "./components/AgentConfigComp"; -import AgentInfoComp from "./components/AgentInfoComp"; - -interface AgentSetupOrchestratorProps { - onImportAgent?: () => void; -} - -export default function AgentSetupOrchestrator({ - onImportAgent, -}: AgentSetupOrchestratorProps) { - return ( - - - {/* Three-column layout using Ant Design Grid */} - - {/* Left column: Agent Management */} - - - - - {/* Middle column: Agent Config */} - - - - - {/* Right column: Agent Info */} - - - - - - ); -} diff --git a/frontend/app/[locale]/agents/components/AgentConfigComp.tsx b/frontend/app/[locale]/agents/components/AgentConfigComp.tsx index 8f8ce4bc3..b510b120a 100644 --- a/frontend/app/[locale]/agents/components/AgentConfigComp.tsx +++ b/frontend/app/[locale]/agents/components/AgentConfigComp.tsx @@ -24,7 +24,7 @@ export default function AgentConfigComp({}: AgentConfigCompProps) { const isCreatingMode = useAgentConfigStore((state) => state.isCreatingMode); - const editable = !!(currentAgentId || isCreatingMode); + const editable = currentAgentId || isCreatingMode; const [isMcpModalOpen, setIsMcpModalOpen] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); @@ -51,7 +51,6 @@ export default function AgentConfigComp({}: AgentConfigCompProps) { } }, [invalidate]); - return ( <> {/* Import handled by Ant Design Upload (no hidden input required) */} @@ -144,7 +143,7 @@ export default function AgentConfigComp({}: AgentConfigCompProps) { diff --git a/frontend/app/[locale]/agents/components/AgentInfoComp.tsx b/frontend/app/[locale]/agents/components/AgentInfoComp.tsx index d4832b6f3..20d7b72c4 100644 --- a/frontend/app/[locale]/agents/components/AgentInfoComp.tsx +++ b/frontend/app/[locale]/agents/components/AgentInfoComp.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Row, Col, Flex, Badge, Divider, Button, Drawer, App } from "antd"; import { Bug, Save, Info } from "lucide-react"; @@ -25,7 +25,8 @@ export default function AgentInfoComp({}: AgentInfoCompProps) { // Get state from store const currentAgentId = useAgentConfigStore((state) => state.currentAgentId); - const editable = !!(currentAgentId || isCreatingMode); + const editable = + (currentAgentId != null && currentAgentId != undefined) || isCreatingMode; // Save guard hook const saveGuard = useSaveGuard(); @@ -84,7 +85,13 @@ export default function AgentInfoComp({}: AgentInfoCompProps) { - + + + + + diff --git a/frontend/app/[locale]/agents/components/agentManage/AgentCallRelationshipModal.tsx b/frontend/app/[locale]/agents/components/agentManage/AgentCallRelationshipModal.tsx index dfb6faeaf..d71f82038 100644 --- a/frontend/app/[locale]/agents/components/agentManage/AgentCallRelationshipModal.tsx +++ b/frontend/app/[locale]/agents/components/agentManage/AgentCallRelationshipModal.tsx @@ -406,7 +406,7 @@ export default function AgentCallRelationshipModal({ onCancel={onClose} footer={null} width={1800} - destroyOnClose + destroyOnHidden centered style={{ top: 20 }} > diff --git a/frontend/app/[locale]/agents/page.tsx b/frontend/app/[locale]/agents/page.tsx new file mode 100644 index 000000000..b259f3584 --- /dev/null +++ b/frontend/app/[locale]/agents/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { Card, Row, Col, Flex } from "antd"; + +import { useSetupFlow } from "@/hooks/useSetupFlow"; +import { motion } from "framer-motion"; +import AgentManageComp from "./components/AgentManageComp"; +import AgentConfigComp from "./components/AgentConfigComp"; +import AgentInfoComp from "./components/AgentInfoComp"; + +export default function AgentSetupOrchestrator() { + const { pageVariants, pageTransition, canAccessProtectedData } = + useSetupFlow(); + + return ( + <> + {canAccessProtectedData ? ( + + + + + {/* Three-column layout using Ant Design Grid */} + + {/* Left column: Agent Management */} + + + + + {/* Middle column: Agent Config */} + + + + + {/* Right column: Agent Info */} + + + + + + + + ) : null} + + ); +} diff --git a/frontend/app/[locale]/chat/internal/ChatContent.tsx b/frontend/app/[locale]/chat/page.tsx similarity index 95% rename from frontend/app/[locale]/chat/internal/ChatContent.tsx rename to frontend/app/[locale]/chat/page.tsx index 4c6eaffee..5349f5cd7 100644 --- a/frontend/app/[locale]/chat/internal/ChatContent.tsx +++ b/frontend/app/[locale]/chat/page.tsx @@ -5,13 +5,13 @@ import { useAuth } from "@/hooks/useAuth"; import { useConfig } from "@/hooks/useConfig"; import { configService } from "@/services/configService"; import { EVENTS } from "@/const/auth"; -import { ChatInterface } from "./chatInterface"; +import { ChatInterface } from "./internal/chatInterface"; /** * ChatContent component - Main chat page content * Handles authentication, config loading, and session management for the chat interface */ -export function ChatContent() { +export default function ChatContent() { const { appConfig } = useConfig(); const { user, isLoading: userLoading, isSpeedMode } = useAuth(); const canAccessProtectedData = isSpeedMode || (!userLoading && !!user); @@ -63,4 +63,3 @@ export function ChatContent() { ); } - diff --git a/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx b/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx index 283730e0f..e189caf52 100644 --- a/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx +++ b/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx @@ -5,7 +5,11 @@ import { useState, useEffect, useRef, useLayoutEffect } from "react"; import { useTranslation } from "react-i18next"; import { App, Modal, Row, Col, theme } from "antd"; -import { ExclamationCircleFilled, WarningFilled, InfoCircleFilled } from "@ant-design/icons"; +import { + ExclamationCircleFilled, + WarningFilled, + InfoCircleFilled, +} from "@ant-design/icons"; import { DOCUMENT_ACTION_TYPES, KNOWLEDGE_BASE_ACTION_TYPES, @@ -784,7 +788,7 @@ function DataConfig({ isActive }: DataConfigProps) { if (showEmbeddingWarning) { return (
- setShowAutoDeselectModal(false)} - onCancel={() => setShowAutoDeselectModal(false)} - okText={t("common.confirm")} - cancelButtonProps={{ style: { display: "none" } }} - centered - okButtonProps={{ type: "primary", danger: true }} - getContainer={() => contentRef.current || document.body} - > -
- -
-
- {t("embedding.knowledgeBaseAutoDeselectModal.title")} -
-
- {t("embedding.knowledgeBaseAutoDeselectModal.content")} -
-
-
-
- + ) : (
+ + setShowAutoDeselectModal(false)} + onCancel={() => setShowAutoDeselectModal(false)} + okText={t("common.confirm")} + cancelButtonProps={{ style: { display: "none" } }} + centered + okButtonProps={{ type: "primary", danger: true }} + getContainer={() => contentRef.current || document.body} + > +
+ +
+
+ {t("embedding.knowledgeBaseAutoDeselectModal.title")} +
+
+ {t("embedding.knowledgeBaseAutoDeselectModal.content")} +
+
+
+
); } diff --git a/frontend/app/[locale]/knowledges/KnowledgesContent.tsx b/frontend/app/[locale]/knowledges/KnowledgesContent.tsx deleted file mode 100644 index 779ab47dc..000000000 --- a/frontend/app/[locale]/knowledges/KnowledgesContent.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; - -import React, {useEffect} from "react"; -import {motion} from "framer-motion"; - -import {useSetupFlow} from "@/hooks/useSetupFlow"; -import {configService} from "@/services/configService"; -import {configStore} from "@/lib/config"; -import { - USER_ROLES, - ConnectionStatus, -} from "@/const/modelConfig"; -import log from "@/lib/logger"; - -import DataConfig from "./KnowledgeBaseConfiguration"; - -interface KnowledgesContentProps { - /** Whether currently saving */ - isSaving: boolean; - /** Connection status */ - connectionStatus?: ConnectionStatus; - /** Is checking connection */ - isCheckingConnection?: boolean; - /** Check connection callback */ - onCheckConnection?: () => void; - /** Callback to expose connection status */ - onConnectionStatusChange?: (status: ConnectionStatus) => void; - /** Callback to expose saving state */ - onSavingStateChange?: (isSaving: boolean) => void; -} - -/** - * KnowledgesContent - Main component for knowledge base configuration - * Can be used in setup flow or as standalone page - */ -export default function KnowledgesContent({ - isSaving = false, - connectionStatus: externalConnectionStatus, - isCheckingConnection: externalIsCheckingConnection, - onCheckConnection: externalOnCheckConnection, - onConnectionStatusChange, - onSavingStateChange, -}: KnowledgesContentProps) { - - // Use custom hook for common setup flow logic - const { - user, - isSpeedMode, - canAccessProtectedData, - pageVariants, - pageTransition, - } = useSetupFlow({ - requireAdmin: false, // Knowledge base accessible to all users - externalConnectionStatus, - externalIsCheckingConnection, - onCheckConnection: externalOnCheckConnection, - onConnectionStatusChange, - }); - - // Update external saving state - useEffect(() => { - onSavingStateChange?.(isSaving); - }, [isSaving, onSavingStateChange]); - - // Knowledge base specific initialization - useEffect(() => { - if (!canAccessProtectedData) return; - - // Trigger knowledge base data acquisition when the page is initialized - window.dispatchEvent( - new CustomEvent("knowledgeBaseDataUpdated", { - detail: {forceRefresh: true}, - }) - ); - - // Load config for normal user - const loadConfigForNormalUser = async () => { - if (!isSpeedMode && user && user.role !== USER_ROLES.ADMIN) { - try { - await configService.loadConfigToFrontend(); - configStore.reloadFromStorage(); - } catch (error) { - log.error("Failed to load config:", error); - } - } - }; - - loadConfigForNormalUser(); - }, [canAccessProtectedData, externalOnCheckConnection]); - - return ( - <> - {canAccessProtectedData ? ( - -
- -
-
- ) : null} - - ); -} - diff --git a/frontend/app/[locale]/knowledges/components/document/DocumentChunk.tsx b/frontend/app/[locale]/knowledges/components/document/DocumentChunk.tsx index 049fc95de..7d40b0df7 100644 --- a/frontend/app/[locale]/knowledges/components/document/DocumentChunk.tsx +++ b/frontend/app/[locale]/knowledges/components/document/DocumentChunk.tsx @@ -776,7 +776,7 @@ const DocumentChunk: React.FC = ({
( const fetchSummary = async () => { if (showDetail && knowledgeBaseId) { try { - const result = await knowledgeBaseService.getSummary( - knowledgeBaseId - ); + const result = + await knowledgeBaseService.getSummary(knowledgeBaseId); setSummary(result); } catch (error) { log.error(t("knowledgeBase.error.getSummary"), error); @@ -338,7 +337,7 @@ const DocumentListContainer = forwardRef( return (
{/* Title bar */}
void - onClick: (kb: KnowledgeBase) => void - onDelete: (id: string) => void - onSync: () => void - onCreateNew: () => void - isSelectable: (kb: KnowledgeBase) => boolean - getModelDisplayName: (modelId: string) => string - containerHeight?: string // Container total height, consistent with DocumentList - onKnowledgeBaseChange?: () => void // New: callback function when knowledge base switches + knowledgeBases: KnowledgeBase[]; + selectedIds: string[]; + activeKnowledgeBase: KnowledgeBase | null; + currentEmbeddingModel: string | null; + isLoading?: boolean; + onSelect: (id: string) => void; + onClick: (kb: KnowledgeBase) => void; + onDelete: (id: string) => void; + onSync: () => void; + onCreateNew: () => void; + isSelectable: (kb: KnowledgeBase) => boolean; + getModelDisplayName: (modelId: string) => string; + containerHeight?: string; // Container total height, consistent with DocumentList + onKnowledgeBaseChange?: () => void; // New: callback function when knowledge base switches } const KnowledgeBaseList: React.FC = ({ @@ -67,8 +68,8 @@ const KnowledgeBaseList: React.FC = ({ onCreateNew, isSelectable, getModelDisplayName, - containerHeight = '70vh', // Default container height consistent with DocumentList - onKnowledgeBaseChange // New: callback function when knowledge base switches + containerHeight = "70vh", // Default container height consistent with DocumentList + onKnowledgeBaseChange, // New: callback function when knowledge base switches }) => { const { t } = useTranslation(); @@ -103,10 +104,7 @@ const KnowledgeBaseList: React.FC = ({ }); return ( -
+
{/* Fixed header area */}
= ({ ); }; -export default KnowledgeBaseList; \ No newline at end of file +export default KnowledgeBaseList; diff --git a/frontend/app/[locale]/knowledges/page.tsx b/frontend/app/[locale]/knowledges/page.tsx new file mode 100644 index 000000000..835018df3 --- /dev/null +++ b/frontend/app/[locale]/knowledges/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import React, { useEffect } from "react"; +import { motion } from "framer-motion"; + +import { useSetupFlow } from "@/hooks/useSetupFlow"; +import { configService } from "@/services/configService"; +import { configStore } from "@/lib/config"; +import { USER_ROLES } from "@/const/modelConfig"; +import log from "@/lib/logger"; + +import DataConfig from "./KnowledgeBaseConfiguration"; + +/** + * KnowledgesContent - Main component for knowledge base configuration + * Can be used in setup flow or as standalone page + */ +export default function KnowledgesContent() { + // Use custom hook for common setup flow logic + const { + user, + isSpeedMode, + pageVariants, + pageTransition, + canAccessProtectedData, + } = useSetupFlow({ + requireAdmin: false, // Knowledge base accessible to all users + }); + + // Knowledge base specific initialization + useEffect(() => { + // Trigger knowledge base data acquisition when the page is initialized + window.dispatchEvent( + new CustomEvent("knowledgeBaseDataUpdated", { + detail: { forceRefresh: true }, + }) + ); + + // Load config for normal user + const loadConfigForNormalUser = async () => { + if (!isSpeedMode && user && user.role !== USER_ROLES.ADMIN) { + try { + await configService.loadConfigToFrontend(); + configStore.reloadFromStorage(); + } catch (error) { + log.error("Failed to load config:", error); + } + } + }; + + loadConfigForNormalUser(); + }, []); + + return ( + <> +
+ {canAccessProtectedData ? ( + +
+ +
+
+ ) : null} +
+ + ); +} diff --git a/frontend/app/[locale]/layout.client.tsx b/frontend/app/[locale]/layout.client.tsx new file mode 100644 index 000000000..6bb748a48 --- /dev/null +++ b/frontend/app/[locale]/layout.client.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { ReactNode, useState } from "react"; +import { Layout, Button } from "antd"; +import { TopNavbar } from "@/components/navigation/TopNavbar"; +import { SideNavigation } from "@/components/navigation/SideNavigation"; +import { FooterLayout } from "@/components/navigation/FooterLayout"; +import { + HEADER_CONFIG, + FOOTER_CONFIG, + SIDER_CONFIG, +} from "@/const/layoutConstants"; +import { AuthDialogs } from "@/components/homepage/AuthDialogs"; +import { useAuth } from "@/hooks/useAuth"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { usePathname } from "next/navigation"; + +const { Header, Sider, Content, Footer } = Layout; + +export function ClientLayout({ children }: { children: ReactNode }) { + const { user, openLoginModal, openRegisterModal, isSpeedMode } = useAuth(); + const pathname = usePathname(); + + // Check if current route is setup page + const isSetupPage = pathname?.includes("/setup"); + + // Authentication dialog states + const [loginPromptOpen, setLoginPromptOpen] = useState(false); + const [adminRequiredPromptOpen, setAdminRequiredPromptOpen] = useState(false); + + // Sidebar collapse state + const [collapsed, setCollapsed] = useState(false); + + // Layout style calculations + const headerReservedHeight = parseInt(HEADER_CONFIG.RESERVED_HEIGHT); + const footerReservedHeight = parseInt(FOOTER_CONFIG.RESERVED_HEIGHT); + + const layoutStyle: React.CSSProperties = { + height: "100vh", + width: "100vw", + overflow: "hidden", + backgroundColor: "#fff", + }; + + const siderStyle: React.CSSProperties = { + textAlign: "start", + display: "flex", + flexDirection: "column", + alignItems: "stretch", + justifyContent: "flex-start", + position: "fixed", + top: headerReservedHeight, + bottom: isSetupPage ? 0 : footerReservedHeight, + left: 0, + backgroundColor: "#fff", + overflow: "visible", + zIndex: 998, + }; + + const siderInnerStyle: React.CSSProperties = { + height: "100%", + overflowY: "auto", + overflowX: "hidden", + WebkitOverflowScrolling: "touch", + display: "flex", + flexDirection: "column", + }; + + const headerStyle: React.CSSProperties = { + textAlign: "center", + height: headerReservedHeight, + backgroundColor: "#fff", + lineHeight: "64px", + paddingInline: 0, + flexShrink: 0, + }; + + const footerStyle: React.CSSProperties = { + textAlign: "center", + height: footerReservedHeight, + lineHeight: footerReservedHeight, + padding: 0, + flexShrink: 0, + backgroundColor: "#fff", + }; + + const contentStyle: React.CSSProperties = { + overflowY: "auto", + overflowX: "hidden", + position: "relative", + marginLeft: collapsed + ? `${SIDER_CONFIG.COLLAPSED_WIDTH}px` + : `${SIDER_CONFIG.EXPANDED_WIDTH}px`, + backgroundColor: "#fff", + }; + + // Authentication handlers + const handleAuthRequired = () => { + if (!isSpeedMode && !user) { + setLoginPromptOpen(true); + } + }; + + const handleAdminRequired = () => { + if (!isSpeedMode && user?.role !== "admin") { + setAdminRequiredPromptOpen(true); + } + }; + + const handleCloseLoginPrompt = () => setLoginPromptOpen(false); + const handleCloseAdminPrompt = () => setAdminRequiredPromptOpen(false); + + return ( + +
+ +
+ + + +
+ +
+
- ) : agents.length === 0 ? ( + ) : agents.length === 0 && featuredItems.length === 0 ? ( ) : ( <> -
+ {/* Featured row per category (show only if there are featured items) */} + {featuredItems.length > 0 && ( +
+
+

+ {t("market.featuredTitle")} +

+
+ + +
+
+ +
+ )} + + {/* Separator between featured and main list (only when both exist) */} + {featuredItems.length > 0 && agents.length > 0 && ( +
+
+
+ )} + + {agents.length > 0 && ( + <> +
{agents.map((agent, index) => (
+ )} + )} )} @@ -465,8 +547,7 @@ export default function MarketContent({ agentDescription={installAgent?.description} /> - ) : null} +
); } - diff --git a/frontend/app/[locale]/mcp-tools/McpToolsContent.tsx b/frontend/app/[locale]/mcp-tools/page.tsx similarity index 78% rename from frontend/app/[locale]/mcp-tools/McpToolsContent.tsx rename to frontend/app/[locale]/mcp-tools/page.tsx index 89c6c03d4..12691f8f2 100644 --- a/frontend/app/[locale]/mcp-tools/McpToolsContent.tsx +++ b/frontend/app/[locale]/mcp-tools/page.tsx @@ -6,43 +6,20 @@ import { useTranslation } from "react-i18next"; import { Puzzle } from "lucide-react"; import { useSetupFlow } from "@/hooks/useSetupFlow"; -import { ConnectionStatus } from "@/const/modelConfig"; - -interface McpToolsContentProps { - /** Connection status */ - connectionStatus?: ConnectionStatus; - /** Is checking connection */ - isCheckingConnection?: boolean; - /** Check connection callback */ - onCheckConnection?: () => void; - /** Callback to expose connection status */ - onConnectionStatusChange?: (status: ConnectionStatus) => void; -} /** * McpToolsContent - MCP tools management coming soon page * This will allow admins to manage MCP servers and tools */ -export default function McpToolsContent({ - connectionStatus: externalConnectionStatus, - isCheckingConnection: externalIsCheckingConnection, - onCheckConnection: externalOnCheckConnection, - onConnectionStatusChange, -}: McpToolsContentProps) { +export default function McpToolsContent({}) { const { t } = useTranslation("common"); // Use custom hook for common setup flow logic - const { canAccessProtectedData, pageVariants, pageTransition } = useSetupFlow({ - requireAdmin: true, - externalConnectionStatus, - externalIsCheckingConnection, - onCheckConnection: externalOnCheckConnection, - onConnectionStatusChange, - }); + const { pageVariants, pageTransition } = useSetupFlow(); return ( <> - {canAccessProtectedData ? ( +
- ) : null} +
); } - - diff --git a/frontend/app/[locale]/memory/MemoryContent.tsx b/frontend/app/[locale]/memory/MemoryContent.tsx deleted file mode 100644 index d116a8606..000000000 --- a/frontend/app/[locale]/memory/MemoryContent.tsx +++ /dev/null @@ -1,532 +0,0 @@ -"use client"; - -import React, { useEffect, useState, useCallback } from "react"; -import { App, Button, Card, Input, List, Menu, Switch, Tabs } from "antd"; -import { motion } from "framer-motion"; -import "./memory.css"; -import { - MessageSquarePlus, - Eraser, - MessageSquareOff, - UsersRound, - UserRound, - Bot, - Share2, - Settings, - MessageSquareDashed, - Check, - X, -} from "lucide-react"; -import { useTranslation, Trans } from "react-i18next"; - -import { useAuth } from "@/hooks/useAuth"; -import { useMemory } from "@/hooks/useMemory"; -import { useSetupFlow } from "@/hooks/useSetupFlow"; -import { USER_ROLES, MEMORY_TAB_KEYS, MemoryTabKey } from "@/const/modelConfig"; -import { MEMORY_SHARE_STRATEGY, MemoryShareStrategy } from "@/const/memoryConfig"; -import { - SETUP_PAGE_CONTAINER, - STANDARD_CARD, -} from "@/const/layoutConstants"; - -import { useConfirmModal } from "@/hooks/useConfirmModal"; - -interface MemoryContentProps { - /** Custom navigation handler (optional) */ - onNavigate?: () => void; -} - -/** - * MemoryContent - Main component for memory management page - * Redesigned from modal to full-page layout with cards - */ -export default function MemoryContent({ onNavigate }: MemoryContentProps) { - const { message } = App.useApp(); - const { t } = useTranslation("common"); - const { user, isSpeedMode } = useAuth(); - const { confirm } = useConfirmModal(); - - // Use custom hook for common setup flow logic - const { canAccessProtectedData, pageVariants, pageTransition } = useSetupFlow({ - requireAdmin: false, - }); - - const role: (typeof USER_ROLES)[keyof typeof USER_ROLES] = (isSpeedMode || - user?.role === USER_ROLES.ADMIN - ? USER_ROLES.ADMIN - : USER_ROLES.USER) as (typeof USER_ROLES)[keyof typeof USER_ROLES]; - - // Mock user and tenant IDs (should come from context) - const currentUserId = "user1"; - const currentTenantId = "tenant1"; - - const memory = useMemory({ - visible: true, - currentUserId, - currentTenantId, - message, - }); - - const handleClearConfirm = (groupKey: string, groupTitle: string) => { - confirm({ - title: t("memoryDeleteModal.title"), - content: ( -
-

- }} - /> -

-

- {t("memoryDeleteModal.prompt")} -

-
- ), - onOk: () => memory.handleClearMemory(groupKey, groupTitle), - }); - }; - - // Render base settings in a horizontal control bar - const renderBaseSettings = () => { - const shareOptionLabels: Record = { - [MEMORY_SHARE_STRATEGY.ALWAYS]: t("memoryManageModal.shareOption.always"), - [MEMORY_SHARE_STRATEGY.ASK]: t("memoryManageModal.shareOption.ask"), - [MEMORY_SHARE_STRATEGY.NEVER]: t("memoryManageModal.shareOption.never"), - }; - - return ( - -
-
- -
- - {t("memoryManageModal.memoryAbility")} - -
-
- -
- - {memory.memoryEnabled && ( -
-
- -
- - {t("memoryManageModal.agentMemoryShare")} - -
-
-
- {Object.entries(shareOptionLabels).map(([key, label]) => ( - - ))} -
-
- )} -
- ); - }; - - // Render add memory input (inline, doesn't expand container) - const renderAddMemoryInput = (groupKey: string) => { - if (memory.addingMemoryKey !== groupKey) return null; - - return ( -
-
- memory.setNewMemoryContent(e.target.value)} - placeholder={t("memoryManageModal.inputPlaceholder")} - maxLength={500} - showCount - onPressEnter={memory.confirmAddingMemory} - disabled={memory.isAddingMemory} - className="flex-1" - autoSize={{ minRows: 1, maxRows: 3 }} - style={{ minHeight: "60px" }} - /> -
-
-
-
- ); - }; - - // Render single list (for tenant shared and user personal) - no card, with header buttons - const renderSingleList = useCallback((group: { title: string; key: string; items: any[] }) => { - return ( -
- {/* Add memory input - appears before the list */} - {memory.addingMemoryKey === group.key && ( -
- {renderAddMemoryInput(group.key)} -
- )} - - - {group.title} -
-
-
- } - bordered - dataSource={group.items} - locale={{ - emptyText: ( -
- -

{t("memoryManageModal.noMemory")}

-
- ), - }} - style={{ height: memory.addingMemoryKey === group.key ? "calc(100vh - 380px)" : "calc(100vh - 280px)", overflowY: "auto" }} - renderItem={(item) => ( - } - onClick={() => memory.handleDeleteMemory(item.id, group.key)} - title={t("memoryManageModal.deleteMemory")} - />, - ]} - > -
{item.memory}
-
- )} - /> -
- ); - }, [memory.addingMemoryKey, memory.startAddingMemory, memory.handleDeleteMemory, handleClearConfirm, renderAddMemoryInput, t]); - - const renderMemoryWithMenu = ( - groups: { title: string; key: string; items: any[] }[], - showSwitch = false - ) => ( - - ); - - const tabItems = [ - { - key: MEMORY_TAB_KEYS.BASE, - label: ( - - - {t("memoryManageModal.baseSettings")} - - ), - children: renderBaseSettings(), - }, - ...(role === USER_ROLES.ADMIN - ? [ - { - key: MEMORY_TAB_KEYS.TENANT, - label: ( - - - {t("memoryManageModal.tenantShareTab")} - - ), - children: renderSingleList(memory.tenantSharedGroup), - disabled: !memory.memoryEnabled, - }, - { - key: MEMORY_TAB_KEYS.AGENT_SHARED, - label: ( - - - {t("memoryManageModal.agentShareTab")} - - ), - children: renderMemoryWithMenu(memory.agentSharedGroups, true), - disabled: - !memory.memoryEnabled || - memory.shareOption === MEMORY_SHARE_STRATEGY.NEVER, - }, - ] - : []), - { - key: MEMORY_TAB_KEYS.USER_PERSONAL, - label: ( - - - {t("memoryManageModal.userPersonalTab")} - - ), - children: renderSingleList(memory.userPersonalGroup), - disabled: !memory.memoryEnabled, - }, - { - key: MEMORY_TAB_KEYS.USER_AGENT, - label: ( - - - {t("memoryManageModal.userAgentTab")} - - ), - children: renderMemoryWithMenu(memory.userAgentGroups, true), - disabled: !memory.memoryEnabled, - }, - ]; - - return ( - <> - - {canAccessProtectedData ? ( -
-
-
- memory.setActiveTabKey(key)} - tabBarStyle={{ - marginBottom: "16px", - }} - /> -
-
-
- ) : null} -
- - - - ); -} - -interface MemoryMenuListProps { - groups: { title: string; key: string; items: any[] }[]; - showSwitch?: boolean; - memory: ReturnType; - t: ReturnType["t"]; - onClearConfirm: (groupKey: string, groupTitle: string) => void; - renderAddMemoryInput: (groupKey: string) => React.ReactNode; -} - -function MemoryMenuList({ - groups, - showSwitch = false, - memory, - t, - onClearConfirm, - renderAddMemoryInput, -}: MemoryMenuListProps) { - const [selectedKey, setSelectedKey] = useState( - groups.length > 0 ? groups[0].key : "" - ); - - useEffect(() => { - if (!groups.some((group) => group.key === selectedKey)) { - setSelectedKey(groups[0]?.key ?? ""); - } - }, [groups, selectedKey]); - - if (groups.length === 0) { - return ( -
- -

{t("memoryManageModal.noMemory")}

-
- ); - } - - const currentGroup = groups.find((g) => g.key === selectedKey) || groups[0]; - const isPlaceholder = /-placeholder$/.test(currentGroup.key); - const disabled = !isPlaceholder && !!memory.disabledGroups[currentGroup.key]; - - const menuItems = groups.map((g) => { - const groupDisabled = !/-placeholder$/.test(g.key) && !!memory.disabledGroups[g.key]; - return { - key: g.key, - label: ( -
- {g.title} - {showSwitch && !/-placeholder$/.test(g.key) && ( -
e.stopPropagation()}> - memory.toggleGroup(g.key, val)} - /> -
- )} -
- ), - disabled: groupDisabled, - }; - }); - - return ( -
- setSelectedKey(key)} - items={menuItems} - style={{ width: 280, height: "100%", overflowY: "auto" }} - /> - -
- {/* Add memory input - appears before the list */} - {memory.addingMemoryKey === currentGroup.key && ( -
- {renderAddMemoryInput(currentGroup.key)} -
- )} - - - {currentGroup.title} -
-
-
- } - bordered - dataSource={currentGroup.items} - locale={{ - emptyText: ( -
- -

{t("memoryManageModal.noMemory")}

-
- ), - }} - style={{ height: memory.addingMemoryKey === currentGroup.key ? "calc(100% - 100px)" : "100%", overflowY: "auto" }} - renderItem={(item) => ( - } - onClick={() => memory.handleDeleteMemory(item.id, currentGroup.key)} - disabled={disabled} - title={t("memoryManageModal.deleteMemory")} - />, - ]} - > -
{item.memory}
-
- )} - /> -
-
- ); -} - diff --git a/frontend/app/[locale]/memory/MemoryMenuList.tsx b/frontend/app/[locale]/memory/MemoryMenuList.tsx new file mode 100644 index 000000000..afb404dea --- /dev/null +++ b/frontend/app/[locale]/memory/MemoryMenuList.tsx @@ -0,0 +1,179 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Button, List, Menu, Switch } from "antd"; +import { + MessageSquarePlus, + Eraser, + MessageSquareOff, + MessageSquareDashed, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +interface MemoryMenuListProps { + groups: { title: string; key: string; items: any[] }[]; + showSwitch?: boolean; + memory: ReturnType; + t: ReturnType["t"]; + onClearConfirm: (groupKey: string, groupTitle: string) => void; + renderAddMemoryInput: (groupKey: string) => React.ReactNode; +} + +export function MemoryMenuList({ + groups, + showSwitch = false, + memory, + t, + onClearConfirm, + renderAddMemoryInput, +}: MemoryMenuListProps) { + const [selectedKey, setSelectedKey] = useState( + groups.length > 0 ? groups[0].key : "" + ); + + useEffect(() => { + if (!groups.some((group) => group.key === selectedKey)) { + setSelectedKey(groups[0]?.key ?? ""); + } + }, [groups, selectedKey]); + + if (groups.length === 0) { + return ( +
+ +

+ {t("memoryManageModal.noMemory")} +

+
+ ); + } + + const currentGroup = groups.find((g) => g.key === selectedKey) || groups[0]; + const isPlaceholder = /-placeholder$/.test(currentGroup.key); + const disabled = !isPlaceholder && !!memory.disabledGroups[currentGroup.key]; + + const menuItems = groups.map((g) => { + const groupDisabled = + !/-placeholder$/.test(g.key) && !!memory.disabledGroups[g.key]; + return { + key: g.key, + label: ( +
+ {g.title} + {showSwitch && !/-placeholder$/.test(g.key) && ( +
e.stopPropagation()}> + memory.toggleGroup(g.key, val)} + /> +
+ )} +
+ ), + disabled: groupDisabled, + }; + }); + + return ( +
+ setSelectedKey(key)} + items={menuItems} + style={{ width: 280, height: "100%", overflowY: "auto" }} + /> + +
+ {/* Add memory input - appears before the list */} + {memory.addingMemoryKey === currentGroup.key && ( +
+ {renderAddMemoryInput(currentGroup.key)} +
+ )} + + + + {currentGroup.title} + +
+
+
+ } + bordered + dataSource={currentGroup.items} + locale={{ + emptyText: ( +
+ +

{t("memoryManageModal.noMemory")}

+
+ ), + }} + style={{ + height: + memory.addingMemoryKey === currentGroup.key + ? "calc(100% - 100px)" + : "100%", + overflowY: "auto", + }} + renderItem={(item) => ( + } + onClick={() => + memory.handleDeleteMemory(item.id, currentGroup.key) + } + disabled={disabled} + title={t("memoryManageModal.deleteMemory")} + />, + ]} + > +
{item.memory}
+
+ )} + /> +
+
+ ); +} + + + + + diff --git a/frontend/app/[locale]/memory/page.tsx b/frontend/app/[locale]/memory/page.tsx new file mode 100644 index 000000000..0828e9af1 --- /dev/null +++ b/frontend/app/[locale]/memory/page.tsx @@ -0,0 +1,404 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { App, Button, Card, Input, List, Menu, Switch, Tabs } from "antd"; +import { motion } from "framer-motion"; +import "./memory.css"; +import { + MessageSquarePlus, + Eraser, + MessageSquareOff, + UsersRound, + UserRound, + Bot, + Share2, + Settings, + MessageSquareDashed, + Check, + X, +} from "lucide-react"; +import { useTranslation, Trans } from "react-i18next"; + +import { useAuth } from "@/hooks/useAuth"; +import { useMemory } from "@/hooks/useMemory"; +import { useSetupFlow } from "@/hooks/useSetupFlow"; +import { USER_ROLES, MEMORY_TAB_KEYS, MemoryTabKey } from "@/const/modelConfig"; +import { + MEMORY_SHARE_STRATEGY, + MemoryShareStrategy, +} from "@/const/memoryConfig"; +import { SETUP_PAGE_CONTAINER, STANDARD_CARD } from "@/const/layoutConstants"; + +import { useConfirmModal } from "@/hooks/useConfirmModal"; +import { MemoryMenuList } from "./MemoryMenuList"; + +/** + * MemoryContent - Main component for memory management page + * Redesigned from modal to full-page layout with cards + */ +export default function MemoryContent() { + const { message } = App.useApp(); + const { t } = useTranslation("common"); + const { user, isSpeedMode } = useAuth(); + const { confirm } = useConfirmModal(); + + // Use custom hook for common setup flow logic + const { canAccessProtectedData, pageVariants, pageTransition } = useSetupFlow( + { + requireAdmin: false, + } + ); + + const role: (typeof USER_ROLES)[keyof typeof USER_ROLES] = ( + isSpeedMode || user?.role === USER_ROLES.ADMIN + ? USER_ROLES.ADMIN + : USER_ROLES.USER + ) as (typeof USER_ROLES)[keyof typeof USER_ROLES]; + + // Mock user and tenant IDs (should come from context) + const currentUserId = "user1"; + const currentTenantId = "tenant1"; + + const memory = useMemory({ + visible: true, + currentUserId, + currentTenantId, + message, + }); + + const handleClearConfirm = (groupKey: string, groupTitle: string) => { + confirm({ + title: t("memoryDeleteModal.title"), + content: ( +
+

+ }} + /> +

+

+ {t("memoryDeleteModal.prompt")} +

+
+ ), + onOk: () => memory.handleClearMemory(groupKey, groupTitle), + }); + }; + + // Render base settings in a horizontal control bar + const renderBaseSettings = () => { + const shareOptionLabels: Record = { + [MEMORY_SHARE_STRATEGY.ALWAYS]: t("memoryManageModal.shareOption.always"), + [MEMORY_SHARE_STRATEGY.ASK]: t("memoryManageModal.shareOption.ask"), + [MEMORY_SHARE_STRATEGY.NEVER]: t("memoryManageModal.shareOption.never"), + }; + + return ( + +
+
+ +
+ + {t("memoryManageModal.memoryAbility")} + +
+
+ +
+ + {memory.memoryEnabled && ( +
+
+ +
+ + {t("memoryManageModal.agentMemoryShare")} + +
+
+
+ {Object.entries(shareOptionLabels).map(([key, label]) => ( + + ))} +
+
+ )} +
+ ); + }; + + // Render add memory input (inline, doesn't expand container) + const renderAddMemoryInput = (groupKey: string) => { + if (memory.addingMemoryKey !== groupKey) return null; + + return ( +
+
+ memory.setNewMemoryContent(e.target.value)} + placeholder={t("memoryManageModal.inputPlaceholder")} + maxLength={500} + showCount + onPressEnter={memory.confirmAddingMemory} + disabled={memory.isAddingMemory} + className="flex-1" + autoSize={{ minRows: 1, maxRows: 3 }} + style={{ minHeight: "60px" }} + /> +
+
+
+
+ ); + }; + + // Render single list (for tenant shared and user personal) - no card, with header buttons + const renderSingleList = useCallback( + (group: { title: string; key: string; items: any[] }) => { + return ( +
+ {/* Add memory input - appears before the list */} + {memory.addingMemoryKey === group.key && ( +
+ {renderAddMemoryInput(group.key)} +
+ )} + + + {group.title} +
+
+
+ } + bordered + dataSource={group.items} + locale={{ + emptyText: ( +
+ +

{t("memoryManageModal.noMemory")}

+
+ ), + }} + style={{ + height: + memory.addingMemoryKey === group.key + ? "calc(100vh - 380px)" + : "calc(100vh - 280px)", + overflowY: "auto", + }} + renderItem={(item) => ( + } + onClick={() => + memory.handleDeleteMemory(item.id, group.key) + } + title={t("memoryManageModal.deleteMemory")} + />, + ]} + > +
{item.memory}
+
+ )} + /> +
+ ); + }, + [ + memory.addingMemoryKey, + memory.startAddingMemory, + memory.handleDeleteMemory, + handleClearConfirm, + renderAddMemoryInput, + t, + ] + ); + + const renderMemoryWithMenu = ( + groups: { title: string; key: string; items: any[] }[], + showSwitch = false + ) => ( + + ); + + const tabItems = [ + { + key: MEMORY_TAB_KEYS.BASE, + label: ( + + + {t("memoryManageModal.baseSettings")} + + ), + children: renderBaseSettings(), + }, + ...(role === USER_ROLES.ADMIN + ? [ + { + key: MEMORY_TAB_KEYS.TENANT, + label: ( + + + {t("memoryManageModal.tenantShareTab")} + + ), + children: renderSingleList(memory.tenantSharedGroup), + disabled: !memory.memoryEnabled, + }, + { + key: MEMORY_TAB_KEYS.AGENT_SHARED, + label: ( + + + {t("memoryManageModal.agentShareTab")} + + ), + children: renderMemoryWithMenu(memory.agentSharedGroups, true), + disabled: + !memory.memoryEnabled || + memory.shareOption === MEMORY_SHARE_STRATEGY.NEVER, + }, + ] + : []), + { + key: MEMORY_TAB_KEYS.USER_PERSONAL, + label: ( + + + {t("memoryManageModal.userPersonalTab")} + + ), + children: renderSingleList(memory.userPersonalGroup), + disabled: !memory.memoryEnabled, + }, + { + key: MEMORY_TAB_KEYS.USER_AGENT, + label: ( + + + {t("memoryManageModal.userAgentTab")} + + ), + children: renderMemoryWithMenu(memory.userAgentGroups, true), + disabled: !memory.memoryEnabled, + }, + ]; + + return ( + <> +
+ + {canAccessProtectedData ? ( +
+
+
+ memory.setActiveTabKey(key)} + tabBarStyle={{ + marginBottom: "16px", + }} + /> +
+
+
+ ) : null} +
+
+ + ); +} diff --git a/frontend/app/[locale]/models/ModelConfiguration.tsx b/frontend/app/[locale]/models/ModelConfiguration.tsx index 3fad16875..1fe154a76 100644 --- a/frontend/app/[locale]/models/ModelConfiguration.tsx +++ b/frontend/app/[locale]/models/ModelConfiguration.tsx @@ -1,32 +1,28 @@ -"use client" +"use client"; -import { useState, useEffect, useRef } from "react" -import { useTranslation } from 'react-i18next' -import { Typography, Row, Col } from "antd" +import { useState, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Typography, Row, Col } from "antd"; -import { - SETUP_PAGE_CONTAINER, - TWO_COLUMN_LAYOUT, +import { + SETUP_PAGE_CONTAINER, + TWO_COLUMN_LAYOUT, STANDARD_CARD, - CARD_HEADER -} from '@/const/layoutConstants' + CARD_HEADER, +} from "@/const/layoutConstants"; -import { AppConfigSection } from './components/appConfig' -import { ModelConfigSection, ModelConfigSectionRef } from './components/modelConfig' +import { AppConfigSection } from "./components/appConfig"; +import { + ModelConfigSection, + ModelConfigSectionRef, +} from "./components/modelConfig"; -const { Title } = Typography +const { Title } = Typography; // Add interface definition interface AppModelConfigProps { skipModelVerification?: boolean; canAccessProtectedData?: boolean; - onSelectedModelsChange?: ( - selected: Record> - ) => void; - onEmbeddingConnectivityChange?: (status: { - // can add multi_embedding in future - embedding?: string; - }) => void; // Expose a ref from parent to allow programmatic dropdown change forwardedRef?: React.Ref; } @@ -34,8 +30,6 @@ interface AppModelConfigProps { export default function AppModelConfig({ skipModelVerification = false, canAccessProtectedData = false, - onSelectedModelsChange, - onEmbeddingConnectivityChange, forwardedRef, }: AppModelConfigProps) { const { t } = useTranslation(); @@ -51,27 +45,10 @@ export default function AppModelConfig({ }; }, [skipModelVerification]); - // Report selected models from child component to parent (if callback provided) - useEffect(() => { - if (!onSelectedModelsChange && !onEmbeddingConnectivityChange) return; - const timer = setInterval(() => { - const current = modelConfigRef.current?.getSelectedModels?.(); - const embeddingConn = - modelConfigRef.current?.getEmbeddingConnectivity?.(); - if (current && onSelectedModelsChange) onSelectedModelsChange(current); - if (embeddingConn && onEmbeddingConnectivityChange) { - onEmbeddingConnectivityChange({ - embedding: embeddingConn.embedding, - }); - } - }, 300); - return () => clearInterval(timer); - }, [onSelectedModelsChange, onEmbeddingConnectivityChange]); - // Bridge internal ref to external forwardedRef so parent can call simulateDropdownChange useEffect(() => { if (!forwardedRef) return; - if (typeof forwardedRef === 'function') { + if (typeof forwardedRef === "function") { forwardedRef(modelConfigRef.current); } else { // @ts-ignore allow writing current @@ -81,16 +58,17 @@ export default function AppModelConfig({ return (
{isClientSide ? ( -
- +
+
); -} \ No newline at end of file +} diff --git a/frontend/app/[locale]/models/ModelsContent.tsx b/frontend/app/[locale]/models/ModelsContent.tsx deleted file mode 100644 index 87e0120d8..000000000 --- a/frontend/app/[locale]/models/ModelsContent.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"use client"; - -import {useRef} from "react"; -import {motion} from "framer-motion"; - -import {useSetupFlow} from "@/hooks/useSetupFlow"; -import { - ConnectionStatus, -} from "@/const/modelConfig"; - -import AppModelConfig from "./ModelConfiguration"; -import {ModelConfigSectionRef} from "./components/modelConfig"; - -interface ModelsContentProps { - /** Custom next button handler (optional) */ - onNext?: () => void; - /** Connection status */ - connectionStatus?: ConnectionStatus; - /** Is checking connection */ - isCheckingConnection?: boolean; - /** Check connection callback */ - onCheckConnection?: () => void; - /** Callback to expose connection status */ - onConnectionStatusChange?: (status: ConnectionStatus) => void; -} - -/** - * ModelsContent - Main component for model configuration - * Can be used in setup flow or as standalone page - */ -export default function ModelsContent({ - connectionStatus: externalConnectionStatus, - isCheckingConnection: externalIsCheckingConnection, - onCheckConnection: externalOnCheckConnection, - onConnectionStatusChange, -}: ModelsContentProps) { - // Use custom hook for common setup flow logic - const { - canAccessProtectedData, - pageVariants, - pageTransition, - } = useSetupFlow({ - requireAdmin: true, - externalConnectionStatus, - externalIsCheckingConnection, - onCheckConnection: externalOnCheckConnection, - onConnectionStatusChange, - nonAdminRedirect: "/setup/knowledges", - }); - - const modelConfigSectionRef = useRef(null); - - return ( - <> - -
- {canAccessProtectedData ? ( - {}} - onEmbeddingConnectivityChange={() => {}} - forwardedRef={modelConfigSectionRef} - canAccessProtectedData={canAccessProtectedData} - /> - ) : null} -
-
- - - ); -} - diff --git a/frontend/app/[locale]/models/components/appConfig.tsx b/frontend/app/[locale]/models/components/appConfig.tsx index 5531ba974..8caf82acb 100644 --- a/frontend/app/[locale]/models/components/appConfig.tsx +++ b/frontend/app/[locale]/models/components/appConfig.tsx @@ -404,7 +404,7 @@ export const AppConfigSection: React.FC = () => { {t("common.confirm")} , ]} - destroyOnClose={true} + destroyOnHidden={true} width={520} centered > diff --git a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx index 4c182ebcd..f70e65641 100644 --- a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx @@ -653,7 +653,7 @@ export const ModelAddDialog = ({ open={isOpen} onCancel={onClose} footer={null} - destroyOnClose + destroyOnHidden >
{/* Batch Import Switch */} @@ -1248,7 +1248,7 @@ export const ModelAddDialog = ({ onOk={handleSettingsSave} cancelText={t("common.cancel")} okText={t("common.confirm")} - destroyOnClose + destroyOnHidden >
diff --git a/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx b/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx index 37670674b..541ed6266 100644 --- a/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelDeleteDialog.tsx @@ -865,7 +865,7 @@ export const ModelDeleteDialog = ({ ), ]} width={520} - destroyOnClose + destroyOnHidden > {!deletingModelType ? (
@@ -1375,7 +1375,7 @@ export const ModelDeleteDialog = ({ onOk={handleSettingsSave} cancelText={t("common.button.cancel")} okText={t("common.button.save")} - destroyOnClose + destroyOnHidden >
@@ -1409,7 +1409,7 @@ export const ModelDeleteDialog = ({ cancelText={t("common.button.cancel")} okText={t("common.button.save")} confirmLoading={savingEmbeddingConfig} - destroyOnClose + destroyOnHidden >
{/* Chunk Size Range */} diff --git a/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx b/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx index 972f30d27..fc4991f0b 100644 --- a/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx @@ -235,7 +235,7 @@ export const ModelEditDialog = ({ open={isOpen} onCancel={onClose} footer={null} - destroyOnClose + destroyOnHidden >
{/* Model Name */} @@ -434,7 +434,7 @@ export const ProviderConfigEditDialog = ({ open={isOpen} onCancel={onClose} footer={null} - destroyOnClose + destroyOnHidden >
diff --git a/frontend/app/[locale]/models/components/modelConfig.tsx b/frontend/app/[locale]/models/components/modelConfig.tsx index e0a330792..08ea8af20 100644 --- a/frontend/app/[locale]/models/components/modelConfig.tsx +++ b/frontend/app/[locale]/models/components/modelConfig.tsx @@ -1,13 +1,15 @@ -import { forwardRef, useEffect, useImperativeHandle, useState, useRef, ReactNode } from 'react' -import { useTranslation } from 'react-i18next' - -import { Button, Card, Col, Row, Space, App } from 'antd' -import { - Plus, - ShieldCheck, - RefreshCw, - PenLine -} from "lucide-react"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useState, + useRef, + ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; + +import { Button, Card, Col, Row, Space, App } from "antd"; +import { Plus, ShieldCheck, RefreshCw, PenLine } from "lucide-react"; import { MODEL_TYPES, @@ -16,10 +18,10 @@ import { CARD_THEMES, } from "@/const/modelConfig"; import { useConfig } from "@/hooks/useConfig"; +import { configStore } from "@/lib/config"; import { modelService } from "@/services/modelService"; import { configService } from "@/services/configService"; import { ModelOption, ModelType } from "@/types/modelConfig"; -import { configStore } from "@/lib/config"; import log from "@/lib/logger"; import { ModelListCard } from "./model/ModelListCard"; @@ -34,9 +36,7 @@ type ModelConnectStatus = (typeof MODEL_STATUS)[keyof typeof MODEL_STATUS]; const getModelData = (t: any) => ({ llm: { title: t("modelConfig.category.llm"), - options: [ - { id: "main", name: t('modelConfig.option.mainModel') }, - ], + options: [{ id: "main", name: t("modelConfig.option.mainModel") }], }, embedding: { title: t("modelConfig.category.embedding"), @@ -96,14 +96,16 @@ export const ModelConfigSection = forwardRef< const { t } = useTranslation(); const { message } = App.useApp(); const { skipVerification = false, canAccessProtectedData = false } = props; - const { modelConfig, updateModelConfig } = useConfig(); + const { modelConfig, updateModelConfig, appConfig } = useConfig(); + const modelEngineEnable = appConfig.modelEngineEnabled; const modelData = getModelData(t); const { confirm } = useConfirmModal(); // State management const [models, setModels] = useState([]); const [isAddModalOpen, setIsAddModalOpen] = useState(false); - const [addModalDefaultIsBatch, setAddModalDefaultIsBatch] = useState(false); + const [addModalDefaultIsBatch, setAddModalDefaultIsBatch] = + useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isVerifying, setIsVerifying] = useState(false); @@ -267,7 +269,6 @@ export const ModelConfigSection = forwardRef< ) : true; - const embedding = modelConfig.embedding.displayName; const embeddingExists = embedding ? allModels.some( @@ -348,7 +349,6 @@ export const ModelConfigSection = forwardRef< }; } - if (!embeddingExists && embedding) { configUpdates.embedding = { modelName: "", @@ -453,12 +453,7 @@ export const ModelConfigSection = forwardRef< const hasStt = !!modelConfig.stt.modelName; hasSelectedModels = - hasLlmMain || - hasEmbedding || - hasReranker || - hasVlm || - hasTts || - hasStt; + hasLlmMain || hasEmbedding || hasReranker || hasVlm || hasTts || hasStt; if (hasSelectedModels) { // Override current selected models with models from configuration @@ -739,7 +734,7 @@ export const ModelConfigSection = forwardRef< if (configKey === "embedding" || configKey === "multiEmbedding") { configUpdate[configKey].dimension = modelInfo?.maxTokens || 0; } - }; + } // embedding needs dimension field if (configKey === "embedding" || configKey === "multiEmbedding") { @@ -989,11 +984,11 @@ export const ModelConfigSection = forwardRef< ? MODEL_TYPES.TTS : MODEL_TYPES.STT : key === "multimodal" - ? MODEL_TYPES.VLM - : key === MODEL_TYPES.EMBEDDING && - option.id === MODEL_TYPES.MULTI_EMBEDDING - ? MODEL_TYPES.MULTI_EMBEDDING - : (key as ModelType) + ? MODEL_TYPES.VLM + : key === MODEL_TYPES.EMBEDDING && + option.id === MODEL_TYPES.MULTI_EMBEDDING + ? MODEL_TYPES.MULTI_EMBEDDING + : (key as ModelType) } modelId={option.id} modelTypeName={option.name} @@ -1039,9 +1034,7 @@ export const ModelConfigSection = forwardRef< }} models={models} /> - -
); -}); \ No newline at end of file +}); diff --git a/frontend/app/[locale]/models/page.tsx b/frontend/app/[locale]/models/page.tsx new file mode 100644 index 000000000..ca81be7ac --- /dev/null +++ b/frontend/app/[locale]/models/page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useRef } from "react"; +import { motion } from "framer-motion"; + +import { useSetupFlow } from "@/hooks/useSetupFlow"; + +import AppModelConfig from "./ModelConfiguration"; +import { ModelConfigSectionRef } from "./components/modelConfig"; + +/** + * ModelsContent - Main component for model configuration + * Can be used in setup flow or as standalone page + */ +export default function ModelsContent() { + // Use custom hook for common setup flow logic + const { canAccessProtectedData, pageVariants, pageTransition } = useSetupFlow( + { + requireAdmin: true, + nonAdminRedirect: "/setup/knowledges", + } + ); + + const modelConfigSectionRef = useRef(null); + + return ( + <> +
+ +
+ {canAccessProtectedData ? ( + + ) : null} +
+
+
+ + ); +} diff --git a/frontend/app/[locale]/monitoring/MonitoringContent.tsx b/frontend/app/[locale]/monitoring/page.tsx similarity index 78% rename from frontend/app/[locale]/monitoring/MonitoringContent.tsx rename to frontend/app/[locale]/monitoring/page.tsx index adb41dcd5..ee625b2a0 100644 --- a/frontend/app/[locale]/monitoring/MonitoringContent.tsx +++ b/frontend/app/[locale]/monitoring/page.tsx @@ -6,43 +6,18 @@ import { useTranslation } from "react-i18next"; import { Activity } from "lucide-react"; import { useSetupFlow } from "@/hooks/useSetupFlow"; -import { ConnectionStatus } from "@/const/modelConfig"; - -interface MonitoringContentProps { - /** Connection status */ - connectionStatus?: ConnectionStatus; - /** Is checking connection */ - isCheckingConnection?: boolean; - /** Check connection callback */ - onCheckConnection?: () => void; - /** Callback to expose connection status */ - onConnectionStatusChange?: (status: ConnectionStatus) => void; -} /** * MonitoringContent - Agent monitoring and operations coming soon page * This will allow admins to monitor and operate agents (health, logs, alerts) */ -export default function MonitoringContent({ - connectionStatus: externalConnectionStatus, - isCheckingConnection: externalIsCheckingConnection, - onCheckConnection: externalOnCheckConnection, - onConnectionStatusChange, -}: MonitoringContentProps) { +export default function MonitoringContent({}) { const { t } = useTranslation("common"); - // Use custom hook for common setup flow logic - const { canAccessProtectedData, pageVariants, pageTransition } = useSetupFlow({ - requireAdmin: true, - externalConnectionStatus, - externalIsCheckingConnection, - onCheckConnection: externalOnCheckConnection, - onConnectionStatusChange, - }); - + const { pageVariants, pageTransition } = useSetupFlow(); return ( <> - {canAccessProtectedData ? ( +
- ) : null} +
); } - - diff --git a/frontend/app/[locale]/page.tsx b/frontend/app/[locale]/page.tsx index 2fde0fa71..79a7418f4 100644 --- a/frontend/app/[locale]/page.tsx +++ b/frontend/app/[locale]/page.tsx @@ -1,536 +1,37 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { NavigationLayout } from "@/components/navigation/NavigationLayout"; +import { useRouter } from "next/navigation"; import { HomepageContent } from "@/components/homepage/HomepageContent"; -import { AuthDialogs } from "@/components/homepage/AuthDialogs"; -import { LoginModal } from "@/components/auth/loginModal"; -import { RegisterModal } from "@/components/auth/registerModal"; import { useAuth } from "@/hooks/useAuth"; -import { ConfigProvider, App } from "antd"; -import log from "@/lib/logger"; - -// Import content components -import MemoryContent from "./memory/MemoryContent"; -import ModelsContent from "./models/ModelsContent"; -import AgentSetupOrchestrator from "./agents/AgentSetupOrchestrator"; -import KnowledgesContent from "./knowledges/KnowledgesContent"; -import { SpaceContent } from "./space/components/SpaceContent"; -import { fetchAgentList } from "@/services/agentConfigService"; -import { useAgentImport, ImportAgentData } from "@/hooks/useAgentImport"; -import SetupLayout from "./setup/SetupLayout"; -import AgentImportWizard from "@/components/agent/AgentImportWizard"; -import { ChatContent } from "./chat/internal/ChatContent"; -import { ChatTopNavContent } from "./chat/internal/ChatTopNavContent"; -import { Badge, Button as AntButton, Flex } from "antd"; -import { RefreshCw } from "lucide-react"; -import { USER_ROLES } from "@/const/modelConfig"; -import MarketContent from "./market/MarketContent"; -import UsersContent from "./users/UsersContent"; -import McpToolsContent from "./mcp-tools/McpToolsContent"; -import MonitoringContent from "./monitoring/MonitoringContent"; -import { getSavedView, saveView } from "@/lib/viewPersistence"; -import { useSaveGuard } from "@/hooks/agent/useSaveGuard"; - -// View type definition -type ViewType = - | "home" - | "memory" - | "models" - | "agents" - | "knowledges" - | "space" - | "setup" - | "chat" - | "market" - | "users" - | "mcpTools" - | "monitoring"; -type SetupStep = "models" | "knowledges" | "agents"; export default function Home() { - const [mounted, setMounted] = useState(false); - - // Prevent hydration errors - useEffect(() => { - setMounted(true); - }, []); - - if (!mounted) { - return null; - } + const router = useRouter(); + const { user, isSpeedMode } = useAuth(); + + const handleAuthRequired = () => { + // This will trigger the global auth dialogs in the layout + if (!isSpeedMode && !user) { + // The layout component will handle showing the login prompt + } + }; + + const handleAdminRequired = () => { + // This will trigger the global auth dialogs in the layout + if (!isSpeedMode && user?.role !== "admin") { + // The layout component will handle showing the admin prompt + } + }; return ( - document.body}> - - - ); - - function FrontpageContent() { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const { - user, - isLoading: userLoading, - openLoginModal, - openRegisterModal, - isSpeedMode, - } = useAuth(); - const [loginPromptOpen, setLoginPromptOpen] = useState(false); - const [adminRequiredPromptOpen, setAdminRequiredPromptOpen] = - useState(false); - - // View state management with localStorage persistence - const [currentView, setCurrentView] = useState(getSavedView); - - // Connection status removed per request. - - // Space-specific states - const [agents, setAgents] = useState([]); - const [isLoadingAgents, setIsLoadingAgents] = useState(false); - const [isImporting, setIsImporting] = useState(false); - - // Agent import wizard states - const [importWizardVisible, setImportWizardVisible] = useState(false); - const [importWizardData, setImportWizardData] = - useState(null); - - // Setup-specific states - const [currentSetupStep, setCurrentSetupStep] = - useState("models"); - const [isSaving, setIsSaving] = useState(false); - - // Handle operations that require login - const handleAuthRequired = () => { - if (!isSpeedMode && !user) { - setLoginPromptOpen(true); - } - }; - - // Confirm login dialog - const handleCloseLoginPrompt = () => { - setLoginPromptOpen(false); - }; - - // Handle operations that require admin privileges - const handleAdminRequired = () => { - if (!isSpeedMode && user?.role !== "admin") { - setAdminRequiredPromptOpen(true); - } - }; - - // Close admin prompt dialog - const handleCloseAdminPrompt = () => { - setAdminRequiredPromptOpen(false); - }; - - // Determine if user is admin - const isAdmin = isSpeedMode || user?.role === USER_ROLES.ADMIN; - - // Unsaved changes guard for setup completion - const checkUnsavedChangesForSetup = useSaveGuard(); - - // Load data for the saved view on initial mount - useEffect(() => { - if (currentView === "space" && agents.length === 0) { - loadAgents(); - } - }, []); // Only run on mount - - // Handle view change from navigation - const handleViewChange = (view: string) => { - const viewType = view as ViewType; - setCurrentView(viewType); - - // Save current view to localStorage for persistence across page refreshes - saveView(viewType); - - // Initialize setup step based on user role - if (viewType === "setup") { - if (isAdmin) { - setCurrentSetupStep("models"); - } else { - setCurrentSetupStep("knowledges"); - } - } - - // Load data for specific views - if (viewType === "space") { - loadAgents(); // Always refresh agents when entering space - } - }; - - // Connection check removed. - - // Load agents for space view - const loadAgents = async () => { - setIsLoadingAgents(true); - try { - const result = await fetchAgentList(); - if (result.success) { - setAgents(result.data); - } else { - message.error(t(result.message) || "Failed to load agents"); - } - } catch (error) { - log.error("Failed to load agents:", error); - message.error("Failed to load agents"); - } finally { - setIsLoadingAgents(false); - } - }; - - // Use unified import hook for space view - const { importFromData } = useAgentImport({ - onSuccess: () => { - message.success(t("businessLogic.config.error.agentImportSuccess")); - loadAgents(); - setIsImporting(false); - setImportWizardVisible(false); - setImportWizardData(null); - }, - onError: (error) => { - log.error(t("agentConfig.agents.importFailed"), error); - message.error(t("businessLogic.config.error.agentImportFailed")); - setIsImporting(false); - }, - }); - - // Handle import agent for space view - open wizard instead of direct import - const handleImportAgent = () => { - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = ".json"; - fileInput.onchange = async (event) => { - const file = (event.target as HTMLInputElement).files?.[0]; - if (!file) return; - - if (!file.name.endsWith(".json")) { - message.error(t("businessLogic.config.error.invalidFileType")); - return; - } - - try { - // Read and parse file - const fileContent = await file.text(); - let agentData: ImportAgentData; - - try { - agentData = JSON.parse(fileContent); - } catch (parseError) { - message.error(t("businessLogic.config.error.invalidFileType")); - return; - } - - // Validate structure - if (!agentData.agent_id || !agentData.agent_info) { - message.error(t("businessLogic.config.error.invalidFileType")); - return; - } - - // Open wizard with parsed data - setImportWizardData(agentData); - setImportWizardVisible(true); - } catch (error) { - log.error("Failed to read import file:", error); - message.error(t("businessLogic.config.error.agentImportFailed")); - } - }; - - fileInput.click(); - }; - - // Handle import completion from wizard - // Note: AgentImportWizard already handles the import internally, - // so we just need to refresh the agent list - const handleImportComplete = () => { - loadAgents(); - setImportWizardVisible(false); - setImportWizardData(null); - }; - - // Setup navigation handlers - const handleSetupNext = () => { - if (currentSetupStep === "models") { - setCurrentSetupStep("knowledges"); - } else if (currentSetupStep === "knowledges") { - if (isAdmin) { - setCurrentSetupStep("agents"); - } - } - }; - - const handleSetupBack = () => { - if (currentSetupStep === "knowledges") { - if (isAdmin) { - setCurrentSetupStep("models"); - } - } else if (currentSetupStep === "agents") { - setCurrentSetupStep("knowledges"); - } - }; - - const handleSetupComplete = async () => { - // Check if we're on the agents step and if there are unsaved changes - if (currentSetupStep === "agents" && isAdmin) { - const canProceed = await checkUnsavedChangesForSetup.saveWithModal; - if (!canProceed) { - return; // Save failed or user cancelled - } - } - - // Proceed to chat - setCurrentView("chat"); - saveView("chat"); - }; - - // Determine setup button visibility based on current step and user role - const getSetupNavigationProps = () => { - if (!isAdmin) { - return { - showBack: false, - showNext: false, - showComplete: true, - }; - } - - switch (currentSetupStep) { - case "models": - return { - showBack: false, - showNext: true, - showComplete: false, - }; - case "knowledges": - return { - showBack: true, - showNext: true, - showComplete: false, - }; - case "agents": - return { - showBack: true, - showNext: false, - showComplete: true, - }; - default: - return { - showBack: false, - showNext: false, - showComplete: false, - }; - } - }; - - // Render content based on current view - const renderContent = () => { - switch (currentView) { - case "home": - return ( -
- { - setCurrentView("chat"); - saveView("chat"); - }} - onSetupNavigate={() => { - setCurrentView("setup"); - saveView("setup"); - }} - onSpaceNavigate={() => { - setCurrentView("space"); - saveView("space"); - }} - /> -
- ); - - case "memory": - return ( -
- -
- ); - - case "models": - return ( -
- -
- ); - - case "agents": - return ( - - - - ); - - case "knowledges": - return ( -
- -
- ); - - case "space": - return ( - <> - { - // Update URL with agent_id parameter for auto-selection in ChatAgentSelector - const url = new URL(window.location.href); - url.searchParams.set("agent_id", agentId); - window.history.replaceState({}, "", url.toString()); - - setCurrentView("chat"); - saveView("chat"); - }} - onEditNavigate={() => { - // Navigate to agents development view - setCurrentView("agents"); - saveView("agents"); - }} - /> - - ); - - case "chat": - return ; - - case "market": - return ( -
- -
- ); - - case "users": - return ( -
- -
- ); - - case "mcpTools": - return ( -
- -
- ); - - case "monitoring": - return ( -
- -
- ); - - case "setup": - const setupNavProps = getSetupNavigationProps(); - return ( - - {currentSetupStep === "models" && isAdmin && ( - - )} - - {currentSetupStep === "knowledges" && ( - - )} - - {currentSetupStep === "agents" && isAdmin && ( - - )} - - ); - - default: - return null; - } - }; - - // Note: Setup connection status UI removed (badge + refresh) per request. - - return ( - + : undefined - } - > - {renderContent()} - - { - setImportWizardVisible(false); - setImportWizardData(null); - }} - initialData={importWizardData} - onImportComplete={handleImportComplete} - title={undefined} // Use default title - agentDisplayName={ - importWizardData?.agent_info?.[String(importWizardData.agent_id)] - ?.display_name - } - agentDescription={ - importWizardData?.agent_info?.[String(importWizardData.agent_id)] - ?.description - } - /> + onChatNavigate={() => router.push('chat')} + onSetupNavigate={() => router.push('setup')} + onSpaceNavigate={() => router.push('space')} + /> +
+ ); - {/* Auth dialogs - only shown in full version */} - {!isSpeedMode && ( - <> - { - setLoginPromptOpen(false); - setAdminRequiredPromptOpen(false); - openLoginModal(); - }} - onRegisterClick={() => { - setLoginPromptOpen(false); - setAdminRequiredPromptOpen(false); - openRegisterModal(); - }} - /> - - - - )} - - ); - } } diff --git a/frontend/app/[locale]/setup/SetupLayout.tsx b/frontend/app/[locale]/setup/SetupLayout.tsx deleted file mode 100644 index d2d98a3e1..000000000 --- a/frontend/app/[locale]/setup/SetupLayout.tsx +++ /dev/null @@ -1,173 +0,0 @@ -"use client"; - -import {ReactNode} from "react"; -import {useTranslation} from "react-i18next"; - -import { Dropdown } from "antd"; -import { - ChevronDown, - Globe, -} from "lucide-react"; -import {languageOptions} from "@/const/constants"; -import {useLanguageSwitch} from "@/lib/language"; -import {CONNECTION_STATUS, ConnectionStatus,} from "@/const/modelConfig"; - -// ================ Setup Header Content Components ================ -// These components are exported so they can be used to customize the TopNavbar - -export function SetupHeaderRightContent() { - const { t } = useTranslation(); - const { currentLanguage, handleLanguageChange } = useLanguageSwitch(); - - return ( -
- ({ - key: opt.value, - label: opt.label, - })), - onClick: ({ key }) => handleLanguageChange(key as string), - }} - > - - - {languageOptions.find((o) => o.value === currentLanguage)?.label || - currentLanguage} - - - -
- ); -} - -// ================ Navigation ================ -interface NavigationProps { - onBack?: () => void; - onNext?: () => void; - onComplete?: () => void; - isSaving?: boolean; - showBack?: boolean; - showNext?: boolean; - showComplete?: boolean; - nextText?: string; - completeText?: string; -} - -function Navigation({ - onBack, - onNext, - onComplete, - isSaving = false, - showBack = false, - showNext = false, - showComplete = false, - nextText, - completeText, -}: NavigationProps) { - const { t } = useTranslation(); - - const handleClick = () => { - if (showComplete && onComplete) { - onComplete(); - } else if (showNext && onNext) { - onNext(); - } - }; - - const buttonText = () => { - if (showComplete) { - return isSaving - ? t("setup.navigation.button.saving") - : completeText || t("setup.navigation.button.complete"); - } - if (showNext) { - return nextText || t("setup.navigation.button.next"); - } - return ""; - }; - - return ( -
-
- {showBack && onBack && ( - - )} -
- -
- {(showNext || showComplete) && ( - - )} -
-
- ); -} - -// ================ Layout ================ -interface SetupLayoutProps { - children: ReactNode; - onBack?: () => void; - onNext?: () => void; - onComplete?: () => void; - isSaving?: boolean; - showBack?: boolean; - showNext?: boolean; - showComplete?: boolean; - nextText?: string; - completeText?: string; -} - -/** - * SetupLayout - Content wrapper for setup pages - * This component should be wrapped by NavigationLayout - */ -export default function SetupLayout({ - children, - onBack, - onNext, - onComplete, - isSaving = false, - showBack = false, - showNext = false, - showComplete = false, - nextText, - completeText, -}: SetupLayoutProps) { - return ( -
- {/* Main content with fixed size */} -
-
- {children} -
- -
-
- ); -} diff --git a/frontend/app/[locale]/setup/page.tsx b/frontend/app/[locale]/setup/page.tsx new file mode 100644 index 000000000..d24e5af40 --- /dev/null +++ b/frontend/app/[locale]/setup/page.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useState } from "react"; +import { USER_ROLES } from "@/const/modelConfig"; +import { Steps, Button } from "antd"; +import { ChevronLeft, ChevronRight, Check } from "lucide-react"; +import { useSetupFlow } from "@/hooks/useSetupFlow"; +import ModelsContent from "../models/page"; +import KnowledgesContent from "../knowledges/page"; +import AgentSetupOrchestrator from "../agents/page"; + +type SetupStep = "models" | "knowledges" | "agents"; + +export default function SetupPage() { + const { t, router, canAccessProtectedData, isSpeedMode, user } = useSetupFlow( + { + requireAdmin: true, + nonAdminRedirect: "/setup/knowledges", + } + ); + + const isAdmin = isSpeedMode || user?.role === USER_ROLES.ADMIN; + + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [isSaving, setIsSaving] = useState(false); + + const steps = [ + { + key: "models" as SetupStep, + title: t("setup.model.description"), + }, + { + key: "knowledges" as SetupStep, + title: t("setup.knowledge.description"), + }, + { + key: "agents" as SetupStep, + title: t("setup.agent.description"), + }, + ]; + + const [completed, setCompleted] = useState( + new Array(steps.length).fill(false) + ); + + const currentStep = steps[currentStepIndex]; + const isFirstStep = currentStepIndex === 0; + const isLastStep = currentStepIndex === steps.length - 1; + + const handleNext = () => { + // mark current as completed then advance (unless last) + setCompleted((prev) => { + const next = [...prev]; + next[currentStepIndex] = true; + return next; + }); + if (!isLastStep) { + setCurrentStepIndex((i) => i + 1); + } else { + // last step -> complete + router.push("/chat"); + } + }; + + const handleBack = () => { + if (!isFirstStep) { + // Mark current step as incomplete when going back + setCompleted((prev) => { + const next = [...prev]; + next[currentStepIndex - 1] = false; + return next; + }); + setCurrentStepIndex((i) => i - 1); + } + }; + + const handleComplete = () => { + router.push("/chat"); + }; + + const renderStepContent = () => { + switch (currentStep.key) { + case "models": + return ; + case "knowledges": + return ; + case "agents": + return ; + default: + return null; + } + }; + + // 如果没有访问权限,返回 null 让 SESSION_EXPIRED 事件处理登录弹窗 + if (!canAccessProtectedData || !isAdmin) { + return null; + } + + return ( +
+ {/* Top fixed Steps bar */} +
+
+ { + // allow jumping only to already completed steps or current + if (idx <= currentStepIndex || completed[idx]) { + setCurrentStepIndex(idx); + } + }} + size="default" + items={steps.map((s, i) => ({ + title: s.title, + status: completed[i] + ? "finish" + : i === currentStepIndex + ? "process" + : "wait", + icon: completed[i] ? : undefined, + }))} + /> +
+
+ + {/* Main container*/} +
+ {/* Main Content area */} + {renderStepContent()} +
+ + {/* Bottom fixed action bar */} +
+
+ + {!isLastStep ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/frontend/app/[locale]/space/components/AgentCard.tsx b/frontend/app/[locale]/space/components/AgentCard.tsx index 044a80d44..4653b9b8f 100644 --- a/frontend/app/[locale]/space/components/AgentCard.tsx +++ b/frontend/app/[locale]/space/components/AgentCard.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; +import { useRouter } from "next/navigation"; import { App } from "antd"; import { Trash2, @@ -25,30 +26,20 @@ import { generateAvatarFromName } from "@/lib/avatar"; import { useAuth } from "@/hooks/useAuth"; import { useConfirmModal } from "@/hooks/useConfirmModal"; import { USER_ROLES } from "@/const/modelConfig"; +import { Agent } from "@/types/agentConfig"; import log from "@/lib/logger"; -interface Agent { - id: string; - name: string; - display_name: string; - description: string; - author?: string; - is_available: boolean; - enabled?: boolean; -} - interface AgentCardProps { agent: Agent; onRefresh: () => void; - onChat: (agentId: string) => void; - onEdit?: () => void; } -export default function AgentCard({ agent, onRefresh, onChat, onEdit }: AgentCardProps) { +export default function AgentCard({ agent, onRefresh }: AgentCardProps) { const { t } = useTranslation("common"); const { message } = App.useApp(); const { user, isSpeedMode } = useAuth(); const { confirm } = useConfirmModal(); + const router = useRouter(); const [isDeleting, setIsDeleting] = useState(false); const [isExporting, setIsExporting] = useState(false); @@ -132,14 +123,12 @@ export default function AgentCard({ agent, onRefresh, onChat, onEdit }: AgentCar // Handle chat const handleChat = () => { - onChat(agent.id); + router.push("/chat"); }; // Handle edit - navigate to agents view const handleEdit = () => { - if (onEdit) { - onEdit(); - } + router.push("/agent"); }; // Handle view detail @@ -198,7 +187,10 @@ export default function AgentCard({ agent, onRefresh, onChat, onEdit }: AgentCar {agent.author ? (

- {t("market.by", { defaultValue: "By {{author}}", author: agent.author })} + {t("market.by", { + defaultValue: "By {{author}}", + author: agent.author, + })}

) : (
@@ -275,7 +267,11 @@ export default function AgentCard({ agent, onRefresh, onChat, onEdit }: AgentCar ? "hover:bg-green-50 dark:hover:bg-green-900/20 text-slate-400 hover:text-green-600 dark:hover:text-green-400" : "text-slate-300 dark:text-slate-600 cursor-not-allowed" }`} - title={agent.is_available ? t("space.actions.chat", "Chat") : t("space.status.unavailable", "Unavailable")} + title={ + agent.is_available + ? t("space.actions.chat", "Chat") + : t("space.status.unavailable", "Unavailable") + } > @@ -300,4 +296,3 @@ export default function AgentCard({ agent, onRefresh, onChat, onEdit }: AgentCar ); } - diff --git a/frontend/app/[locale]/space/components/SpaceContent.tsx b/frontend/app/[locale]/space/components/SpaceContent.tsx deleted file mode 100644 index e849bfb04..000000000 --- a/frontend/app/[locale]/space/components/SpaceContent.tsx +++ /dev/null @@ -1,211 +0,0 @@ -"use client"; - -import React from "react"; -import { useRouter } from "next/navigation"; -import { useTranslation } from "react-i18next"; -import { motion } from "framer-motion"; -import { Plus, RefreshCw, Upload } from "lucide-react"; -import { USER_ROLES } from "@/const/modelConfig"; -import { useAuth } from "@/hooks/useAuth"; -import { useSetupFlow } from "@/hooks/useSetupFlow"; -import AgentCard from "./AgentCard"; - -interface Agent { - id: string; - name: string; - display_name: string; - description: string; - is_available: boolean; -} - -interface SpaceContentProps { - agents: Agent[]; - isLoading: boolean; - isImporting: boolean; - onRefresh: () => void; - onLoadAgents: () => void; - onImportAgent: () => void; - onChatNavigate?: (agentId: string) => void; - onEditNavigate?: () => void; -} - -/** - * Agent Space content component - * Displays agent cards grid and management controls - */ -export function SpaceContent({ - agents, - isLoading, - isImporting, - onRefresh, - onLoadAgents, - onImportAgent, - onChatNavigate, - onEditNavigate, -}: SpaceContentProps) { - const router = useRouter(); - const { t } = useTranslation("common"); - const { user, isSpeedMode } = useAuth(); - const { canAccessProtectedData, pageVariants, pageTransition } = useSetupFlow({ - requireAdmin: false, - }); - - // Check if user is admin (or in speed mode where all features are available) - const isAdmin = isSpeedMode || user?.role === USER_ROLES.ADMIN; - - // Handle create new agent - navigate to agents view - const handleCreateAgent = () => { - if (onEditNavigate) { - onEditNavigate(); - } - }; - - // Handle chat with agent - const handleChat = (agentId: string) => { - if (onChatNavigate) { - onChatNavigate(agentId); - } else { - // Fallback to URL navigation if callback not provided - router.push(`/chat?agent_id=${agentId}`); - } - }; - - if (!canAccessProtectedData) { - return null; - } - - return ( - -
- {/* Page header */} -
- -

- {t("space.title", "Agent Space")} -

-

- {t( - "space.description", - "Manage and interact with your intelligent agents" - )} -

-
- - {/* Refresh button */} - - - -
- - {/* Agent cards grid */} - - {/* Create/Import agent card - only for admin */} - {isAdmin && ( - -
- {/* Create new agent - top half */} - - - {/* Import agent - bottom half */} - -
-
- )} - - {/* Agent cards */} - {agents.map((agent, index) => ( - - - - ))} -
- - {/* Empty state */} - {!isLoading && agents.length === 0 && ( - -

- {t( - "space.noAgents", - "No agents yet. Create your first agent to get started!" - )} -

-
- )} -
-
- ); -} - diff --git a/frontend/app/[locale]/space/page.tsx b/frontend/app/[locale]/space/page.tsx new file mode 100644 index 000000000..b5d14e9b9 --- /dev/null +++ b/frontend/app/[locale]/space/page.tsx @@ -0,0 +1,245 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; +import { App } from "antd"; +import { Plus, RefreshCw, Upload } from "lucide-react"; +import { USER_ROLES } from "@/const/modelConfig"; +import { useAuth } from "@/hooks/useAuth"; +import { useSetupFlow } from "@/hooks/useSetupFlow"; +import { useAgentList } from "@/hooks/agent/useAgentList"; +import { Agent } from "@/types/agentConfig"; +import AgentCard from "./components/AgentCard"; +import { ImportAgentData } from "@/hooks/useAgentImport"; +import AgentImportWizard from "@/components/agent/AgentImportWizard"; +import log from "@/lib/logger"; + +/** + * Agent Space page component + * Displays agent cards grid and management controls + */ +export default function SpacePage() { + const router = useRouter(); + + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const { user, isSpeedMode } = useAuth(); + const { canAccessProtectedData, pageVariants, pageTransition } = useSetupFlow( + { + requireAdmin: false, + } + ); + const [isImporting, setIsImporting] = useState(false); + const { agents, isLoading, invalidate } = useAgentList(); + + // Import wizard state + const [importWizardVisible, setImportWizardVisible] = useState(false); + const [importWizardData, setImportWizardData] = + useState(null); + + // Check if user is admin (or in speed mode where all features are available) + const isAdmin = isSpeedMode || user?.role === USER_ROLES.ADMIN; + + const handleCreateAgent = () => { + router.push("/agent"); + }; + + const onRefresh = () => { + invalidate(); + }; + + const onImportAgent = () => { + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".json"; + fileInput.onchange = async (event) => { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) return; + + if (!file.name.endsWith(".json")) { + message.error(t("businessLogic.config.error.invalidFileType")); + return; + } + + try { + // Read and parse file + const fileContent = await file.text(); + let agentData: ImportAgentData; + + try { + agentData = JSON.parse(fileContent); + } catch (parseError) { + message.error(t("businessLogic.config.error.invalidFileType")); + return; + } + + // Validate structure + if (!agentData.agent_id || !agentData.agent_info) { + message.error(t("businessLogic.config.error.invalidFileType")); + return; + } + + // Open wizard with parsed data + setImportWizardData(agentData); + setImportWizardVisible(true); + } catch (error) { + log.error("Failed to read import file:", error); + message.error(t("businessLogic.config.error.agentImportFailed")); + } + }; + + fileInput.click(); + }; + + if (!canAccessProtectedData) { + return null; + } + + return ( +
+ +
+ {/* Page header */} +
+ +

+ {t("space.title", "Agent Space")} +

+

+ {t( + "space.description", + "Manage and interact with your intelligent agents" + )} +

+
+ + {/* Refresh button */} + + + +
+ + {/* Agent cards grid */} + + {/* Create/Import agent card - only for admin */} + {isAdmin && ( + +
+ {/* Create new agent - top half */} + + + {/* Import agent - bottom half */} + +
+
+ )} + + {/* Agent cards */} + {agents.map((agent: Agent, index: number) => ( + + + + ))} +
+ + {/* Empty state */} + {!isLoading && agents.length === 0 && ( + +

+ {t( + "space.noAgents", + "No agents yet. Create your first agent to get started!" + )} +

+
+ )} +
+
+ + {/* Import Wizard Modal */} + { + setImportWizardVisible(false); + setImportWizardData(null); + }} + initialData={importWizardData} + onImportComplete={() => { + setImportWizardVisible(false); + setImportWizardData(null); + invalidate(); // Refresh the agent list + }} + /> +
+ ); +} diff --git a/frontend/app/[locale]/users/UsersContent.tsx b/frontend/app/[locale]/users/UsersContent.tsx deleted file mode 100644 index 4e1ed132b..000000000 --- a/frontend/app/[locale]/users/UsersContent.tsx +++ /dev/null @@ -1,131 +0,0 @@ -"use client"; - -import React from "react"; -import { motion } from "framer-motion"; -import { useTranslation } from "react-i18next"; -import { Users } from "lucide-react"; - -import { useSetupFlow } from "@/hooks/useSetupFlow"; -import { ConnectionStatus } from "@/const/modelConfig"; - -interface UsersContentProps { - /** Connection status */ - connectionStatus?: ConnectionStatus; - /** Is checking connection */ - isCheckingConnection?: boolean; - /** Check connection callback */ - onCheckConnection?: () => void; - /** Callback to expose connection status */ - onConnectionStatusChange?: (status: ConnectionStatus) => void; -} - -/** - * UsersContent - User management coming soon page - * This will allow admins to manage users, roles, and permissions - */ -export default function UsersContent({ - connectionStatus: externalConnectionStatus, - isCheckingConnection: externalIsCheckingConnection, - onCheckConnection: externalOnCheckConnection, - onConnectionStatusChange, -}: UsersContentProps) { - const { t } = useTranslation("common"); - - // Use custom hook for common setup flow logic - const { - canAccessProtectedData, - pageVariants, - pageTransition, - } = useSetupFlow({ - requireAdmin: true, // User management requires admin privileges - externalConnectionStatus, - externalIsCheckingConnection, - onCheckConnection: externalOnCheckConnection, - onConnectionStatusChange, - }); - - return ( - <> - {canAccessProtectedData ? ( - -
- {/* Icon */} - - - - - {/* Title */} - - {t("users.comingSoon.title")} - - - {/* Description */} - - {t("users.comingSoon.description")} - - - {/* Feature list */} - -
  • - - - {t("users.comingSoon.feature1")} - -
  • -
  • - - - {t("users.comingSoon.feature2")} - -
  • -
  • - - - {t("users.comingSoon.feature3")} - -
  • -
    - - {/* Coming soon badge */} - - {t("users.comingSoon.badge")} - -
    -
    - ) : null} - - ); -} - diff --git a/frontend/app/[locale]/users/page.tsx b/frontend/app/[locale]/users/page.tsx new file mode 100644 index 000000000..65cf1eecc --- /dev/null +++ b/frontend/app/[locale]/users/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import React from "react"; +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import { Users } from "lucide-react"; + +import { useSetupFlow } from "@/hooks/useSetupFlow"; + +/** + * UsersContent - User management coming soon page + * This will allow admins to manage users, roles, and permissions + */ +export default function UsersContent({}) { + const { t } = useTranslation("common"); + + // Use custom hook for common setup flow logic + const { canAccessProtectedData, pageVariants, pageTransition } = useSetupFlow( + { + requireAdmin: true, // User management requires admin privileges + } + ); + + return ( + <> +
    + {canAccessProtectedData ? ( + +
    + {/* Icon */} + + + + + {/* Title */} + + {t("users.comingSoon.title")} + + + {/* Description */} + + {t("users.comingSoon.description")} + + + {/* Feature list */} + +
  • + + + {t("users.comingSoon.feature1")} + +
  • +
  • + + + {t("users.comingSoon.feature2")} + +
  • +
  • + + + {t("users.comingSoon.feature3")} + +
  • +
    + + {/* Coming soon badge */} + + {t("users.comingSoon.badge")} + +
    +
    + ) : null} +
    + + ); +} diff --git a/frontend/components/agent/AgentImportWizard.tsx b/frontend/components/agent/AgentImportWizard.tsx index 10e7b0899..5723f334c 100644 --- a/frontend/components/agent/AgentImportWizard.tsx +++ b/frontend/components/agent/AgentImportWizard.tsx @@ -108,14 +108,14 @@ export default function AgentImportWizard({ const [currentStep, setCurrentStep] = useState(0); const [llmModels, setLlmModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); - + // Model selection mode: "unified" (one model for all) or "individual" (separate model for each agent) const [modelSelectionMode, setModelSelectionMode] = useState<"unified" | "individual">("unified"); - + // Unified mode: single model for all agents const [selectedModelId, setSelectedModelId] = useState(null); const [selectedModelName, setSelectedModelName] = useState(""); - + // Individual mode: model for each agent const [selectedModelsByAgent, setSelectedModelsByAgent] = useState>({}); @@ -216,13 +216,13 @@ export default function AgentImportWizard({ // Initialize model selection for individual mode const initializeModelSelection = () => { if (!initialData?.agent_info) return; - + const initialModels: Record = {}; - + Object.keys(initialData.agent_info).forEach(agentKey => { initialModels[agentKey] = { modelId: null, modelName: "" }; }); - + setSelectedModelsByAgent(initialModels); }; @@ -288,7 +288,7 @@ export default function AgentImportWizard({ }); setAgentNameConflicts(conflicts); - + // Update successfully renamed agents based on initial check // Only add to successfullyRenamedAgents if there was a conflict that was resolved // For initial check, we don't add anything since no renaming has happened yet @@ -327,7 +327,7 @@ export default function AgentImportWizard({ const hasDisplayNameConflict = checkResult?.display_name_conflict || false; const hasConflict = hasNameConflict || hasDisplayNameConflict; const conflictAgentsRaw = Array.isArray(checkResult?.conflict_agents) ? checkResult.conflict_agents : []; - + // Deduplicate by name/display_name const seen = new Set(); const conflictAgents = conflictAgentsRaw.reduce((acc: Array<{ name?: string; display_name?: string }>, curr: any) => { @@ -457,7 +457,7 @@ export default function AgentImportWizard({ try { const models = await modelService.getLLMModels(); setLlmModels(models.filter(m => m.connect_status === "available")); - + // Auto-select first available model if (models.length > 0 && models[0].connect_status === "available") { setSelectedModelId(models[0].id); @@ -656,11 +656,11 @@ export default function AgentImportWizard({ // Check each MCP server from mcp_info const serversToInstall: McpServerToInstall[] = initialData.mcp_info.map((mcp: any) => { const isUrlConfigNeeded = needsConfig(mcp.mcp_url); - + // Check if already installed (match by both name and url) const isInstalled = !isUrlConfigNeeded && existing.some( - (existingMcp: McpServer) => - existingMcp.service_name === mcp.mcp_server_name && + (existingMcp: McpServer) => + existingMcp.service_name === mcp.mcp_server_name && existingMcp.mcp_url === mcp.mcp_url ); @@ -773,7 +773,7 @@ export default function AgentImportWizard({ try { // Prepare the data structure for import const importData = prepareImportData(); - + if (!importData) { message.error(t("market.install.error.invalidData", "Invalid agent data")); return; @@ -784,9 +784,7 @@ export default function AgentImportWizard({ setIsImporting(true); // Import using agentConfigService directly const result = await importAgent(importData, { forceImport: false }); - if (result.success) { - message.success(t("market.install.success", "Agent installed successfully!")); queryClient.invalidateQueries({ queryKey: ["agents"] }); onImportComplete?.(); handleCancel(); // Close wizard after success @@ -825,7 +823,7 @@ export default function AgentImportWizard({ Object.entries(agentJson.agent_info).forEach(([agentKey, agentInfo]: [string, any]) => { agentInfo.model_id = selectedModelId; agentInfo.model_name = selectedModelName; - + // Clear business logic model fields agentInfo.business_logic_model_id = null; agentInfo.business_logic_model_name = null; @@ -837,7 +835,7 @@ export default function AgentImportWizard({ if (modelSelection && modelSelection.modelId && modelSelection.modelName) { agentInfo.model_id = modelSelection.modelId; agentInfo.model_name = modelSelection.modelName; - + // Clear business logic model fields agentInfo.business_logic_model_id = null; agentInfo.business_logic_model_name = null; @@ -957,7 +955,7 @@ export default function AgentImportWizard({ } const currentStepKey = steps[currentStep]?.key; - + if (currentStepKey === "rename") { return true; } else if (currentStepKey === "tools") { @@ -978,13 +976,13 @@ export default function AgentImportWizard({ return configFields.every(field => configValues[field.valueKey]?.trim()); } else if (currentStepKey === "mcp") { // All non-editable MCPs should be installed or have edited URLs - return mcpServers.every(mcp => - mcp.isInstalled || + return mcpServers.every(mcp => + mcp.isInstalled || (mcp.isUrlEditable && mcp.editedUrl && mcp.editedUrl.trim() !== "") || (!mcp.isUrlEditable && mcp.mcp_url && mcp.mcp_url.trim() !== "") ); } - + return true; }; @@ -1384,7 +1382,7 @@ export default function AgentImportWizard({

    {t("market.install.model.description.unified", "Select a model from your configured models. This model will be applied to all agents (main agent and sub-agents).")}

    - +