您可以利用面板小程序开发构建出一个基于 Ray 框架的 AI 宠物设备面板。
详见 面板小程序 > 搭建环境。
首先需要创建一个产品,定义产品有哪些功能点,然后再在面板中一一实现这些功能点。
注册登录 涂鸦开发者平台,并在平台创建产品:

 
   







面板小程序的开发在 小程序开发者 平台上进行操作,首先请前往 小程序开发者平台 完成平台的注册登录。
详细操作步骤可以参考 面板小程序 > 创建面板小程序。
打开 IDE 创建一个基于 AI 宠物面板模板 的面板小程序项目,需要在 Tuya MiniApp IDE 上进行操作。
详细操作步骤可以参考 面板小程序 > 初始化项目工程。
在项目内配置您在前面 创建产品 > 步骤 10 中复制的智能体 ID,修改模板文件 /src/constant/index.ts:
// 智能体 ID,修改为产品配置的智能体 ID
const AGENT_ID = "xxx";
使用面板之前要先添加宠物信息,按照引导流程选择宠物类型、品种、性别、活跃度,上传正面照,并填写宠物的昵称、生日、体重等信息。
 
  
 
 
  
 
<View className={styles.content}>
  {step === 0 && <PetType value={type} onChange={handlePetTypeChange} />}
  {step === 1 && (
    <PetBreed
      petType={type}
      value={breed}
      onChange={handlePetBreedChange}
      onBack={() => setStep((step) => step - 1)}
    />
  )}
  {step === 2 && (
    <PetSex
      value={sex}
      onChange={handlePetSexChange}
      onBack={() => setStep((step) => step - 1)}
    />
  )}
  {step === 3 && (
    <PetActiveness
      petType={type}
      value={activeness}
      onChange={handlePetActivenessChange}
      onBack={() => setStep((step) => step - 1)}
    />
  )}
  {step === 4 && (
    <PetAnalytics
      goNext={handleGoNext}
      onBack={() => setStep((step) => step - 1)}
    />
  )}
  {step === 5 && (
    <PetInfo
      petType={type}
      breed={breed}
      sex={sex}
      activeness={activeness}
      profile={profile}
    />
  )}
</View>
const handleSave = async () => {
  if (name.trim() === '') {
    ToastInstance({
      context: this,
      message: Strings.getLang('pet_info_name_empty'),
    });
    return;
  }
  if (weight === 0) {
    ToastInstance({
      context: this,
      message: Strings.getLang('weight_not_zero'),
    });
    return;
  }
  try {
    showLoading({
      title: '',
      mask: true,
    });
    const petId = await (dispatch as AppDispatch)(
      addPet({
        petType,
        breedCode: breed,
        sex,
        activeness,
        name: name.trim(),
        avatar: bizUrlRef.current,
        weight: weight * 1000,
        birth: birthday,
        ownerId: getHomeId(),
        timeZone: moment().format('Z'),
        tuyaAppId: getTuyaAppId(),
        idPhotos: profile?.idPhotos,
        features: profile?.features,
      })
    ).unwrap();
    dispatch(fetchPetDetail({ petId, forceUpdate: true }));
    setNavigationBarBack({ type: 'system' });
    navigateBack();
    if (store.getState().global.selectedPetId === -1) {
      dispatch(setSelectedPetId(petId));
    }
  } catch (err) {
    errorToast(err);
  } finally {
    hideLoading();
  }
};
点击某个宠物可以查看该宠物的详细信息,在详情页面可更新宠物信息、删除宠物记录。

