Repository: volcengine/rtc-aigc-demo Branch: main Commit: a5ee31a8d083 Files: 100 Total size: 184.0 KB Directory structure: gitextract_939achzv/ ├── .eslintrc ├── .gitignore ├── .npmrc ├── .prettierrc ├── .stylelintrc ├── LICENSE ├── README.md ├── Server/ │ ├── .npmrc │ ├── README.md │ ├── app.js │ ├── nodemon.json │ ├── package.json │ ├── scenes/ │ │ └── Custom.json │ ├── token.js │ └── util.js ├── craco.config.js ├── message.js ├── package.json ├── public/ │ ├── index.html │ └── robots.txt ├── src/ │ ├── App.tsx │ ├── app/ │ │ ├── api.ts │ │ ├── base.ts │ │ ├── index.ts │ │ └── type.ts │ ├── components/ │ │ ├── AIAvatarLoading/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── AiAvatarCard/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── AiChangeCard/ │ │ │ ├── CheckScene/ │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── DrawerRowItem/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── FullScreenCard/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── Header/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── Loading/ │ │ │ ├── AudioLoading/ │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── HorizonLoading/ │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ └── VerticalLoading/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── LocalPlayerSet/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── NetworkIndicator/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── ResizeWrapper/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ └── UserTag/ │ │ ├── index.module.less │ │ └── index.tsx │ ├── config/ │ │ └── index.ts │ ├── index.less │ ├── index.module.less │ ├── index.tsx │ ├── interface.ts │ ├── lib/ │ │ ├── RtcClient.ts │ │ ├── listenerHooks.ts │ │ └── useCommon.ts │ ├── pages/ │ │ ├── MainPage/ │ │ │ ├── MainArea/ │ │ │ │ ├── Antechamber/ │ │ │ │ │ ├── InvokeButton/ │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── loading.tsx │ │ │ │ │ ├── index.module.less │ │ │ │ │ └── index.tsx │ │ │ │ ├── Room/ │ │ │ │ │ ├── AudioController.tsx │ │ │ │ │ ├── CameraArea.tsx │ │ │ │ │ ├── Conversation.tsx │ │ │ │ │ ├── ToolBar.tsx │ │ │ │ │ ├── index.module.less │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── Menu/ │ │ │ │ ├── components/ │ │ │ │ │ ├── DeviceDrawerButton/ │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── Operation/ │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── Subtitle/ │ │ │ │ │ ├── index.module.less │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ └── Mobile/ │ │ ├── MobileToolBar/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── SettingsDrawer/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ └── components/ │ │ └── SettingsItem/ │ │ ├── index.module.less │ │ └── index.tsx │ ├── react-app-env.d.ts │ ├── store/ │ │ ├── index.ts │ │ └── slices/ │ │ ├── device.ts │ │ └── room.ts │ ├── theme.less │ └── utils/ │ ├── handler.ts │ ├── logger.ts │ ├── utils.less │ └── utils.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc ================================================ { "root": true, "env": { "browser": true, "commonjs": true, "es6": true, "node": true, "jest": true }, "parser": "@typescript-eslint/parser", "extends": [ "airbnb", "plugin:react/recommended", "plugin:prettier/recommended", "plugin:react-hooks/recommended" ], "parserOptions": { "ecmaFeatures": { "experimentalObjectRestSpread": true }, "sourceType": "module" }, "plugins": ["react", "babel", "@typescript-eslint/eslint-plugin"], "globals": { "ActiveXObject": false, "describe": false, "it": false, "expect": false, "jest": false, "$": false, "afterEach": false, "beforeEach": false }, "overrides": [ { "files": ["*.ts", "*.tsx"], "rules": { "@typescript-eslint/no-unused-vars": [2, { "args": "none" }], "@typescript-eslint/no-use-before-define": [2, { "functions": false, "classes": false }] } } ], "rules": { "prettier/prettier": ["warn", { "trailingComma": "es5", "printWidth": 100 }], "linebreak-style": "off", "no-console": ["warn", { "allow": ["warn", "error", "log"] }], "no-case-declarations": 0, "no-param-reassign": 0, "no-underscore-dangle": 0, "no-useless-constructor": 0, "no-unused-vars": [2, { "vars": "all", "args": "none" }], "no-restricted-syntax": 0, "no-unused-expressions": ["error", { "allowShortCircuit": true, "allowTernary": true }], "no-plusplus": 0, "no-return-assign": 0, "no-script-url": 0, "no-extend-native": 0, "no-restricted-globals": 0, "no-nested-ternary": 0, "no-empty": 0, "no-void": 0, "no-useless-escape": 0, "no-bitwise": 0, "no-mixed-operators": 0, "consistent-return": 0, "one-var": 0, "prefer-promise-reject-errors": 0, "prefer-destructuring": 0, "global-require": 0, "guard-for-in": 0, "func-names": 0, "strict": 0, "radix": 0, "no-prototype-builtins": 0, "class-methods-use-this": 0, "import/no-dynamic-require": 0, "import/no-unresolved": 0, "import/extensions": 0, "import/no-extraneous-dependencies": 0, "import/prefer-default-export": 0, "import/no-absolute-path": 0, "react/no-danger": 0, "react/forbid-prop-types": 0, "react/prop-types": 0, "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx", "ts", "tsx"] }], "react/sort-comp": 0, "react/no-did-update-set-state": 0, "react/prefer-stateless-function": 0, "react/jsx-closing-tag-location": 0, "react/jsx-no-bind": 0, "react/no-array-index-key": 0, "react/no-children-prop": 0, "react/no-did-mount-set-state": 0, "react/no-find-dom-node": 0, "react/default-props-match-prop-types": 0, "react/jsx-one-expression-per-line": 0, "react/react-in-jsx-scope": 0, "react/jsx-props-no-spreading": 0, "jsx-a11y/anchor-is-valid": 0, "jsx-a11y/no-static-element-interactions": 0, "jsx-a11y/click-events-have-key-events": 0, "jsx-a11y/no-noninteractive-element-interactions": 0, "jsx-a11y/alt-text": 0, "jsx-a11y/label-has-for": 0, "jsx-a11y/label-has-associated-control": 0, "jsx-a11y/no-noninteractive-tabindex": 0, "jsx-a11y/tabindex-no-positive": 0, "react/jsx-indent": 0, "react/display-name": 0, "react/no-multi-comp": 0, "react/destructuring-assignment": 0, "react/no-access-state-in-setstate": 0, "react/button-has-type": 0, "react/require-default-props": 0, "react/jsx-wrap-multilines": 0, "react/no-render-return-value": 0, "array-callback-return": 0, "no-cond-assign": 0, "@typescript-eslint/explicit-function-return-type": 0, "no-use-before-define": 0, "@typescript-eslint/no-use-before-define": 2, "@typescript-eslint/no-var-requires": 0, "@typescript-eslint/no-empty-function": 0, "no-shadow": 0, "no-continue": 0, "no-loop-func": 0, "prefer-spread": 0, "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "no-undef": 0 } } ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js .vscode # testing /coverage # production /build # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* .eslintcache /Server/node_modules /Server/log /log yarn.lock .eslintcache pnpm-lock.yaml dist/ log/ ================================================ FILE: .npmrc ================================================ registry = 'https://registry.npmjs.org/' ================================================ FILE: .prettierrc ================================================ { "arrowParens": "always", "semi": true, "singleQuote": true, "jsxSingleQuote": false, "printWidth": 100, "useTabs": false, "tabWidth": 2, "trailingComma": "es5" } ================================================ FILE: .stylelintrc ================================================ { "extends": ["stylelint-config-standard", "stylelint-config-prettier"], "customSyntax": "postcss-less", "rules": { "no-descending-specificity": null, "no-duplicate-selectors": null, "font-family-no-missing-generic-family-keyword": null, "block-opening-brace-space-before": "always", "declaration-block-trailing-semicolon": null, "declaration-colon-newline-after": null, "indentation": null, "selector-descendant-combinator-no-non-space": null, "selector-class-pattern": null, "keyframes-name-pattern": null, "no-invalid-position-at-import-rule": null, "number-max-precision": 6, "color-function-notation": null, "selector-pseudo-class-no-unknown": [ true, { "ignorePseudoClasses": ["global"] } ] } } ================================================ FILE: LICENSE ================================================ Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ====== ====== THE FOLLOWING SETS FORTH ATTRIBUTION NOTICES FOR THIRD PARTY SOFTWARE THAT MAY BE CONTAINED IN PORTIONS OF RTC AIGC DEMO. ====== WebRTC Copyright (c) 2011, The WebRTC project authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ====== ================================================ FILE: README.md ================================================ # 交互式AIGC场景 AIGC Demo 此 Demo 为简化版本, 如您有 1.5.x 版本 UI 的诉求, 可切换至 1.5.1 分支。 跑通阶段时, 无须关心代码实现,仅需按需完成 `Server/scenes/*.json` 的场景信息填充即可。 ## 简介 - 在 AIGC 对话场景下,火山引擎 AIGC-RTC Server 云端服务,通过整合 RTC 音视频流处理,ASR 语音识别,大模型接口调用集成,以及 TTS 语音生成等能力,提供基于流式语音的端到端AIGC能力链路。 - 用户只需调用基于标准的 OpenAPI 接口即可配置所需的 ASR、LLM、TTS 类型和参数。火山引擎云端计算服务负责边缘用户接入、云端资源调度、音视频流压缩、文本与语音转换处理以及数据订阅传输等环节。简化开发流程,让开发者更专注在对大模型核心能力的训练及调试,从而快速推进AIGC产品应用创新。 - 同时火山引擎 RTC拥有成熟的音频 3A 处理、视频处理等技术以及大规模音视频聊天能力,可支持 AIGC 产品更便捷的支持多模态交互、多人互动等场景能力,保持交互的自然性和高效性。 ## 【必看】环境准备 **Node 版本: 16.0+** ### 1. 运行环境 需要准备两个 Terminal,分别启动服务端和前端页面。 ### 2. 服务开通 开通 ASR、TTS、LLM、RTC 等服务,可参考 [开通服务](https://www.volcengine.com/docs/6348/1315561?s=g) 进行相关服务的授权与开通。 ### 3. 场景配置 `Server/scenes/*.json` 您可以自定义具体场景, 并按需根据模版填充 `SceneConfig`、`AccountConfig`、`RTCConfig`、`VoiceChat` 中需要的参数。 Demo 中以 `Custom` 场景为例,您可以自行新增场景。 注意: - `SceneConfig`:场景的信息,例如名称、头像等。 - `AccountConfig`:场景下的账号信息,https://console.volcengine.com/iam/keymanage/ 获取 AK/SK。 - `RTCConfig`:场景下的 RTC 配置。 - AppId、AppKey 可从 https://console.volcengine.com/rtc/aigc/listRTC 中获取。 - RoomId、UserId 可自定义也可不填,交由服务端生成。 - `VoiceChat`: 场景下的 AIGC 配置。 - 可参考 https://www.volcengine.com/docs/6348/1558163 中参数描述,完整填写参数内容。 - 可通过 [快速跑通 Demo](https://console.volcengine.com/rtc/aigc/run?s=g) 快速获取参数, 跑通后点击右上角 `接入 API` 按钮复制相关代码贴到 JSON 配置文件中即可。 ## 快速开始 请注意,服务端和 Web 端都需要启动, 启动步骤如下: ### 服务端 进到项目根目录 #### 安装依赖 ```shell cd Server yarn ``` #### 运行项目 ```shell yarn dev ``` ### 前端页面 进到项目根目录 #### 安装依赖 ```shell yarn ``` #### 运行项目 ```shell yarn dev ``` ### 常见问题 | 问题 | 解决方案 | | :-- | :-- | | 如何使用第三方模型、Coze Bot | 模型相关配置代码对应目录 `src/config/scenes/` 下json 文件,填写对应官方模型/ Coze/ 第三方模型的参数后,可点击页面上的 "修改 AI 人设" 进行切换。 | | **启动智能体之后, 对话无反馈,或者一直停留在 "AI 准备中, 请稍侯";在启用数字人的情况下,一直停留在“数字人准备中,请稍候”** |
  • 可能因为控制台中相关权限没有正常授予,请参考[流程](https://www.volcengine.com/docs/6348/1315561?s=g)再次确认下是否完成相关操作。此问题的可能性较大,建议仔细对照是否已经将相应的权限开通。
  • 参数传递可能有问题, 例如参数大小写、类型等问题,请再次确认下这类型问题是否存在。
  • 相关资源可能未开通或者用量不足/欠费,请再次确认。
  • **请检查当前使用的模型 ID / 数字人 AppId / Token 等内容都是正确且可用的。**
  • 数字人服务有并发限制,当达到并发限制时,同样会表现为一直停留在“数字人准备中”状态
  • | | **浏览器报了 `Uncaught (in promise) r: token_error` 错误** | 请检查您填在项目中的 RTC Token 是否合法,检测用于生成 Token 的 UserId、RoomId 以及 Token 本身是否与项目中填写的一致;或者 Token 可能过期, 可尝试重新生成下。 | | **[StartVoiceChat]Failed(Reason: The task has been started. Please do not call the startup task interface repeatedly.)** 报错 | 如果设置的 RoomId、UserId 为固定值,重复调用 startAgent 会导致出错,只需先调用 stopAgent 后再重新 startAgent 即可。 | | 为什么麦克风、摄像头开启失败?浏览器报了`TypeError: Cannot read properties of undefined (reading 'getUserMedia')` | 检查当前页面是否为[安全上下文](https://developer.mozilla.org/zh-CN/docs/Web/Security/Secure_Contexts)(简单来说,检查当前页面是否为 `localhost` 或者 是否为 https 协议)。浏览器[限制](https://developer.mozilla.org/zh-CN/docs/Web/Security/Secure_Contexts/features_restricted_to_secure_contexts) `getUserMedia` 只能在安全上下文中使用。 | | 为什么我的麦克风正常、摄像头也正常,但是设备没有正常工作? | 可能是设备权限未授予,详情可参考 [Web 排查设备权限获取失败问题](https://www.volcengine.com/docs/6348/1356355?s=g)。 | | 接口调用时, 返回 "Invalid 'Authorization' header, Pls check your authorization header" 错误 | `Server/app.js` 中的 AK/SK 不正确 | | 什么是 RTC | **R**eal **T**ime **C**ommunication, RTC 的概念可参考[官网文档](https://www.volcengine.com/docs/6348/66812?s=g)。 | | 不清楚什么是主账号,什么是子账号 | 可以参考[官方概念](https://www.volcengine.com/docs/6257/64963?hyperlink_open_type=lark.open_in_browser&s=g) 。| | 我有自己的服务端了, 我应该怎么让前端调用我的服务端呢 | 修改 `src/config/index.ts` 中的 `AIGC_PROXY_HOST` 请求域名和接口并在 `src/app/api.ts` 中修改接口参数配置 `APIS_CONFIG` | 如果有上述以外的问题,欢迎联系我们反馈。 ### 相关文档 - [场景介绍](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) ## 更新日志 ### OpenAPI 更新 参考 [OpenAPI 更新](https://www.volcengine.com/docs/6348/1544162) 中与 实时对话式 AI 相关的更新内容。 ### Demo 更新 #### [1.6.0] - 2025-09-30 - 更新数字人场景相关配置 - 2025-07-08 - 更新 RTC Web SDK 版本至 4.66.20 - 2025-06-26 - 修复进房有问题的 BUG - 2025-06-23 - 简化 Demo 使用, 配置归一化。 - 删除无用组件。 - 追加服务端 README。 - 2025-06-18 - 更新 RTC Web SDK 版本至 4.66.16 - 更新 UI 和参数配置方式 - 更新 Readme 文档 - 追加 Node 服务的参数检测能力 - 追加 Node 服务的 Token 生成能力 ================================================ FILE: Server/.npmrc ================================================ registry = 'https://registry.npmjs.org/' ================================================ FILE: Server/README.md ================================================ # Node Server ## 启动命令 ``` yarn yarn dev ``` ## 使用须知 Node 服务启动时会自动读取 `Server/scenes` 下的所有文件作为可用的场景, 并通过接口 API 返回相关信息。 因此,您需要: 1. 在 `Server/scenes` 目录下参考其它 JSON 的格式, 自定义创建一个 `xxxx.json` 文件,用于描述您的场景,其中 xxxx 为场景名称。 2. 确保您的 `.json` 文件符合模版定义(可参考 Custom.json), 大小写敏感。 3. 新增场景 JSON 后须重启 Node 服务,保证场景信息被正常读取。 4. JSON 文件中, 若 `RTCConfig.RoomId`、`RTCConfig.UserId`、`RTCConfig.Token` 其中之一未填写, Node 服务将自动生成对应的值以保证对话可以正常启动。 ## 相关参数获取 - AccountConfig - 可在 https://console.volcengine.com/iam/keymanage/ 获取 AK/SK。 - RTCConfig - AppId、AppKey 可从 https://console.volcengine.com/rtc/aigc/listRTC 中获取。 - RoomId、UserId 可自定义也可不填,交由服务端生成。 - VoiceChat - 可参考 https://www.volcengine.com/docs/6348/1558163 中参数描述 - 可通过 [快速跑通 Demo](https://console.volcengine.com/rtc/aigc/run?s=g) 快速获取参数, 跑通后点击右上角 `接入 API` 按钮复制相关代码贴到 JSON 配置文件中即可。 ## 注意 - 相关错误会通过服务端接口返回。 - Node 服务会根据您配置的 `VoiceChat` 中是否存在视觉模型相关的配置返回相关信息给前端页面, 从而控制相关 UI 是否展示。 - 使用时请留意相关服务已开通。 ================================================ FILE: Server/app.js ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ const Koa = require('koa'); const uuid = require('uuid'); const bodyParser = require('koa-bodyparser'); const cors = require('koa2-cors'); const { Signer } = require('@volcengine/openapi'); const fetch = require('node-fetch'); const { wrapper, assert, readFiles } = require('./util'); const TokenManager = require('./token'); const Privileges = require('./token').privileges; const Scenes = readFiles('./scenes', '.json'); const app = new Koa(); app.use(cors({ origin: '*' })); app.use(bodyParser()); app.use(async ctx => { /** * @brief 代理 AIGC 的 OpenAPI 请求 */ await wrapper({ ctx, apiName: 'proxy', containResponseMetadata: false, logic: async () => { const { Action, Version = '2024-12-01' } = ctx.query || {}; assert(Action, 'Action 不能为空'); assert(Version, 'Version 不能为空'); const { SceneID } = ctx.request.body; assert(SceneID, 'SceneID 不能为空, SceneID 用于指定场景的 JSON'); const JSONData = Scenes[SceneID]; assert(JSONData, `${SceneID} 不存在, 请先在 Server/scenes 下定义该场景的 JSON.`); const { VoiceChat = {}, AccountConfig = {} } = JSONData; assert(AccountConfig.accessKeyId, 'AccountConfig.accessKeyId 不能为空'); assert(AccountConfig.secretKey, 'AccountConfig.secretKey 不能为空'); let body = {}; switch(Action) { case 'StartVoiceChat': body = VoiceChat; break; case 'StopVoiceChat': const { AppId, RoomId, TaskId } = VoiceChat; assert(AppId, 'VoiceChat.AppId 不能为空'); assert(RoomId, 'VoiceChat.RoomId 不能为空'); assert(TaskId, 'VoiceChat.TaskId 不能为空'); body = { AppId, RoomId, TaskId }; break; default: break; } /** 参考 https://github.com/volcengine/volc-sdk-nodejs 可获取更多 火山 TOP 网关 SDK 的使用方式 */ const openApiRequestData = { region: 'cn-north-1', method: 'POST', params: { Action, Version, }, headers: { Host: 'rtc.volcengineapi.com', 'Content-type': 'application/json', }, body, }; const signer = new Signer(openApiRequestData, "rtc"); signer.addAuthorization(AccountConfig); /** 参考 https://www.volcengine.com/docs/6348/69828 可获取更多 OpenAPI 的信息 */ const result = await fetch(`https://rtc.volcengineapi.com?Action=${Action}&Version=${Version}`, { method: 'POST', headers: openApiRequestData.headers, body: JSON.stringify(body), }); return result.json(); } }); wrapper({ ctx, apiName: 'getScenes', logic: () => { const scenes = Object.keys(Scenes).map((scene) => { const { SceneConfig, RTCConfig = {}, VoiceChat } = Scenes[scene]; const { AppId, RoomId, UserId, AppKey, Token } = RTCConfig; assert(AppId, `${scene} 场景的 RTCConfig.AppId 不能为空`); if (AppId && (!Token || !UserId || !RoomId)) { RTCConfig.RoomId = VoiceChat.RoomId = RoomId || uuid.v4(); RTCConfig.UserId = VoiceChat.AgentConfig.TargetUserId[0] = UserId || uuid.v4(); assert(AppKey, `自动生成 Token 时, ${scene} 场景的 AppKey 不可为空`); const key = new TokenManager.AccessToken(AppId, AppKey, RTCConfig.RoomId, RTCConfig.UserId); key.addPrivilege(Privileges.PrivSubscribeStream, 0); key.addPrivilege(Privileges.PrivPublishStream, 0); key.expireTime(Math.floor(new Date() / 1000) + (24 * 3600)); RTCConfig.Token = key.serialize(); } SceneConfig.id = scene; SceneConfig.botName = VoiceChat?.AgentConfig?.UserId; SceneConfig.isInterruptMode = VoiceChat?.Config?.InterruptMode === 0; SceneConfig.isVision = VoiceChat?.Config?.LLMConfig?.VisionConfig?.Enable; SceneConfig.isScreenMode = VoiceChat?.Config?.LLMConfig?.VisionConfig?.SnapshotConfig?.StreamType === 1; SceneConfig.isAvatarScene = VoiceChat?.Config?.AvatarConfig?.Enabled; SceneConfig.avatarBgUrl = VoiceChat?.Config?.AvatarConfig?.BackgroundUrl; delete RTCConfig.AppKey; return { scene: SceneConfig || {}, rtc: RTCConfig, }; }); return { scenes, }; } }); }); app.listen(3001, () => { console.log('AIGC Server is running at http://localhost:3001'); }); ================================================ FILE: Server/nodemon.json ================================================ { "watch": ["."], "ext": "js,json", "ignore": ["node_modules/*"] } ================================================ FILE: Server/package.json ================================================ { "name": "AIGCServer", "version": "1.0.0", "description": "Server for demo to call open api", "main": "app.js", "license": "BSD-3-Clause", "private": true, "dependencies": { "@volcengine/openapi": "^1.22.0", "koa": "^2.15.3", "koa-bodyparser": "^4.4.1", "koa2-cors": "^2.0.6", "lodash": "^4.17.21", "node-fetch": "^2.3.2", "uuid": "^11.1.0" }, "devDependencies": { "nodemon": "^3.1.10" }, "scripts": { "dev": "nodemon app.js", "start": "nodemon app.js" } } ================================================ FILE: Server/scenes/Custom.json ================================================ { "SceneConfig": { "icon": "https://lf3-rtc-demo.volccdn.com/obj/rtc-aigc-assets/DoubaoAvatar.png", "name": "自定义助手" }, "AccountConfig": { "accessKeyId": "", "secretKey": "" }, "RTCConfig": { "AppId": "", "AppKey": "", "RoomId": "", "UserId": "", "Token": "" }, "VoiceChat": { "AppId": "", "RoomId": "", "TaskId": "", "AgentConfig": { "TargetUserId": [ "" ], "WelcomeMessage": "你好,我是小宁,有什么需要帮忙的吗?", "UserId": "", "EnableConversationStateCallback": true }, "Config": { "ASRConfig": { "Provider": "volcano", "ProviderParams": { "Mode": "smallmodel", "AppId": "", "Cluster": "volcengine_streaming_common" } }, "TTSConfig": { "Provider": "volcano", "ProviderParams": { "app": { "appid": "", "cluster": "volcano_tts" }, "audio": { "voice_type": "BV001_streaming", "speed_ratio": 1, "pitch_ratio": 1, "volume_ratio": 1 } } }, "LLMConfig": { "Mode": "ArkV3", "EndPointId": "", "SystemMessages": [ "你是小宁,性格幽默又善解人意。你在表达时需简明扼要,有自己的观点。" ], "VisionConfig": { "Enable": false } }, "AvatarConfig": { "Enabled": false, "AvatarType": "3min", "AvatarRole": "250623-zhibo-linyunzhi", "BackgroundUrl": "", "VideoBitrate": 2000, "AvatarAppID": "", "AvatarToken": "" }, "InterruptMode": 0 } } } ================================================ FILE: Server/token.js ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ var crypto = require('crypto'); var randomInt = Math.floor(Math.random() * 0xFFFFFFFF); const VERSION = "001"; const VERSION_LENGTH = 3; const APP_ID_LENGTH = 24; privileges = { PrivPublishStream: 0, // not exported, do not use directly privPublishAudioStream: 1, privPublishVideoStream: 2, privPublishDataStream: 3, PrivSubscribeStream: 4, }; module.exports.privileges = privileges; // Initializes token struct by required parameters. var AccessToken = function (appID, appKey, roomID, userID) { let token = this; this.appID = appID; this.appKey = appKey; this.roomID = roomID; this.userID = userID; this.issuedAt = Math.floor(new Date() / 1000); this.nonce = randomInt; this.expireAt = 0; this.privileges = {}; // AddPrivilege adds permission for token with an expiration. this.addPrivilege = function (privilege, expireTimestamp) { if (token.privileges === undefined) { token.privileges = {} } token.privileges[privilege] = expireTimestamp; if (privilege === privileges.PrivPublishStream) { token.privileges[privileges.privPublishVideoStream] = expireTimestamp; token.privileges[privileges.privPublishAudioStream] = expireTimestamp; token.privileges[privileges.privPublishDataStream] = expireTimestamp; } }; // ExpireTime sets token expire time, won't expire by default. // The token will be invalid after expireTime no matter what privilege's expireTime is. this.expireTime = function (expireTimestamp) { token.expireAt = expireTimestamp; }; this.packMsg = function () { var bufM = new ByteBuf(); bufM.putUint32(token.nonce); bufM.putUint32(token.issuedAt); bufM.putUint32(token.expireAt); bufM.putString(token.roomID); bufM.putString(token.userID); bufM.putTreeMapUInt32(token.privileges); return bufM.pack() }; // Serialize generates the token string this.serialize = function () { var bytesM = this.packMsg(); var signature = encodeHMac(token.appKey, bytesM); var content = new ByteBuf().putBytes(bytesM).putBytes(signature).pack(); return (VERSION + token.appID + content.toString('base64')); }; // Verify checks if this token valid, called by server side. this.verify = function (key) { if (token.expireAt > 0 && Math.floor(new Date() / 1000) > token.expireAt) { return false } token.appKey = key; return encodeHMac(token.appKey, this.packMsg()).toString() === token.signature; } }; // Parse retrieves token information from raw string var Parse = function (raw) { try { if (raw.length <= VERSION_LENGTH + APP_ID_LENGTH) { return } if (raw.substr(0, VERSION_LENGTH) !== VERSION) { return } var token = new AccessToken("", "", "", ""); token.appID = raw.substr(VERSION_LENGTH, APP_ID_LENGTH); var contentBuf = Buffer.from(raw.substr(VERSION_LENGTH + APP_ID_LENGTH), 'base64'); var readbuf = new ReadByteBuf(contentBuf); var msg = readbuf.getString(); token.signature = readbuf.getString().toString(); // parse msg var msgBuf = new ReadByteBuf(msg); token.nonce = msgBuf.getUint32(); token.issuedAt = msgBuf.getUint32(); token.expireAt = msgBuf.getUint32(); token.roomID = msgBuf.getString().toString(); token.userID = msgBuf.getString().toString(); token.privileges = msgBuf.getTreeMapUInt32(); return token } catch (err) { console.log(err); } }; module.exports.version = VERSION; module.exports.AccessToken = AccessToken; module.exports.Parse = Parse; var encodeHMac = function (key, message) { return crypto.createHmac('sha256', key).update(message).digest(); }; var ByteBuf = function () { var that = { buffer: Buffer.alloc(1024) , position: 0 }; that.pack = function () { var out = Buffer.alloc(that.position); that.buffer.copy(out, 0, 0, out.length); return out; }; that.putUint16 = function (v) { that.buffer.writeUInt16LE(v, that.position); that.position += 2; return that; }; that.putUint32 = function (v) { that.buffer.writeUInt32LE(v, that.position); that.position += 4; return that; }; that.putBytes = function (bytes) { that.putUint16(bytes.length); bytes.copy(that.buffer, that.position); that.position += bytes.length; return that; }; that.putString = function (str) { return that.putBytes(Buffer.from(str)); }; that.putTreeMap = function (map) { if (!map) { that.putUint16(0); return that; } that.putUint16(Object.keys(map).length); for (var key in map) { that.putUint16(key); that.putString(map[key]); } return that; }; that.putTreeMapUInt32 = function (map) { if (!map) { that.putUint16(0); return that; } that.putUint16(Object.keys(map).length); for (var key in map) { that.putUint16(key); that.putUint32(map[key]); } return that; }; return that; }; var ReadByteBuf = function (bytes) { var that = { buffer: bytes , position: 0 }; that.getUint16 = function () { var ret = that.buffer.readUInt16LE(that.position); that.position += 2; return ret; }; that.getUint32 = function () { var ret = that.buffer.readUInt32LE(that.position); that.position += 4; return ret; }; that.getString = function () { var len = that.getUint16(); var out = Buffer.alloc(len); that.buffer.copy(out, 0, that.position, (that.position + len)); that.position += len; return out; }; that.getTreeMapUInt32 = function () { var map = {}; var len = that.getUint16(); for (var i = 0; i < len; i++) { var key = that.getUint16(); var value = that.getUint32(); map[key] = value; } return map; }; return that; }; ================================================ FILE: Server/util.js ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ const fs = require('fs'); const path = require('path'); const judgeMethodPath = (method) => { return (ctx, pathname) => ctx.method.toLowerCase() === method && ctx.url.startsWith(`/${pathname}`); } const readFiles = (dir, suffix) => { const scenes = {}; fs.readdirSync(path.join(__dirname, dir)).map((p) => { const data = JSON.parse(fs.readFileSync(path.join(__dirname, dir, p))); scenes[p.replace(suffix, '')] = data; }); return scenes; } const assert = (expression, msg) => { if (!!!expression || expression?.includes?.(' ')) { console.log(`\x1b[31m校验失败: ${msg}\x1b[0m`) throw new Error(msg); } } const wrapper = async ({ ctx, method = 'post', apiName, logic, containResponseMetadata = true, }) => { if (judgeMethodPath(method)(ctx, apiName)) { const ResponseMetadata = { Action: apiName }; try { const res = await logic(); ctx.body = containResponseMetadata ? { ResponseMetadata, Result: res, } : res; } catch (e) { ResponseMetadata.Error = { Code: -1, Message: e?.toString(), }; ctx.body = { ResponseMetadata, } } } } const deepAssert = (params = {}, prefix = '') => { if (typeof params === 'object') { Object.keys(params).forEach(key => { assert(params[key], `${prefix}: ${key} 不能为空, 请修改 /Server/sensitive.js`); deepAssert(params[key], `${prefix}: ${key}.`); }) } } module.exports = { wrapper, assert, readFiles, } ================================================ FILE: craco.config.js ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ const CracoLessPlugin = require('craco-less'); const path = require('path'); module.exports = { webpack: { alias: { '@': path.resolve(__dirname, 'src'), }, }, plugins: [ { plugin: CracoLessPlugin, options: { lessLoaderOptions: { lessOptions: { javascriptEnabled: true, }, }, }, }, ], }; ================================================ FILE: message.js ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ const reset = '\x1b[0m'; const bright = '\x1b[1m'; const green = '\x1b[32m'; console.log(`${bright}${bright}===================================================`); console.log(`${bright}${green}| 请查看目录下的 README.md 内容, 否则启动可能失败 |`); console.log(`${bright}${reset}===================================================${reset}`); ================================================ FILE: package.json ================================================ { "name": "aigc", "version": "1.6.0", "license": "BSD-3-Clause", "private": true, "dependencies": { "@reduxjs/toolkit": "^1.8.3", "@volcengine/rtc": "~4.66.20", "@arco-design/web-react": "^2.65.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-redux": "^8.0.2", "react-router": "^6.3.0", "react-router-dom": "^6.3.0", "redux": "^4.2.0", "uuid": "^8.3.2" }, "scripts": { "dev": "npm run echo && npm run start", "start": "cross-env REACT_APP_LOCAL=cn craco start", "server:start": "node Server/app.js", "build": "craco build", "test": "craco test", "eject": "react-scripts eject", "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'", "eslint": "eslint src/ --fix --cache --quiet --ext .js,.jsx,.ts,.tsx", "stylelint": "stylelint 'src/**/*.less' --fix", "pre-commit": "npm run eslint && npm run stylelint", "echo": "node message.js" }, "eslintConfig": { "extends": [ "react-app", "react-app/jest" ] }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] }, "devDependencies": { "@craco/craco": "^6.4.5", "@types/lodash": "^4.17.4", "@types/node": "^16.11.45", "@types/react": "^18.0.15", "@types/react-dom": "^18.0.6", "@types/react-helmet": "^6.1.11", "@types/uuid": "^8.3.4", "craco-less": "^2.0.0", "cross-env": "^7.0.3", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^8.5.0", "eslint-plugin-babel": "^5.3.1", "eslint-plugin-prettier": "^4.2.1", "postcss-less": "^6.0.0", "prettier": "^2.7.1", "react-scripts": "5.0.1", "stylelint": "^14.9.1", "stylelint-config-prettier": "^9.0.3", "stylelint-config-standard": "^26.0.0", "typescript": "^4.7.4" } } ================================================ FILE: public/index.html ================================================ 火山引擎 RTC 实时对话式 AI 体验 Demo ——— 支持 DeepSeek 和 豆包视觉理解模型
    ================================================ FILE: public/robots.txt ================================================ # https://www.robotstxt.org/robotstxt.html User-agent: * Disallow: ================================================ FILE: src/App.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import MainPage from './pages/MainPage'; import '@arco-design/web-react/dist/css/arco.css'; function App() { console.warn('运行问题可参考 README 内容进行排查'); return ( } /> } /> ); } export default App; ================================================ FILE: src/app/api.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ /** * @brief Basic APIs */ export const BasicAPIs = [ { action: 'getScenes', apiPath: '/getScenes', method: 'post', }, ] as const; /** * @brief Basic APIs */ export const AigcAPIs = [ { action: 'StartVoiceChat', apiPath: '/proxy', method: 'post', }, { action: 'StopVoiceChat', apiPath: '/proxy', method: 'post', }, ] as const; ================================================ FILE: src/app/base.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { Message } from '@arco-design/web-react'; import { AIGC_PROXY_HOST } from '@/config'; import type { RequestResponse, ApiConfig, ApiNames, Apis } from './type'; type Headers = Record; export type DeepPartial = { [P in keyof T]?: T[P] extends Array ? Array> : T[P] extends object ? DeepPartial : T[P]; }; /** * @brief Get * @param apiName * @param headers */ export const requestGetMethod = ({ action, headers = {}, }: { action: string; headers?: Record; }) => { return async (params: Record = {}) => { const url = `${AIGC_PROXY_HOST}?Action=${action}&${Object.keys(params) .map((key) => `${key}=${params[key]}`) .join('&')}`; const res = await fetch(url, { headers: { ...headers, }, }); return res; }; }; /** * @brief Post */ export const requestPostMethod = ({ action, apiPath, isJson = true, headers = {}, }: { action: string; apiPath: string; isJson?: boolean; headers?: Headers; }) => { return async (params: T) => { const res = await fetch(`${AIGC_PROXY_HOST}${apiPath}?Action=${action}`, { method: 'post', headers: { 'content-type': 'application/json', ...headers, }, body: (isJson ? JSON.stringify(params) : params) as BodyInit, }); return res; }; }; /** * @brief Return handler * @param res */ export const resultHandler = (res: RequestResponse) => { const { Result, ResponseMetadata } = res || {}; // Record request id for debug. if (ResponseMetadata.Action === 'StartVoiceChat') { const requestId = ResponseMetadata.RequestId; requestId && sessionStorage.setItem('RequestID', requestId); } if (ResponseMetadata.Error) { Message.error( `[${ResponseMetadata?.Action}]call failed(reason: ${ResponseMetadata.Error?.Message})` ); throw new Error( `[${ResponseMetadata?.Action}]call failed(${JSON.stringify(ResponseMetadata, null, 2)})` ); } return Result; }; /** * @brief Generate APIs by apiConfigs * @param apiConfigs */ export const generateAPIs = (apiConfigs: T) => apiConfigs.reduce>((store, cur) => { const { action, apiPath = '', method = 'get' } = cur; const actionKey = action as ApiNames; store[actionKey] = async (params) => { const queryData = method === 'get' ? await requestGetMethod({ action })(params) : await requestPostMethod({ action, apiPath })(params); const res = await queryData?.json(); return resultHandler(res); }; return store; }, {} as Apis); ================================================ FILE: src/app/index.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { AigcAPIs, BasicAPIs } from './api'; import { generateAPIs } from './base'; const VoiceChat = generateAPIs(AigcAPIs); const Basic = generateAPIs(BasicAPIs); export default { VoiceChat, Basic, }; ================================================ FILE: src/app/type.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ export type RequestParams = Record; export interface RequestResponse { ResponseMetadata: Partial<{ Action: string; Version: string; Service: string; Region: string; RequestId: string; Error: { Code: string; Message: string; }; }>; Result: any; } type TupleToUnion = T[number]; type RequestFn = (params?: RequestParams[T]) => RequestResponse[T]; type PromiseRequestFn = ( params?: RequestParams[T] ) => Promise; export type ApiConfig = { action: string; method: string; apiPath?: string }; export type ApiNames = TupleToUnion['action']; export type Apis = Record< ApiNames, RequestFn | PromiseRequestFn >; ================================================ FILE: src/components/AIAvatarLoading/index.module.less ================================================ .container { display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; } .avatarContainer { position: relative; display: flex; flex-direction: column; align-items: center; } .avatarSvg { max-width: 80%; height: auto; } /* 加载文字样式 */ .loadingText { margin-top: 30px; font-size: 18px; text-align: center; } ================================================ FILE: src/components/AIAvatarLoading/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import styles from './index.module.less'; function AIAvatarReadying() { return (
    {/* SVG 包含人型轮廓和流光效果 */} {/* 原始人型轮廓 */} {/* 渐变定义 */} {/* 原始渐变 */} {/* 添加动画效果,使渐变沿着路径运动 */} {/* 加载文字 */}
    数字人准备中,请稍候...
    ); } export default AIAvatarReadying; ================================================ FILE: src/components/AiAvatarCard/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .card { position: absolute; top: 0; bottom: 0; left: 0; right: 0; text-align: center; text-align: center; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px; .avatar { position: relative; border-radius: 50%; width: 167.5px; height: 167.5px; img { width: 100%; height: 100%; } } .aiStatus { position: absolute; border: 1px solid; border-image-source: linear-gradient(77.86deg, #e5f2ff -3.23%, #d9e5ff 51.11%, #f6e2ff 98.65%); box-shadow: 0px 2px 22px 0px #0000001a; width: 93px; height: 73px; border-radius: 24px; top: -28px; left: -56px; color: #635bff; font-weight: 500; font-size: 16px; display: flex; justify-content: center; align-items: center; flex-direction: column; gap: 8px; background: #ffffff; } .barContainer { display: flex; gap: 4px; } .bar { width: 11px; height: 16px; border-radius: 6px; animation: shake 1s ease infinite; background-color: #4f4fff; } .bar:nth-child(1) { animation-delay: -0.4s; } .bar:nth-child(2) { animation-delay: -0.2s; } @keyframes shake { 0% { transform: scaleY(1); } 50% { transform: scaleY(0.5); } 100% { transform: scaleY(1); } } } .fullScreen { .avatar { width: 115px; height: 115px; } .aiStatus { width: 72px; height: 56px; top: -24px; left: 86px; font-size: 12px; } } ================================================ FILE: src/components/AiAvatarCard/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useSelector } from 'react-redux'; import { RootState } from '@/store'; import UserTag from '../UserTag'; import { useDeviceState, useScene } from '@/lib/useCommon'; import style from './index.module.less'; interface IAiAvatarCardProps { showStatus: boolean; showUserTag: boolean; className?: string; } const THRESHOLD_VOLUME = 18; function AiAvatarCard(props: IAiAvatarCardProps) { const { showStatus, showUserTag, className } = props; const room = useSelector((state: RootState) => state.room); const { icon } = useScene(); const { scene, isAITalking, isFullScreen } = room; const volume = room.localUser.audioPropertiesInfo?.linearVolume || 0; const { isAudioPublished } = useDeviceState(); const isLoading = volume >= THRESHOLD_VOLUME && isAudioPublished; return (
    Avatar {showStatus ? ( isAITalking ? (
    ) : isLoading ? (
    正在听...
    ) : null ) : null}
    {showUserTag ? : null}
    ); } export default AiAvatarCard; ================================================ FILE: src/components/AiChangeCard/CheckScene/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .wrapper { position: relative; width: max-content; height: 50px; border-radius: 100px; display: flex; justify-content: center; align-items: center; cursor: pointer; color: #737a87; font-size: 14px; line-height: 22px; border: 1px solid#DDE2E9; margin-bottom: 16px; .content { width: 100%; height: 100%; padding: 12px; display: flex; justify-content: center; align-items: center; z-index: 1; gap: 4px; .icon { border-radius: 50%; width: 26px; height: 26px; } .checked-text { width: max-content; font-size: 13px; line-height: 22px; } } } .wrapper:hover { box-shadow: 0px 5px 6px 0px rgba(82, 102, 133, 0.15); } .active { border: 1px solid transparent; background: linear-gradient(77.86deg, #f1f9ff -3.23%, #edf3ff 51.11%, #faf4ff 98.65%) padding-box, linear-gradient(77.86deg, #3b91ff -3.23%, #0d5eff 51.11%, #c069ff 98.65%) border-box; .content { .checked-text { background: linear-gradient(90deg, #004fff 38.86%, #9865ff 100%); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-size: 13px; font-weight: 500; line-height: 22px; } } } .active:hover { box-shadow: 0px 5px 6px 0px rgba(82, 102, 133, 0.15); } .tag { position: absolute; top: 0; right: 0; z-index: 3; font-size: 10px; font-weight: 500; line-height: 18px; transform: translate(20%, -50%); background: rgba(134, 123, 227, 1); padding: 0px 6px 0px 6px; border-radius: 20px 20px 20px 0px; color: white; } ================================================ FILE: src/components/AiChangeCard/CheckScene/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import styles from './index.module.less'; interface IProps { checked: boolean; title?: string; onClick?: () => void; icon?: string; tag?: string; } function CheckScene(props: IProps) { const { tag, icon, title, checked, onClick } = props; return (
    {tag ?
    {tag}
    : ''}
    {icon ? icon : ''}
    {title}
    ); } export default CheckScene; ================================================ FILE: src/components/AiChangeCard/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .card { position: relative; text-align: center; text-align: center; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px; .avatar { img { width: 128px; height: 128px; } border-radius: 50%; width: 128px; height: 128px; background: linear-gradient(180deg, #c3e4ff 0%, #98d6fe 100%); } .title { font-weight: 500; font-size: 24px; line-height: 32px; color: #1d2129; } .desc { margin-top: 8px; font-weight: 400; font-size: 14px; line-height: 22px; color: #737a87; } .exceededTitle { font-weight: 500; font-size: 24px; line-height: 32px; background: linear-gradient(77.86deg, #3b91ff -3.23%, #0d5eff 51.11%, #c069ff 98.65%); -webkit-background-clip: text; background-clip: text; color: transparent; cursor: pointer; img { margin-left: 4px; } } .sceneContainer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; width: max-content; } } ================================================ FILE: src/components/AiChangeCard/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useDispatch, useSelector } from 'react-redux'; import { RootState } from '@/store'; import CheckScene from './CheckScene'; import { SceneConfig, updateScene } from '@/store/slices/room'; import { useScene } from '@/lib/useCommon'; import style from './index.module.less'; function AIChangeCard() { const { scene, sceneConfigMap } = useSelector((state: RootState) => state.room); const dispatch = useDispatch(); const { icon, isVision } = useScene(); const Scenes = Object.keys(sceneConfigMap).map(key => sceneConfigMap[key]); const handleChecked = (checkedScene: string) => { dispatch(updateScene(checkedScene)); }; return (
    Avatar
    Hi,欢迎体验实时对话式 AI
    {isVision ? <>支持豆包 Vision 模型和 深度思考模型, : ''} 超多对话场景等你开启
    {Scenes.map((key: SceneConfig) => handleChecked(key.id)} /> )}
    ); } export default AIChangeCard; ================================================ FILE: src/components/DrawerRowItem/index.module.less ================================================ /** * 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; /* Body/body-2 medium */ font-family: "PingFang SC"; font-size: 13px; font-style: normal; font-weight: 500; line-height: 22px; /* 169.231% */ 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; } } .footer { width: calc(100% - 12px); display: flex; flex-direction: row; justify-content: flex-end; align-items: center; .cancel { width: 88px; height: 32px; border-radius: 6px; border: 1px solid var(--line-color-border-3, rgba(221, 226, 233, 1)); margin-right: 12px; } .confirm { width: 88px; height: 32px; border-radius: 6px; background: linear-gradient(95.87deg, #1664FF 0%, #8040FF 97.7%); color: white; } } .children { width: 100%; height: 100%; position: relative; overflow: hidden; } :global { .ant-drawer-body { padding: 12px 24px 0px 24px; } } ================================================ FILE: src/components/DrawerRowItem/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import React, { useState } from 'react'; import { Drawer, DrawerProps } from '@arco-design/web-react'; import { IconRight } from '@arco-design/web-react/icon'; import styles from './index.module.less'; type IDrawerRowItemProps = { btnSrc?: string; btnText: string; suffix?: React.ReactNode; drawer?: { title: string; width?: string | number; onOpen?: () => void; onClose?: () => void; onCancel?: () => void; onConfirm?: (handleClose: () => void) => void; children?: React.ReactNode; footer?: React.ReactNode | boolean; } & DrawerProps; } & React.HTMLAttributes; function DrawerRowItem(props: IDrawerRowItemProps) { const { btnSrc, btnText, suffix, drawer, style, className = '' } = props; const [open, setOpen] = useState(false); const { onClose, onOpen } = drawer!; const handleClose = () => { drawer?.onCancel?.(); setOpen(false); onClose?.(); }; const handleOpen = () => { setOpen(true); if (drawer) { onOpen?.(); } }; return ( <>
    {btnSrc ? svg : ''} {btnText} {suffix}
    {drawer?.children}
    ); } export default DrawerRowItem; ================================================ FILE: src/components/FullScreenCard/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .card { position: absolute; top: 0; bottom: 0; left: 0; right: 0; text-align: center; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #fff; .tag { position: absolute; left: 16px; top: 16px; } &.hidden { display: none; } } .blur-bg { background-position: center; background-size: cover; background-repeat: no-repeat; filter: blur(20px); transform: scale(1.1); } ================================================ FILE: src/components/FullScreenCard/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useSelector } from 'react-redux'; import UserTag from '../UserTag'; import { RootState } from '@/store'; import style from './index.module.less'; import { useScene } from '@/lib/useCommon'; import { isMobile } from '@/utils/utils'; export const LocalFullID = 'local-full-player'; export const RemoteFullID = 'remote-full-player'; function FullScreenCard() { const isFullScreen = useSelector((state: RootState) => state.room.isFullScreen); const scene = useScene(); return ( <>
    {!isMobile() ? : null}
    ); } export default FullScreenCard; ================================================ FILE: src/components/Header/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .header { height: 48px; background: white; width: 100%; display: flex; align-items: center; justify-content: space-between; position: relative; :global { .arco-popover-content-top { padding: 0px; } } } .header-logo { display: flex; align-items: center; justify-content: space-between; margin-left: 24px; :global { img { height: 24px; } .arco-popover-content { padding: 0; } } } .menu-wrapper { display: flex; flex-direction: column; align-items: center; row-gap: 8px; justify-content: space-between; } .header-logo-text { background: linear-gradient(90deg, #004FFF 38.86%, #9865FF 100%); -webkit-background-clip: text; background-clip: text; color: transparent; font-size: 16px; } .header-right { z-index: 2; color: #fff; display: flex; align-items: center; :global { span { height: 24px; } } } .header-setting-btn { background-color: transparent; border: none; margin-right: 24px; color: #000000; font-size: 16px; cursor: pointer; } .header-pop { :global { .ant-popover-arrow { left: 16px; .ant-popover-arrow-content { &:before { background-color: white; } } } .ant-popover-content { margin-left: 12px; } .ant-popover-inner { margin-right: 12px; } .ant-popover-inner-content { padding: 0; background-color: white; position: relative; width: 100px; height: 100px; display: flex; align-items: center; flex-direction: column; justify-content: space-between; padding-bottom: 7px; padding-top: 7px; cursor: pointer; color: black; div { font-size: 13px; font-weight: 400; line-height: 20px; &:hover { color: #1664ff; } } } } } .divider { margin-top: 2px; margin-bottom: 2px; min-width: 70%; width: 70%; } .header-right-text { color: #000000; margin-right: 24px; cursor: pointer; } ================================================ FILE: src/components/Header/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { Button, Divider, Popover } from '@arco-design/web-react'; import { IconMenu } from '@arco-design/web-react/icon'; import NetworkIndicator from '@/components/NetworkIndicator'; import { useIsMobile } from '@/utils/utils'; import Logo from '@/assets/img/Logo.svg'; import styles from './index.module.less'; const Disclaimer = 'https://www.volcengine.com/docs/6348/68916'; const ReversoContext = 'https://www.volcengine.com/docs/6348/68918'; const UserAgreement = 'https://www.volcengine.com/docs/6348/128955'; interface HeaderProps { children?: React.ReactNode; hide?: boolean; } function Header(props: HeaderProps) { const { children, hide } = props; const MenuProps = [ { name: '免责声明', url: Disclaimer, }, { name: '隐私政策', url: ReversoContext, }, { name: '用户协议', url: UserAgreement, }, ]; return (
    {useIsMobile() ? null : ( {MenuProps.map((menuItem) => ( ))}
    } > )} Logo 实时对话式 AI 体验馆
    {children} {useIsMobile() ? null : (
    window.open('https://www.volcengine.com/product/veRTC/ConversationalAI', '_blank') } > 官网链接
    window.open( 'https://www.volcengine.com/contact/product?t=%E5%AF%B9%E8%AF%9D%E5%BC%8Fai&source=%E4%BA%A7%E5%93%81%E5%92%A8%E8%AF%A2', '_blank' ) } > 联系我们
    )}
    ); } export default Header; ================================================ FILE: src/components/Loading/AudioLoading/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .loader { display: flex; flex-direction: row; justify-content: center; align-items: center; gap: 6px; height: 36px; margin-top: 4px; } .dot { width: 20px; height: 20px; border-radius: 12px; background-color: rgba(148, 116, 255, 1); } .dotter { animation: glow 0.9s infinite; } @keyframes glow { 0% { height: 20px; } 50% { height: 36px; } 100% { height: 20px; } } ================================================ FILE: src/components/Loading/AudioLoading/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { memo } from 'react'; import style from './index.module.less'; interface IAudioLoadingProps extends React.HTMLAttributes { loading?: boolean; } function AudioLoading(props: IAudioLoadingProps) { const { loading = false, className = '', color, ...rest } = props; return (
    {Array(3) .fill(0) .map((_, index) => (
    ))}
    ); } export default memo(AudioLoading); ================================================ FILE: src/components/Loading/HorizonLoading/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .loader { display: flex; } .dot { width: 10px; height: 10px; border-radius: 50%; background-color: white; animation: glow 0.9s infinite; } ================================================ FILE: src/components/Loading/HorizonLoading/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { memo } from 'react'; import style from './index.module.less'; interface ILoadingProps extends React.HTMLAttributes { dotClassName?: string; speed?: number; gap?: number; } function Loading(props: ILoadingProps) { const { dotClassName, gap = 5, speed = 0.9, className = '', ...rest } = props; return (
    {Array(3) .fill(0) .map((_, index) => (
    ))}
    ); } export default memo(Loading); ================================================ FILE: src/components/Loading/VerticalLoading/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .loader { width: 40px; height: 10px; display: flex; justify-content: center; align-items: center; margin: 6px 0px; } .bar { width: 3px; height: 12px; margin: 1px; display: inline-block; animation: shake 0.6s ease infinite; } /* 为每个 bar 指定不同的延迟来实现抖动效果 */ .bar:nth-child(1) { animation-delay: -0.2s; } .bar:nth-child(2) { animation-delay: -0.1s; } .bar:nth-child(3) { } @keyframes shake { 0% { transform: scaleY(1); background-color: var(--primary-color-primary-7, rgba(23, 89, 221, 1)); } 50% { transform: scaleY(0.5); background-color: var(--primary-color-primary-3, rgba(151, 188, 255, 1)); } 100% { transform: scaleY(1); background-color: var(--primary-color-primary-7, rgba(23, 89, 221, 1)); } } ================================================ FILE: src/components/Loading/VerticalLoading/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { memo } from 'react'; import styles from './index.module.less'; function Loading() { return ( ); } export default memo(Loading); ================================================ FILE: src/components/LocalPlayerSet/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .container { position: absolute; right: 0; top: 148px; width: 36px; height: 36px; border-radius: 4px; cursor: pointer; z-index: 1; img { width: 100%; height: 100%; } } ================================================ FILE: src/components/LocalPlayerSet/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Popover } from '@arco-design/web-react'; import { RootState } from '@/store'; import { updateFullScreen } from '@/store/slices/room'; import SET_LOCAL_PLAYER from '@/assets/img/setLocalPlayer.svg'; import styles from './index.module.less'; function LocalPlayerSet() { const dispatch = useDispatch(); const room = useSelector((state: RootState) => state.room); const { isFullScreen } = room; const [loading, setLoading] = useState(false); const [isFull, setFull] = useState(isFullScreen); const setLocalPlayer = () => { setLoading(true); setFull(!isFull); dispatch(updateFullScreen({ isFullScreen: !isFull })); setLoading(false); }; return (
    fullSize
    ); } export default LocalPlayerSet; ================================================ FILE: src/components/NetworkIndicator/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .panel { display: flex; .label { width: 90px; display: flex; flex-direction: column; gap: 4px; .state { font-weight: bold; } } .value { display: flex; flex-direction: column; gap: 4px; width: max-content; .state { font-weight: bold; } .loss { display: flex; flex-direction: row; justify-content: space-between; gap: 12px; } } } .wrapper { display: flex; align-items: flex-end; height: 14px; width: 14px; margin: 14px; column-gap: 1.5px; background-color: rgba(142, 142, 142, 0.05); border-radius: 3px; padding: 2px; .indicator { width: 30%; border-color: rgba(127, 127, 127, 0.184); border-width: 1px; border-radius: 1px; border-style: solid; opacity: 0.8; transition: height 0.3s; box-sizing: border-box; } } ================================================ FILE: src/components/NetworkIndicator/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useMemo } from 'react'; import { Popover } from '@arco-design/web-react'; import { useSelector } from 'react-redux'; import { IconArrowDown, IconArrowUp } from '@arco-design/web-react/icon'; import { NetworkQuality } from '@volcengine/rtc'; import { RootState } from '@/store'; import { useScene } from '@/lib/useCommon'; import style from './index.module.less'; enum INDICATOR_COLORS { GREAT = 'rgba(35, 195, 67, 1)', FAIR = 'rgba(208, 141, 6, 1)', BAD = 'rgba(245, 78, 78, 1)', PLACE_HOLDER = 'transparent', } const INDICATOR_TEXT = { [NetworkQuality.UNKNOWN]: '正常', [NetworkQuality.EXCELLENT]: '正常', [NetworkQuality.GOOD]: '正常', [NetworkQuality.POOR]: '一般', [NetworkQuality.BAD]: '一般', [NetworkQuality.VBAD]: '较差', [NetworkQuality.DOWN]: '较差', }; function NetworkIndicator() { const room = useSelector((state: RootState) => state.room); const { botName } = useScene(); const networkQuality = room.networkQuality; const delay = room.localUser.audioStats?.rtt; const audioLossRateUpper = room.localUser.audioStats?.audioLossRate || 0; const audioLossRateLower = room.remoteUsers.find((user) => user.userId === botName)?.audioStats ?.audioLossRate || 0; const indicators = useMemo(() => { switch (networkQuality) { case NetworkQuality.UNKNOWN: case NetworkQuality.EXCELLENT: case NetworkQuality.GOOD: return Array(3).fill(INDICATOR_COLORS.GREAT); case NetworkQuality.POOR: case NetworkQuality.BAD: return Array(2).fill(INDICATOR_COLORS.FAIR).concat(INDICATOR_COLORS.PLACE_HOLDER); case NetworkQuality.VBAD: case NetworkQuality.DOWN: default: return [INDICATOR_COLORS.BAD].concat(...Array(2).fill(INDICATOR_COLORS.PLACE_HOLDER)); } }, [networkQuality]); return (
    网络状态
    延迟
    丢包率
    {INDICATOR_TEXT[networkQuality]}
    {delay ? delay.toFixed(0) : '- '}ms
    {`${audioLossRateUpper}` ? (audioLossRateUpper * 100)?.toFixed(0) : '- '}%
    {`${audioLossRateLower}` ? (audioLossRateLower * 100)?.toFixed(0) : '- '}%
    } >
    {indicators.map((color, index) => (
    ))}
    ); } export default NetworkIndicator; ================================================ FILE: src/components/ResizeWrapper/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .container { position: relative; } ================================================ FILE: src/components/ResizeWrapper/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useEffect, useRef } from 'react'; import styles from './index.module.less'; export type IWrapperProps = React.PropsWithChildren & { className?: string; }; export default function (props: IWrapperProps) { const { children, className = '' } = props; const ref = useRef(null); const resize = () => { if (ref.current) { ref.current.style.height = `${window.innerHeight}px`; } }; useEffect(() => { resize(); window.addEventListener('resize', resize); return () => { window.removeEventListener('resize', resize); }; }, []); return (
    {children}
    ); } ================================================ FILE: src/components/UserTag/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .userTagWrapper { display: flex; border-radius: 6px; border: 0.4px solid #1f232926; background-color: #fff; width: max-content; z-index: 1; margin-bottom: 45px; } .iconContainer { background-color: #5a6169; display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-top-left-radius: 6px; border-bottom-left-radius: 6px; } .nameContainer { color: #0c0d0e; padding: 0 4px; height: 20px; line-height: 20px; font-size: 12px; font-weight: 500; } ================================================ FILE: src/components/UserTag/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { IconUser } from '@arco-design/web-react/icon'; import styles from './index.module.less'; interface IUserTagProps { name: string; className?: string; } function UserTag(props: IUserTagProps) { const { name, className } = props; return (
    {name}
    ); } export default UserTag; ================================================ FILE: src/config/index.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ export const Disclaimer = 'https://www.volcengine.com/docs/6348/68916'; export const ReversoContext = 'https://www.volcengine.com/docs/6348/68918'; export const UserAgreement = 'https://www.volcengine.com/docs/6348/128955'; /** * @note 请求的 API Proxy Server(对应此 Demo 中包含的 Node server) 地址。 * 您可按需改成自己需要访问的地址。 */ export const AIGC_PROXY_HOST = 'http://localhost:3001'; export interface IScene { icon: string; name: string; questions: string[]; agentConfig: Record; llmConfig: Record; asrConfig: Record; ttsConfig: Record; } ================================================ FILE: src/index.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ @import './theme.less'; body { margin: 0; overflow: hidden; width: 100% !important; background: linear-gradient( 109.22deg, rgba(116, 37, 255, 0.05) 0.27%, rgba(39, 88, 255, 0.05) 51.39%, rgba(0, 102, 255, 0.05) 99.54% ); img { user-drag: none; -webkit-user-drag: none; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; } a { text-decoration: none; } } @keyframes glow { 0% { opacity: 1; } 40% { opacity: 0.7; } 100% { opacity: 0.3; } } ================================================ FILE: src/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ #demo-for-xxx-provider { flex: 1; // ------背景色------ // 页面可以配置背景色,但不建议,设计同学建议将背景色设置成透明,透出主应用的渐变背景色 background: transparent; // ----------------- // ------适配------ width: 100%; height: 100%; min-width: 730px; // 最小宽度,可根据情况自定义,页面显示区域不够最小高度时,会允许scroll。 // 建议pc端最小宽度小于等于730px(渲染区域的最小尺寸),这样可以避免页面滚动,用户体验更好。 min-height: 1000px; // 最小高度,可根据情况自定义,页面显示区域不够最小高度时,会允许scroll。 // 官网规范,<768px时为移动端 @media (max-width: 767px) { width: 100%; // 移动端渲染区域的宽度,等于设备屏幕的宽度 } // ----------------- // 写全局样式要防止与官网样式冲突 * { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; box-sizing: border-box; } .container-box { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; } } ================================================ FILE: src/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import ReactDOM from 'react-dom/client'; import { Provider } from 'react-redux'; import App from './App'; import store from './store'; import './index.less'; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( ); ================================================ FILE: src/interface.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ export enum DeviceType { Camera = 'camera', Microphone = 'microphone', } ================================================ FILE: src/lib/RtcClient.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import VERTC, { MirrorType, StreamIndex, IRTCEngine, RoomProfileType, onUserJoinedEvent, onUserLeaveEvent, MediaType, LocalStreamStats, RemoteStreamStats, StreamRemoveReason, LocalAudioPropertiesInfo, RemoteAudioPropertiesInfo, AudioProfileType, DeviceInfo, AutoPlayFailedEvent, PlayerEvent, NetworkQuality, VideoRenderMode, ScreenEncoderConfig, } from '@volcengine/rtc'; import RTCAIAnsExtension from '@volcengine/rtc/extension-ainr'; import { Message } from '@arco-design/web-react'; import Apis from '@/app/index'; import { string2tlv } from '@/utils/utils'; import { COMMAND, INTERRUPT_PRIORITY } from '@/utils/handler'; 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; mediaType: MediaType; reason: StreamRemoveReason; }) => void; handleRemoteStreamStats: (e: RemoteStreamStats) => void; handleLocalStreamStats: (e: LocalStreamStats) => void; handleLocalAudioPropertiesReport: (e: LocalAudioPropertiesInfo[]) => void; handleRemoteAudioPropertiesReport: (e: RemoteAudioPropertiesInfo[]) => void; handleAudioDeviceStateChanged: (e: DeviceInfo) => void; handleAutoPlayFail: (e: AutoPlayFailedEvent) => void; handlePlayerEvent: (e: PlayerEvent) => void; handleRoomBinaryMessageReceived: (e: { userId: string; message: ArrayBuffer }) => void; handleNetworkQuality: ( uplinkNetworkQuality: NetworkQuality, downlinkNetworkQuality: NetworkQuality ) => void; } export interface BasicBody { app_id: string; room_id: string; user_id: string; token?: string; } /** * @brief RTC Core Client * @notes Refer to official website documentation to get more information about the API. */ export class RTCClient { engine!: IRTCEngine; basicInfo!: BasicBody; private _audioCaptureDevice?: string; private _videoCaptureDevice?: string; audioBotEnabled = false; audioBotStartTime = 0; createEngine = async () => { this.engine = VERTC.createEngine(this.basicInfo.app_id); try { const AIAnsExtension = new RTCAIAnsExtension(); await this.engine.registerExtension(AIAnsExtension); AIAnsExtension.enable(); } catch (error) { console.warn( `当前环境不支持 AI 降噪, 此错误可忽略, 不影响实际使用, e: ${(error as any).message}` ); } }; addEventListeners = ({ handleError, handleUserJoin, handleUserLeave, handleTrackEnded, handleUserPublishStream, handleUserUnpublishStream, handleRemoteStreamStats, handleLocalStreamStats, handleLocalAudioPropertiesReport, handleRemoteAudioPropertiesReport, handleAudioDeviceStateChanged, handleAutoPlayFail, handlePlayerEvent, handleRoomBinaryMessageReceived, handleNetworkQuality, }: IEventListener) => { 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); this.engine.on(VERTC.events.onLocalStreamStats, handleLocalStreamStats); 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.onAutoplayFailed, handleAutoPlayFail); this.engine.on(VERTC.events.onPlayerEvent, handlePlayerEvent); this.engine.on(VERTC.events.onRoomBinaryMessageReceived, handleRoomBinaryMessageReceived); this.engine.on(VERTC.events.onNetworkQuality, handleNetworkQuality); }; joinRoom = () => { console.log(' ------ userJoinRoom\n', `roomId: ${this.basicInfo.room_id}\n`, `uid: ${this.basicInfo.user_id}`); return this.engine.joinRoom( this.basicInfo.token!, `${this.basicInfo.room_id!}`, { userId: this.basicInfo.user_id!, extraInfo: JSON.stringify({ call_scene: 'RTC-AIGC', user_name: this.basicInfo.user_id, user_id: this.basicInfo.user_id, }), }, { isAutoPublish: true, isAutoSubscribeAudio: true, roomProfileType: RoomProfileType.chat, } ); }; leaveRoom = () => { this.audioBotEnabled = false; this.engine.leaveRoom().catch(); VERTC.destroyEngine(this.engine); this._audioCaptureDevice = undefined; }; checkPermission(): Promise<{ video: boolean; audio: boolean; }> { return VERTC.enableDevices({ video: false, audio: true, }); } /** * @brief get the devices * @returns */ async getDevices(props?: { video?: boolean; audio?: boolean }): Promise<{ audioInputs: MediaDeviceInfo[]; audioOutputs: MediaDeviceInfo[]; videoInputs: MediaDeviceInfo[]; }> { 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 { audioInputs, audioOutputs, videoInputs, }; } startVideoCapture = async (camera?: string) => { await this.engine.startVideoCapture(camera || this._videoCaptureDevice); }; stopVideoCapture = async () => { this.engine.setLocalVideoMirrorType(MirrorType.MIRROR_TYPE_RENDER); 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); }; stopAudioCapture = async () => { await this.engine.stopAudioCapture(); }; publishStream = (mediaType: MediaType) => { this.engine.publishStream(mediaType); }; unpublishStream = (mediaType: MediaType) => { 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 */ setBusinessId = (businessId: string) => { this.engine.setBusinessId(businessId); }; setAudioVolume = (volume: number) => { this.engine.setCaptureVolume(StreamIndex.STREAM_INDEX_MAIN, volume); this.engine.setCaptureVolume(StreamIndex.STREAM_INDEX_SCREEN, volume); }; /** * @brief 设置音质档位 */ setAudioProfile = (profile: AudioProfileType) => { this.engine.setAudioProfile(profile); }; /** * @brief 切换设备 */ switchDevice = (deviceType: MediaType, deviceId: string) => { if (deviceType === MediaType.AUDIO) { this._audioCaptureDevice = deviceId; this.engine.setAudioCaptureDevice(deviceId); } if (deviceType === MediaType.VIDEO) { this._videoCaptureDevice = deviceId; this.engine.setVideoCaptureDevice(deviceId); } if (deviceType === MediaType.AUDIO_AND_VIDEO) { this._audioCaptureDevice = deviceId; this._videoCaptureDevice = deviceId; this.engine.setVideoCaptureDevice(deviceId); this.engine.setAudioCaptureDevice(deviceId); } }; setLocalVideoMirrorType = (type: MirrorType) => { return this.engine.setLocalVideoMirrorType(type); }; setLocalVideoPlayer = ( userId: string, renderDom?: string | HTMLElement, isScreenShare = false, renderMode = VideoRenderMode.RENDER_MODE_FILL ) => { return this.engine.setLocalVideoPlayer( isScreenShare ? StreamIndex.STREAM_INDEX_SCREEN : StreamIndex.STREAM_INDEX_MAIN, { renderDom, userId, renderMode, } ); }; setRemoteVideoPlayer = (userId: string, renderDom?: string | HTMLElement, renderMode = VideoRenderMode.RENDER_MODE_HIDDEN) => { return this.engine.setRemoteVideoPlayer( StreamIndex.STREAM_INDEX_MAIN, { renderDom, userId, renderMode, } ); } /** * @brief 移除播放器 */ removeLocalVideoPlayer = (userId: string, scope: StreamIndex | 'Both' = 'Both') => { let removeScreen = scope === StreamIndex.STREAM_INDEX_SCREEN; let removeCamera = scope === StreamIndex.STREAM_INDEX_MAIN; if (scope === 'Both') { removeCamera = true; removeScreen = true; } if (removeScreen) { this.engine.setLocalVideoPlayer(StreamIndex.STREAM_INDEX_SCREEN, { userId }); } if (removeCamera) { this.engine.setLocalVideoPlayer(StreamIndex.STREAM_INDEX_MAIN, { userId }); } }; /** * @brief 启用 AIGC */ startAgent = async (scene: string) => { if (this.audioBotEnabled) { await this.stopAgent(scene); } await Apis.VoiceChat.StartVoiceChat({ SceneID: scene, }); this.audioBotEnabled = true; this.audioBotStartTime = Date.now(); }; /** * @brief 关闭 AIGC */ stopAgent = async (scene: string) => { if (this.audioBotEnabled || sessionStorage.getItem('audioBotEnabled')) { await Apis.VoiceChat.StopVoiceChat({ SceneID: scene, }); this.audioBotStartTime = 0; sessionStorage.removeItem('audioBotEnabled'); } this.audioBotEnabled = false; }; /** * @brief 命令 AIGC */ commandAgent = ({ command, agentName, interruptMode = INTERRUPT_PRIORITY.NONE, message = '', }: { command: COMMAND; agentName: string; interruptMode?: INTERRUPT_PRIORITY; message?: string; }) => { if (this.audioBotEnabled) { this.engine.sendUserBinaryMessage( agentName, string2tlv( JSON.stringify({ Command: command, InterruptMode: interruptMode, Message: message, }), 'ctrl' ) ); return; } console.warn('Interrupt failed, bot not enabled.'); }; /** * @brief 更新 AIGC 配置 */ updateAgent = async (scene: string) => { if (this.audioBotEnabled) { await this.stopAgent(scene); await this.startAgent(scene); } else { await this.startAgent(scene); } }; /** * @brief 获取当前 AI 是否启用 */ getAgentEnabled = () => { return this.audioBotEnabled; }; } export default new RTCClient(); ================================================ FILE: src/lib/listenerHooks.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import VERTC, { LocalAudioPropertiesInfo, RemoteAudioPropertiesInfo, LocalStreamStats, MediaType, onUserJoinedEvent, onUserLeaveEvent, RemoteStreamStats, StreamRemoveReason, StreamIndex, DeviceInfo, AutoPlayFailedEvent, PlayerEvent, NetworkQuality, } from '@volcengine/rtc'; import { useDispatch } from 'react-redux'; import { useRef } from 'react'; import { IUser, remoteUserJoin, remoteUserLeave, updateLocalUser, updateRemoteUser, addAutoPlayFail, removeAutoPlayFail, updateNetworkQuality, } from '@/store/slices/room'; import RtcClient, { IEventListener } from './RtcClient'; import { setMicrophoneList, updateSelectedDevice } from '@/store/slices/device'; import { useMessageHandler } from '@/utils/handler'; import store from '@/store'; const useRtcListeners = (): IEventListener => { const dispatch = useDispatch(); const { parser } = useMessageHandler(); const playStatus = useRef<{ [key: string]: { audio: boolean; video: boolean } }>({}); 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, }) ); } }; const handleUserJoin = (e: onUserJoinedEvent) => { const extraInfo = JSON.parse(e.userInfo.extraInfo || '{}'); const userId = extraInfo.user_id || e.userInfo.userId; const username = extraInfo.user_name || e.userInfo.userId; dispatch( remoteUserJoin({ userId, username, }) ); }; const handleError = (e: { errorCode: typeof VERTC.ErrorCode.DUPLICATE_LOGIN }) => { const { errorCode } = e; if (errorCode === VERTC.ErrorCode.DUPLICATE_LOGIN) { console.log('踢人'); } }; const handleUserLeave = (e: onUserLeaveEvent) => { dispatch(remoteUserLeave(e.userInfo)); dispatch(removeAutoPlayFail(e.userInfo)); }; const handleUserPublishStream = (e: { userId: string; mediaType: MediaType }) => { const { userId, mediaType } = e; const payload: IUser = { userId }; if (mediaType === MediaType.AUDIO) { payload.publishAudio = true; } else if (mediaType === MediaType.VIDEO) { payload.publishVideo = true; } else if (mediaType === MediaType.AUDIO_AND_VIDEO) { payload.publishAudio = true; payload.publishVideo = true; } const isFullScreen = store.getState().room.isFullScreen; RtcClient.setRemoteVideoPlayer(userId, isFullScreen ? 'remote-video-player' : 'remote-full-player'); dispatch(updateRemoteUser(payload)); }; const handleUserUnpublishStream = (e: { userId: string; mediaType: MediaType; reason: StreamRemoveReason; }) => { const { userId, mediaType } = e; const payload: IUser = { userId }; if (mediaType === MediaType.AUDIO) { payload.publishAudio = false; } if (mediaType === MediaType.AUDIO_AND_VIDEO) { payload.publishAudio = false; } RtcClient.setRemoteVideoPlayer(userId); dispatch(updateRemoteUser(payload)); }; const handleRemoteStreamStats = (e: RemoteStreamStats) => { dispatch( updateRemoteUser({ userId: e.userId, audioStats: e.audioStats, }) ); }; const handleLocalStreamStats = (e: LocalStreamStats) => { dispatch( updateLocalUser({ audioStats: e.audioStats, }) ); }; const handleLocalAudioPropertiesReport = (e: LocalAudioPropertiesInfo[]) => { const localAudioInfo = e.find( (audioInfo) => audioInfo.streamIndex === StreamIndex.STREAM_INDEX_MAIN ); if (localAudioInfo) { dispatch( updateLocalUser({ audioPropertiesInfo: localAudioInfo.audioPropertiesInfo, }) ); } }; const handleRemoteAudioPropertiesReport = (e: RemoteAudioPropertiesInfo[]) => { const remoteAudioInfo = e .filter((audioInfo) => audioInfo.streamKey.streamIndex === StreamIndex.STREAM_INDEX_MAIN) .map((audioInfo) => ({ userId: audioInfo.streamKey.userId, audioPropertiesInfo: audioInfo.audioPropertiesInfo, })); if (remoteAudioInfo.length) { dispatch(updateRemoteUser(remoteAudioInfo)); } }; const handleAudioDeviceStateChanged = async (device: DeviceInfo) => { const devices = await RtcClient.getDevices(); if (device.mediaDeviceInfo.kind === 'audioinput') { let deviceId = device.mediaDeviceInfo.deviceId; if (device.deviceState === 'inactive') { deviceId = devices.audioInputs?.[0].deviceId || ''; } RtcClient.switchDevice(MediaType.AUDIO, deviceId); dispatch(setMicrophoneList(devices.audioInputs)); dispatch( updateSelectedDevice({ selectedMicrophone: deviceId, }) ); } }; const handleAutoPlayFail = (event: AutoPlayFailedEvent) => { const { userId, kind } = event; let playUser = playStatus.current?.[userId] || {}; playUser = { ...playUser, [kind]: false }; playStatus.current[userId] = playUser; dispatch( addAutoPlayFail({ userId, }) ); }; const addFailUser = (userId: string) => { dispatch(addAutoPlayFail({ userId })); }; const playerFail = (params: { type: 'audio' | 'video'; userId: string }) => { const { type, userId } = params; let playUser = playStatus.current?.[userId] || {}; playUser = { ...playUser, [type]: false }; const { audio, video } = playUser; if (audio === false || video === false) { addFailUser(userId); } return playUser; }; const handlePlayerEvent = (event: PlayerEvent) => { const { userId, rawEvent, type } = event; let playUser = playStatus.current?.[userId] || {}; if (!playStatus.current) return; if (rawEvent.type === 'playing') { playUser = { ...playUser, [type]: true }; const { audio, video } = playUser; if (audio !== false && video !== false) { dispatch(removeAutoPlayFail({ userId })); } } else if (rawEvent.type === 'pause') { playUser = playerFail({ type, userId }); } playStatus.current[userId] = playUser; }; const handleNetworkQuality = ( uplinkNetworkQuality: NetworkQuality, downlinkNetworkQuality: NetworkQuality ) => { dispatch( updateNetworkQuality({ networkQuality: Math.floor( (uplinkNetworkQuality + downlinkNetworkQuality) / 2 ) as NetworkQuality, }) ); }; const handleRoomBinaryMessageReceived = (event: { userId: string; message: ArrayBuffer }) => { const { message } = event; parser(message); }; return { handleError, handleUserJoin, handleUserLeave, handleTrackEnded, handleUserPublishStream, handleUserUnpublishStream, handleRemoteStreamStats, handleLocalStreamStats, handleLocalAudioPropertiesReport, handleRemoteAudioPropertiesReport, handleAudioDeviceStateChanged, handleAutoPlayFail, handlePlayerEvent, handleRoomBinaryMessageReceived, handleNetworkQuality, }; }; export default useRtcListeners; ================================================ FILE: src/lib/useCommon.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useEffect, useState, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import VERTC, { MediaType } from '@volcengine/rtc'; import { Modal } from '@arco-design/web-react'; import RtcClient from '@/lib/RtcClient'; import { clearCurrentMsg, clearHistoryMsg, localJoinRoom, localLeaveRoom, updateAIGCState, updateLocalUser, } from '@/store/slices/room'; import useRtcListeners from '@/lib/listenerHooks'; import { RootState } from '@/store'; import { updateMediaInputs, updateSelectedDevice, setDevicePermissions, } from '@/store/slices/device'; import logger from '@/utils/logger'; export const ABORT_VISIBILITY_CHANGE = 'abortVisibilityChange'; export interface FormProps { username: string; roomId: string; publishAudio: boolean; } export const useScene = () => { const { scene, sceneConfigMap } = useSelector((state: RootState) => state.room); return sceneConfigMap[scene] || {}; } export const useRTC = () => { const { scene, rtcConfigMap } = useSelector((state: RootState) => state.room); return rtcConfigMap[scene] || {}; } export const useDeviceState = () => { const dispatch = useDispatch(); const room = useSelector((state: RootState) => state.room); 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, video: type === MediaType.VIDEO, }); if (type === MediaType.AUDIO) { dispatch( updateMediaInputs({ audioInputs: mediaDevices.audioInputs, }) ); dispatch( updateSelectedDevice({ selectedMicrophone: mediaDevices.audioInputs[0]?.deviceId, }) ); } else { dispatch( updateMediaInputs({ videoInputs: mediaDevices.videoInputs, }) ); dispatch( updateSelectedDevice({ selectedCamera: mediaDevices.videoInputs[0]?.deviceId, }) ); } return mediaDevices; }; const switchMic = async (controlPublish = true) => { if (controlPublish) { await (!isAudioPublished ? RtcClient.publishStream(MediaType.AUDIO) : RtcClient.unpublishStream(MediaType.AUDIO)); } queryDevices(MediaType.AUDIO); await (!isAudioPublished ? RtcClient.startAudioCapture() : RtcClient.stopAudioCapture()); dispatch( updateLocalUser({ publishAudio: !isAudioPublished, }) ); }; const switchCamera = async (controlPublish = true) => { if (controlPublish) { await (!isVideoPublished ? RtcClient.publishStream(MediaType.VIDEO) : RtcClient.unpublishStream(MediaType.VIDEO)); } queryDevices(MediaType.VIDEO); await (!isVideoPublished ? RtcClient.startVideoCapture() : RtcClient.stopVideoCapture()); dispatch( updateLocalUser({ publishVideo: !isVideoPublished, }) ); }; const switchScreenCapture = async (controlPublish = true) => { try { !isScreenPublished ? sessionStorage.setItem(ABORT_VISIBILITY_CHANGE, 'true') : sessionStorage.removeItem(ABORT_VISIBILITY_CHANGE); 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.'); } sessionStorage.removeItem(ABORT_VISIBILITY_CHANGE); return false; }; 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, () => Promise ] => { const devicePermissions = useSelector((state: RootState) => state.device.devicePermissions); const room = useSelector((state: RootState) => state.room); const dispatch = useDispatch(); const { id } = useScene(); const { switchMic } = useDeviceState(); const [joining, setJoining] = useState(false); const listeners = useRtcListeners(); const handleAIGCModeStart = async () => { if (room.isAIGCEnable) { await RtcClient.stopAgent(id); dispatch(clearCurrentMsg()); await RtcClient.startAgent(id); } else { await RtcClient.startAgent(id); } dispatch(updateAIGCState({ isAIGCEnable: true })); }; async function disPatchJoin(): Promise { if (joining) { return; } const isSupported = await VERTC.isSupported(); if (!isSupported) { Modal.error({ title: '不支持 RTC', content: '您的浏览器可能不支持 RTC 功能,请尝试更换浏览器或升级浏览器后再重试。', }); return; } setJoining(true); /** 1. Create RTC Engine */ await RtcClient.createEngine(); /** 2.1 Set events callbacks */ RtcClient.addEventListeners(listeners); /** 2.2 RTC starting to join room */ await RtcClient.joinRoom(); /** 3. Set users' devices info */ const mediaDevices = await RtcClient.getDevices({ audio: true, video: false, }); dispatch( localJoinRoom({ roomId: RtcClient.basicInfo.room_id, user: { username: RtcClient.basicInfo.user_id, userId: RtcClient.basicInfo.user_id, }, }) ); dispatch( updateSelectedDevice({ selectedMicrophone: mediaDevices.audioInputs[0]?.deviceId, selectedCamera: mediaDevices.videoInputs[0]?.deviceId, }) ); dispatch(updateMediaInputs(mediaDevices)); setJoining(false); if (devicePermissions.audio) { try { await switchMic(); } catch (e) { logger.debug('No permission for mic'); } } handleAIGCModeStart(); } return [joining, disPatchJoin]; }; export const useLeave = () => { const dispatch = useDispatch(); const { id } = useScene(); const idRef = useRef(id); idRef.current = id; return async function () { await Promise.all([ RtcClient.stopAudioCapture, RtcClient.stopScreenCapture, RtcClient.stopVideoCapture, ]); await RtcClient.stopAgent(idRef.current); await RtcClient.leaveRoom(); dispatch(clearHistoryMsg()); dispatch(clearCurrentMsg()); dispatch(localLeaveRoom()); dispatch(updateAIGCState({ isAIGCEnable: false })); }; }; ================================================ FILE: src/pages/MainPage/MainArea/Antechamber/InvokeButton/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .wrapper { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; .btn { width: max-content; height: max-content; border-radius: 50%; display: flex; justify-content: center; align-items: center; .icon { position: absolute; } } .text { margin-top: 8px; color: rgba(115, 122, 135, 1); } } .cursor { cursor: pointer; } .cursor:hover { opacity: 0.8; } .cursor:active { opacity: 1; } .loader { display: flex; gap: 5px; } .dot { width: 10px; height: 10px; border-radius: 50%; background-color: white; animation: glow 0.9s infinite; } @keyframes glow { 0% { opacity: 1; } 40% { opacity: 0.7; } 100% { opacity: 0.3; } } ================================================ FILE: src/pages/MainPage/MainArea/Antechamber/InvokeButton/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import Loading from './loading'; import style from './index.module.less'; import CallButtonSVG from '@/assets/img/CallWrapper.svg'; import PhoneSVG from '@/assets/img/Phone.svg'; interface IInvokeButtonProps extends React.HTMLAttributes { loading?: boolean; } function InvokeButton(props: IInvokeButtonProps) { const { loading, className, ...rest } = props; return (
    call {loading ? ( ) : ( phone )}
    {loading ? '连接中' : '通话'}
    ); } export default InvokeButton; ================================================ FILE: src/pages/MainPage/MainArea/Antechamber/InvokeButton/loading.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import style from './index.module.less'; function Loading(props: React.HTMLAttributes) { const { className = '', ...rest } = props; return (
    {Array(3) .fill(0) .map((_, index) => (
    ))}
    ); } export default Loading; ================================================ FILE: src/pages/MainPage/MainArea/Antechamber/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .wrapper { position: relative; width: 100%; height: 100%; border-radius: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; .avatar { /** * height = 128px in AvatarCard.avatar * */ margin-top: -128px; /** * width = 128px in AvatarCard.avatar * 128px / 2 = 64px * */ transform: translateX(calc(50% - 64px)); } .mobile { transform: none !important; } .description { font-family: PingFang SC; font-weight: 400; font-size: 10px; line-height: 20px; color: #c7ccd6; position: absolute; bottom: 24px; left: 24px; } .invoke-btn { margin-top: 32px; } .mobileDesc { font-weight: 400; font-size: 14px; line-height: 22px; text-align: center; color: #737a87; position: absolute; bottom: 12px; } } .mobile { background: /* 图层1 (最上层): 背景图片 */ /* url(...) [position] / [size] [repeat] */ url('../../../../assets/img/mobileBg.png') center center / cover no-repeat, /* 图层2 (下层): 渐变背景 */ linear-gradient(167.98deg, #f5f7ff 0%, #faf3ff 100%); border-radius: 0; } ================================================ FILE: src/pages/MainPage/MainArea/Antechamber/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useDispatch } from 'react-redux'; import { isMobile } from '@/utils/utils'; import InvokeButton from '@/pages/MainPage/MainArea/Antechamber/InvokeButton'; import { useJoin, useScene } from '@/lib/useCommon'; import AIChangeCard from '@/components/AiChangeCard'; import { updateFullScreen, updateShowSubtitle } from '@/store/slices/room'; import style from './index.module.less'; function Antechamber() { const dispatch = useDispatch(); const [joining, dispatchJoin] = useJoin(); const { isScreenMode, isAvatarScene } = useScene(); const handleJoinRoom = () => { dispatch(updateFullScreen({ isFullScreen: !isMobile() && !isScreenMode && !isAvatarScene })); // 初始化 dispatch(updateShowSubtitle({ isShowSubtitle: !isAvatarScene })); if (!joining) { dispatchJoin(); } }; return (
    {isMobile() ? null : (
    Powered by 豆包大模型和火山引擎视频云 RTC
    )}
    ); } export default Antechamber; ================================================ FILE: src/pages/MainPage/MainArea/Room/AudioController.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useDispatch, useSelector } from 'react-redux'; import AudioLoading from '@/components/Loading/AudioLoading'; import { RootState } from '@/store'; import RtcClient from '@/lib/RtcClient'; import { setInterruptMsg } from '@/store/slices/room'; import { useDeviceState, useScene } from '@/lib/useCommon'; import { COMMAND } from '@/utils/handler'; import style from './index.module.less'; const THRESHOLD_VOLUME = 18; function AudioController(props: React.HTMLAttributes) { const { className, ...rest } = props; const dispatch = useDispatch(); const { isInterruptMode, botName } = useScene(); const room = useSelector((state: RootState) => state.room); const volume = room.localUser.audioPropertiesInfo?.linearVolume || 0; const { isAudioPublished } = useDeviceState(); const { isAITalking } = room; const isAIReady = room.msgHistory.length > 0; const isLoading = volume >= THRESHOLD_VOLUME && isAudioPublished; const handleInterrupt = () => { RtcClient.commandAgent({ agentName: botName, command: COMMAND.INTERRUPT, }); dispatch(setInterruptMsg()); }; return (
    {isAudioPublished ? ( isAIReady && isAITalking ? (
    {isInterruptMode ?
    语音打断 或
    : null}
    点此打断
    ) : isLoading ? null : (
    请开始说话
    ) ) : (
    你已关闭麦克风
    )}
    ); } export default AudioController; ================================================ FILE: src/pages/MainPage/MainArea/Room/CameraArea.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useSelector } from 'react-redux'; import { VideoRenderMode } from '@volcengine/rtc'; import { useEffect } from 'react'; import { RootState } from '@/store'; import { useDeviceState, useScene } from '@/lib/useCommon'; import RtcClient from '@/lib/RtcClient'; import styles from './index.module.less'; import UserTag from '@/components/UserTag'; import LocalPlayerSet from '@/components/LocalPlayerSet'; import AiAvatarCard from '@/components/AiAvatarCard'; import UserAvatar from '@/assets/img/userAvatar.png'; import CameraCloseNoteSVG from '@/assets/img/CameraCloseNote.svg'; import ScreenCloseNoteSVG from '@/assets/img/ScreenCloseNote.svg'; import { LocalFullID, RemoteFullID } from '@/components/FullScreenCard'; const LocalVideoID = 'local-video-player'; const LocalScreenID = 'local-screen-player'; const RemoteVideoID = 'remote-video-player'; function CameraArea(props: React.HTMLAttributes) { const { className, ...rest } = props; const room = useSelector((state: RootState) => state.room); const { isFullScreen, scene } = room; const { isVision, isScreenMode, botName } = useScene(); const { isVideoPublished, isScreenPublished, switchCamera, switchScreenCapture } = useDeviceState(); const isRemoteVideoPublished = room.remoteUsers.find(user => user.username === botName)?.publishVideo ?? false const setVideoPlayer = () => { RtcClient.removeLocalVideoPlayer(room.localUser.username!); if (isVideoPublished || isScreenPublished) { RtcClient.setLocalVideoPlayer( room.localUser.username!, isFullScreen ? LocalFullID : isScreenMode ? LocalScreenID : LocalVideoID, isScreenPublished, isScreenMode ? VideoRenderMode.RENDER_MODE_FILL : VideoRenderMode.RENDER_MODE_HIDDEN ); if(isRemoteVideoPublished) { RtcClient.setRemoteVideoPlayer( botName, isFullScreen ? RemoteVideoID : RemoteFullID, ); } } }; const handleOperateCamera = () => { switchCamera(); }; const handleOperateScreenShare = () => { switchScreenCapture(); }; useEffect(() => { setVideoPlayer(); }, [isVideoPublished, isScreenPublished, isScreenMode, isFullScreen, isVision]); return (
    {isFullScreen ? ( ) : null} {isVideoPublished || isScreenPublished ? : null}
    close {isFullScreen ? null : (
    {isScreenMode ? ( <> 打开 屏幕共享
    体验豆包视觉理解模型
    ) : isVision ? ( <> 打开 摄像头
    体验豆包视觉理解模型
    ) : null}
    )}
    ); } export default CameraArea; ================================================ FILE: src/pages/MainPage/MainArea/Room/Conversation.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import React, { useRef, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { Tag, Spin } from '@arco-design/web-react'; import { RootState } from '@/store'; import Loading from '@/components/Loading/HorizonLoading'; import { isMobile } from '@/utils/utils'; import { useScene } from '@/lib/useCommon'; import USER_AVATAR from '@/assets/img/userAvatar.png'; import styles from './index.module.less'; import AIAvatarReadying from '@/components/AIAvatarLoading'; const lines: (string | React.ReactNode)[] = []; function Conversation(props: React.HTMLAttributes & { showSubtitle: boolean }) { const { className, showSubtitle, ...rest } = props; const room = useSelector((state: RootState) => state.room); const { msgHistory, isFullScreen } = room; const { userId } = useSelector((state: RootState) => state.room.localUser); const { isAITalking, isUserTalking, scene } = useSelector((state: RootState) => state.room); const isAIReady = msgHistory.length > 0; const containerRef = useRef(null); const { botName, icon, isAvatarScene } = useScene(); const isUserTextLoading = (owner: string) => { return owner === userId && isUserTalking; }; const isAITextLoading = (owner: string) => { return (owner === botName || owner.includes('voiceChat_')) && isAITalking; }; useEffect(() => { const container = containerRef.current; if (container) { container.scrollTop = container.scrollHeight - container.clientHeight; } }, [msgHistory.length]); return (
    {lines.map((line) => line)} {!isAIReady ? (
    {isAvatarScene ? ( ) : ( <> AI 准备中, 请稍侯 )}
    ) : ( '' )} {(showSubtitle ? msgHistory : [])?.map(({ value, user, isInterrupted }, index) => { const isUserMsg = user === userId; const isRobotMsg = user === botName || user.includes('voiceChat_'); if (!isUserMsg && !isRobotMsg) { return ''; } return (
    {!isMobile() && (
    Avatar
    {isUserMsg ? '我' : scene}
    )}
    {value}
    {isAIReady && (isUserTextLoading(user) || isAITextLoading(user)) && index === msgHistory.length - 1 ? ( ) : ( '' )}
    {!isUserMsg && isInterrupted ? 已打断 : ''}
    ); })}
    ); } export default Conversation; ================================================ FILE: src/pages/MainPage/MainArea/Room/ToolBar.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { memo, useState } from 'react'; import { Drawer } from '@arco-design/web-react'; import { useDeviceState, useLeave, useScene } from '@/lib/useCommon'; import { isMobile } from '@/utils/utils'; import Menu from '../../Menu'; import style from './index.module.less'; import CameraOpenSVG from '@/assets/img/CameraOpen.svg'; import CameraCloseSVG from '@/assets/img/CameraClose.svg'; import MicOpenSVG from '@/assets/img/MicOpen.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 [open, setOpen] = useState(false); const { isVision, isScreenMode } = useScene(); const leaveRoom = useLeave(); const { isAudioPublished, isVideoPublished, isScreenPublished, switchMic, switchCamera, switchScreenCapture, } = useDeviceState(); return (
    switchMic(true)} className={style.btn} alt="mic" /> {!isVision ? null : isScreenMode && !isMobile() ? ( switchScreenCapture()} className={style.btn} alt="screenShare" /> ) : ( switchCamera(true)} className={style.btn} alt="camera" /> )} {isScreenMode && ( switchScreenCapture(true)} className={style.btn} alt="screenShare" /> )} leave {isMobile() ? ( setOpen(false)} style={{ width: 'max-content', }} footer={null} > ) : null}
    ); } export default memo(ToolBar); ================================================ FILE: src/pages/MainPage/MainArea/Room/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .wrapper { position: relative; width: 100%; height: 100%; border-radius: 16px; padding: 32px; box-sizing: border-box; .conversation, .fullScreen, .mobileConversation { width: 100%; position: relative; height: 100%; /** * 100% 为容器高度 * 128px 为上层 DouBao Card Height * 24px 为 margin top * 36px * 2 为容器 padding * 128 + 24 + 36 * 2 = 224px */ max-height: calc(100% - 224px - 8px); display: flex; flex-direction: column; padding-bottom: 12px; overflow-x: hidden; overflow-y: auto; margin-top: 48px; .sentence { position: relative; display: flex; flex-direction: row; justify-content: flex-start; flex-wrap: wrap; align-items: center; width: max-content; white-space: normal; max-width: 70%; padding: 12px 16px; margin-left: 32px; gap: 8px; .content { width: max-content; } } .user { width: max-content; border: 0px solid; padding: 8px 12px 8px 12px; border-radius: 12px; background: #f1f3f5; margin-bottom: 12px; } .robot { font-family: PingFang SC; color: #0c0d0e; font-size: 14px; font-weight: 500; letter-spacing: 0.003em; border: 1px solid transparent; border-radius: 12px; background: linear-gradient(77.86deg, #fff -3.23%, #fff 51.11%, #fff 98.65%) padding-box, linear-gradient(77.86deg, #e5f2ff -3.23%, #d9e5ff 51.11%, #f6e2ff 98.65%) border-box; margin-bottom: 12px; } .loading-wrapper { width: max-content; display: inline-block; .loading { margin-left: 8px; width: max-content; } .dot { background-color: rgba(193, 163, 237, 1); width: 8px; height: 8px; } } .aiReadying { font-family: PingFang SC; font-size: 16px; font-weight: 500; color: rgba(27, 30, 61, 0.6); text-align: center; display: flex; flex-direction: row; justify-content: flex-start; align-items: center; line-height: 28px; } .aiReading-spin { margin-right: 12px; line-height: 16px; } .msgName { display: flex; gap: 8px; align-items: center; font-size: 12px; line-height: 20px; color: #737a87; margin-bottom: 4px; .avatar { border-radius: 50%; width: 24px; height: 24px; img { width: 100%; height: 100%; } } } } .fullScreen { .msgName { color: #fff; text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3); } .sentence { color: #fff; } .user { background: rgba(0, 0, 0, 0.25); } .robot { background: rgba(0, 12, 71, 0.5); } } .conversation::-webkit-scrollbar { width: 0px; height: 0px; } .conversation::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0); border-radius: 0px; } .conversation::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0); border-radius: 0px; } .toolBar { position: absolute; right: 0px; margin-right: 36px; bottom: 36px; } .controller { position: absolute; left: 0px; bottom: 36px; margin-left: 50%; transform: translateX(-50%); } .declare { position: absolute; bottom: 8px; left: 12px; color: var(--text-color-text-4, rgba(199, 204, 214, 1)); font-size: 10px; font-weight: 400; line-height: 20px; } } .text { width: 100%; text-align: center; color: rgba(148, 116, 255, 1); font-size: 14px; font-weight: 500; 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; flex-direction: row; justify-content: flex-end; align-items: center; gap: 16px; .setting { background-color: rgba(111, 111, 111, 0.497); border-radius: 50%; width: 48px; height: 48px; padding: 12px; box-sizing: border-box; cursor: pointer; } .btn { cursor: pointer; } .btn:hover { opacity: 0.8; } .btn:active { opacity: 1; } } .column { margin-right: 0 !important; justify-content: space-around; align-items: center; bottom: 64px !important; gap: 0; img { width: 84px; height: 84px; } } .interruptContainer { color: #635bff; display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500; } .interruptIcon { display: inline-block; width: 8px; height: 8px; background-color: #635bff; border-radius: 2px; } .interrupt { display: flex; flex-direction: row; justify-content: center; align-items: center; background: rgba(99, 91, 255, 0.1); border-radius: 4px; width: max-content; height: 26px; padding: 0 8px; gap: 4px; cursor: pointer; user-select: none; -webkit-user-select: none; /* Safari */ -moz-user-select: none; /* Firefox */ -ms-user-select: none; /* Internet Explorer/Edge */ &:hover { opacity: 0.8; } &:active { opacity: 1; } } .camera-wrapper { position: absolute; top: 16px; right: 16px; width: 264px; border-radius: 8px; background: var(--line-color-border-2, rgba(234, 237, 241, 1)); display: flex; flex-direction: column; justify-content: center; align-items: center; border: 0.81px solid var(--line-color-border-3, rgba(221, 226, 233, 1)); z-index: 4; .camera-player { width: 100%; height: 184px; border-radius: 8px; overflow: hidden; } .camera-player-hidden { display: none !important; } .camera-placeholder { width: 100%; height: 184px; display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 12px; color: #737a87; border-bottom-left-radius: inherit; border-bottom-right-radius: inherit; text-align: center; .camera-placeholder-close-note { margin-bottom: 8px; width: 60px; height: 60px; } .camera-open-btn { color: var(--primary-color-primary-6, rgba(22, 100, 255, 1)); cursor: pointer; margin-left: 2px; } } .userTag { position: absolute; top: 4px; left: 4px; } .subTitleUserTag { position: absolute; top: -16px; right: -16px; } } .visionDescriptionArea { width: 100%; background: linear-gradient(77.86deg, #f1f9ff -3.23%, #edf3ff 51.11%, #faf4ff 98.65%); padding: 10px 0; text-align: center; border-bottom-left-radius: inherit; border-bottom-right-radius: inherit; box-sizing: border-box; font-size: 12px; line-height: 20px; color: #737a87; .visionTitleText { color: #42464e; font-weight: 500; } } .subtitleAiAvatar { opacity: 0.3; } .fullScreenAiAvatar { height: 184px; } .mobile { background: /* 图层1 (最上层): 背景图片 */ /* url(...) [position] / [size] [repeat] */ url('../../../../assets/img/mobileBg.png') center center / cover no-repeat, /* 图层2 (下层): 渐变背景 */ linear-gradient(167.98deg, #f5f7ff 0%, #faf3ff 100%); .controller { bottom: 156px; } border-radius: 0; } .mobileConversation { display: flex; max-height: calc(100% - 324px) !important; margin-top: 64px !important; .sentence { margin-left: 0 !important; max-width: 85% !important; } .mobileLine { display: flex; } } .mobilePlayer { width: 100%; height: 100%; position: absolute; top: 0; left: 0; } @media (max-width: 767px) { .mobileLine { display: flex; justify-content: flex-start; } .user { align-self: flex-end; } } ================================================ FILE: src/pages/MainPage/MainArea/Room/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useSelector } from 'react-redux'; import Conversation from './Conversation'; import ToolBar from './ToolBar'; import CameraArea from './CameraArea'; import AudioController from './AudioController'; import { isMobile } from '@/utils/utils'; import style from './index.module.less'; import AiAvatarCard from '@/components/AiAvatarCard'; import { RootState } from '@/store'; import UserTag from '@/components/UserTag'; import FullScreenCard from '@/components/FullScreenCard'; import MobileToolBar from '@/pages/Mobile/MobileToolBar'; import { useScene } from '@/lib/useCommon'; function Room() { const room = useSelector((state: RootState) => state.room); const { isShowSubtitle, scene, isFullScreen } = room; const { isAvatarScene } = useScene(); return (
    {isMobile() ?
    : null} {isMobile() ? : null} {isShowSubtitle && !isMobile() ? ( ) : null} {isAvatarScene || (isFullScreen && !isMobile()) ? ( ) : isMobile() && isShowSubtitle ? null : ( )} {isMobile() ? null : }
    AI生成内容由大模型生成,不能完全保障真实
    ); } export default Room; ================================================ FILE: src/pages/MainPage/MainArea/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .wrapper { width: 100%; height: 100%; background-color: white; border: 1px solid var(--line-color-border-2, rgba(234, 237, 241, 1)); border-radius: 16px; padding: 20px 12.5%; .space { width: 100%; min-height: 40px; } .doubaoIcon { width: 111px; height: 111px; min-height: 111px; overflow: hidden; } .interruptTag { width: max-content; height: 22px; padding: 0px 6px 0px 6px; border-radius: 4px; margin-left: 4px; font-family: PingFang SC; font-size: 12px; font-weight: 400; line-height: 22px; letter-spacing: 0.003em; color: var(--text-color-text-3, rgba(115, 122, 135, 1)); background: var(--security-unknown-tag-unknown-1, rgba(241, 243, 245, 1)); } .welcome { font-family: PingFang SC; font-size: 24px; font-weight: 500; line-height: 32px; letter-spacing: 0.003em; text-align: left; margin-top: 8px; } .weight { background: linear-gradient(90deg, #004FFF 38.86%, #9865FF 100%); -webkit-background-clip: text; background-clip: text; color: transparent; } .tip { font-family: PingFang SC; font-size: 13px; font-weight: 400; line-height: 22px; letter-spacing: 0.003em; text-align: left; color: rgba(27, 30, 61, 0.6); margin-top: 18px; margin-bottom: 18px; } .tagProblem { width: max-content; border-radius: 4px; font-family: PingFang SC; font-size: 12px; font-weight: 500; line-height: 20px; letter-spacing: 0.003em; text-align: center; margin-bottom: 12px; color: rgba(66, 70, 78, 1); } .conversation { overflow-x: hidden; overflow-y: auto; width: 100%; position: relative; height: calc(75% - 12px); display: flex; flex-direction: column; padding-bottom: 12px; .aiReadying { font-family: PingFang SC; font-size: 16px; font-weight: 500; line-height: 18px; letter-spacing: 0.003em; color: rgba(27, 30, 61, 0.6); margin-top: 12px; text-align: center; display: flex; flex-direction: row; justify-content: flex-start; align-items: center; } .aiReading-spin { margin-right: 12px; } } .conversation::-webkit-scrollbar { width: 0px; height: 0px; } .conversation::-webkit-scrollbar-thumb { background: rgba(0,0,0,0); border-radius: 0px; } .conversation::-webkit-scrollbar-track { background: rgba(0,0,0,0); border-radius: 0px; } .sentence { display: flex; flex-direction: row; justify-content: flex-start; align-items: center; width: 100%; } .user { width: max-content; border: 0px solid; align-self: flex-end; padding: 8px 12px 8px 12px; border-radius: 12px 0px 12px 12px; background: var(--background-color-bg-5, rgba(241, 243, 245, 1)); margin-top: 12px; } .robot { font-family: PingFang SC; font-size: 14px; font-weight: 400; letter-spacing: 0.003em; border: 0px solid; align-self: flex-start; padding: 3px 12px 3px 0px; } .userTalkingWave { height: 100px; } .userStopTalkingWave { height: 100px; transform: scaleY(.5); } .status { overflow: hidden; width: 100%; height: 25%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 8px; .status-row { display: flex; flex-direction: row; justify-content: center; align-items: center; .status-icon { width: 24px; height: 24px; margin-right: 6px; } .status-text { font-family: PingFang SC; font-size: 14px; font-weight: 500; line-height: 22px; letter-spacing: 0.003em; } } .desc { font-family: PingFang SC; font-size: 10px; font-weight: 400; line-height: 18px; letter-spacing: 0.003em; text-align: center; color: var(--text-color-text-4, rgba(199, 204, 214, 1)); } .micNotify { display: flex; flex-direction: row; justify-content: center; align-items: center; } .micReopen { position: relative; width: 107px; height: 40px; padding: 5px 16px 5px 16px; margin-left: 12px; margin-right: 12px; background-clip: padding-box; /* 确保背景不覆盖边框 */ border-radius: 12px; &:hover, &:active, &:focus { opacity: 1; color: rgba(0, 0, 0, 0.85); border-color: #d9d9d9; } } } .interrupt { display: flex; flex-direction: row; justify-content: center; align-items: center; margin-top: 12px; width: max-content; line-height: 28px; padding: 1px 6px 1px 6px; border-radius: 4px; margin-left: 4px; font-family: PingFang SC; font-size: 12px; font-weight: 400; letter-spacing: 0.003em; text-align: left; box-shadow: 0px 0px 0px 1px rgba(221, 226, 233, 1); color: var(--text-color-text-3, rgba(115, 122, 135, 1)); &:hover, &:active, &:focus { opacity: 1; border-color: #d9d9d9; } img { margin-right: 8px; } } } ================================================ FILE: src/pages/MainPage/MainArea/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useSelector } from 'react-redux'; import Antechamber from './Antechamber'; import Room from './Room'; function MainArea() { const room = useSelector((state: any) => state.room); const isJoined = room.isJoined; return isJoined ? : ; } export default MainArea; ================================================ FILE: src/pages/MainPage/Menu/components/DeviceDrawerButton/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .wrapper { width: 100%; display: flex; flex-direction: row; gap: 24px; padding: 8px 16px; .label { display: flex; flex-direction: column; align-items: flex-start; line-height: 16px; gap: 12px; .label-text { font-family: PingFang SC; font-size: 14px; font-weight: 500; line-height: 22px; letter-spacing: 0.003em; text-align: left; } } .value { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; gap: 18px; } } ================================================ FILE: src/pages/MainPage/Menu/components/DeviceDrawerButton/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { MediaType } from '@volcengine/rtc'; import { Switch, Select } from '@arco-design/web-react'; import DrawerRowItem from '@/components/DrawerRowItem'; import { RootState } from '@/store'; import RtcClient from '@/lib/RtcClient'; import { useDeviceState, useScene } from '@/lib/useCommon'; import { updateSelectedDevice } from '@/store/slices/device'; import { isMobile } from '@/utils/utils'; import styles from './index.module.less'; interface IDeviceDrawerButtonProps { type?: MediaType.AUDIO | MediaType.VIDEO; } const DEVICE_NAME = { [MediaType.AUDIO]: '麦克风', [MediaType.VIDEO]: '摄像头', }; function DeviceDrawerButton(props: IDeviceDrawerButtonProps) { const { type = MediaType.AUDIO } = props; const device = useDeviceState(); const isEnable = type === MediaType.AUDIO ? device.isAudioPublished : device.isVideoPublished; const switcher = type === MediaType.AUDIO ? device.switchMic : device.switchCamera; const devicePermissions = useSelector((state: RootState) => state.device.devicePermissions); const devices = useSelector((state: RootState) => state.device); const selectedDevice = type === MediaType.AUDIO ? devices.selectedMicrophone : devices.selectedCamera; const permission = devicePermissions?.[type === MediaType.AUDIO ? 'audio' : 'video']; const { isScreenMode } = useScene(); const isScreenEnable = device.isScreenPublished; const changeScreenPublished = device.switchScreenCapture; const SETTING_NAME = { [MediaType.AUDIO]: '麦克风', [MediaType.VIDEO]: isScreenMode ? '屏幕共享' : '视频', }; const dispatch = useDispatch(); const deviceList = useMemo( () => (type === MediaType.AUDIO ? devices.audioInputs : devices.videoInputs), [devices] ); const handleDeviceChange = (value: string) => { RtcClient.switchDevice(type, value); if (type === MediaType.AUDIO) { dispatch( updateSelectedDevice({ selectedMicrophone: value, }) ); } if (type === MediaType.VIDEO) { dispatch( updateSelectedDevice({ selectedCamera: value, }) ); } }; return ( {!isScreenMode && (
    {DEVICE_NAME[type]}
    switcher(enable)} disabled={!permission} />
    )} {type === MediaType.VIDEO && isScreenMode && (
    屏幕共享
    )} ), }} /> ); } export default DeviceDrawerButton; ================================================ FILE: src/pages/MainPage/Menu/components/Operation/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .device { display: flex; flex-direction: column; gap: 16px; } .box { position: relative; width: 100%; border-radius: 16px; background-color: white; border: 1px solid var(--line-color-border-2, rgba(234, 237, 241, 1)); padding: 16px 24px 16px 24px; box-sizing: border-box; margin-bottom: 16px; } ================================================ FILE: src/pages/MainPage/Menu/components/Operation/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { MediaType } from '@volcengine/rtc'; import DeviceDrawerButton from '../DeviceDrawerButton'; import Subtitle from '../Subtitle'; import { useScene } from '@/lib/useCommon'; import styles from './index.module.less'; function Operation() { const { isVision } = useScene(); return (
    {isVision && }
    ); } export default Operation; ================================================ FILE: src/pages/MainPage/Menu/components/Subtitle/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .subtitle { position: relative; display: flex; flex-direction: row; align-items: center; justify-content: space-between; .label { font-size: 13px; font-weight: 400; line-height: 22px; color: var(--text-color-text-1, rgba(12, 13, 14, 1)); .icon { margin-left: 4px; } } } ================================================ FILE: src/pages/MainPage/Menu/components/Subtitle/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useState } from 'react'; import { Switch } from '@arco-design/web-react'; import { useDispatch, useSelector } from 'react-redux'; import { RootState } from '@/store'; import { updateShowSubtitle } from '@/store/slices/room'; import styles from './index.module.less'; function Subtitle() { const dispatch = useDispatch(); const room = useSelector((state: RootState) => state.room); const { isShowSubtitle } = room; const [checked, setChecked] = useState(isShowSubtitle); const [loading, setLoading] = useState(false); const handleChange = () => { setLoading(true); setChecked(!checked); dispatch(updateShowSubtitle({ isShowSubtitle: !checked })); setLoading(false); }; return (
    字幕
    ); } export default Subtitle; ================================================ FILE: src/pages/MainPage/Menu/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .wrapper { width: 210px; height: 100%; border-radius: 16px; display: flex; flex-direction: column; align-items: center; .info { display: flex; flex-direction: column; gap: 12px; .title { font-weight: 500; font-size: 14px; line-height: 22px; color: #0c0d0e; } .desc { display: flex; flex-direction: row; align-items: center; gap: 6px; font-size: 12px; line-height: 20px; color: #737a87; :global { div.arco-typography, p.arco-typography { margin-bottom: 0px; } } } .bold { font-size: 13px; font-weight: 500; line-height: 22px; color: var(--text-color-text-1, rgba(12, 13, 14, 1)); } .gray { display: flex; flex-direction: row; align-items: center; font-size: 13px; font-weight: 400; line-height: 22px; color: var(--text-color-text-3, rgba(115, 122, 135, 1)); .value { width: 65%; font-size: 12px; font-weight: 500; margin-left: 5px; } :global { .arco-typography { margin-bottom: 0px; display: inline-block; color: #737a87; } } } .buttonArea { width: 100%; display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-top: 8px; .getMore { width: 100%; color: #fff; height: 36px; text-shadow: none; box-shadow: none; border: none; text-align: center; background: linear-gradient(56.59deg, #3c73ff 15.53%, #6e41ee 62.28%, #d641ee 90.32%), radial-gradient( 203.56% 121.74% at 27.12% -21.74%, rgba(82, 182, 255, 0.2) 0%, rgba(143, 65, 238, 0) 100% ), radial-gradient( 134.75% 51.95% at 26.69% 5.8%, rgba(157, 214, 255, 0.1) 0%, rgba(143, 65, 238, 0) 100% ), radial-gradient( 82.39% 83.92% at 147.46% 76.45%, rgba(82, 99, 255, 0.8) 0%, rgba(143, 65, 238, 0) 100% ); border-radius: 6px; display: flex; flex-direction: row; justify-content: center; align-items: center; color: var(--Primary-Neutral-0, #fff); text-align: center; /* Body/body-2 medium */ font-family: 'PingFang SC'; font-size: 13px; font-style: normal; font-weight: 500; cursor: pointer; } .getMore:hover { opacity: 0.9; } .getMore:active { opacity: 1; } .getMore[disabled], .getMore[disabled]:hover { color: #fff; background: linear-gradient(95.87deg, #1664ff 0%, #8040ff 97.7%); opacity: 0.8; } } } .questions { display: flex; flex-direction: column; gap: 8px; .title { font-size: 13px; font-weight: 500; line-height: 22px; } .line { font-size: 12px; font-weight: 400; line-height: 20px; color: rgba(66, 70, 78, 1); cursor: pointer; } } .device { display: flex; flex-direction: column; gap: 16px; } .box { position: relative; width: 100%; border-radius: 16px; background-color: white; border: 1px solid var(--line-color-border-2, rgba(234, 237, 241, 1)); padding: 16px 24px 16px 24px; box-sizing: border-box; margin-bottom: 16px; } .resetTime { position: relative; width: 100%; border-radius: 16px; padding: 0px 24px 8px 24px; box-sizing: border-box; display: flex; flex-direction: row; justify-content: center; align-items: center; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; .normalLine { color: #42464e; /* Body/body-1 regular */ font-family: 'PingFang SC'; font-size: 12px; font-style: normal; font-weight: 400; line-height: 20px; /* 166.667% */ letter-spacing: 0.036px; opacity: 0.8; } } .tagWrapper { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 4px; } } .mobile-camera-wrapper { position: relative; width: 100%; height: 100%; border-radius: 16px; display: flex; flex-direction: column; justify-content: center; align-items: center; margin-bottom: 16px; .mobile-camera { position: relative !important; width: 100% !important; height: 100% !important; top: auto !important; right: auto !important; } } ================================================ FILE: src/pages/MainPage/Menu/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import VERTC from '@volcengine/rtc'; import { Tooltip, Typography } from '@arco-design/web-react'; import { useSelector } from 'react-redux'; import { RootState } from '@/store'; import Operation from './components/Operation'; import CameraArea from '../MainArea/Room/CameraArea'; import { isMobile } from '@/utils/utils'; import { useScene } from '@/lib/useCommon'; import packageJson from '../../../../package.json'; import styles from './index.module.less'; function Menu() { const room = useSelector((state: RootState) => state.room); const isJoined = room?.isJoined; const { isVision, name } = useScene(); const requestId = sessionStorage.getItem('RequestID'); return (
    {isJoined && isMobile() && isVision ? (
    ) : null}
    AI 人设:{name}
    {isJoined ? : ''}
    {isJoined ? '其他信息' : '版本信息'}
    Demo Version {packageJson.version}
    SDK Version {VERTC.getSdkVersion()}
    {isJoined ? (
    房间ID{' '} {room.roomId || '-'}
    ) : ( '' )} {room.isAIGCEnable ? (
    RequestID{' '} {requestId || '-'}
    ) : ( '' )}
    ); } export default Menu; ================================================ FILE: src/pages/MainPage/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .main { position: relative; width: 100%; height: calc(100% - 48px); display: flex; flex-direction: row; align-items: center; box-sizing: border-box; .mainArea { position: relative; width: calc(100% - 220px); height: 100%; margin-right: 2%; background-color: white; border-radius: 16px; overflow: hidden; border: 1px solid var(--line-color-border-2, #eaedf1); } .isMobile { width: 100% !important; margin-right: 0% !important; border-radius: 0px !important; } .operationArea { position: relative; width: 200px; height: 100%; } } ================================================ FILE: src/pages/MainPage/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useEffect } from 'react'; import { useDispatch } from 'react-redux'; import Header from '@/components/Header'; import ResizeWrapper from '@/components/ResizeWrapper'; import Menu from './Menu'; import { useIsMobile } from '@/utils/utils'; import Apis from '@/app/index'; import MainArea from './MainArea'; import { ABORT_VISIBILITY_CHANGE, useLeave } from '@/lib/useCommon'; import { RTCConfig, SceneConfig, updateRTCConfig, updateScene, updateSceneConfig } from '@/store/slices/room'; import styles from './index.module.less'; export default function () { const leaveRoom = useLeave(); const dispatch = useDispatch(); const getScenes = async () => { const { scenes }: { scenes: { rtc: RTCConfig; scene: SceneConfig; }[]; } = await Apis.Basic.getScenes(); dispatch(updateScene(scenes[0].scene.id)); dispatch(updateSceneConfig( scenes.reduce>((prev, cur) => { prev[cur.scene.id] = cur.scene; return prev; }, {}) )); dispatch(updateRTCConfig( scenes.reduce>((prev, cur) => { prev[cur.scene.id] = cur.rtc; return prev; }, {}) )); } useEffect(() => { getScenes(); const isOriginalDemo = window.location.host.startsWith('localhost'); const handler = () => { if ( document.visibilityState === 'hidden' && !sessionStorage.getItem(ABORT_VISIBILITY_CHANGE) ) { leaveRoom(); } }; !isOriginalDemo && document.addEventListener('visibilitychange', handler); return () => { !isOriginalDemo && document.removeEventListener('visibilitychange', handler); }; }, []); return (
    {useIsMobile() ? null : (
    )}
    ); } ================================================ FILE: src/pages/Mobile/MobileToolBar/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .wrapper { position: absolute; display: flex; align-items: center; justify-content: space-between; padding: 16px; z-index: 2; top: 0; left: 0; right: 0; color: #42464e; font-size: 16px; font-weight: 500; > div { display: flex; align-items: center; gap: 8px; } .setting { background-color: #fff; width: 36px; height: 36px; line-height: 28px; border-radius: 50%; text-align: center; border: 0.5px solid #ffffff80; cursor: pointer; font-weight: 500; } .aiSetting { background-color: #fff; height: 36px; border-radius: 18px; line-height: 36px; padding: 0 8px; cursor: pointer; } .screen, .subtitle { cursor: pointer; background-color: #fff; height: 36px; border-radius: 18px; line-height: 36px; padding: 0 8px; } .showSubTitle { background: #9474ff; color: #fff; } } ================================================ FILE: src/pages/Mobile/MobileToolBar/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useDispatch, useSelector } from 'react-redux'; import { memo, useEffect, useState } from 'react'; import { VideoRenderMode } from '@volcengine/rtc'; import { useDeviceState, useScene } from '@/lib/useCommon'; import { RootState } from '@/store'; import RtcClient from '@/lib/RtcClient'; import { updateShowSubtitle } from '@/store/slices/room'; import SettingsDrawer from '../SettingsDrawer'; import styles from './index.module.less'; function MobileToolBar(props: React.HTMLAttributes) { const dispatch = useDispatch(); const room = useSelector((state: RootState) => state.room); const { isShowSubtitle } = room; const [open, setOpen] = useState(false); const [subTitleStatus, setSubTitleStatus] = useState(isShowSubtitle); const { isScreenMode } = useScene(); const { isVideoPublished, isScreenPublished } = useDeviceState(); const switchSubtitle = () => { setSubTitleStatus(!subTitleStatus); dispatch(updateShowSubtitle({ isShowSubtitle: !subTitleStatus })); }; const setVideoPlayer = () => { if (isVideoPublished || isScreenPublished) { RtcClient.setLocalVideoPlayer( room.localUser.username!, 'mobile-local-player', isScreenPublished, isScreenMode ? VideoRenderMode.RENDER_MODE_FILL : VideoRenderMode.RENDER_MODE_HIDDEN ); } }; useEffect(() => { setVideoPlayer(); }, [isVideoPublished, isScreenPublished, isScreenMode]); return (
    字幕
    setOpen(false)} />
    ); } export default memo(MobileToolBar); ================================================ FILE: src/pages/Mobile/SettingsDrawer/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .settingsPage { background: linear-gradient(109.22deg, #7425ff0d 0.27%, #2758ff0d 51.39%, #0066ff0d 99.54%); display: flex; flex-direction: column; height: 100%; box-sizing: border-box; padding: 12px 0; } .settingsGroup { background-color: #ffffff; border-radius: 8px; margin: 0 16px 12px 16px; overflow: hidden; &:last-of-type { margin-bottom: 0; } } .logoutButtonContainer { margin: 20px 16px 0 16px; width: calc(100% - 32px); } .logoutButton { width: 100%; padding: 15px; background-color: #ffffff; color: #ff706d; border: none; border-radius: 8px; font-size: 15px; font-weight: 500; cursor: pointer; text-align: center; &:hover { background-color: #f7f8fa; } } .versionInfo { display: flex; flex-direction: column; align-items: flex-end; font-size: 14px; line-height: 1.5; } .copyLinkText { color: #165dff; } ================================================ FILE: src/pages/Mobile/SettingsDrawer/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import VERTC from '@volcengine/rtc'; import { Drawer, Message } from '@arco-design/web-react'; // Import Message if you plan to use it import { useSelector } from 'react-redux'; import { RootState } from '@/store'; import { useLeave } from '@/lib/useCommon'; import { Disclaimer, ReversoContext, UserAgreement } from '@/config'; import { SettingsItem } from '../components/SettingsItem'; import packageJSON from '../../../../package.json'; import styles from './index.module.less'; interface SettingsDrawerProps { visible: boolean; onCancel: () => void; } function SettingsDrawer({ visible, onCancel }: SettingsDrawerProps) { const room = useSelector((state: RootState) => state.room); const { roomId } = room; const leaveRoom = useLeave(); const handleLogout = () => { leaveRoom(); }; const handleCopyLink = () => { const pcLink = window.location.origin + window.location.pathname; navigator.clipboard .writeText(pcLink) .then(() => { Message.success('链接已复制'); }) .catch((err) => { console.error('复制链接失败:', err); Message.error('复制失败,请手动复制'); }); }; return (
    window.open(ReversoContext, '_blank')} /> window.open(UserAgreement, '_blank')} /> window.open(Disclaimer, '_blank')} /> Demo version {packageJSON.version} SDK version {VERTC.getSdkVersion()}
    } showArrow={false} />
    ); } export default SettingsDrawer; ================================================ FILE: src/pages/Mobile/components/SettingsItem/index.module.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ .settingsItem { display: flex; justify-content: space-between; align-items: center; padding: 16px; background-color: #ffffff; cursor: pointer; min-height: 50px; box-sizing: border-box; &:not(:last-child) { border-bottom: 0.5px solid #f0f0f0; // 更细的分割线 } .label { font-size: 16px; color: #0c0d0e; font-weight: 500; min-width: 64px; } .valueContainer { color: #737a87; font-size: 14px; display: flex; align-items: center; justify-content: flex-end; gap: 6px; text-align: right; } } ================================================ FILE: src/pages/Mobile/components/SettingsItem/index.tsx ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { IconRight } from '@arco-design/web-react/icon'; import styles from './index.module.less'; interface SettingsItemProps { label: string; value?: string | React.ReactNode; onClick?: () => void; showArrow?: boolean; valueClassName?: string; } export function SettingsItem({ label, value, onClick, showArrow = true, valueClassName, }: SettingsItemProps) { return (
    {label}
    {value && {value}} {showArrow && }
    ); } ================================================ FILE: src/react-app-env.d.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ /// declare module '*.less' { const content: { [className: string]: string }; export default content; } ================================================ FILE: src/store/index.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { configureStore } from '@reduxjs/toolkit'; import roomSlice, { RoomState } from './slices/room'; import deviceSlice, { DeviceState } from './slices/device'; export interface RootState { room: RoomState; device: DeviceState; } const store = configureStore({ reducer: { room: roomSlice, device: deviceSlice, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false, }), }); export default store; ================================================ FILE: src/store/slices/device.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { DeviceType } from '@/interface'; export const medias = [DeviceType.Microphone]; export const MediaName = { [DeviceType.Microphone]: 'microphone', [DeviceType.Camera]: 'camera', }; export interface DeviceState { audioInputs: MediaDeviceInfo[]; videoInputs: MediaDeviceInfo[]; selectedCamera?: string; selectedMicrophone?: string; devicePermissions: { audio: boolean; video: boolean; }; } const initialState: DeviceState = { audioInputs: [], videoInputs: [], devicePermissions: { audio: true, video: true, }, }; export const DeviceSlice = createSlice({ name: 'deivce', initialState, reducers: { updateMediaInputs: (state, { payload }) => { if (payload.audioInputs) { state.audioInputs = payload.audioInputs; } if (payload.videoInputs) { state.videoInputs = payload.videoInputs; } }, updateSelectedDevice: (state, { payload }) => { if (payload.selectedCamera) { state.selectedCamera = payload.selectedCamera; } if (payload.selectedMicrophone) { state.selectedMicrophone = payload.selectedMicrophone; } }, setMicrophoneList: (state, action: PayloadAction) => { state.audioInputs = action.payload; }, setDevicePermissions: ( state, action: PayloadAction<{ audio: boolean; video: boolean; }> ) => { state.devicePermissions = action.payload; }, }, }); export const { updateMediaInputs, updateSelectedDevice, setMicrophoneList, setDevicePermissions } = DeviceSlice.actions; export default DeviceSlice.reducer; ================================================ FILE: src/store/slices/room.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { createSlice } from '@reduxjs/toolkit'; import { AudioPropertiesInfo, LocalAudioStats, NetworkQuality, RemoteAudioStats, } from '@volcengine/rtc'; import RtcClient from '@/lib/RtcClient'; export interface IUser { username?: string; userId?: string; publishAudio?: boolean; publishVideo?: boolean; publishScreen?: boolean; audioStats?: RemoteAudioStats; audioPropertiesInfo?: AudioPropertiesInfo; } export type LocalUser = Omit & { loginToken?: string; audioStats?: LocalAudioStats; }; export interface Msg { value: string; time: string; user: string; paragraph?: boolean; definite?: boolean; isInterrupted?: boolean; } export interface SceneConfig { id: string; icon?: string; name?: string; questions?: string[]; botName: string; isVision: boolean; isScreenMode: boolean; isInterruptMode: boolean; isAvatarScene: boolean; avatarBgUrl: string; } export interface RTCConfig { AppId: string; RoomId: string; UserId: string; Token: string; } export interface RoomState { time: number; roomId?: string; localUser: LocalUser; remoteUsers: IUser[]; autoPlayFailUser: string[]; /** * @brief 是否已加房 */ isJoined: boolean; /** * @brief 选择的场景 */ scene: string; /** * @brief 场景下的配置 */ sceneConfigMap: Record; /** * @brief RTC 相关的配置 */ rtcConfigMap: Record; /** * @brief AI 通话是否启用 */ isAIGCEnable: boolean; /** * @brief AI 是否正在说话 */ isAITalking: boolean; /** * @brief AI 思考中 */ isAIThinking: boolean; /** * @brief 用户是否正在说话 */ isUserTalking: boolean; /** * @brief 网络质量 */ networkQuality: NetworkQuality; /** * @brief 对话记录 */ msgHistory: Msg[]; /** * @brief 当前的对话 */ currentConversation: { [user: string]: { /** * @brief 实时对话内容 */ msg: string; /** * @brief 当前实时对话内容是否能被定义为 "问题" */ definite: boolean; }; }; /** * @brief 是否显示字幕 */ isShowSubtitle: boolean; /** * @brief 是否全屏 */ isFullScreen: boolean; /** * @brief 自定义人设名称 */ customSceneName: string; } const initialState: RoomState = { time: -1, scene: '', sceneConfigMap: {}, rtcConfigMap: {}, remoteUsers: [], localUser: { publishAudio: false, publishVideo: false, publishScreen: false, }, autoPlayFailUser: [], isJoined: false, isAIGCEnable: false, isAIThinking: false, isAITalking: false, isUserTalking: false, networkQuality: NetworkQuality.UNKNOWN, msgHistory: [], currentConversation: {}, isShowSubtitle: true, isFullScreen: false, customSceneName: '', }; export const roomSlice = createSlice({ name: 'room', initialState, reducers: { localJoinRoom: ( state, { payload, }: { payload: { roomId: string; user: LocalUser; }; } ) => { state.roomId = payload.roomId; state.localUser = { ...state.localUser, ...payload.user, }; state.isJoined = true; }, localLeaveRoom: (state) => { state.roomId = undefined; state.time = -1; state.localUser = { publishAudio: false, publishVideo: false, publishScreen: false, }; state.remoteUsers = []; state.isJoined = false; }, remoteUserJoin: (state, { payload }) => { state.remoteUsers.push(payload); }, remoteUserLeave: (state, { payload }) => { const findIndex = state.remoteUsers.findIndex((user) => user.userId === payload.userId); state.remoteUsers.splice(findIndex, 1); }, updateScene: (state, { payload }) => { state.scene = payload; }, updateSceneConfig: (state, { payload }) => { state.sceneConfigMap = payload; }, updateRTCConfig: (state, { payload }) => { state.rtcConfigMap = payload; RtcClient.basicInfo = { app_id: payload[state.scene].AppId, room_id: payload[state.scene].RoomId, user_id: payload[state.scene].UserId, token: payload[state.scene].Token, }; }, updateLocalUser: (state, { payload }: { payload: Partial }) => { state.localUser = { ...state.localUser, ...(payload || {}), }; }, updateNetworkQuality: (state, { payload }) => { state.networkQuality = payload.networkQuality; }, updateRemoteUser: (state, { payload }: { payload: IUser | IUser[] }) => { if (!Array.isArray(payload)) { payload = [payload]; } payload.forEach((user) => { const findIndex = state.remoteUsers.findIndex((u) => u.userId === user.userId); state.remoteUsers[findIndex] = { ...state.remoteUsers[findIndex], ...user, }; }); }, updateRoomTime: (state, { payload }) => { state.time = payload.time; }, addAutoPlayFail: (state, { payload }) => { const autoPlayFailUser = state.autoPlayFailUser; const index = autoPlayFailUser.findIndex((item) => item === payload.userId); if (index === -1) { state.autoPlayFailUser.push(payload.userId); } }, removeAutoPlayFail: (state, { payload }) => { const autoPlayFailUser = state.autoPlayFailUser; const _autoPlayFailUser = autoPlayFailUser.filter((item) => item !== payload.userId); state.autoPlayFailUser = _autoPlayFailUser; }, clearAutoPlayFail: (state) => { state.autoPlayFailUser = []; }, updateAIGCState: (state, { payload }) => { 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; }, clearHistoryMsg: (state) => { state.msgHistory = []; }, setHistoryMsg: (state, { payload }) => { const { paragraph, definite } = payload; const lastMsg = state.msgHistory.at(-1)! || {}; /** 是否需要再创建新句子 */ const fromBot = payload.user === state.sceneConfigMap[state.scene].botName || payload.user.includes('voiceChat_'); /** * Bot 的语句: * 1. 在 SubtitleMode=0 时(未启用数字人时默认值),以 definite 判断是否需要追加新内容 * 2. 在 SubtitleMode=1 时(启用数字人时强制设定为 1),以 paragraph 判断是否需要追加新内容 * User 的语句以 paragraph 判断是否需要追加新内容 */ const currentSubtitleMode = state.sceneConfigMap[state.scene].isAvatarScene ? 1 : 0; const lastMsgCompleted = !fromBot || currentSubtitleMode ? lastMsg.paragraph : lastMsg.definite; if (state.msgHistory.length) { /** 如果上一句话是完整的则新增语句 */ if (lastMsgCompleted) { state.msgHistory.push({ value: payload.text, time: new Date().toString(), user: payload.user, definite, paragraph, }); } else { /** 话未说完, 更新文字内容 */ if (fromBot && currentSubtitleMode) { lastMsg.value += payload.text; } else { lastMsg.value = payload.text; } lastMsg.time = new Date().toString(); lastMsg.paragraph = paragraph; lastMsg.definite = definite; lastMsg.user = payload.user; } } else { /** 首句话首字不会被打断 */ state.msgHistory.push({ value: payload.text, time: new Date().toString(), user: payload.user, paragraph, }); } }, setInterruptMsg: (state) => { 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 = {}; state.msgHistory = []; state.isAITalking = false; state.isUserTalking = false; }, updateShowSubtitle: (state, { payload }) => { state.isShowSubtitle = payload.isShowSubtitle; }, updateFullScreen: (state, { payload }) => { state.isFullScreen = payload.isFullScreen; }, updatecustomSceneName: (state, { payload }) => { state.customSceneName = payload.customSceneName; }, }, }); export const { localJoinRoom, localLeaveRoom, remoteUserJoin, remoteUserLeave, updateRemoteUser, updateLocalUser, updateRoomTime, addAutoPlayFail, removeAutoPlayFail, clearAutoPlayFail, updateAIGCState, updateAITalkState, updateAIThinkState, setHistoryMsg, clearHistoryMsg, clearCurrentMsg, setInterruptMsg, updateNetworkQuality, updateScene, updateSceneConfig, updateRTCConfig, updateShowSubtitle, updateFullScreen, updatecustomSceneName, } = roomSlice.actions; export default roomSlice.reducer; ================================================ FILE: src/theme.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ @primary-color: #1664ff; ================================================ FILE: src/utils/handler.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useDispatch } from 'react-redux'; import logger from './logger'; import { setHistoryMsg, setInterruptMsg, updateAITalkState, updateAIThinkState, } from '@/store/slices/room'; import RtcClient from '@/lib/RtcClient'; import { string2tlv, tlv2String } from '@/utils/utils'; export type AnyRecord = Record; export enum MESSAGE_TYPE { BRIEF = 'conv', SUBTITLE = 'subv', FUNCTION_CALL = 'tool', } export enum AGENT_BRIEF { UNKNOWN, LISTENING, THINKING, SPEAKING, INTERRUPTED, FINISHED, } /** * @brief 指令类型 */ export enum COMMAND { /** * @brief 打断指令 */ INTERRUPT = 'interrupt', /** * @brief 发送外部文本驱动 TTS */ EXTERNAL_TEXT_TO_SPEECH = 'ExternalTextToSpeech', /** * @brief 发送外部文本驱动 LLM */ EXTERNAL_TEXT_TO_LLM = 'ExternalTextToLLM', } /** * @brief 打断的类型 */ export enum INTERRUPT_PRIORITY { /** * @brief 占位 */ NONE, /** * @brief 高优先级。传入信息直接打断交互,进行处理。 */ HIGH, /** * @brief 中优先级。等待当前交互结束后,进行处理。 */ MEDIUM, /** * @brief 低优先级。如当前正在发生交互,直接丢弃 Message 传入的信息。 */ LOW, } export const MessageTypeCode = { [MESSAGE_TYPE.SUBTITLE]: 1, [MESSAGE_TYPE.FUNCTION_CALL]: 2, [MESSAGE_TYPE.BRIEF]: 3, }; export const useMessageHandler = () => { const dispatch = useDispatch(); const maps = { /** * @brief 接收状态变化信息 * @note https://www.volcengine.com/docs/6348/1415216?s=g */ [MESSAGE_TYPE.BRIEF]: (parsed: AnyRecord) => { const { Stage } = parsed || {}; const { Code, Description } = Stage || {}; logger.debug('[MESSAGE_TYPE.BRIEF]: ', 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(setInterruptMsg()); break; default: break; } }, /** * @brief 字幕 * @note https://www.volcengine.com/docs/6348/1337284?s=g */ [MESSAGE_TYPE.SUBTITLE]: (parsed: AnyRecord) => { const data = parsed.data?.[0] || {}; /** debounce 记录用户输入文字 */ if (data) { const { text: msg, definite, userId: user, paragraph } = data; const isAudioEnable = RtcClient.getAgentEnabled(); if ((window as any)._debug_mode) { logger.debug('handleRoomBinaryMessageReceived', data); } if (isAudioEnable) { dispatch(setHistoryMsg({ text: msg, user, paragraph, definite })); } } }, /** * @brief Function calling * @note https://www.volcengine.com/docs/6348/1359441?s=g */ [MESSAGE_TYPE.FUNCTION_CALL]: (parsed: AnyRecord) => { const name: string = parsed?.tool_calls?.[0]?.function?.name; console.log('[Function Call] - Called by sendUserBinaryMessage'); const map: Record = { getcurrentweather: '今天下雪, 最低气温零下10度', }; RtcClient.engine.sendUserBinaryMessage( 'RobotMan_', string2tlv( JSON.stringify({ ToolCallID: parsed?.tool_calls?.[0]?.id, Content: map[name.toLocaleLowerCase().replaceAll('_', '')], }), 'func' ) ); }, }; return { parser: (buffer: ArrayBuffer) => { try { const { type, value } = tlv2String(buffer); maps[type as MESSAGE_TYPE]?.(JSON.parse(value)); } catch (e) { logger.debug('parse error', e); } }, }; }; ================================================ FILE: src/utils/logger.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ class Logger { public debug(...args: any[]) { console.debug(...args); } public log(...args: any[]) { console.log(...args); } public error(...args: any[]) { console.error(...args); } public warn(...args: any[]) { console.warn(...args); } } export default new Logger(); ================================================ FILE: src/utils/utils.less ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ @meetingBackgroundColor: #1e2128; .flex-box { display: flex; flex-direction: row; justify-content: space-between; align-items: center; } .split-line(@h, @m) { display: inline-block; height: @h; width: 0; border-right: 1px solid #4e5969; margin: 0 @m; } .place-holder { font-family: "PingFang SC"; font-style: normal; font-weight: normal; font-size: 14px; line-height: 22px; color: #86909c; position: relative; left: 5px; } ================================================ FILE: src/utils/utils.ts ================================================ /** * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. * SPDX-license-identifier: BSD-3-Clause */ import { useEffect, useState } from 'react'; /** * @brief 将字符串包装成 TLV */ export const string2tlv = (str: string, type: string) => { const typeBuffer = new Uint8Array(4); for (let i = 0; i < type.length; i++) { typeBuffer[i] = type.charCodeAt(i); } const lengthBuffer = new Uint32Array(1); const valueBuffer = new TextEncoder().encode(str); lengthBuffer[0] = valueBuffer.length; const tlvBuffer = new Uint8Array(typeBuffer.length + 4 + valueBuffer.length); tlvBuffer.set(typeBuffer, 0); tlvBuffer[4] = (lengthBuffer[0] >> 24) & 0xff; tlvBuffer[5] = (lengthBuffer[0] >> 16) & 0xff; tlvBuffer[6] = (lengthBuffer[0] >> 8) & 0xff; tlvBuffer[7] = lengthBuffer[0] & 0xff; tlvBuffer.set(valueBuffer, 8); return tlvBuffer.buffer; }; /** * @brief TLV 数据格式转换成字符串 * @note TLV 数据格式 * | magic number | length(big-endian) | value | * @param {ArrayBufferLike} tlvBuffer * @returns */ export const tlv2String = (tlvBuffer: ArrayBufferLike) => { const typeBuffer = new Uint8Array(tlvBuffer, 0, 4); const lengthBuffer = new Uint8Array(tlvBuffer, 4, 4); const valueBuffer = new Uint8Array(tlvBuffer, 8); let type = ''; for (let i = 0; i < typeBuffer.length; i++) { type += String.fromCharCode(typeBuffer[i]); } const length = (lengthBuffer[0] << 24) | (lengthBuffer[1] << 16) | (lengthBuffer[2] << 8) | lengthBuffer[3]; const value = new TextDecoder().decode(valueBuffer.subarray(0, length)); return { type, value }; }; export const isMobile = () => /Mobi|Android|iPhone|iPad|Windows Phone/i.test(window.navigator.userAgent) || window?.innerWidth < 767; export function useIsMobile() { const getIsMobile = () => /Mobi|Android|iPhone|iPad|Windows Phone/i.test(window.navigator.userAgent) || window.innerWidth < 767; const [isMobile, setIsMobile] = useState(getIsMobile()); useEffect(() => { const handleResize = () => { const value = getIsMobile(); setIsMobile(value); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); return isMobile; } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ES6", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "baseUrl": ".", "paths": { "@/*": ["./src/*"] } }, "include": ["src"] }