From 4f088b0e89acab0e961bf97ad092ef9929b8bb9c Mon Sep 17 00:00:00 2001 From: "quemingyi.wudong" Date: Mon, 31 Mar 2025 15:55:54 +0800 Subject: [PATCH] feat: add screen share for vision model & fix subtitle and ui bugs & update sdk version to 4.66.1 --- README.md | 22 +- package.json | 4 +- src/app/base.ts | 2 +- src/assets/img/SCREEN_READER.png | Bin 0 -> 52654 bytes src/assets/img/ScreenCloseNote.svg | 5 + src/assets/img/ScreenOff.svg | 5 + src/assets/img/ScreenOn.svg | 4 + src/components/AISettings/index.module.less | 54 --- src/components/AISettings/index.tsx | 380 +++++++++--------- src/components/AvatarCard/index.module.less | 53 +++ src/components/AvatarCard/index.tsx | 27 +- src/config/common.ts | 28 ++ src/config/config.ts | 33 +- src/lib/RtcClient.ts | 83 +++- src/lib/listenerHooks.ts | 38 +- src/lib/useCommon.ts | 340 +++++++++------- .../MainPage/MainArea/Room/CameraArea.tsx | 98 +++-- .../MainPage/MainArea/Room/Conversation.tsx | 12 +- src/pages/MainPage/MainArea/Room/ToolBar.tsx | 44 +- .../MainPage/MainArea/Room/index.module.less | 15 + .../AISettingAnchor/index.module.less | 44 ++ .../Menu/components/AISettingAnchor/index.tsx | 29 ++ .../Menu/components/Operation/index.tsx | 6 +- src/pages/MainPage/Menu/index.tsx | 43 +- src/store/slices/room.ts | 124 ++++-- src/utils/handler.ts | 14 +- src/utils/utils.ts | 39 +- yarn.lock | 8 +- 28 files changed, 943 insertions(+), 611 deletions(-) create mode 100644 src/assets/img/SCREEN_READER.png create mode 100644 src/assets/img/ScreenCloseNote.svg create mode 100644 src/assets/img/ScreenOff.svg create mode 100644 src/assets/img/ScreenOn.svg create mode 100644 src/pages/MainPage/Menu/components/AISettingAnchor/index.module.less create mode 100644 src/pages/MainPage/Menu/components/AISettingAnchor/index.tsx diff --git a/README.md b/README.md index 2cb00e9..0e89c2d 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,12 @@ ## 【必看】环境准备 - **Node 版本: 16.0+** 1. 需要准备两个 Terminal,分别启动服务端、前端页面。 -2. **根据你自定义的 +2. 开通 ASR、TTS、LLM、RTC 等服务,可通过 [无代码跑通实时对话式](https://console.volcengine.com/rtc/guide) 快速开通服务, 点击 **快速开始** 中的 **跑通 Demo** 进行服务开通。 +3. **根据你自定义的 RoomId、UserId 以及申请的 AppID、BusinessID(如有)、Token、ASR AppID、TTS AppID,修改 `src/config/config.ts` 文件中 `ConfigFactory` 中 `BaseConfig` 的配置信息**。 -3. 使用火山引擎控制台账号的 [AK、SK](https://console.volcengine.com/iam/keymanage?s=g)、[SessionToken](https://www.volcengine.com/docs/6348/1315561#sub?s=g)(临时token, 子账号才需要), 修改 `Server/app.js` 文件中的 `ACCOUNT_INFO`。 -4. 您需要在 [火山方舟-在线推理](https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint?config=%7B%7D&s=g) 中创建接入点, 并将模型对应的接入点 ID 填入 `src/config/common.ts` 文件中的 `ARK_V3_MODEL_ID`, 否则无法正常启动智能体。 -5. 如果您已经自行完成了服务端的逻辑,可以不依赖 Demo 中的 Server,直接修改前端代码文件 `src/config/index.ts` 中的 `AIGC_PROXY_HOST` 请求域名和接口,并在 `src/app/api.ts` 中修改接口的参数配置 `APIS_CONFIG`。 +4. 使用火山引擎控制台账号的 [AK、SK](https://console.volcengine.com/iam/keymanage?s=g)、[SessionToken](https://www.volcengine.com/docs/6348/1315561#sub?s=g)(临时token, 子账号才需要), 修改 `Server/app.js` 文件中的 `ACCOUNT_INFO`。 +5. 您需要在 [火山方舟-在线推理](https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint?config=%7B%7D&s=g) 中创建接入点, 并将模型对应的接入点 ID 填入 `src/config/common.ts` 文件中的 `ARK_V3_MODEL_ID`, 否则无法正常启动智能体。 +6. 如果您已经自行完成了服务端的逻辑,可以不依赖 Demo 中的 Server,直接修改前端代码文件 `src/config/index.ts` 中的 `AIGC_PROXY_HOST` 请求域名和接口,并在 `src/app/api.ts` 中修改接口的参数配置 `APIS_CONFIG`。 ## 快速开始 请注意,服务端和 Web 端都需要启动, 启动步骤如下: @@ -44,8 +45,8 @@ yarn dev | :-- | :-- | | **启动智能体之后, 对话无反馈,或者一直停留在 "AI 准备中, 请稍侯"** |
  • 可能因为控制台中相关权限没有正常授予,请参考[流程](https://www.volcengine.com/docs/6348/1315561?s=g)再次确认下是否完成相关操作。此问题的可能性较大,建议仔细对照是否已经将相应的权限开通。
  • 参数传递可能有问题, 例如参数大小写、类型等问题,请再次确认下这类型问题是否存在。
  • 相关资源可能未开通或者用量不足,请再次确认。
  • **请检查当前使用的模型 ID 等内容都是正确且可用的。**
  • | | `Server/app.js` 中的 `sessionToken` 是什么,该怎么填,为什么要填 | `sessionToken` 是火山引擎子账号发起 OpenAPI 请求时所必须携带的临时 Token,获取方式可参考 [此文章末尾](https://www.volcengine.com/docs/6348/1315561?s=g)。 | -| **浏览器报了 `Uncaught (in promise) r: token_error` 错误** | 请检查您填在项目中的 RTC Token 是否合法,检测用于生成 Token 的 UserId、RoomId 是否与项目中填写的一致。 | -| [StartVoiceChat]Failed(Reason: The task has been started. Please do not call the startup task interface repeatedly.) 报错 | 由于目前设置的 RoomId、UserId 为固定值,重复调用 startAudioBot 会导致出错,只需先调用 stopAudioBot 后再重新 startAudioBot 即可。 | +| **浏览器报了 `Uncaught (in promise) r: token_error` 错误** | 请检查您填在项目中的 RTC Token 是否合法,检测用于生成 Token 的 UserId、RoomId 是否与项目中填写的一致;或者 Token 可能过期, 可尝试重新生成下。 | +| **[StartVoiceChat]Failed(Reason: The task has been started. Please do not call the startup task interface repeatedly.)** 报错 | 由于目前设置的 RoomId、UserId 为固定值,重复调用 startAudioBot 会导致出错,只需先调用 stopAudioBot 后再重新 startAudioBot 即可。 | | 为什么我的麦克风正常、摄像头也正常,但是设备没有正常工作? | 可能是设备权限未授予,详情可参考 [Web 排查设备权限获取失败问题](https://www.volcengine.com/docs/6348/1356355?s=g)。 | | 接口调用时, 返回 "Invalid 'Authorization' header, Pls check your authorization header" 错误 | `Server/app.js` 中的 AK/SK/SessionToken 不正确 | | 什么是 RTC | **R**eal **T**ime **C**ommunication, RTC 的概念可参考[官网文档](https://www.volcengine.com/docs/6348/66812?s=g)。 | @@ -57,3 +58,12 @@ yarn dev - [场景介绍](https://www.volcengine.com/docs/6348/1310537?s=g) - [Demo 体验](https://www.volcengine.com/docs/6348/1310559?s=g) - [场景搭建方案](https://www.volcengine.com/docs/6348/1310560?s=g) + +## 更新日志 + +### [1.5.0] - [2025-03-31] +- 修复部分 UI 问题 +- 追加屏幕共享能力 (视觉模型可用,**读屏助手** 人设下可使用) +- 修改字幕逻辑,避免字幕回调中标点符号、大小写不一致引起的字幕重复问题 +- 更新 RTC Web SDK 版本至 4.66.1 +- 追加设备权限未授予时的提示 \ No newline at end of file diff --git a/package.json b/package.json index 1b64c3c..8f477f8 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "aigc", - "version": "1.4.0", + "version": "1.5.0", "license": "BSD-3-Clause", "private": true, "dependencies": { "@reduxjs/toolkit": "^1.8.3", - "@volcengine/rtc": "4.58.9", + "@volcengine/rtc": "4.66.1", "@arco-design/web-react": "^2.65.0", "autolinker": "^4.0.0", "i18next": "^21.8.16", diff --git a/src/app/base.ts b/src/app/base.ts index 2ad8372..6f469c4 100644 --- a/src/app/base.ts +++ b/src/app/base.ts @@ -63,6 +63,6 @@ export const resultHandler = (res: any) => { const error = ResponseMetadata?.Error?.Message || Result; Modal.error({ title: '接口调用错误', - content: `[${ResponseMetadata?.Action}]Failed(Reason: ${error})`, + content: `[${ResponseMetadata?.Action}]Failed(Reason: ${error}), 请参考 README 文档排查问题。`, }); }; diff --git a/src/assets/img/SCREEN_READER.png b/src/assets/img/SCREEN_READER.png new file mode 100644 index 0000000000000000000000000000000000000000..2f179164c5eb78da67ba544f42bd1f63eaf8b771 GIT binary patch literal 52654 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 DQvYg3 literal 0 HcmV?d00001 diff --git a/src/assets/img/ScreenCloseNote.svg b/src/assets/img/ScreenCloseNote.svg new file mode 100644 index 0000000..434afbe --- /dev/null +++ b/src/assets/img/ScreenCloseNote.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/assets/img/ScreenOff.svg b/src/assets/img/ScreenOff.svg new file mode 100644 index 0000000..57fdd64 --- /dev/null +++ b/src/assets/img/ScreenOff.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/assets/img/ScreenOn.svg b/src/assets/img/ScreenOn.svg new file mode 100644 index 0000000..067d5e9 --- /dev/null +++ b/src/assets/img/ScreenOn.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/components/AISettings/index.module.less b/src/components/AISettings/index.module.less index 4f01ac6..61d7ce5 100644 --- a/src/components/AISettings/index.module.less +++ b/src/components/AISettings/index.module.less @@ -152,60 +152,6 @@ } - -.button { - position: relative; - width: max-content !important; - height: 24px !important; - margin-top: 8px; - border-radius: 4px !important; - font-size: 12px !important; - background: linear-gradient(77.86deg, rgba(229, 242, 255, 0.5) -3.23%, rgba(217, 229, 255, 0.5) 51.11%, rgba(246, 226, 255, 0.5) 98.65%); - cursor: pointer; - - .button-text { - background: linear-gradient(77.86deg, #3384FF -3.23%, #014BDE 51.11%, #A945FB 98.65%); - -webkit-background-clip: text; - background-clip: text; - color: transparent; - font-weight: 500; - line-height: 20px; - text-align: center; - } -} - -.button::after { - content: ''; - position: absolute; - border-radius: 3px; - top: 0px; - left: 0px; - width: 100%; - height: 22px; - background: white; - z-index: -1; -} - -.button::before { - content: ''; - position: absolute; - border-radius: 5px; - top: -2px; - left: -2px; - width: calc(100% + 4px); - height: 26px; - background: linear-gradient(90deg, rgba(0, 139, 255, 0.5) 0%, rgba(0, 98, 255, 0.5) 49.5%, rgba(207, 92, 255, 0.5) 100%); - z-index: -2; -} - -.button:hover { - background: linear-gradient(77.86deg, rgba(200, 220, 255, 0.7) -3.23%, rgba(190, 210, 255, 0.7) 51.11%, rgba(230, 210, 255, 0.7) 98.65%); -} - -.button:active { - background: linear-gradient(77.86deg, rgba(170, 190, 255, 0.9) -3.23%, rgba(160, 180, 255, 0.9) 51.11%, rgba(210, 180, 255, 0.9) 98.65%); -} - .footer { width: calc(100% - 12px); display: flex; diff --git a/src/components/AISettings/index.tsx b/src/components/AISettings/index.tsx index 8831d01..1cbbf27 100644 --- a/src/components/AISettings/index.tsx +++ b/src/components/AISettings/index.tsx @@ -7,6 +7,7 @@ import { Button, Drawer, Input, Message } from '@arco-design/web-react'; import { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { IconSwap } from '@arco-design/web-react/icon'; +import { StreamIndex } from '@volcengine/rtc'; import CheckIcon from '../CheckIcon'; import Config, { Icon, @@ -20,6 +21,7 @@ import Config, { ModelSourceType, VOICE_INFO_MAP, VOICE_TYPE, + isVisionMode, } from '@/config'; import TitleCard from '../TitleCard'; import CheckBoxSelector from '@/components/CheckBoxSelector'; @@ -28,15 +30,22 @@ import { clearHistoryMsg, updateAIConfig, updateScene } from '@/store/slices/roo import { RootState } from '@/store'; import utils from '@/utils/utils'; import { useDeviceState } from '@/lib/useCommon'; + import VoiceTypeChangeSVG from '@/assets/img/VoiceTypeChange.svg'; import DoubaoModelSVG from '@/assets/img/DoubaoModel.svg'; import ModelChangeSVG from '@/assets/img/ModelChange.svg'; import styles from './index.module.less'; +export interface IAISettingsProps { + open: boolean; + onOk?: () => void; + onCancel?: () => void; +} + const SCENES = [ SCENE.INTELLIGENT_ASSISTANT, + SCENE.SCREEN_READER, SCENE.VIRTUAL_GIRL_FRIEND, - // SCENE.TEACHER, SCENE.TRANSLATE, SCENE.CHILDREN_ENCYCLOPEDIA, SCENE.CUSTOMER_SERVICE, @@ -44,13 +53,13 @@ const SCENES = [ SCENE.CUSTOM, ]; -function AISettings() { +function AISettings({ open, onCancel, onOk }: IAISettingsProps) { const dispatch = useDispatch(); - const { isVideoPublished, switchCamera } = useDeviceState(); + const { isVideoPublished, isScreenPublished, switchScreenCapture, switchCamera } = + useDeviceState(); const room = useSelector((state: RootState) => state.room); const [loading, setLoading] = useState(false); const [use3Part, setUse3Part] = useState(false); - const [open, setOpen] = useState(false); const [scene, setScene] = useState(room.scene); const [data, setData] = useState({ prompt: Prompt[scene], @@ -63,10 +72,6 @@ function AISettings() { customModelName: '', }); - const handleClick = () => { - setOpen(true); - }; - const handleVoiceTypeChanged = (key: string) => { setData((prev) => ({ ...prev, @@ -116,18 +121,33 @@ function AISettings() { Config.WelcomeSpeech = data.welcome; dispatch(updateAIConfig(Config.aigcConfig)); + if (isVisionMode(data.model)) { + switch (scene) { + case SCENE.SCREEN_READER: + /** 关摄像头,打开屏幕采集 */ + room.isJoined && isVideoPublished && switchCamera(); + Config.VisionSourceType = StreamIndex.STREAM_INDEX_SCREEN; + break; + default: + /** 关屏幕采集,打开摄像头 */ + room.isJoined && !isVideoPublished && switchCamera(); + room.isJoined && isScreenPublished && switchScreenCapture(); + Config.VisionSourceType = StreamIndex.STREAM_INDEX_MAIN; + break; + } + } else { + /** 全关 */ + room.isJoined && isVideoPublished && switchCamera(); + room.isJoined && isScreenPublished && switchScreenCapture(); + } + if (RtcClient.getAudioBotEnabled()) { dispatch(clearHistoryMsg()); await RtcClient.updateAudioBot(); } - if (data.model === AI_MODEL.VISION) { - room.isJoined && !isVideoPublished && switchCamera(true); - } else { - room.isJoined && isVideoPublished && switchCamera(true); - } setLoading(false); - setOpen(false); + onOk?.(); }; useEffect(() => { @@ -137,193 +157,193 @@ function AISettings() { }, [open]); return ( - <> - - -
    AI 配置修改后,退出房间将不再保存该配置方案
    - - - - } - visible={open} - onCancel={() => setOpen(false)} - > -
    - 选择你所需要的 - AI 人设 + +
    AI 配置修改后,退出房间将不再保存该配置方案
    + +
    -
    - 我们已为您配置好对应人设的基本参数,您也可以根据自己的需求进行自定义设置 -
    -
    - {SCENES.map((key) => ( + } + visible={open} + onCancel={onCancel} + > +
    + 选择你所需要的 + AI 人设 +
    +
    + 我们已为您配置好对应人设的基本参数,您也可以根据自己的需求进行自定义设置 +
    +
    + {[...SCENES, null].map((key) => + key ? ( handleChecked(key as SCENE)} /> - ))} -
    -
    - {utils.isMobile() ? null : ( -
    - )} - - { - setData((prev) => ({ - ...prev, - prompt: val, - })); - }} - placeholder="请输入你需要的 Prompt 设定" - /> - - - { - setData((prev) => ({ - ...prev, - welcome: val, - })); - }} - placeholder="请输入欢迎语" - /> - + ) : utils.isMobile() ? ( +
    + ) : null + )} +
    +
    + {utils.isMobile() ? null : (
    - -
    - { - const info = VOICE_INFO_MAP[VOICE_TYPE[type as keyof typeof VOICE_TYPE]]; - return { - key: VOICE_TYPE[type as keyof typeof VOICE_TYPE], - label: type, - icon: info.icon, - description: info.description, - }; - })} - onChange={handleVoiceTypeChanged} - value={data.voice} - moreIcon={VoiceTypeChangeSVG} - moreText="更换音色" - placeHolder="请选择你需要的音色" - /> -
    -
    -
    - {use3Part ? ( - <> - - { - setData((prev) => ({ - ...prev, - Url: val, - })); - }} - placeholder="请输入第三方模型地址" - /> - - - { - setData((prev) => ({ - ...prev, - APIKey: val, - })); - }} - placeholder="请输入请求密钥" - /> - - - { - setData((prev) => ({ - ...prev, - customModelName: val, - })); - }} - placeholder="请输入模型名称" - /> - - - ) : ( - - ({ - key: AI_MODEL[type as keyof typeof AI_MODEL], - label: type.replaceAll('_', ' '), - icon: DoubaoModelSVG, - }))} - moreIcon={ModelChangeSVG} - moreText="更换模型" - placeHolder="请选择你需要的模型" - onChange={(key) => { + /> + )} + + { + setData((prev) => ({ + ...prev, + prompt: val, + })); + }} + placeholder="请输入你需要的 Prompt 设定" + /> + + + { + setData((prev) => ({ + ...prev, + welcome: val, + })); + }} + placeholder="请输入欢迎语" + /> + +
    + +
    + { + const info = VOICE_INFO_MAP[VOICE_TYPE[type as keyof typeof VOICE_TYPE]]; + return { + key: VOICE_TYPE[type as keyof typeof VOICE_TYPE], + label: type, + icon: info.icon, + description: info.description, + }; + })} + onChange={handleVoiceTypeChanged} + value={data.voice} + moreIcon={VoiceTypeChangeSVG} + moreText="更换音色" + placeHolder="请选择你需要的音色" + /> +
    +
    +
    + {use3Part ? ( + <> + + { setData((prev) => ({ ...prev, - model: key as AI_MODEL, + Url: val, })); }} - value={data.model} + placeholder="请输入第三方模型地址" /> - )} + + { + setData((prev) => ({ + ...prev, + APIKey: val, + })); + }} + placeholder="请输入请求密钥" + /> + + + { + setData((prev) => ({ + ...prev, + customModelName: val, + })); + }} + placeholder="请输入模型名称" + /> + + + ) : ( + + ({ + key: AI_MODEL[type as keyof typeof AI_MODEL], + label: type.replaceAll('_', ' '), + icon: DoubaoModelSVG, + }))} + moreIcon={ModelChangeSVG} + moreText="更换模型" + placeHolder="请选择你需要的模型" + onChange={(key) => { + setData((prev) => ({ + ...prev, + model: key as AI_MODEL, + })); + }} + value={data.model} + /> + + )} - -
    +
    - - +
    + ); } diff --git a/src/components/AvatarCard/index.module.less b/src/components/AvatarCard/index.module.less index be6b768..9b8d029 100644 --- a/src/components/AvatarCard/index.module.less +++ b/src/components/AvatarCard/index.module.less @@ -137,4 +137,57 @@ background-color: #EAEDF1; transform: rotate(-90deg); } +} + +.button { + position: relative; + width: max-content !important; + height: 24px !important; + margin-top: 8px; + border-radius: 4px !important; + font-size: 12px !important; + background: linear-gradient(77.86deg, rgba(229, 242, 255, 0.5) -3.23%, rgba(217, 229, 255, 0.5) 51.11%, rgba(246, 226, 255, 0.5) 98.65%); + cursor: pointer; + + .button-text { + background: linear-gradient(77.86deg, #3384FF -3.23%, #014BDE 51.11%, #A945FB 98.65%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + font-weight: 500; + line-height: 20px; + text-align: center; + } +} + +.button::after { + content: ''; + position: absolute; + border-radius: 3px; + top: 0px; + left: 0px; + width: 100%; + height: 22px; + background: white; + z-index: -1; +} + +.button::before { + content: ''; + position: absolute; + border-radius: 5px; + top: -2px; + left: -2px; + width: calc(100% + 4px); + height: 26px; + background: linear-gradient(90deg, rgba(0, 139, 255, 0.5) 0%, rgba(0, 98, 255, 0.5) 49.5%, rgba(207, 92, 255, 0.5) 100%); + z-index: -2; +} + +.button:hover { + background: linear-gradient(77.86deg, rgba(200, 220, 255, 0.7) -3.23%, rgba(190, 210, 255, 0.7) 51.11%, rgba(230, 210, 255, 0.7) 98.65%); +} + +.button:active { + background: linear-gradient(77.86deg, rgba(170, 190, 255, 0.9) -3.23%, rgba(160, 180, 255, 0.9) 51.11%, rgba(210, 180, 255, 0.9) 98.65%); } \ No newline at end of file diff --git a/src/components/AvatarCard/index.tsx b/src/components/AvatarCard/index.tsx index 0ccccc4..f30772a 100644 --- a/src/components/AvatarCard/index.tsx +++ b/src/components/AvatarCard/index.tsx @@ -4,6 +4,8 @@ */ import { useSelector } from 'react-redux'; +import { Button } from '@arco-design/web-react'; +import { useState } from 'react'; import AISettings from '../AISettings'; import style from './index.module.less'; import DouBaoAvatar from '@/assets/img/DoubaoAvatarGIF.webp'; @@ -14,16 +16,24 @@ interface IAvatarCardProps extends React.HTMLAttributes { avatar?: string; } -const ReversedVoiceType = Object.entries(VOICE_TYPE).reduce>((acc, [key, value]) => { - acc[value] = key; - return acc; -}, {}); +const ReversedVoiceType = Object.entries(VOICE_TYPE).reduce>( + (acc, [key, value]) => { + acc[value] = key; + return acc; + }, + {} +); function AvatarCard(props: IAvatarCardProps) { const room = useSelector((state: RootState) => state.room); + const [open, setOpen] = useState(false); const scene = room.scene; const { LLMConfig, TTSConfig } = room.aiConfig.Config || {}; const { avatar, className, ...rest } = props; + const voice = TTSConfig.ProviderParams.audio.voice_type; + + const handleOpenDrawer = () => setOpen(true); + const handleCloseDrawer = () => setOpen(false); return (
    @@ -40,11 +50,12 @@ function AvatarCard(props: IAvatarCardProps) {
    {Name[scene]}
    -
    - 声源来自 {ReversedVoiceType[TTSConfig?.VoiceType || '']} -
    +
    声源来自 {ReversedVoiceType[voice || '']}
    模型 {LLMConfig.ModelName}
    - + +
    diff --git a/src/config/common.ts b/src/config/common.ts index 0aeb7fc..7f0c33d 100644 --- a/src/config/common.ts +++ b/src/config/common.ts @@ -11,6 +11,7 @@ import TRANSLATE from '@/assets/img/TRANSLATE.png'; import CHILDREN_ENCYCLOPEDIA from '@/assets/img/CHILDREN_ENCYCLOPEDIA.png'; import TEACHING_ASSISTANT from '@/assets/img/TEACHING_ASSISTANT.png'; import CUSTOMER_SERVICE from '@/assets/img/CUSTOMER_SERVICE.png'; +import SCREEN_READER from '@/assets/img/SCREEN_READER.png'; export enum ModelSourceType { Custom = 'Custom', @@ -130,9 +131,12 @@ export enum SCENE { CUSTOMER_SERVICE = 'CUSTOMER_SERVICE', CHILDREN_ENCYCLOPEDIA = 'CHILDREN_ENCYCLOPEDIA', TEACHING_ASSISTANT = 'TEACHING_ASSISTANT', + SCREEN_READER = 'SCREEN_READER', CUSTOM = 'CUSTOM', } +export const ScreenShareScene = [SCENE.SCREEN_READER]; + export const Icon = { [SCENE.INTELLIGENT_ASSISTANT]: INTELLIGENT_ASSISTANT, [SCENE.VIRTUAL_GIRL_FRIEND]: VIRTUAL_GIRL_FRIEND, @@ -140,6 +144,7 @@ export const Icon = { [SCENE.CHILDREN_ENCYCLOPEDIA]: CHILDREN_ENCYCLOPEDIA, [SCENE.CUSTOMER_SERVICE]: CUSTOMER_SERVICE, [SCENE.TEACHING_ASSISTANT]: TEACHING_ASSISTANT, + [SCENE.SCREEN_READER]: SCREEN_READER, [SCENE.CUSTOM]: INTELLIGENT_ASSISTANT, }; @@ -150,6 +155,7 @@ export const Name = { [SCENE.CHILDREN_ENCYCLOPEDIA]: '儿童百科', [SCENE.CUSTOMER_SERVICE]: '售后客服', [SCENE.TEACHING_ASSISTANT]: '课后助教', + [SCENE.SCREEN_READER]: '读屏助手', [SCENE.CUSTOM]: '自定义', }; @@ -163,6 +169,7 @@ export const Welcome = { [SCENE.CHILDREN_ENCYCLOPEDIA]: '你好小朋友,你的小脑袋里又有什么问题啦?', [SCENE.CUSTOMER_SERVICE]: '感谢您在我们餐厅用餐,请问您有什么问题需要反馈吗?', [SCENE.TEACHING_ASSISTANT]: '你碰到什么问题啦?让我来帮帮你。', + [SCENE.SCREEN_READER]: '欢迎使用读屏助手, 请开启屏幕采集,我会为你解说屏幕内容。', [SCENE.CUSTOM]: '', }; @@ -173,6 +180,7 @@ export const Model = { [SCENE.CHILDREN_ENCYCLOPEDIA]: AI_MODEL.DOUBAO_PRO_32K, [SCENE.CUSTOMER_SERVICE]: AI_MODEL.DOUBAO_PRO_32K, [SCENE.TEACHING_ASSISTANT]: AI_MODEL.VISION, + [SCENE.SCREEN_READER]: AI_MODEL.VISION, [SCENE.CUSTOM]: AI_MODEL.DOUBAO_PRO_32K, }; @@ -183,6 +191,7 @@ export const Voice = { [SCENE.CHILDREN_ENCYCLOPEDIA]: VOICE_TYPE.通用女声, [SCENE.CUSTOMER_SERVICE]: VOICE_TYPE.通用女声, [SCENE.TEACHING_ASSISTANT]: VOICE_TYPE.通用女声, + [SCENE.SCREEN_READER]: VOICE_TYPE.通用男声, [SCENE.CUSTOM]: VOICE_TYPE.通用女声, }; @@ -213,6 +222,7 @@ export const Questions = { '你们空调开得太冷了。', ], [SCENE.TEACHING_ASSISTANT]: ['这个单词是什么意思?', '这道题该怎么做?', '我的表情是什么样的?'], + [SCENE.SCREEN_READER]: ['屏幕里这是什么?', '这道题你会做吗?', '帮我翻译解说下屏幕里的内容?'], [SCENE.CUSTOM]: ['你能帮我解决什么问题?', '今天北京天气怎么样?', '你喜欢哪位流行歌手?'], }; @@ -299,5 +309,23 @@ export const Prompt = { ##约束 - 回答问题要简明扼要,避免复杂冗长的表述,尽量不超过50个字; - 回答中不要有“图片”、“图中”等相关字眼;`, + [SCENE.SCREEN_READER]: `##人设 +你是人们的 AI 伙伴,可以通过 【屏幕共享实时解析】+【百科知识】来为人们提供服务。 + +##技能 +1. 实时理解屏幕中的内容,包括图片、文字、窗口焦点,自动捕捉光标轨迹; +2. 拥有丰富的百科知识; +3. 如果用户询问与视频和图片有关的问题,请结合【屏幕共享实时解析】的内容、你的【知识】和【用户问题】进行回答; + +##风格 +语言风格可以随着屏幕内容和用户需求调整,可以是幽默搞笑的娱乐解说,也可以是严谨硬核的技术分析。 +- 如果屏幕内容是娱乐节目、动画、游戏等,语言风格偏幽默、活波一些,可以使用夸张的比喻、流行梗、弹幕互动式语言; +- 如果屏幕内容是办公软件、新闻、文章等,语言风格偏专业、正经一些。 + +## 约束 +不要有任何特殊标点符号和任何 Markdown 格式输出,例如 *,# 等。 +`, [SCENE.CUSTOM]: '', }; + +export const isVisionMode = (model: AI_MODEL) => model.startsWith('Vision'); diff --git a/src/config/config.ts b/src/config/config.ts index cdfa7b3..24d6719 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -3,6 +3,7 @@ * SPDX-license-identifier: BSD-3-Clause */ +import { StreamIndex } from '@volcengine/rtc'; import { TTS_CLUSTER, ARK_V3_MODEL_ID, @@ -16,6 +17,7 @@ import { AI_MODEL, AI_MODE_MAP, AI_MODEL_MODE, + isVisionMode, } from '.'; export const CONVERSATION_SIGNATURE = 'conversation'; @@ -37,6 +39,7 @@ export class ConfigFactory { BusinessId: undefined, /** * @brief 必填, 房间 ID, 自定义即可,例如 "Room123"。 + * @note 建议使用有特定规则、不重复的房间号名称。 */ RoomId: 'Room123', /** @@ -59,7 +62,7 @@ export class ConfigFactory { TTSAppId: 'Your TTS AppId', /** * @brief 已开通需要的语音合成服务的token。 - * 使用火山引擎双向流式语音合成服务时必填。 + * 使用火山引擎双向流式语音合成服务时 必填。 */ TTSToken: undefined, /** @@ -69,7 +72,7 @@ export class ConfigFactory { ASRAppId: 'Your ASR AppId', /** * @brief 已开通流式语音识别大模型服务 AppId 对应的 Access Token。 - * 使用流式语音识别大模型服务时该参数为必填。 + * 使用流式语音识别大模型服务时该参数为 必填。 */ ASRToken: undefined, }; @@ -116,6 +119,11 @@ export class ConfigFactory { */ InterruptMode = true; + /** + * @brief 如果使用视觉模型,用的是哪种源,有摄像头采集流/屏幕流 + */ + VisionSourceType = StreamIndex.STREAM_INDEX_MAIN; + get LLMConfig() { const params: Record = { Mode: AI_MODE_MAP[this.Model || ''] || AI_MODEL_MODE.CUSTOM, @@ -134,16 +142,21 @@ export class ConfigFactory { Url: this.Url, Feature: JSON.stringify({ Http: true }), }; - if (this.Model === AI_MODEL.VISION) { + if (isVisionMode(this.Model)) { params.VisionConfig = { Enable: true, + SnapshotConfig: { + StreamType: this.VisionSourceType, + Height: 640, + ImagesLimit: 1, + }, }; } return params; } get ASRConfig() { - return { + const params: Record = { Provider: 'volcano', ProviderParams: { /** @@ -152,7 +165,6 @@ export class ConfigFactory { */ Mode: 'smallmodel', AppId: this.BaseConfig.ASRAppId, - ...(this.BaseConfig.ASRToken ? { AccessToken: this.BaseConfig.ASRToken } : {}), /** * @note 具体流式语音识别服务对应的 Cluster ID,可在流式语音服务控制台开通对应服务后查询。 * 具体链接为: https://console.volcengine.com/speech/service/16?s=g @@ -165,15 +177,18 @@ export class ConfigFactory { }, VolumeGain: 0.3, }; + if (this.BaseConfig.ASRToken) { + params.ProviderParams.AccessToken = this.BaseConfig.ASRToken; + } + return params; } get TTSConfig() { - return { + const params: Record = { Provider: 'volcano', ProviderParams: { app: { AppId: this.BaseConfig.TTSAppId, - ...(this.BaseConfig.TTSToken ? { Token: this.BaseConfig.TTSToken } : {}), Cluster: TTS_CLUSTER.TTS, }, audio: { @@ -183,6 +198,10 @@ export class ConfigFactory { }, IgnoreBracketText: [1, 2, 3, 4, 5], }; + if (this.BaseConfig.TTSToken) { + params.ProviderParams.app.Token = this.BaseConfig.TTSToken; + } + return params; } get aigcConfig() { diff --git a/src/lib/RtcClient.ts b/src/lib/RtcClient.ts index 8b2490f..b1fe23f 100644 --- a/src/lib/RtcClient.ts +++ b/src/lib/RtcClient.ts @@ -3,7 +3,6 @@ * SPDX-license-identifier: BSD-3-Clause */ - import VERTC, { MirrorType, StreamIndex, @@ -23,8 +22,10 @@ import VERTC, { PlayerEvent, NetworkQuality, VideoRenderMode, + ScreenEncoderConfig, } from '@volcengine/rtc'; import RTCAIAnsExtension from '@volcengine/rtc/extension-ainr'; +import { Message } from '@arco-design/web-react'; import openAPIs from '@/app/api'; import aigcConfig from '@/config'; import Utils from '@/utils/utils'; @@ -34,6 +35,7 @@ export interface IEventListener { handleError: (e: { errorCode: any }) => void; handleUserJoin: (e: onUserJoinedEvent) => void; handleUserLeave: (e: onUserLeaveEvent) => void; + handleTrackEnded: (e: { kind: string; isScreen: boolean }) => void; handleUserPublishStream: (e: { userId: string; mediaType: MediaType }) => void; handleUserUnpublishStream: (e: { userId: string; @@ -45,7 +47,6 @@ export interface IEventListener { handleLocalAudioPropertiesReport: (e: LocalAudioPropertiesInfo[]) => void; handleRemoteAudioPropertiesReport: (e: RemoteAudioPropertiesInfo[]) => void; handleAudioDeviceStateChanged: (e: DeviceInfo) => void; - handleUserMessageReceived: (e: { userId: string; message: any }) => void; handleAutoPlayFail: (e: AutoPlayFailedEvent) => void; handlePlayerEvent: (e: PlayerEvent) => void; handleUserStartAudioCapture: (e: { userId: string }) => void; @@ -103,7 +104,9 @@ export class RTCClient { await this.engine.registerExtension(AIAnsExtension); AIAnsExtension.enable(); } catch (error) { - console.error((error as any).message); + console.warn( + `当前环境不支持 AI 降噪, 此错误可忽略, 不影响实际使用, e: ${(error as any).message}` + ); } }; @@ -111,6 +114,7 @@ export class RTCClient { handleError, handleUserJoin, handleUserLeave, + handleTrackEnded, handleUserPublishStream, handleUserUnpublishStream, handleRemoteStreamStats, @@ -118,7 +122,6 @@ export class RTCClient { handleLocalAudioPropertiesReport, handleRemoteAudioPropertiesReport, handleAudioDeviceStateChanged, - handleUserMessageReceived, handleAutoPlayFail, handlePlayerEvent, handleUserStartAudioCapture, @@ -129,6 +132,7 @@ export class RTCClient { this.engine.on(VERTC.events.onError, handleError); this.engine.on(VERTC.events.onUserJoined, handleUserJoin); this.engine.on(VERTC.events.onUserLeave, handleUserLeave); + this.engine.on(VERTC.events.onTrackEnded, handleTrackEnded); this.engine.on(VERTC.events.onUserPublishStream, handleUserPublishStream); this.engine.on(VERTC.events.onUserUnpublishStream, handleUserUnpublishStream); this.engine.on(VERTC.events.onRemoteStreamStats, handleRemoteStreamStats); @@ -136,7 +140,6 @@ export class RTCClient { this.engine.on(VERTC.events.onAudioDeviceStateChanged, handleAudioDeviceStateChanged); this.engine.on(VERTC.events.onLocalAudioPropertiesReport, handleLocalAudioPropertiesReport); this.engine.on(VERTC.events.onRemoteAudioPropertiesReport, handleRemoteAudioPropertiesReport); - this.engine.on(VERTC.events.onUserMessageReceived, handleUserMessageReceived); this.engine.on(VERTC.events.onAutoplayFailed, handleAutoPlayFail); this.engine.on(VERTC.events.onPlayerEvent, handlePlayerEvent); this.engine.on(VERTC.events.onUserStartAudioCapture, handleUserStartAudioCapture); @@ -193,21 +196,42 @@ export class RTCClient { audioOutputs: MediaDeviceInfo[]; videoInputs: MediaDeviceInfo[]; }> { - const { video, audio = true } = props || {}; + const { video = false, audio = true } = props || {}; let audioInputs: MediaDeviceInfo[] = []; let audioOutputs: MediaDeviceInfo[] = []; let videoInputs: MediaDeviceInfo[] = []; + const { video: hasVideoPermission, audio: hasAudioPermission } = await VERTC.enableDevices({ + video, + audio, + }); if (audio) { const inputs = await VERTC.enumerateAudioCaptureDevices(); const outputs = await VERTC.enumerateAudioPlaybackDevices(); audioInputs = inputs.filter((i) => i.deviceId && i.kind === 'audioinput'); audioOutputs = outputs.filter((i) => i.deviceId && i.kind === 'audiooutput'); this._audioCaptureDevice = audioInputs.filter((i) => i.deviceId)?.[0]?.deviceId; + if (hasAudioPermission) { + if (!audioInputs?.length) { + Message.error('无麦克风设备, 请先确认设备情况。'); + } + if (!audioOutputs?.length) { + Message.error('无扬声器设备, 请先确认设备情况。'); + } + } else { + Message.error('暂无麦克风设备权限, 请先确认设备权限授予情况。'); + } } if (video) { videoInputs = await VERTC.enumerateVideoCaptureDevices(); videoInputs = videoInputs.filter((i) => i.deviceId && i.kind === 'videoinput'); this._videoCaptureDevice = videoInputs?.[0]?.deviceId; + if (hasVideoPermission) { + if (!videoInputs?.length) { + Message.error('无摄像头设备, 请先确认设备情况。'); + } + } else { + Message.error('暂无摄像头设备权限, 请先确认设备权限授予情况。'); + } } return { @@ -226,6 +250,16 @@ export class RTCClient { await this.engine.stopVideoCapture(); }; + startScreenCapture = async (enableAudio = false) => { + await this.engine.startScreenCapture({ + enableAudio, + }); + }; + + stopScreenCapture = async () => { + await this.engine.stopScreenCapture(); + }; + startAudioCapture = async (mic?: string) => { await this.engine.startAudioCapture(mic || this._audioCaptureDevice); }; @@ -242,6 +276,18 @@ export class RTCClient { this.engine.unpublishStream(mediaType); }; + publishScreenStream = async (mediaType: MediaType) => { + await this.engine.publishScreen(mediaType); + }; + + unpublishScreenStream = async (mediaType: MediaType) => { + await this.engine.unpublishScreen(mediaType); + }; + + setScreenEncoderConfig = async (description: ScreenEncoderConfig) => { + await this.engine.setScreenEncoderConfig(description); + }; + /** * @brief 设置业务标识参数 * @param businessId @@ -286,12 +332,19 @@ export class RTCClient { return this.engine.setLocalVideoMirrorType(type); }; - setLocalVideoPlayer = (userId: string, renderDom?: string | HTMLElement) => { - return this.engine.setLocalVideoPlayer(StreamIndex.STREAM_INDEX_MAIN, { - renderDom, - userId, - renderMode: VideoRenderMode.RENDER_MODE_HIDDEN, - }); + setLocalVideoPlayer = ( + userId: string, + renderDom?: string | HTMLElement, + isScreenShare = false + ) => { + return this.engine.setLocalVideoPlayer( + isScreenShare ? StreamIndex.STREAM_INDEX_SCREEN : StreamIndex.STREAM_INDEX_MAIN, + { + renderDom, + userId, + renderMode: VideoRenderMode.RENDER_MODE_FILL, + } + ); }; /** @@ -344,11 +397,7 @@ export class RTCClient { /** * @brief 命令 AIGC */ - commandAudioBot = ( - command: COMMAND, - interruptMode = INTERRUPT_PRIORITY.NONE, - message = '' - ) => { + commandAudioBot = (command: COMMAND, interruptMode = INTERRUPT_PRIORITY.NONE, message = '') => { if (this.audioBotEnabled) { this.engine.sendUserBinaryMessage( aigcConfig.BotName, diff --git a/src/lib/listenerHooks.ts b/src/lib/listenerHooks.ts index ff2249c..af94c00 100644 --- a/src/lib/listenerHooks.ts +++ b/src/lib/listenerHooks.ts @@ -30,14 +30,11 @@ import { addAutoPlayFail, removeAutoPlayFail, updateAITalkState, - setHistoryMsg, - setCurrentMsg, updateNetworkQuality, } from '@/store/slices/room'; import RtcClient, { IEventListener } from './RtcClient'; import { setMicrophoneList, updateSelectedDevice } from '@/store/slices/device'; -import Utils from '@/utils/utils'; import { useMessageHandler } from '@/utils/handler'; const useRtcListeners = (): IEventListener => { @@ -45,12 +42,19 @@ const useRtcListeners = (): IEventListener => { const { parser } = useMessageHandler(); const playStatus = useRef<{ [key: string]: { audio: boolean; video: boolean } }>({}); - const debounceSetHistoryMsg = Utils.debounce((text: string, user: string) => { - const isAudioEnable = RtcClient.getAudioBotEnabled(); - if (isAudioEnable) { - dispatch(setHistoryMsg({ text, user })); + const handleTrackEnded = async (event: { kind: string; isScreen: boolean }) => { + const { kind, isScreen } = event; + /** 浏览器自带的屏幕共享关闭触发方式,通过 onTrackEnd 事件去关闭 */ + if (isScreen && kind === 'video') { + await RtcClient.stopScreenCapture(); + await RtcClient.unpublishScreenStream(MediaType.VIDEO); + dispatch( + updateLocalUser({ + publishScreen: false, + }) + ); } - }, 600); + }; const handleUserJoin = (e: onUserJoinedEvent) => { const extraInfo = JSON.parse(e.userInfo.extraInfo || '{}'); @@ -167,22 +171,6 @@ const useRtcListeners = (): IEventListener => { } }; - const handleUserMessageReceived = (e: { userId: string; message: any }) => { - /** debounce 记录用户输入文字 */ - if (e.message) { - const msgObj = JSON.parse(e.message || '{}'); - if (msgObj.text) { - const { text: msg, definite, user_id: user } = msgObj; - if ((window as any)._debug_mode) { - dispatch(setHistoryMsg({ msg, user })); - } else { - debounceSetHistoryMsg(msg, user); - } - dispatch(setCurrentMsg({ msg, definite, user })); - } - } - }; - const handleAutoPlayFail = (event: AutoPlayFailedEvent) => { const { userId, kind } = event; let playUser = playStatus.current?.[userId] || {}; @@ -264,6 +252,7 @@ const useRtcListeners = (): IEventListener => { handleError, handleUserJoin, handleUserLeave, + handleTrackEnded, handleUserPublishStream, handleUserUnpublishStream, handleRemoteStreamStats, @@ -271,7 +260,6 @@ const useRtcListeners = (): IEventListener => { handleLocalAudioPropertiesReport, handleRemoteAudioPropertiesReport, handleAudioDeviceStateChanged, - handleUserMessageReceived, handleAutoPlayFail, handlePlayerEvent, handleUserStartAudioCapture, diff --git a/src/lib/useCommon.ts b/src/lib/useCommon.ts index 1dc25ea..14057c9 100644 --- a/src/lib/useCommon.ts +++ b/src/lib/useCommon.ts @@ -27,7 +27,7 @@ import { setDevicePermissions, } from '@/store/slices/device'; import logger from '@/utils/logger'; -import aigcConfig, { AI_MODEL } from '@/config'; +import aigcConfig, { ScreenShareScene, isVisionMode } from '@/config'; export interface FormProps { username: string; @@ -37,148 +37,9 @@ export interface FormProps { export const useVisionMode = () => { const room = useSelector((state: RootState) => state.room); - return [AI_MODEL.VISION].includes(room.aiConfig?.Config?.LLMConfig.ModelName); -}; - -export const useGetDevicePermission = () => { - const [permission, setPermission] = useState<{ - audio: boolean; - }>(); - - const dispatch = useDispatch(); - - useEffect(() => { - (async () => { - const permission = await RtcClient.checkPermission(); - dispatch(setDevicePermissions(permission)); - setPermission(permission); - })(); - }, [dispatch]); - return permission; -}; - -export const useJoin = (): [ - boolean, - (formValues: FormProps, fromRefresh: boolean) => Promise -] => { - const devicePermissions = useSelector((state: RootState) => state.device.devicePermissions); - const room = useSelector((state: RootState) => state.room); - - const dispatch = useDispatch(); - - const [joining, setJoining] = useState(false); - const listeners = useRtcListeners(); - - const handleAIGCModeStart = async () => { - if (room.isAIGCEnable) { - await RtcClient.stopAudioBot(); - dispatch(clearCurrentMsg()); - await RtcClient.startAudioBot(); - } else { - await RtcClient.startAudioBot(); - } - dispatch(updateAIGCState({ isAIGCEnable: true })); - }; - - async function disPatchJoin(formValues: FormProps): Promise { - if (joining) { - return; - } - - const isSupported = await VERTC.isSupported(); - if (!isSupported) { - Modal.error({ - title: '不支持 RTC', - content: '您的浏览器可能不支持 RTC 功能,请尝试更换浏览器或升级浏览器后再重试。', - }); - return; - } - - setJoining(true); - const { username, roomId } = formValues; - const isVisionMode = aigcConfig.Model === AI_MODEL.VISION; - - const token = aigcConfig.BaseConfig.Token; - - /** 1. Create RTC Engine */ - await RtcClient.createEngine({ - appId: aigcConfig.BaseConfig.AppId, - roomId, - uid: username, - } as any); - - /** 2.1 Set events callbacks */ - RtcClient.addEventListeners(listeners); - - /** 2.2 RTC starting to join room */ - await RtcClient.joinRoom(token!, username); - console.log(' ------ userJoinRoom\n', `roomId: ${roomId}\n`, `uid: ${username}`); - /** 3. Set users' devices info */ - const mediaDevices = await RtcClient.getDevices({ - audio: true, - video: isVisionMode, - }); - - if (devicePermissions.audio) { - try { - await RtcClient.startAudioCapture(); - // RtcClient.setAudioVolume(30); - } catch (e) { - logger.debug('No permission for mic'); - } - } - - if (devicePermissions.video && isVisionMode) { - try { - await RtcClient.startVideoCapture(); - } catch (e) { - logger.debug('No permission for camera'); - } - } - - dispatch( - localJoinRoom({ - roomId, - user: { - username, - userId: username, - publishAudio: true, - publishVideo: devicePermissions.video && isVisionMode, - }, - }) - ); - dispatch( - updateSelectedDevice({ - selectedMicrophone: mediaDevices.audioInputs[0]?.deviceId, - selectedCamera: mediaDevices.videoInputs[0]?.deviceId, - }) - ); - dispatch(updateMediaInputs(mediaDevices)); - - setJoining(false); - - Utils.setSessionInfo({ - username, - roomId, - publishAudio: true, - }); - - handleAIGCModeStart(); - } - - return [joining, disPatchJoin]; -}; - -export const useLeave = () => { - const dispatch = useDispatch(); - - return async function () { - dispatch(localLeaveRoom()); - dispatch(updateAIGCState({ isAIGCEnable: false })); - await Promise.all([RtcClient.stopAudioCapture]); - RtcClient.leaveRoom(); - dispatch(clearHistoryMsg()); - dispatch(clearCurrentMsg()); + return { + isVisionMode: isVisionMode(room.aiConfig?.Config?.LLMConfig.ModelName), + isScreenMode: ScreenShareScene.includes(room.scene), }; }; @@ -188,7 +49,7 @@ export const useDeviceState = () => { const localUser = room.localUser; const isAudioPublished = localUser.publishAudio; const isVideoPublished = localUser.publishVideo; - + const isScreenPublished = localUser.publishScreen; const queryDevices = async (type: MediaType) => { const mediaDevices = await RtcClient.getDevices({ audio: type === MediaType.AUDIO, @@ -220,40 +81,207 @@ export const useDeviceState = () => { return mediaDevices; }; - const switchMic = (publish = true) => { - if (publish) { - !isAudioPublished + const switchMic = async (controlPublish = true) => { + if (controlPublish) { + await (!isAudioPublished ? RtcClient.publishStream(MediaType.AUDIO) - : RtcClient.unpublishStream(MediaType.AUDIO); + : RtcClient.unpublishStream(MediaType.AUDIO)); } queryDevices(MediaType.AUDIO); - !isAudioPublished ? RtcClient.startAudioCapture() : RtcClient.stopAudioCapture(); + await (!isAudioPublished ? RtcClient.startAudioCapture() : RtcClient.stopAudioCapture()); dispatch( updateLocalUser({ - publishAudio: !localUser.publishAudio, + publishAudio: !isAudioPublished, }) ); }; - const switchCamera = (publish = true) => { - if (publish) { - !isVideoPublished + const switchCamera = async (controlPublish = true) => { + if (controlPublish) { + await (!isVideoPublished ? RtcClient.publishStream(MediaType.VIDEO) - : RtcClient.unpublishStream(MediaType.VIDEO); + : RtcClient.unpublishStream(MediaType.VIDEO)); } queryDevices(MediaType.VIDEO); - !localUser.publishVideo ? RtcClient.startVideoCapture() : RtcClient.stopVideoCapture(); + await (!isVideoPublished ? RtcClient.startVideoCapture() : RtcClient.stopVideoCapture()); dispatch( updateLocalUser({ - publishVideo: !localUser.publishVideo, + publishVideo: !isVideoPublished, }) ); }; + const switchScreenCapture = async (controlPublish = true) => { + try { + if (controlPublish) { + await (!isScreenPublished + ? RtcClient.publishScreenStream(MediaType.VIDEO) + : RtcClient.unpublishScreenStream(MediaType.VIDEO)); + } + await (!isScreenPublished ? RtcClient.startScreenCapture() : RtcClient.stopScreenCapture()); + dispatch( + updateLocalUser({ + publishScreen: !isScreenPublished, + }) + ); + } catch { + console.warn('Not Authorized.'); + } + }; + return { isAudioPublished, isVideoPublished, + isScreenPublished, switchMic, switchCamera, + switchScreenCapture, + }; +}; + +export const useGetDevicePermission = () => { + const [permission, setPermission] = useState<{ + audio: boolean; + }>(); + + const dispatch = useDispatch(); + + useEffect(() => { + (async () => { + const permission = await RtcClient.checkPermission(); + dispatch(setDevicePermissions(permission)); + setPermission(permission); + })(); + }, [dispatch]); + return permission; +}; + +export const useJoin = (): [ + boolean, + (formValues: FormProps, fromRefresh: boolean) => Promise +] => { + const devicePermissions = useSelector((state: RootState) => state.device.devicePermissions); + const room = useSelector((state: RootState) => state.room); + + const dispatch = useDispatch(); + + const { switchCamera, switchMic } = useDeviceState(); + const [joining, setJoining] = useState(false); + const listeners = useRtcListeners(); + + const handleAIGCModeStart = async () => { + if (room.isAIGCEnable) { + await RtcClient.stopAudioBot(); + dispatch(clearCurrentMsg()); + await RtcClient.startAudioBot(); + } else { + await RtcClient.startAudioBot(); + } + dispatch(updateAIGCState({ isAIGCEnable: true })); + }; + + async function disPatchJoin(formValues: FormProps): Promise { + if (joining) { + return; + } + + const isSupported = await VERTC.isSupported(); + if (!isSupported) { + Modal.error({ + title: '不支持 RTC', + content: '您的浏览器可能不支持 RTC 功能,请尝试更换浏览器或升级浏览器后再重试。', + }); + return; + } + + setJoining(true); + const { username, roomId } = formValues; + const isVision = isVisionMode(aigcConfig.Model); + const shouldGetVideoPermission = isVision && !ScreenShareScene.includes(room.scene); + + const token = aigcConfig.BaseConfig.Token; + + /** 1. Create RTC Engine */ + const engineParams = { + appId: aigcConfig.BaseConfig.AppId, + roomId, + uid: username, + }; + await RtcClient.createEngine(engineParams); + + /** 2.1 Set events callbacks */ + RtcClient.addEventListeners(listeners); + + /** 2.2 RTC starting to join room */ + await RtcClient.joinRoom(token!, username); + console.log(' ------ userJoinRoom\n', `roomId: ${roomId}\n`, `uid: ${username}`); + /** 3. Set users' devices info */ + const mediaDevices = await RtcClient.getDevices({ + audio: true, + video: shouldGetVideoPermission, + }); + + dispatch( + localJoinRoom({ + roomId, + user: { + username, + userId: username, + }, + }) + ); + dispatch( + updateSelectedDevice({ + selectedMicrophone: mediaDevices.audioInputs[0]?.deviceId, + selectedCamera: mediaDevices.videoInputs[0]?.deviceId, + }) + ); + dispatch(updateMediaInputs(mediaDevices)); + + setJoining(false); + + if (devicePermissions.audio) { + try { + await switchMic(); + // RtcClient.setAudioVolume(30); + } catch (e) { + logger.debug('No permission for mic'); + } + } + + if (devicePermissions.video && shouldGetVideoPermission) { + try { + await switchCamera(); + } catch (e) { + logger.debug('No permission for camera'); + } + } + + Utils.setSessionInfo({ + username, + roomId, + publishAudio: true, + }); + + handleAIGCModeStart(); + } + + return [joining, disPatchJoin]; +}; + +export const useLeave = () => { + const dispatch = useDispatch(); + + return async function () { + await Promise.all([ + RtcClient.stopAudioCapture, + RtcClient.stopScreenCapture, + RtcClient.stopVideoCapture, + ]); + await RtcClient.leaveRoom(); + dispatch(clearHistoryMsg()); + dispatch(clearCurrentMsg()); + dispatch(localLeaveRoom()); + dispatch(updateAIGCState({ isAIGCEnable: false })); }; }; diff --git a/src/pages/MainPage/MainArea/Room/CameraArea.tsx b/src/pages/MainPage/MainArea/Room/CameraArea.tsx index f855dc8..7cfe32f 100644 --- a/src/pages/MainPage/MainArea/Room/CameraArea.tsx +++ b/src/pages/MainPage/MainArea/Room/CameraArea.tsx @@ -3,68 +3,88 @@ * SPDX-license-identifier: BSD-3-Clause */ -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { useEffect } from 'react'; -import { MediaType } from '@volcengine/rtc'; import { RootState } from '@/store'; -import { useVisionMode } from '@/lib/useCommon'; +import { useDeviceState, useVisionMode } from '@/lib/useCommon'; +import RtcClient from '@/lib/RtcClient'; +import { ScreenShareScene } from '@/config'; + import styles from './index.module.less'; import CameraCloseNoteSVG from '@/assets/img/CameraCloseNote.svg'; -import RtcClient from '@/lib/RtcClient'; -import { updateLocalUser } from '@/store/slices/room'; +import ScreenCloseNoteSVG from '@/assets/img/ScreenCloseNote.svg'; const LocalVideoID = 'local-video-player'; +const LocalScreenID = 'local-screen-player'; function CameraArea(props: React.HTMLAttributes) { const { className, ...rest } = props; - const dispatch = useDispatch(); const room = useSelector((state: RootState) => state.room); - const isVisionMode = useVisionMode(); - const localUser = room.localUser; - const isVideoPublished = localUser.publishVideo; + const { isVisionMode } = useVisionMode(); + const isScreenMode = ScreenShareScene.includes(room.scene); + const { isVideoPublished, isScreenPublished, switchCamera, switchScreenCapture } = + useDeviceState(); + + const setVideoPlayer = () => { + if (isVisionMode && (isVideoPublished || isScreenPublished)) { + RtcClient.setLocalVideoPlayer( + room.localUser.username!, + isScreenMode ? LocalScreenID : LocalVideoID, + isScreenPublished + ); + } + }; const handleOperateCamera = () => { - !localUser.publishVideo ? RtcClient.startVideoCapture() : RtcClient.stopVideoCapture(); + switchCamera(); + }; - !localUser.publishVideo - ? RtcClient.publishStream(MediaType.VIDEO) - : RtcClient.unpublishStream(MediaType.VIDEO); - - dispatch( - updateLocalUser({ - publishVideo: !localUser.publishVideo, - }) - ); + const handleOperateScreenShare = () => { + switchScreenCapture(); }; useEffect(() => { - if (isVisionMode && isVideoPublished) { - RtcClient.setLocalVideoPlayer(room.localUser.username!, LocalVideoID); - } else { - RtcClient.setLocalVideoPlayer(room.localUser.username!); - } - }, [isVisionMode, isVideoPublished]); + setVideoPlayer(); + }, [isVideoPublished, isScreenPublished, isScreenMode]); return isVisionMode ? (
    - {isVideoPublished ? ( -
    - ) : ( -
    - close -
    - 请 +
    +
    +
    + close +
    + 请 + {isScreenMode ? ( + + 打开屏幕采集 + + ) : ( 打开摄像头 -
    -
    体验豆包视觉理解模型
    + )}
    - )} +
    体验豆包视觉理解模型
    +
    ) : null; } diff --git a/src/pages/MainPage/MainArea/Room/Conversation.tsx b/src/pages/MainPage/MainArea/Room/Conversation.tsx index c4f50f3..ddd0e3d 100644 --- a/src/pages/MainPage/MainArea/Room/Conversation.tsx +++ b/src/pages/MainPage/MainArea/Room/Conversation.tsx @@ -21,6 +21,14 @@ function Conversation(props: React.HTMLAttributes) { const isAIReady = msgHistory.length > 0; const containerRef = useRef(null); + const isUserTextLoading = (owner: string) => { + return owner === userId && isUserTalking; + }; + + const isAITextLoading = (owner: string) => { + return owner === Config.BotName && isAITalking; + }; + useEffect(() => { const container = containerRef.current; if (container) { @@ -53,7 +61,9 @@ function Conversation(props: React.HTMLAttributes) {
    {value}
    - {isAIReady && (isUserTalking || isAITalking) && index === msgHistory.length - 1 ? ( + {isAIReady && + (isUserTextLoading(user) || isAITextLoading(user)) && + index === msgHistory.length - 1 ? ( ) : ( '' diff --git a/src/pages/MainPage/MainArea/Room/ToolBar.tsx b/src/pages/MainPage/MainArea/Room/ToolBar.tsx index 3e0f66f..7c1c9f4 100644 --- a/src/pages/MainPage/MainArea/Room/ToolBar.tsx +++ b/src/pages/MainPage/MainArea/Room/ToolBar.tsx @@ -4,11 +4,12 @@ */ import { useSelector } from 'react-redux'; -import { useState } from 'react'; +import { memo, useState } from 'react'; import { Drawer } from '@arco-design/web-react'; import { useDeviceState, useLeave } from '@/lib/useCommon'; import { RootState } from '@/store'; -import { AI_MODEL } from '@/config'; +import { isVisionMode } from '@/config/common'; +import { ScreenShareScene } from '@/config'; import utils from '@/utils/utils'; import Menu from '../../Menu'; @@ -19,14 +20,25 @@ import MicOpenSVG from '@/assets/img/MicOpen.svg'; import SettingSVG from '@/assets/img/Setting.svg'; import MicCloseSVG from '@/assets/img/MicClose.svg'; import LeaveRoomSVG from '@/assets/img/LeaveRoom.svg'; +import ScreenOnSVG from '@/assets/img/ScreenOn.svg'; +import ScreenOffSVG from '@/assets/img/ScreenOff.svg'; function ToolBar(props: React.HTMLAttributes) { const { className, ...rest } = props; const room = useSelector((state: RootState) => state.room); const [open, setOpen] = useState(false); const model = room.aiConfig.Config.LLMConfig?.ModelName; + const isScreenMode = ScreenShareScene.includes(room.scene); const leaveRoom = useLeave(); - const { isAudioPublished, isVideoPublished, switchMic, switchCamera } = useDeviceState(); + const { + isAudioPublished, + isVideoPublished, + isScreenPublished, + switchMic, + switchCamera, + switchScreenCapture, + } = useDeviceState(); + const handleSetting = () => { setOpen(true); }; @@ -41,13 +53,22 @@ function ToolBar(props: React.HTMLAttributes) { className={style.btn} alt="mic" /> - {model === AI_MODEL.VISION ? ( - switchCamera(true)} - className={style.btn} - alt="camera" - /> + {isVisionMode(model) ? ( + isScreenMode ? ( + switchScreenCapture()} + className={style.btn} + alt="screenShare" + /> + ) : ( + switchCamera(true)} + className={style.btn} + alt="camera" + /> + ) ) : ( '' )} @@ -60,6 +81,7 @@ function ToolBar(props: React.HTMLAttributes) { style={{ width: 'max-content', }} + footer={null} > @@ -67,4 +89,4 @@ function ToolBar(props: React.HTMLAttributes) {
    ); } -export default ToolBar; +export default memo(ToolBar); diff --git a/src/pages/MainPage/MainArea/Room/index.module.less b/src/pages/MainPage/MainArea/Room/index.module.less index e5978ed..03d4d62 100644 --- a/src/pages/MainPage/MainArea/Room/index.module.less +++ b/src/pages/MainPage/MainArea/Room/index.module.less @@ -172,6 +172,15 @@ line-height: 22px; } +.closed { + width: 100%; + text-align: center; + color: #737A87; + font-size: 14px; + font-weight: 400; + line-height: 19.6px; +} + .btns { width: 100%; display: flex; @@ -262,6 +271,10 @@ border-radius: 8px; } + .camera-player-hidden { + display: none !important; + } + .camera-placeholder { width: 100%; display: flex; @@ -273,6 +286,8 @@ .camera-placeholder-close-note { margin-bottom: 8px; + width: 60px; + height: 60px; } .camera-open-btn { diff --git a/src/pages/MainPage/Menu/components/AISettingAnchor/index.module.less b/src/pages/MainPage/Menu/components/AISettingAnchor/index.module.less new file mode 100644 index 0000000..1ede302 --- /dev/null +++ b/src/pages/MainPage/Menu/components/AISettingAnchor/index.module.less @@ -0,0 +1,44 @@ +/** + * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. + * SPDX-license-identifier: BSD-3-Clause + */ + +.row { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + cursor: pointer; + + .firstPart { + display: flex; + flex-direction: row; + align-items: center; + width: 90%; + color: var(--text-color-text-2, var(--text-color-text-2, #42464E)); + text-align: center; + + font-family: "PingFang SC"; + font-size: 13px; + font-style: normal; + font-weight: 500; + line-height: 22px; + letter-spacing: 0.039px; + } + + .finalPart { + display: flex; + flex-direction: row; + align-items: center; + width: 10%; + justify-content: flex-end; + + .rightOutlined { + font-size: 12px; + } + } + + .icon { + margin-right: 4px; + } +} \ No newline at end of file diff --git a/src/pages/MainPage/Menu/components/AISettingAnchor/index.tsx b/src/pages/MainPage/Menu/components/AISettingAnchor/index.tsx new file mode 100644 index 0000000..d3cbaf2 --- /dev/null +++ b/src/pages/MainPage/Menu/components/AISettingAnchor/index.tsx @@ -0,0 +1,29 @@ +/** + * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. + * SPDX-license-identifier: BSD-3-Clause + */ + +import { useState } from 'react'; +import { IconRight } from '@arco-design/web-react/icon'; +import AISettings from '@/components/AISettings'; +import styles from './index.module.less'; + +function AISettingAnchor() { + const [open, setOpen] = useState(false); + + const handleOpenDrawer = () => setOpen(true); + const handleCloseDrawer = () => setOpen(false); + return ( + <> +
    +
    AI 设置
    +
    + +
    +
    + + + ); +} + +export default AISettingAnchor; diff --git a/src/pages/MainPage/Menu/components/Operation/index.tsx b/src/pages/MainPage/Menu/components/Operation/index.tsx index 860944e..7f5c453 100644 --- a/src/pages/MainPage/Menu/components/Operation/index.tsx +++ b/src/pages/MainPage/Menu/components/Operation/index.tsx @@ -6,16 +6,18 @@ import { MediaType } from '@volcengine/rtc'; import DeviceDrawerButton from '../DeviceDrawerButton'; import { useVisionMode } from '@/lib/useCommon'; +import AISettingAnchor from '../AISettingAnchor'; import Interrupt from '../Interrupt'; import styles from './index.module.less'; function Operation() { - const isVisionMode = useVisionMode(); + const { isVisionMode, isScreenMode } = useVisionMode(); return (
    + - {isVisionMode ? : ''} + {isVisionMode && !isScreenMode ? : ''}
    ); } diff --git a/src/pages/MainPage/Menu/index.tsx b/src/pages/MainPage/Menu/index.tsx index a60a6ad..1542751 100644 --- a/src/pages/MainPage/Menu/index.tsx +++ b/src/pages/MainPage/Menu/index.tsx @@ -4,6 +4,7 @@ */ import VERTC from '@volcengine/rtc'; +import { useEffect, useState } from 'react'; import { Tooltip, Typography } from '@arco-design/web-react'; import { useDispatch, useSelector } from 'react-redux'; import { useVisionMode } from '@/lib/useCommon'; @@ -13,37 +14,39 @@ import Operation from './components/Operation'; import { Questions } from '@/config'; import { COMMAND, INTERRUPT_PRIORITY } from '@/utils/handler'; import CameraArea from '../MainArea/Room/CameraArea'; -import { setCurrentMsg, setHistoryMsg } from '@/store/slices/room'; +import { setHistoryMsg, setInterruptMsg } from '@/store/slices/room'; import utils from '@/utils/utils'; +import packageJson from '../../../../package.json'; import styles from './index.module.less'; function Menu() { const dispatch = useDispatch(); + const [question, setQuestion] = useState(''); const room = useSelector((state: RootState) => state.room); const scene = room.scene; const isJoined = room?.isJoined; const isVisionMode = useVisionMode(); - const handleQuestion = (question: string) => { - RtcClient.commandAudioBot(COMMAND.EXTERNAL_TEXT_TO_LLM, INTERRUPT_PRIORITY.HIGH, question); - dispatch( - setHistoryMsg({ - text: question, - user: RtcClient.basicInfo.user_id, - paragraph: true, - definite: true, - }) - ); - dispatch( - setCurrentMsg({ - text: question, - user: RtcClient.basicInfo.user_id, - paragraph: true, - definite: true, - }) - ); + const handleQuestion = (que: string) => { + RtcClient.commandAudioBot(COMMAND.EXTERNAL_TEXT_TO_LLM, INTERRUPT_PRIORITY.HIGH, que); + setQuestion(que); }; + useEffect(() => { + if (question && !room.isAITalking) { + dispatch(setInterruptMsg()); + dispatch( + setHistoryMsg({ + text: question, + user: RtcClient.basicInfo.user_id, + paragraph: true, + definite: true, + }) + ); + setQuestion(''); + } + }, [question, room.isAITalking]); + return (
    {isJoined && utils.isMobile() && isVisionMode ? ( @@ -52,7 +55,7 @@ function Menu() {
    ) : null}
    -
    Demo Version 1.4.0
    +
    Demo Version {packageJson.version}
    SDK Version {VERTC.getSdkVersion()}
    {isJoined ? (
    diff --git a/src/store/slices/room.ts b/src/store/slices/room.ts index 0812fe7..89e6af4 100644 --- a/src/store/slices/room.ts +++ b/src/store/slices/room.ts @@ -11,7 +11,6 @@ import { RemoteAudioStats, } from '@volcengine/rtc'; import config, { SCENE } from '@/config'; -import utils from '@/utils/utils'; export interface IUser { username?: string; @@ -33,6 +32,7 @@ export interface Msg { time: string; user: string; paragraph?: boolean; + definite?: boolean; isInterrupted?: boolean; } @@ -59,6 +59,10 @@ export interface RoomState { * @brief AI 是否正在说话 */ isAITalking: boolean; + /** + * @brief AI 思考中 + */ + isAIThinking: boolean; /** * @brief 用户是否正在说话 */ @@ -99,12 +103,14 @@ const initialState: RoomState = { scene: SCENE.INTELLIGENT_ASSISTANT, remoteUsers: [], localUser: { - publishAudio: true, - publishVideo: true, + publishAudio: false, + publishVideo: false, + publishScreen: false, }, autoPlayFailUser: [], isJoined: false, isAIGCEnable: false, + isAIThinking: false, isAITalking: false, isUserTalking: false, networkQuality: NetworkQuality.UNKNOWN, @@ -131,15 +137,19 @@ export const roomSlice = createSlice({ } ) => { state.roomId = payload.roomId; - state.localUser = payload.user; + state.localUser = { + ...state.localUser, + ...payload.user, + }; state.isJoined = true; }, localLeaveRoom: (state) => { state.roomId = undefined; state.time = -1; state.localUser = { - publishAudio: true, - publishVideo: true, + publishAudio: false, + publishVideo: false, + publishScreen: false, }; state.remoteUsers = []; state.isJoined = false; @@ -159,7 +169,7 @@ export const roomSlice = createSlice({ updateLocalUser: (state, { payload }: { payload: Partial }) => { state.localUser = { ...state.localUser, - ...payload, + ...(payload || {}), }; }, @@ -204,8 +214,14 @@ export const roomSlice = createSlice({ state.isAIGCEnable = payload.isAIGCEnable; }, updateAITalkState: (state, { payload }) => { + state.isAIThinking = false; + state.isUserTalking = false; state.isAITalking = payload.isAITalking; }, + updateAIThinkState: (state, { payload }) => { + state.isAIThinking = payload.isAIThinking; + state.isUserTalking = false; + }, updateAIConfig: (state, { payload }) => { state.aiConfig = Object.assign(state.aiConfig, payload); }, @@ -213,36 +229,74 @@ export const roomSlice = createSlice({ state.msgHistory = []; }, setHistoryMsg: (state, { payload }) => { - const paragraph = payload.paragraph; - const aiTalking = payload.user === config.BotName; - const userTalking = payload.user === state.localUser.userId; - if (paragraph) { - if (state.isAITalking) { - state.isAITalking = false; - } - if (state.isUserTalking) { - state.isUserTalking = false; + const { paragraph, definite } = payload; + /** 是否需要再创建新句子 */ + const shouldCreateSentence = payload.definite; + state.isUserTalking = payload.user === state.localUser.userId; + if (state.msgHistory.length) { + const lastMsg = state.msgHistory.at(-1)!; + /** 当前讲话人更新字幕 */ + if (lastMsg.user === payload.user) { + /** 如果上一句话是完整的 & 本次的话也是完整的, 则直接塞入 */ + if (lastMsg.definite) { + state.msgHistory.push({ + value: payload.text, + time: new Date().toString(), + user: payload.user, + definite, + paragraph, + }); + } else { + /** 话未说完, 更新文字内容 */ + lastMsg.value = payload.text; + lastMsg.time = new Date().toString(); + lastMsg.paragraph = paragraph; + lastMsg.definite = definite; + lastMsg.user = payload.user; + } + /** 如果本次的话已经说完了, 提前塞入空字符串做准备 */ + if (shouldCreateSentence) { + state.msgHistory.push({ + value: '', + time: new Date().toString(), + user: '', + }); + } + } else { + /** 换人说话了,塞入新句子 */ + state.msgHistory.push({ + value: payload.text, + time: new Date().toString(), + user: payload.user, + definite, + paragraph, + }); } } else { - if (state.isAITalking !== aiTalking) { - state.isAITalking = aiTalking; - } - if (state.isUserTalking !== userTalking) { - state.isUserTalking = userTalking; - } + /** 首句话首字不会被打断 */ + state.msgHistory.push({ + value: payload.text, + time: new Date().toString(), + user: payload.user, + paragraph, + }); } - utils.addMsgWithoutDuplicate(state.msgHistory, { - user: payload.user, - value: payload.text, - time: new Date().toLocaleString(), - isInterrupted: false, - paragraph, - }); }, setInterruptMsg: (state) => { - const msg = state.msgHistory[state.msgHistory.length - 1]; - msg.isInterrupted = true; - state.msgHistory[state.msgHistory.length - 1] = msg; + state.isAITalking = false; + if (!state.msgHistory.length) { + return; + } + /** 找到最后一个末尾的字幕, 将其状态置换为打断 */ + for (let id = state.msgHistory.length - 1; id >= 0; id--) { + const msg = state.msgHistory[id]; + if (msg.value) { + if (!msg.definite) { + state.msgHistory[id].isInterrupted = true; + } + break; + } + } }, clearCurrentMsg: (state) => { state.currentConversation = {}; @@ -250,10 +304,6 @@ export const roomSlice = createSlice({ state.isAITalking = false; state.isUserTalking = false; }, - setCurrentMsg: (state, { payload }) => { - const { user, ...info } = payload; - state.currentConversation[user || state.localUser.userId] = info; - }, }, }); @@ -270,9 +320,9 @@ export const { clearAutoPlayFail, updateAIGCState, updateAITalkState, + updateAIThinkState, updateAIConfig, setHistoryMsg, - setCurrentMsg, clearHistoryMsg, clearCurrentMsg, setInterruptMsg, diff --git a/src/utils/handler.ts b/src/utils/handler.ts index ef7fc67..b232bf1 100644 --- a/src/utils/handler.ts +++ b/src/utils/handler.ts @@ -6,10 +6,10 @@ import { useDispatch } from 'react-redux'; import logger from './logger'; import { - setCurrentMsg, setHistoryMsg, setInterruptMsg, updateAITalkState, + updateAIThinkState, } from '@/store/slices/room'; import RtcClient from '@/lib/RtcClient'; import Utils from '@/utils/utils'; @@ -89,11 +89,16 @@ export const useMessageHandler = () => { const { Code, Description } = Stage || {}; logger.debug(Code, Description); switch (Code) { + case AGENT_BRIEF.THINKING: + dispatch(updateAIThinkState({ isAIThinking: true })); + break; + case AGENT_BRIEF.SPEAKING: + dispatch(updateAITalkState({ isAITalking: true })); + break; case AGENT_BRIEF.FINISHED: dispatch(updateAITalkState({ isAITalking: false })); break; case AGENT_BRIEF.INTERRUPTED: - dispatch(updateAITalkState({ isAITalking: false })); dispatch(setInterruptMsg()); break; default: @@ -118,7 +123,6 @@ export const useMessageHandler = () => { dispatch(setHistoryMsg({ text: msg, user, paragraph, definite })); } } - dispatch(setCurrentMsg({ msg, definite, user, paragraph })); } }, /** @@ -141,8 +145,8 @@ export const useMessageHandler = () => { ToolCallID: parsed?.tool_calls?.[0]?.id, Content: map[name.toLocaleLowerCase().replaceAll('_', '')], }), - 'func', - ), + 'func' + ) ); }, }; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 2166d39..ffdd964 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -3,12 +3,7 @@ * SPDX-license-identifier: BSD-3-Clause */ -import { Msg, RoomState } from '@/store/slices/room'; -import RtcClient from '@/lib/RtcClient'; - - class Utils { - formatTime = (time: number): string => { if (time < 0) { return '00:00'; @@ -46,9 +41,9 @@ class Utils { const query = window.location.search.substring(1); const pairs = query.split('&'); return pairs.reduce<{ [key: string]: string }>((queries, pair) => { - const [key, value] = decodeURIComponent(pair).split('='); + const [key, value] = pair.split('='); if (key && value) { - queries[key] = value; + queries[key] = decodeURIComponent(value); } return queries; }, {}); @@ -58,34 +53,6 @@ class Utils { isArray = Array.isArray; - debounce = (func: (...args: any[]) => void, wait: number) => { - let timeoutId: ReturnType | null = null; - return function (...args: any[]) { - if (timeoutId !== null) { - clearTimeout(timeoutId); - } - - timeoutId = setTimeout(() => { - func(...args); - }, wait); - }; - }; - - addMsgWithoutDuplicate = (arr: RoomState['msgHistory'], added: Msg) => { - if (arr.length) { - const last = arr.at(-1)!; - const { user, value, isInterrupted } = last; - if ( - (added.user === RtcClient.basicInfo.user_id && last.user === added.user) || - (user === added.user && added.value.startsWith(value) && value.trim()) - ) { - arr.pop(); - added.isInterrupted = isInterrupted; - } - } - arr.push(added); - }; - /** * @brief 将字符串包装成 TLV */ @@ -119,7 +86,7 @@ class Utils { * @note TLV 数据格式 * | magic number | length(big-endian) | value | * @param {ArrayBufferLike} tlvBuffer - * @returns + * @returns */ tlv2String(tlvBuffer: ArrayBufferLike) { const typeBuffer = new Uint8Array(tlvBuffer, 0, 4); diff --git a/yarn.lock b/yarn.lock index 7d8dc66..2f1e555 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2210,10 +2210,10 @@ "@typescript-eslint/types" "5.31.0" eslint-visitor-keys "^3.3.0" -"@volcengine/rtc@4.58.9": - version "4.58.9" - resolved "https://registry.yarnpkg.com/@volcengine/rtc/-/rtc-4.58.9.tgz#841ebaddd5d4963c71abd33037bd76d1d490d928" - integrity sha512-nnXnNW9pVo8ynBSxVe0ikNIdxWfoSx5oOnwK7EoMCXdc2bJgHATpz/B+Kv2F1k4GjYAbo7ZcOm/g3cchvHgH5Q== +"@volcengine/rtc@4.66.1": + version "4.66.1" + resolved "https://registry.yarnpkg.com/@volcengine/rtc/-/rtc-4.66.1.tgz#1934c269b31216f43718ae46b169c59ac5e474f2" + integrity sha512-APznH6eosmKJC1HYJJ8s6G3Mq3OSgw6ivv6uCiayM5QNMBj+GW6zxf+MVsk5rm6r4R92TLwQErWonJ8yzGO4xA== dependencies: eventemitter3 "^4.0.7"