// 更新宠物信息
const handleSave = async () => {
  if (name.trim() === '') {
    ToastInstance({
      context: this,
      message: Strings.getLang('pet_info_name_empty'),
    });
    return;
  }
  try {
    showLoading({
      title: '',
      mask: true,
    });
    await (dispatch as AppDispatch)(
      updatePet({
        id: pet.id,
        petType: pet.petType,
        breedCode,
        sex,
        activeness,
        name: name.trim(),
        avatar: bizUrlRef.current,
        weight: weight * 1000,
        birth: birthday,
        rfid: pet.rfid,
        ownerId: getHomeId(),
        timeZone: moment().format('Z'),
        tuyaAppId: getTuyaAppId(),
        idPhotos: profile?.idPhotos,
        features: profile?.features,
      })
    ).unwrap();
    if (pet.id) {
      await (dispatch as AppDispatch)(
        fetchPetDetail({ petId: Number(pet.id), forceUpdate: true })
      ).unwrap();
    }
    navigateBack();
  } catch (err) {
    console.log(err);
  } finally {
    hideLoading();
  }
};
// 删除宠物
const handleDelete = async () => {
  try {
    await DialogInstance.confirm({
      context: this,
      title: Strings.getLang('tips'),
      message: Strings.getLang('pet_delete_tips'),
      confirmButtonText: Strings.getLang('confirm'),
      cancelButtonText: Strings.getLang('cancel'),
    });
    showLoading({
      title: '',
      mask: true,
    });
    await (dispatch as AppDispatch)(deletePet(pet.id)).unwrap();
    navigateBack();
  } catch (err) {
    console.log(err);
  } finally {
    hideLoading();
  }
};
chooseImage 方法,支持用户从本地相册选择图片或使用相机拍照。resizeImage 方法,保持原图长宽比裁剪至目标尺寸,再根据文件大小限制进行质量压缩。petsDetectCreate 方法,初始化 AI 宠物图像质量检测实例。petsPictureQualityDetectForImage 方法,输入参数进行质量检测并返回结果。petsDetectDestory 方法销毁实例,避免内存泄漏。objectKeyobjectKey 至特征分析接口。taskId,通过轮询获取分析进度: analysisResult 为 2,表示分析成功。analysisResult 为 1,表示分析失败。 
  
 
export async function pictureQualityDetect(pathUrl: string) {
  const result = await new Promise<{
    imagePath: string;
    lowQuality: boolean;
    lowQualityReason: number;
  }>((resolve, reject) => {
    petsPictureQualityDetectForImage({
      inputImagePath: pathUrl,
      labelAllow: 1,
      objectAreaPercent: 30,
      objectFaceRotationAngle: 40,
      objectFaceSideAngle: 45,
      maximumPictureBrightness: 80,
      minimumPictureBrightness: 0,
      success: res => {
        resolve(res);
      },
      fail: error => {
        reject(error);
      },
    });
  });
  return result;
}
  const handleChooseImg = async () => {
    let paths = [];
    try {
      // paths = await chooseImage(3);
      paths = await chooseImage(3, () => enter('analyzing'));
    } catch (err) {
      return;
    }
    // 先进入下一阶段
    // enter('analyzing');
    setState({
      analyzingText: Strings.getLang('add_pet_analytics_upload_img'),
    });
    const controller = new AbortController();
    controllerRef.current = controller;
    let tempPaths: Array<{ imagePath: string; lowQuality: boolean; lowQualityReason: number }> = [];
    let pathList: Array<{ imagePath: string; lowQuality: boolean; lowQualityReason: number }> = [];
    if (petType === 'cat') {
      try {
        tempPaths = (await Promise.all(paths.map(url => pictureQualityDetect(url)))).map(d => d);
        pathList = tempPaths.filter(item => !item.lowQuality) as Array<{
          imagePath: string;
          lowQuality: boolean;
          lowQualityReason: number;
        }>;
        const lowQualityList = tempPaths
          .filter(item => item.lowQuality)
          .map(item => item.lowQualityReason) as Array<number>;
        if (pathList.length === 0) {
          const errorText = Array.from(new Set(lowQualityList)).map((lowQualityItem, index) => {
            return `${Strings.getLang(`dsc_${lowQualityItem}`)}`;
          });
          setPicErrorTips(errorText.join('、'));
          setTimeout(() => {
            enter('failed');
          }, 1000);
          return;
        }
      } catch (error) {
        enter('failed');
        return;
      }
    }
    let images: Array<{ imageDisplayUrl: string; objectKey: string }> = [];
    const pathSourceList = petType === 'cat' ? pathList.map(element => element.imagePath) : paths;
    try {
      images = (
        await Promise.all(pathSourceList.map(p => uploadImageCat(p, ANALYTICS_BIZ_TYPE)))
      ).map(d => ({
        imageDisplayUrl: d.publicUrl,
        objectKey: d.cloudKey,
      }));
      if (controller.signal.aborted) {
        return;
      }
    } catch (error) {
      setTimeout(() => {
        enter('failed');
      }, 1000);
      return;
    }
    let idx = 0;
    const tips = [
      Strings.getLang('add_pet_analytics_upload_tip1'),
      Strings.getLang('add_pet_analytics_upload_tip2'),
      Strings.getLang('add_pet_analytics_upload_tip3'),
      Strings.getLang('add_pet_analytics_upload_tip4'),
      Strings.getLang('add_pet_analytics_upload_tip5'),
    ];
    const id = setInterval(() => {
      setState({
        analyzingText: tips[idx++ % tips.length],
      });
    }, 3000);
    try {
      const taskId = await analyzePetFeature({
        ownerId: homeId,
        images,
        miniAppId,
        agentId: AGENT_ID,
      });
      const [infoRes] = await Promise.all([
        loopGetAnalysisResult({
          taskId,
          controller,
          type: AnalysType.Profile,
        }),
      ]);
      if (infoRes) {
        setState({
          similarShow: true,
          petResInfo: infoRes,
        });
      } else if (infoRes) {
        emitter.emit('selectProfile', infoRes);
        enter('success');
      } else {
        // 超时
        enter('failed');
      }
    } catch (error) {
      enter('failed');
      return;
    } finally {
      clearInterval(id);
    }
  };
// 上传图片
export async function uploadImage(filePath: string, bizType: UploadFileBizType) {
  const fileName = parseFileName(filePath);
  const signInfo = await getPetUploadSign({ bizType, fileName });
  const { url, objectKey } = signInfo;
  await uploadFile(url, filePath, fileName);
  return { cloudKey: objectKey };
}
主要展示家庭下宠物进食情况,当宠物来进食时,会识别出具体哪只宠物,并生成一条进食记录。

const [eatingRecords, setEatingRecords] = useState<IEatingRecord[]>([]);
const getData = async (pageNo: number, isFresh?: boolean) => {
  try {
    const day = dayjs();
    const startOfDay = day.clone().startOf('day').valueOf();
    const endOfDay = day.clone().endOf('day').valueOf();
    setRefreshing(!!isFresh);
    const eatParams = {
      ownerId: homeId,
      uuid: getDevInfo().uuid,
      startTime: startOfDay,
      endTime: endOfDay,
      pageNo,
      pageSize: 100,
    };
    const { pageNo: pageNumber, hasNext, data = [] } = await fetchPetEatingRecordApi(eatParams);
    setRefreshing(false);
    setHasNext(hasNext);
    setCurrentPageNo(pageNumber);
    const formattedEatingRecord = data.map(item => {
      const petName = Array.isArray(item.pets)
        ? item.pets.map(p => {
            return find(pets, { id: p.petId })?.name;
          })
        : Strings.getLang('pet');
      return {
        ...item,
        timeStamp: item.recordTime,
        type: RECORD_DATA_TYPE.feed,
        desc: Strings.formatValue('dsc_feed_eating', petName),
      };
    });
    if (pageNo > 1) {
      setEatingRecords([...eatingRecords, ...formattedEatingRecord]);
    } else {
      setEatingRecords(formattedEatingRecord);
    }
  } catch (error) {
    setRefreshing(false);
    console.log('fetch error: ', error);
  }
};
支持用户录制并播放特定场景音频,帮助用户安抚宠物、寻找宠物或进行趣味互动。
 
  
// 初始化录音数据
useEffect(() => {
  dispatch(fetchAudios());
}, []);
// Home组件
<PageContainer
  show={showVoices}
  customStyle="backgroundColor: transparent"
  position="bottom"
  overlayStyle="background: rgba(0, 0, 0, 0.5);"
  onLeave={() => setShowVoices(false)}
  onAfterEnter={() => setReady(true)}
  onClickOverlay={() => setShowVoices(false)}
>
  <Voices ready={ready} onClose={() => setShowVoices(false)} />
</PageContainer>;
// 录音流程
  import { getRecorderManager } from '@ray-js/ray';
  const recordManager = useRef(getRecorderManager());
  const recordFile = useRef<string>();
// (1)开始录音
  const handleStart = () => {
    recordManager.current.start({
      frameSize: undefined,
      format: 'wav',
      success: res => {
        recordFile.current = res.tempFilePath;
        setIsRecording(true);
      },
      fail: err => {
        console.log(err);
      },
    });
  };
// (2)结束录音
  const handleFinish = () => {
    recordManager.current.stop({
      success: () => {
        setIsRecording(false);
        onRecorded(recordFile.current);
      },
    });
  };
// (3)试听录音
  import { getRecorderManager } from '@ray-js/ray';
  const audioContext = useRef<ty.CreateInnerAudioContextTask>();
  audioContext.current = createInnerAudioContext();
  const handleListen = () => {
    if (isPlaying) {
      audioContext.current?.stop?.({
        success: () => {
          setIsPlaying(false);
        },
      });
    } else {
      audioContext.current?.play?.({
        src: file,
        autoplay: true,
        loop: false,
        success: () => {
          setIsPlaying(true);
        },
      });
    }
  };
  // (4)保存录音
    const handleSave = async () => {
    try {
      const {
        data: { inputValue },
      } = await DialogInstance.input({
        context: this,
        title: Strings.getLang('dsc_input_audio_name'),
        overlayStyle: { background: 'transparent' },
        value: '',
        cancelButtonText: Strings.getLang('dsc_cancel'),
        confirmButtonText: Strings.getLang('dsc_confirm'),
        selector: '#smart-dialog-voice',
      });
      if (file) {
        try {
          showLoading({
            title: Strings.getLang('dsc_uploading'),
          });
          const { cloudKey } = await uploadAudio(
            file,
            'pet_media-device',
            'application/octet-stream'
          );
          await fileRelationSave(
            {
              objectKey: cloudKey,
              fileName: `${inputValue}_@_${duration}`,
            },
            devId
          );
          await (dispatch as AppDispatch)(fetchAudios()).unwrap();
          onSave();
        } catch (err) {
          showToast({
            title: Strings.getLang('dsc_save_fail'),
            icon: 'fail',
          });
        } finally {
          hideLoading();
        }
      }
    } catch (err) {
      console.log(err);
    }
  };
  // (5)删除录音
  import { showLoading, hideLoading, showToast } from '@ray-js/ray';
  const [edit, setEdit] = useState(false);
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
    const handleEdit = async () => {
    if (audiosTotal === 0) return;
    if (edit) {
      try {
        if (selectedIds.length !== 0) {
          showLoading({
            title: '',
          });
          await (dispatch as AppDispatch)(deleteAudios(selectedIds)).unwrap();
        }
        setEdit(false);
        setSelectedIds([]);
      } catch (err) {
        showToast({
          title: Strings.getLang('dsc_delete_failed'),
          icon: 'fail',
        });
      } finally {
        hideLoading();
      }
    } else {
      setEdit(true);
    }
  };
主要支持用户通过双向语音对讲,陪伴宠物,增强宠物的情感联系。

// 核心组件
<IpcPlayer
  objectFit="contain"
  defaultMute={isMute}
  devId={devId}
  onlineStatus={isOnline}
  updateLayout={`${playerLayout}`}
  scalable={false}
  onChangeStreamStatus={handleChangeStreamStatus}
  onCtx={handleCtx}
  onPlayerTap={handlePlayerClick}
  clarity={videoClarityObj[mainDeviceCameraConfig.videoClarity]}
  privateState={dpBasicPrivate ?? false}
  playerStyle={{ borderRadius: 20 }}
/>;
// 导出音视频实例
const handleCtx = (ctx) => {
  dispatch(updateIpcCommon({ playerCtx: ctx }));
};
// 开启双向对讲
export const startTalk = async () => {
  const { isTwoTalking } = store.getState().ipcCommon;
  // 开启对讲
  return new Promise((resolve, reject) => {
    const { playerCtx } = store.getState().ipcCommon;
    playerCtx.ctx.startTalk({
      success: () => {
        if (isTwoTalking) showToast("ipc_3s_can_not_donging", "none");
        resolve(true);
      },
      fail: () => {
        showToast();
        reject();
      },
    });
  });
};
// 关闭双向对讲
export const stopTalk = (showErrorToast = true) => {
  return new Promise((resolve, reject) => {
    const { playerCtx } = store.getState().ipcCommon;
    playerCtx.ctx.stopTalk({
      success: () => {
        resolve(false);
      },
      fail: () => {
        showErrorToast && showToast();
        reject();
      },
    });
  });
};
getAiFilterTemplateData 获取到 B 端客户配置的写真素材资源。chooseMediaAsync 封装方法,支持用户从本地相册批量选择图片或视频素材资源。compressImageAsyncBatch 封装方法,保持原图长宽比裁剪至目标尺寸,再根据文件大小限制进行质量压缩。addPhotosAsync 封装方法,将宠物素材资源缓存于 App 沙盒中,便于后续二次编辑。createForegroundVideoService 方法,初始化宠物写真服务实例。processPetForegroundMediaByTemplate 方法,输入参数进行宠物写真生成并返回写真媒体资源。destroyForegroundVideoService 方法销毁实例,避免内存泄漏。 
  
 
// 初始化写真素材资源
useEffect(() => {
  // 拉取滤镜类型数据
  getAiFilterTemplateData()
    .then(res => {
      console.log('~ getAiFilterTemplateData res ~', res);
      const tempRes = {};
      res.forEach(item => {
        tempRes[item.style] = item.templates;
      });
      console.log('~ getAiFilterTemplateData tempRes ~', tempRes);
      dispatch(initAiFilterData(tempRes));
    })
    .catch(err => {
      console.log('~ getAiFilterTemplateData ~', err);
    });
}, []);
// 选择宠物素材资源
const handleAdd = React.useCallback(
  (type: string) => {
    authorizeAsync({ scope: 'scope.writePhotosAlbum' })
      .then(() => {
        return chooseMediaAsync({
          count: type === 'image' ? MAX_CHOOSE_IMAGE_NUM : MAX_CHOOSE_VIDEO_NUM,
          mediaType: type,
          sourceType: ['album'],
          isFetchVideoFile: false,
          isGetAlbumFileName: true,
        });
      })
      .then(res => {
        showLoading({ title: Strings.getLang('loadingTip') });
        const newPhotos = res.tempFiles.map(d => ({
          ...d,
          fileType: d.fileType as 'image' | 'video',
        }));
        // 筛选出重复照片
        const filterPhotos = newPhotos.filter(d =>
          photos.find(p => p.tempFilePath === d.tempFilePath || p.originUrl === d.tempFilePath)
        );
        if (filterPhotos?.length) {
          showToast({ title: Strings.getLang('samePhotoTip'), icon: 'none' });
        }
        const _newPhotos = newPhotos
          .filter(
            d =>
              !photos.find(
                p => p.tempFilePath === d.tempFilePath || p.originUrl === d.tempFilePath
              )
          )
          .map(d => ({ ...d, originUrl: d.tempFilePath }));
        if (!_newPhotos?.length) {
          hideLoading();
          return;
        }
        if (type === 'image') {
          handleImageCompress(_newPhotos);
          return;
        }
        // 视频不做压缩
        hideLoading();
        dispatch(addPhotosAsync(_newPhotos));
      })
      .catch(err => {
        hideLoading();
        console.log('=== authorizeAsync or chooseMediaAsync err', err);
      });
  },
  [photos, resolution]
);
// 处理图片压缩
const handleImageCompress = (_newPhotos) => {
  const { width, height } = getResolution(resolution);
  const _newPhotosPath = _newPhotos.map(d => d.tempFilePath);
  compressImageAsyncBatch(_newPhotosPath, width, height)
    .then(res => {
      hideLoading();
      const { fileList = [] } = (res || {}) as { fileList: string[] };
      const _newPhotosRes = _newPhotos.map((d, index) => ({
        ...d,
        tempFilePath: fileList[index],
      }));
      dispatch(addPhotosAsync(_newPhotosRes));
      handleImageThumbnail(_newPhotosRes);
    })
    .catch(err => {
      hideLoading();
      console.error('=== compressImageAsyncBatch err', err);
      showToast({ title: Strings.getLang('addImageError'), icon: 'none' });
    });
};
// 处理图片缩略图
const handleImageThumbnail = (_newPhotosRes) => {
  const _imgCheckedList = _newPhotosRes.map((current, i) => {
    const { fileFullName } = getFileNameAndExtension(current?.tempFilePath || '') || {};
    return {
      id: i + 1,
      src: current?.tempFilePath,
      originUrl: current?.originUrl,
      thumbnail: current?.thumbTempFilePath,
      fileName: fileFullName,
      fileType: current?.fileType,
      title: '',
    };
  });
  const _imgCheckedListPath = _imgCheckedList.map(d => d.src);
  fetchImageThumbnailBatch(_imgCheckedListPath, [])
    .then(res => {
      const _imgCheckedListRes = _imgCheckedList.map((d, index) => ({
        ...d,
        thumbnail: (res[index] || '') as string,
      }));
      dispatch(updateImgCheckedList(_imgCheckedListRes));
    })
    .catch(err => {
      console.error('~ fetchImageThumbnailBatch ~', err);
    });
};
// 宠物写真生成
useEffect(() => {
  clearAiFilterEventId();
  createForegroundVideoService();
  return () => {
    destroyForegroundVideoService();
  };
}, []);
// 生成宠物写真
const changeAppAiFilterImg = (url: string, templateCode: string, aiFilter: any) => {
  if (!templateCode) return;
  showLoading({
    title: Strings.getLang('aiAppFilterTip'),
    mask: true,
  });
  processPetForegroundMediaByTemplate({
    templateObject: {
      effect: aiFilter,
      type: 'APP',
      outputType: 'image',
    },
    mediaSource: url,
    success(res) {
      console.log('createForegroundVideoService', res);
      const path = res.outputPath;
      hideLoading();
      const __tempCurrentImg = {
        ...currentImg,
        filterCode: templateCode,
        filterSrc: path,
      };
      updateCurrentImg(__tempCurrentImg);
      if (path?.slice(-4) === '.mp4') {
        fetchVideoThumbnail(path, __tempCurrentImg);
      } else {
        fetchImageThumbnail(path, __tempCurrentImg);
      }
    },
    fail(err) {
      console.log('createForegroundVideoService err', err);
      hideLoading();
      showToast({
        icon: 'error',
        title: Strings.getLang(`aiFilterFailed${Math.abs(err?.innerError?.errorCode) || ''}`),
      });
    },
  });
};