Full Code of volcengine/rtc-aigc-demo for AI

main a5ee31a8d083 cached
100 files
184.0 KB
55.5k tokens
90 symbols
1 requests
Download .txt
Showing preview only (215K chars total). Download the full file or copy to clipboard to get everything.
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 准备中, 请稍侯";在启用数字人的情况下,一直停留在“数字人准备中,请稍候”** | <li>可能因为控制台中相关权限没有正常授予,请参考[流程](https://www.volcengine.com/docs/6348/1315561?s=g)再次确认下是否完成相关操作。此问题的可能性较大,建议仔细对照是否已经将相应的权限开通。</li><li>参数传递可能有问题, 例如参数大小写、类型等问题,请再次确认下这类型问题是否存在。</li><li>相关资源可能未开通或者用量不足/欠费,请再次确认。</li><li>**请检查当前使用的模型 ID / 数字人 AppId / Token 等内容都是正确且可用的。**</li><li>数字人服务有并发限制,当达到并发限制时,同样会表现为一直停留在“数字人准备中”状态</li> |
| **浏览器报了 `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
================================================
<!--
 * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
 * SPDX-license-identifier: BSD-3-Clause
 -->
<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta name="description" content="volc video demo" />
    <link rel="icon" href="/favicon.png" />
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <title>火山引擎 RTC 实时对话式 AI 体验 Demo ——— 支持 DeepSeek 和 豆包视觉理解模型</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>


================================================
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 (
    <BrowserRouter>
      <Routes>
        <Route path="/">
          <Route index element={<MainPage />} />
          <Route path="/*" element={<MainPage />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

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<string, string>;

export type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends Array<infer U>
    ? Array<DeepPartial<U>>
    : T[P] extends object
    ? DeepPartial<T[P]>
    : T[P];
};

/**
 * @brief Get
 * @param apiName
 * @param headers
 */
export const requestGetMethod = ({
  action,
  headers = {},
}: {
  action: string;
  headers?: Record<string, string>;
}) => {
  return async (params: Record<string, any> = {}) => {
    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 <T>(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 = <T extends readonly ApiConfig[]>(apiConfigs: T) =>
  apiConfigs.reduce<Apis<T>>((store, cur) => {
    const { action, apiPath = '', method = 'get' } = cur;

    const actionKey = action as ApiNames<T>;
    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<T>);


================================================
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<string, any>;

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 extends readonly unknown[]> = T[number];
type RequestFn = <T extends keyof RequestResponse>(params?: RequestParams[T]) => RequestResponse[T];
type PromiseRequestFn = <T extends keyof RequestResponse>(
  params?: RequestParams[T]
) => Promise<RequestResponse[T]>;

export type ApiConfig = { action: string; method: string; apiPath?: string };
export type ApiNames<T extends readonly ApiConfig[]> = TupleToUnion<T>['action'];
export type Apis<T extends readonly ApiConfig[]> = Record<
  ApiNames<T>,
  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 (
    <div className={styles.container}>
      <div className={styles.avatarContainer}>
        {/* SVG 包含人型轮廓和流光效果 */}
        <svg
          className={styles.avatarSvg}
          width="35vh"
          height="42vh"
          viewBox="0 0 457 549"
          fill="none"
          xmlns="http://www.w3.org/2000/svg"
        >
          {/* 原始人型轮廓 */}
          <path
            d="M175.137 244.821C175.12 240.915 174.986 232.095 174.729 228.127L174.727 228.106L174.726 228.087L174.668 227.474C174.045 221.385 171.924 216.347 168.181 212.481L167.801 212.098C164.091 208.429 159.982 204.706 155.477 200.929C153.336 198.887 151.625 196.437 150.444 193.724L150.433 193.697L150.42 193.671L149.841 192.422C148.509 189.52 147.278 186.572 146.149 183.585C144.846 179.572 143.541 175.295 142.214 170.751L141.912 169.719L140.886 169.401L140.555 169.295C138.934 168.753 137.41 167.955 136.041 166.931C134.262 165.342 132.653 163.572 131.238 161.651C129.191 158.679 127.692 155.364 126.813 151.863L126.785 151.752L126.745 151.645L126.492 150.944C125.279 147.431 124.799 143.704 125.085 139.994C125.478 136.364 126.156 133.326 127.121 130.858L127.127 130.842L127.133 130.826C128.206 127.935 129.823 125.278 131.897 122.997L132.438 122.403L132.418 121.6C132.157 111.445 132.679 101.284 133.98 91.2086C135.184 81.8895 137.078 72.6727 139.647 63.6344L139.651 63.6207L139.654 63.608C142.166 54.2848 146.286 45.4712 151.827 37.5641L151.84 37.5446L151.854 37.525C156.768 30.1535 162.226 24.1949 168.194 19.6344C174.287 15.0104 180.474 11.3978 186.775 8.77893L186.779 8.77698C192.676 6.31057 198.595 4.59753 204.487 3.63049L205.665 3.4469C212.051 2.52917 218.207 2.07191 224.173 2.0719H226.075C232.924 2.20416 239.709 3.04996 246.463 4.62952L246.472 4.63147C253.613 6.26715 260.58 8.59178 267.272 11.5719V11.5709C273.733 14.4735 279.477 17.7844 284.513 21.4576C289.542 25.1405 293.399 28.7986 296.171 32.3785L296.179 32.3893L296.188 32.4C302.893 40.8262 307.772 50.094 310.875 60.2135L310.878 60.2203C313.858 69.819 316.08 79.6371 317.523 89.5836C318.827 100.26 319.489 111.077 319.489 122.036V123.048L320.306 123.648C321.902 124.82 323.183 126.369 324.034 128.157L324.062 128.216L324.095 128.273C325.021 129.93 325.815 132.135 326.456 134.989V134.99C327.041 137.657 327.136 141.06 326.627 145.227L326.624 145.255L326.621 145.285C326.108 150.808 325.015 155.025 323.453 158.076L323.449 158.082L323.446 158.089C321.83 161.302 320.045 163.696 318.165 165.395L318.148 165.41L318.133 165.425C316.207 167.244 313.9 168.612 311.381 169.429L310.377 169.755L310.079 170.768C308.749 175.302 307.465 179.574 306.145 183.578C304.848 187.009 303.364 190.366 301.697 193.633L301.694 193.639C300.082 196.825 298.307 199.225 296.446 200.897C292.063 204.433 288.379 207.515 285.397 210.127L284.158 211.221C280.486 214.496 278.291 219.798 277.187 226.703C276.335 231 276.102 240.098 276.349 244.339L276.35 244.348V244.357C276.652 248.822 277.859 253.232 279.914 257.618L279.917 257.625L279.921 257.631C282.032 262.048 285.236 266.234 289.477 270.139C293.797 274.118 299.755 277.607 307.276 280.617V280.618C313.953 283.342 321.212 285.795 329.068 287.978L329.071 287.979C331.164 288.557 334.747 289.129 338.918 289.705C343.13 290.286 348.095 290.889 353.013 291.508C357.944 292.129 362.834 292.768 366.946 293.424C371.118 294.091 374.29 294.746 375.915 295.364V295.365C377.947 296.146 381.43 296.926 385.408 297.711C389.43 298.504 394.194 299.346 398.827 300.216C403.491 301.092 408.052 302.002 411.788 302.947C413.656 303.419 415.286 303.893 416.601 304.365C417.952 304.85 418.829 305.283 419.296 305.629L419.3 305.631C424.589 309.523 428.393 314.851 430.644 321.74C445.731 382.021 453.785 439.764 454.411 481.881C454.725 502.978 453.172 520 449.79 531.429C448.096 537.155 445.996 541.291 443.602 543.85C441.281 546.33 438.717 547.314 435.77 546.913L435.755 546.911L435.741 546.91L433.611 546.654C388.296 541.315 305.942 536.993 226.451 532.245C145.556 527.413 67.7489 522.144 34.3936 514.951H34.3926C31.4052 514.293 29.0212 513.642 27.2168 513.005C25.3611 512.349 24.3001 511.77 23.8018 511.342L23.7822 511.325L23.7617 511.309L23.4365 511.037C20.0453 508.119 15.7049 502.035 11.8477 492.491C7.88642 482.689 4.49264 469.376 3.15625 452.444C0.490903 418.673 6.01322 370.536 31.4814 307.264C34.9856 304.532 39.8152 302.214 45.5293 300.212C51.4403 298.14 58.1707 296.444 65.1162 294.954C72.0624 293.463 79.164 292.19 85.8398 290.95C92.4891 289.714 98.7347 288.507 103.871 287.155C108.374 285.97 118.591 284.526 128.896 282.753C133.986 281.877 139.052 280.926 143.308 279.9C147.458 278.899 151.135 277.76 153.242 276.407L153.243 276.408C158.848 272.856 163.246 269.382 166.311 265.953C169.34 262.586 171.586 259.177 173.007 255.68C174.418 252.207 175.137 248.593 175.137 244.83V244.821Z"
            stroke="url(#paint0_linear)"
            strokeWidth="4"
          />

          {/* 渐变定义 */}
          <defs>
            {/* 原始渐变 */}
            <linearGradient
              id="paint0_linear"
              x1="142.5"
              y1="83.5"
              x2="299.5"
              y2="401.5"
              gradientUnits="userSpaceOnUse"
            >
              <stop stopColor="#6792FF" />
              <stop offset="0.138788" stopColor="#D093FF" />
              <stop offset="0.282833" stopColor="#9DFFE3" stopOpacity="0.318618" />
              <stop offset="0.519953" stopColor="white" stopOpacity="0" />
              <stop offset="1" stopColor="white" stopOpacity="0" />

              {/* 添加动画效果,使渐变沿着路径运动 */}
              <animate attributeName="x1" values="0; 457; 0" dur="4s" repeatCount="indefinite" />
              <animate
                attributeName="y1"
                values="549; 157; 549"
                dur="4s"
                repeatCount="indefinite"
              />
              <animate
                attributeName="x2"
                values="157; 614; 614"
                dur="4s"
                repeatCount="indefinite"
              />
              <animate
                attributeName="y2"
                values="157; 706; 157"
                dur="4s"
                repeatCount="indefinite"
              />
            </linearGradient>
          </defs>
        </svg>

        {/* 加载文字 */}
        <div className={styles.loadingText}>数字人准备中,请稍候...</div>
      </div>
    </div>
  );
}

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 (
    <div className={`${style.card} ${className} ${isFullScreen ? style.fullScreen : ''}`}>
      <div className={style.avatar}>
        <img id="avatar-card" src={icon} alt="Avatar" />
        {showStatus ? (
          isAITalking ? (
            <div className={style.aiStatus}>
              <div className={style.barContainer}>
                <div className={style.bar} />
                <div className={style.bar} />
                <div className={style.bar} />
              </div>
            </div>
          ) : isLoading ? (
            <div className={style.aiStatus}>正在听...</div>
          ) : null
        ) : null}
      </div>
      {showUserTag ? <UserTag name={scene} /> : null}
    </div>
  );
}

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 (
    <div className={`${styles.wrapper} ${checked ? styles.active : ''}`} onClick={onClick}>
      {tag ? <div className={styles.tag}>{tag}</div> : ''}
      <div className={styles.content}>
        {icon ? <img className={styles.icon} src={icon} alt="icon" /> : ''}
        <div className={styles['checked-text']}>{title}</div>
      </div>
    </div>
  );
}

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 (
    <div className={style.card}>
      <div className={style.avatar}>
        <img id="avatar-card" src={icon} alt="Avatar" />
      </div>
      <div className={style.title}>
        <div>Hi,欢迎体验实时对话式 AI</div>
        <div className={style.desc}>
          {isVision ? <>支持豆包 Vision 模型和 深度思考模型,</> : ''}
          超多对话场景等你开启
        </div>
      </div>
      <div className={style.sceneContainer}>
        {Scenes.map((key: SceneConfig) =>
          <CheckScene
            key={key.name}
            icon={key.icon}
            title={key.name}
            checked={key.id === scene}
            onClick={() => handleChecked(key.id)}
          />
        )}
      </div>
    </div>
  );
}

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<HTMLDivElement>;

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 (
    <>
      <div style={style || {}} className={`${styles.row} ${className}`} onClick={handleOpen}>
        <div className={styles.firstPart}>
          {btnSrc ? <img src={btnSrc} className={styles.icon} alt="svg" /> : ''}
          {btnText}
          {suffix}
        </div>
        <div className={styles.finalPart}>
          <IconRight className={styles.rightOutlined} />
        </div>
      </div>
      <Drawer
        closable
        title={drawer?.title || ''}
        width={drawer?.width || 400}
        className={styles.drawer}
        visible={open}
        onCancel={handleClose}
        footer={null}
      >
        <div className={styles.children}>{drawer?.children}</div>
      </Drawer>
    </>
  );
}

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 (
    <>
      <div className={`${style.card} ${!isFullScreen ? style.hidden : ''}`} id={LocalFullID}>
        <UserTag name="我" className={style.tag} />
      </div>
      <div
        className={`${style.card} ${isFullScreen ? style.hidden : ''} ${style['blur-bg']}`}
        style={{ backgroundImage: `url(${scene.avatarBgUrl})` }}
      />
      <div
        className={`${style.card} ${isFullScreen ? style.hidden : ''}`}
        style={{ background: 'unset' }}
      >
        <div id={RemoteFullID} style={{ width: '60%', height: '100%' }} />
        {!isMobile() ? <UserTag name="AI" className={style.tag} /> : null}
      </div>
    </>
  );
}

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 (
    <div
      className={styles.header}
      style={{
        display: hide ? 'none' : 'flex',
      }}
    >
      <div className={styles['header-logo']}>
        {useIsMobile() ? null : (
          <Popover
            content={
              <div className={styles['menu-wrapper']}>
                {MenuProps.map((menuItem) => (
                  <Button
                    type="text"
                    key={menuItem.name}
                    onClick={() => {
                      window.open(menuItem.url, '_blank');
                    }}
                  >
                    {menuItem.name}
                  </Button>
                ))}
              </div>
            }
          >
            <IconMenu className={styles['header-setting-btn']} />
          </Popover>
        )}
        <img src={Logo} alt="Logo" />
        <Divider type="vertical" />
        <span className={styles['header-logo-text']}>实时对话式 AI 体验馆</span>
        <NetworkIndicator />
      </div>
      {children}
      {useIsMobile() ? null : (
        <div className={styles['header-right']}>
          <div
            className={styles['header-right-text']}
            onClick={() =>
              window.open('https://www.volcengine.com/product/veRTC/ConversationalAI', '_blank')
            }
          >
            官网链接
          </div>
          <div
            className={styles['header-right-text']}
            onClick={() =>
              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'
              )
            }
          >
            联系我们
          </div>
        </div>
      )}
    </div>
  );
}

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<HTMLDivElement> {
  loading?: boolean;
}

function AudioLoading(props: IAudioLoadingProps) {
  const { loading = false, className = '', color, ...rest } = props;
  return (
    <div className={`${style.loader} ${className}`} {...rest}>
      {Array(3)
        .fill(0)
        .map((_, index) => (
          <div
            key={index}
            className={`${style.dot} ${loading ? style.dotter : ''}`}
            style={{
              animationDelay: `${index * 0.3}s`,
              backgroundColor: color || 'rgba(148, 116, 255, 1)',
            }}
          />
        ))}
    </div>
  );
}

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<HTMLDivElement> {
  dotClassName?: string;
  speed?: number;
  gap?: number;
}

function Loading(props: ILoadingProps) {
  const { dotClassName, gap = 5, speed = 0.9, className = '', ...rest } = props;
  return (
    <div
      className={`${style.loader} ${className}`}
      style={{
        gap: `${gap}px`,
      }}
      {...rest}
    >
      {Array(3)
        .fill(0)
        .map((_, index) => (
          <div
            key={index}
            className={`${style.dot} ${dotClassName}`}
            style={{
              animation: `glow linear ${speed.toFixed(1)}s infinite`,
              animationDelay: `${(index * (speed / 3)).toFixed(1)}s`,
            }}
          />
        ))}
    </div>
  );
}

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 (
    <span className={styles.loader}>
      <span className={styles.bar} />
      <span className={styles.bar} />
      <span className={styles.bar} />
    </span>
  );
}

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 (
    <div
      onClick={setLocalPlayer}
      className={styles.container}
      style={{ cursor: loading ? 'not-allowed' : 'pointer' }}
    >
      <Popover content="切换屏幕">
        <img src={SET_LOCAL_PLAYER} alt="fullSize" />
      </Popover>
    </div>
  );
}

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 (
    <Popover
      position="bl"
      content={
        <div className={style.panel}>
          <div className={style.label}>
            <div className={style.state}>网络状态</div>
            <div className={style.item}>延迟</div>
            <div className={style.item}>丢包率</div>
          </div>
          <div className={style.value}>
            <div
              className={style.state}
              style={{
                color: indicators?.[0] || INDICATOR_COLORS.BAD,
              }}
            >
              {INDICATOR_TEXT[networkQuality]}
            </div>
            <div className={style.item}>{delay ? delay.toFixed(0) : '- '}ms</div>
            <div className={style.loss}>
              <div>
                <IconArrowUp style={{ color: 'rgba(22, 100, 255, 1)' }} />
                <span>
                  {`${audioLossRateUpper}` ? (audioLossRateUpper * 100)?.toFixed(0) : '- '}%
                </span>
              </div>
              <div>
                <IconArrowDown />
                <span>
                  {`${audioLossRateLower}` ? (audioLossRateLower * 100)?.toFixed(0) : '- '}%
                </span>
              </div>
            </div>
          </div>
        </div>
      }
    >
      <div className={style.wrapper}>
        {indicators.map((color, index) => (
          <div
            key={index}
            className={style.indicator}
            style={{
              height: `${20 + (80 * (index + 1)) / 3}%`,
              backgroundColor: color,
            }}
          />
        ))}
      </div>
    </Popover>
  );
}

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<HTMLDivElement>(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 (
    <div className={`${styles.container} ${className}`} ref={ref}>
      {children}
    </div>
  );
}


================================================
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 (
    <div className={`${styles.userTagWrapper} ${className}`}>
      <div className={styles.iconContainer}>
        <IconUser style={{ fill: '#fff', strokeWidth: 0 }} />
      </div>
      <div className={styles.nameContainer}>{name}</div>
    </div>
  );
}

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<string, any>;
  llmConfig: Record<string, any>;
  asrConfig: Record<string, any>;
  ttsConfig: Record<string, any>;
}


================================================
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(
  <Provider store={store}>
    <App />
  </Provider>
);


================================================
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<void | boolean>
] => {
  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<boolean | undefined> {
    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<HTMLDivElement> {
  loading?: boolean;
}

function InvokeButton(props: IInvokeButtonProps) {
  const { loading, className, ...rest } = props;

  return (
    <div className={`${style.wrapper} ${loading ? '' : style.cursor} ${className}`} {...rest}>
      <div className={style.btn}>
        <img src={CallButtonSVG} alt="call" />
        {loading ? (
          <Loading className={style.icon} />
        ) : (
          <img src={PhoneSVG} className={style.icon} alt="phone" />
        )}
      </div>
      <div className={style.text}>{loading ? '连接中' : '通话'}</div>
    </div>
  );
}

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<HTMLDivElement>) {
  const { className = '', ...rest } = props;
  return (
    <div className={`${style.loader} ${className}`} {...rest}>
      {Array(3)
        .fill(0)
        .map((_, index) => (
          <div
            key={index}
            className={style.dot}
            style={{
              animationDelay: `${index * 0.3}s`,
            }}
          />
        ))}
    </div>
  );
}

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 (
    <div className={`${style.wrapper} ${isMobile() ? style.mobile : ''}`}>
      <AIChangeCard />
      <InvokeButton onClick={handleJoinRoom} loading={joining} className={style['invoke-btn']} />
      {isMobile() ? null : (
        <div className={style.description}>Powered by 豆包大模型和火山引擎视频云 RTC</div>
      )}
    </div>
  );
}

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<HTMLDivElement>) {
  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 (
    <div className={`${className}`} {...rest}>
      {isAudioPublished ? (
        isAIReady && isAITalking ? (
          <div className={style.interruptContainer}>
            {isInterruptMode ? <div>语音打断 或 </div> : null}
            <div onClick={handleInterrupt} className={style.interrupt}>
              <div className={style.interruptIcon} />
              <span>点此打断</span>
            </div>
          </div>
        ) : isLoading ? null : (
          <div className={style.closed}>请开始说话</div>
        )
      ) : (
        <div className={style.closed}>你已关闭麦克风</div>
      )}
      <AudioLoading loading={isLoading} color={isAudioPublished ? undefined : '#EAEDF1'} />
    </div>
  );
}
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<HTMLDivElement>) {
  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 (
    <div className={`${styles['camera-wrapper']} ${className}`} {...rest}>
      <UserTag name={isFullScreen ? scene : '我'} className={styles.userTag} />
      {isFullScreen ? (
        <AiAvatarCard showUserTag={false} showStatus className={styles.fullScreenAiAvatar} />
      ) : null}
      {isVideoPublished || isScreenPublished ? <LocalPlayerSet /> : null}
      <div
        id={LocalVideoID}
        className={`${styles['camera-player']} ${
          isVideoPublished && !isScreenMode ? '' : styles['camera-player-hidden']
        }`}
      />
      <div
        id={LocalScreenID}
        className={`${styles['camera-player']} ${
          isScreenPublished && isScreenMode ? '' : styles['camera-player-hidden']
        }`}
      />
      <div
        id={RemoteVideoID}
        className={`${styles['camera-player']} ${
          isFullScreen && isRemoteVideoPublished ? '' : styles['camera-player-hidden']
        }`}
        style={{ position: 'absolute' }}
      />
      <div
        className={`${styles['camera-placeholder']} ${
          isVideoPublished || isScreenPublished ? styles['camera-player-hidden'] : ''
        }`}
      >
        <img
          src={isScreenMode ? ScreenCloseNoteSVG : isVision ? CameraCloseNoteSVG : UserAvatar}
          alt="close"
          className={styles['camera-placeholder-close-note']}
        />

        {isFullScreen ? null : (
          <div>
            {isScreenMode ? (
              <>
                打开
                <span onClick={handleOperateScreenShare} className={styles['camera-open-btn']}>
                  屏幕共享
                </span>
                <div>体验豆包视觉理解模型</div>
              </>
            ) : isVision ? (
              <>
                打开
                <span onClick={handleOperateCamera} className={styles['camera-open-btn']}>
                  摄像头
                </span>
                <div>体验豆包视觉理解模型</div>
              </>
            ) : null}
          </div>
        )}
      </div>
    </div>
  );
}

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<HTMLDivElement> & { 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<HTMLDivElement>(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 (
    <div
      ref={containerRef}
      className={`${styles.conversation} ${className} ${isFullScreen ? styles.fullScreen : ''} ${
        isMobile() ? styles.mobileConversation : ''
      }`}
      style={isAvatarScene && !isAIReady ? { justifyContent: 'center' } : {}}
      {...rest}
    >
      {lines.map((line) => line)}
      {!isAIReady ? (
        <div className={styles.aiReadying}>
          {isAvatarScene ? (
            <AIAvatarReadying />
          ) : (
            <>
              <Spin size={16} className={styles['aiReading-spin']} />
              AI 准备中, 请稍侯
            </>
          )}
        </div>
      ) : (
        ''
      )}
      {(showSubtitle ? msgHistory : [])?.map(({ value, user, isInterrupted }, index) => {
        const isUserMsg = user === userId;
        const isRobotMsg = user === botName || user.includes('voiceChat_');
        if (!isUserMsg && !isRobotMsg) {
          return '';
        }
        return (
          <div
            key={`msg-container-${index}`}
            className={styles.mobileLine}
            style={{ justifyContent: isUserMsg && isMobile() ? 'flex-end' : '' }}
          >
            {!isMobile() && (
              <div className={styles.msgName}>
                <div className={styles.avatar}>
                  <img src={isUserMsg ? USER_AVATAR : icon} alt="Avatar" />
                </div>
                {isUserMsg ? '我' : scene}
              </div>
            )}
            <div
              className={`${styles.sentence} ${isUserMsg ? styles.user : styles.robot}`}
              key={`msg-${index}`}
            >
              <div className={styles.content}>
                {value}
                <div className={styles['loading-wrapper']}>
                  {isAIReady &&
                  (isUserTextLoading(user) || isAITextLoading(user)) &&
                  index === msgHistory.length - 1 ? (
                    <Loading gap={3} className={styles.loading} dotClassName={styles.dot} />
                  ) : (
                    ''
                  )}
                </div>
              </div>
              {!isUserMsg && isInterrupted ? <Tag className={styles.interruptTag}>已打断</Tag> : ''}
            </div>
          </div>
        );
      })}
    </div>
  );
}

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<HTMLDivElement>) {
  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 (
    <div className={`${className} ${style.btns} ${isMobile() ? style.column : ''}`} {...rest}>
      <img
        src={isAudioPublished ? MicOpenSVG : MicCloseSVG}
        onClick={() => switchMic(true)}
        className={style.btn}
        alt="mic"
      />
      {!isVision ? null : isScreenMode && !isMobile() ? (
        <img
          src={isScreenPublished ? 'new-screen-off.svg' : 'new-screen-on.svg'}
          onClick={() => switchScreenCapture()}
          className={style.btn}
          alt="screenShare"
        />
      ) : (
        <img
          src={isVideoPublished ? CameraOpenSVG : CameraCloseSVG}
          onClick={() => switchCamera(true)}
          className={style.btn}
          alt="camera"
        />
      )}
      {isScreenMode && (
        <img
          src={isScreenPublished ? ScreenOnSVG : ScreenOffSVG}
          onClick={() => switchScreenCapture(true)}
          className={style.btn}
          alt="screenShare"
        />
      )}
      <img src={LeaveRoomSVG} onClick={leaveRoom} className={style.btn} alt="leave" />
      {isMobile() ? (
        <Drawer
          title="设置"
          visible={open}
          onCancel={() => setOpen(false)}
          style={{
            width: 'max-content',
          }}
          footer={null}
        >
          <Menu />
        </Drawer>
      ) : null}
    </div>
  );
}
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 (
    <div className={`${style.wrapper} ${isMobile() ? style.mobile : ''}`}>
      {isMobile() ? <div className={style.mobilePlayer} id="mobile-local-player" /> : null}
      {isMobile() ? <MobileToolBar /> : null}
      {isShowSubtitle && !isMobile() ? (
        <UserTag name={scene} className={style.subTitleUserTag} />
      ) : null}
      {isAvatarScene || (isFullScreen && !isMobile()) ? (
        <FullScreenCard />
      ) : isMobile() && isShowSubtitle ? null : (
        <AiAvatarCard
          showUserTag={!isShowSubtitle}
          showStatus={!isShowSubtitle}
          className={isShowSubtitle ? style.subtitleAiAvatar : ''}
        />
      )}
      {isMobile() ? null : <CameraArea />}
      <Conversation className={style.conversation} showSubtitle={isShowSubtitle} />
      <ToolBar className={style.toolBar} />
      <AudioController className={style.controller} />
      <div className={style.declare}>AI生成内容由大模型生成,不能完全保障真实</div>
    </div>
  );
}

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 ? <Room /> : <Antechamber />;
}

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 (
    <DrawerRowItem
      btnText={`${SETTING_NAME[type]}设置`}
      drawer={{
        width: isMobile() ? '100%' : undefined,
        title: `${SETTING_NAME[type]}设置`,
        footer: null,
        children: (
          <>
            {!isScreenMode && (
              <div className={styles.wrapper}>
                <div className={styles.label}>{DEVICE_NAME[type]}</div>
                <div className={styles.value}>
                  <Switch
                    checked={isEnable}
                    size="small"
                    onChange={(enable) => switcher(enable)}
                    disabled={!permission}
                  />
                  <Select
                    style={{ width: 250 }}
                    value={selectedDevice}
                    onChange={handleDeviceChange}
                  >
                    {deviceList.map((device) => (
                      <Select.Option key={device.deviceId} value={device.deviceId}>
                        {device.label}
                      </Select.Option>
                    ))}
                  </Select>
                </div>
              </div>
            )}
            {type === MediaType.VIDEO && isScreenMode && (
              <div className={styles.wrapper}>
                <div className={styles.label}>屏幕共享</div>
                <div className={styles.value}>
                  <Switch checked={isScreenEnable} size="small" onChange={changeScreenPublished} />
                </div>
              </div>
            )}
          </>
        ),
      }}
    />
  );
}

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 (
    <div className={`${styles.box} ${styles.device}`}>
      <Subtitle />
      {isVision && <DeviceDrawerButton type={MediaType.VIDEO} />}
      <DeviceDrawerButton />
    </div>
  );
}

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 (
    <div className={styles.subtitle}>
      <div className={styles.label}>字幕</div>
      <div className={styles.value}>
        <Switch size="small" loading={loading} checked={checked} onChange={handleChange} />
      </div>
    </div>
  );
}

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 (
    <div className={styles.wrapper}>
      {isJoined && isMobile() && isVision ? (
        <div className={styles['mobile-camera-wrapper']}>
          <CameraArea className={styles['mobile-camera']} />
        </div>
      ) : null}
      <div className={`${styles.box} ${styles.info}`}>
        <div className={styles.title}>AI 人设:{name}</div>
      </div>
      {isJoined ? <Operation /> : ''}
      <div className={`${styles.box} ${styles.info}`}>
        <div className={styles.title}>{isJoined ? '其他信息' : '版本信息'}</div>
        <div className={styles.desc}>Demo Version {packageJson.version}</div>
        <div className={styles.desc}>SDK Version {VERTC.getSdkVersion()}</div>
        {isJoined ? (
          <div className={styles.desc}>
            房间ID{' '}
            <Tooltip content={room.roomId || '-'}>
              <Typography.Paragraph
                ellipsis={{
                  rows: 1,
                  expandable: false,
                }}
                className={styles.value}
              >
                {room.roomId || '-'}
              </Typography.Paragraph>
            </Tooltip>
          </div>
        ) : (
          ''
        )}
        {room.isAIGCEnable ? (
          <div className={styles.desc}>
            RequestID{' '}
            <Tooltip content={requestId || '-'}>
              <Typography.Paragraph
                ellipsis={{
                  rows: 1,
                  expandable: false,
                }}
                className={styles.value}
              >
                {requestId || '-'}
              </Typography.Paragraph>
            </Tooltip>
          </div>
        ) : (
          ''
        )}
      </div>
    </div>
  );
}

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<Record<string, SceneConfig>>((prev, cur) => {
        prev[cur.scene.id] = cur.scene;
        return prev;
      }, {})
    ));
    dispatch(updateRTCConfig(
      scenes.reduce<Record<string, RTCConfig>>((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 (
    <ResizeWrapper className={styles.container}>
      <Header />
      <div
        className={styles.main}
        style={{
          padding: useIsMobile() ? '' : '24px',
        }}
      >
        <div className={`${styles.mainArea} ${useIsMobile() ? styles.isMobile : ''}`}>
          <MainArea />
        </div>
        {useIsMobile() ? null : (
          <div className={styles.operationArea}>
            <Menu />
          </div>
        )}
      </div>
    </ResizeWrapper>
  );
}


================================================
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<HTMLDivElement>) {
  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 (
    <div className={styles.wrapper}>
      <div>
        <div
          className={`${styles.subtitle} ${subTitleStatus ? styles.showSubTitle : ''}`}
          onClick={switchSubtitle}
        >
          字幕
        </div>
      </div>

      <SettingsDrawer visible={open} onCancel={() => setOpen(false)} />
    </div>
  );
}
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 (
    <Drawer
      title="设置"
      visible={visible}
      onCancel={onCancel}
      footer={null}
      className={styles.settingsDrawer}
      width="100%"
      bodyStyle={{ padding: 0 }}
    >
      <div className={styles.settingsPage}>
        <div className={styles.settingsGroup}>
          <SettingsItem label="房间ID" value={roomId} showArrow={false} />
          <SettingsItem label="隐私政策" onClick={() => window.open(ReversoContext, '_blank')} />
          <SettingsItem label="用户协议" onClick={() => window.open(UserAgreement, '_blank')} />
          <SettingsItem label="免责声明" onClick={() => window.open(Disclaimer, '_blank')} />
          <SettingsItem
            label="当前版本"
            value={
              <div className={styles.versionInfo}>
                <span>Demo version {packageJSON.version}</span>
                <span>SDK version {VERTC.getSdkVersion()}</span>
              </div>
            }
            showArrow={false}
          />
        </div>

        <div className={styles.settingsGroup}>
          <SettingsItem
            label="复制链接到 PC 体验"
            value="复制链接"
            onClick={handleCopyLink}
            showArrow={false}
            valueClassName={styles.copyLinkText}
          />
        </div>

        <div className={styles.logoutButtonContainer}>
          <button className={styles.logoutButton} onClick={handleLogout}>
            退出房间
          </button>
        </div>
      </div>
    </Drawer>
  );
}

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 (
    <div className={styles.settingsItem} onClick={onClick}>
      <span className={styles.label}>{label}</span>
      <div className={styles.valueContainer}>
        {value && <span className={`${styles.value} ${valueClassName || ''}`}>{value}</span>}
        {showArrow && <IconRight className={styles.arrowIcon} />}
      </div>
    </div>
  );
}


================================================
FILE: src/react-app-env.d.ts
================================================
/**
 * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
 * SPDX-license-identifier: BSD-3-Clause
 */

/// <reference types="react-scripts" />

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<MediaDeviceInfo[]>) => {
      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<IUser, 'audioStats'> & {
  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<string, SceneConfig>;
  /**
   * @brief RTC 相关的配置
   */
  rtcConfigMap: Record<string, RTCConfig>;

  /**
   * @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<LocalUser> }) => {
      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<string, any>;

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<string, string> = {
        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 = 
Download .txt
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
Download .txt
SYMBOL INDEX (90 symbols across 44 files)

FILE: Server/token.js
  constant VERSION (line 10) | const VERSION = "001";
  constant VERSION_LENGTH (line 11) | const VERSION_LENGTH = 3;
  constant APP_ID_LENGTH (line 13) | const APP_ID_LENGTH = 24;

FILE: src/App.tsx
  function App (line 10) | function App() {

FILE: src/app/base.ts
  type Headers (line 10) | type Headers = Record<string, string>;
  type DeepPartial (line 12) | type DeepPartial<T> = {

FILE: src/app/type.ts
  type RequestParams (line 6) | type RequestParams = Record<string, any>;
  type RequestResponse (line 8) | interface RequestResponse {
  type TupleToUnion (line 23) | type TupleToUnion<T extends readonly unknown[]> = T[number];
  type RequestFn (line 24) | type RequestFn = <T extends keyof RequestResponse>(params?: RequestParam...
  type PromiseRequestFn (line 25) | type PromiseRequestFn = <T extends keyof RequestResponse>(
  type ApiConfig (line 29) | type ApiConfig = { action: string; method: string; apiPath?: string };
  type ApiNames (line 30) | type ApiNames<T extends readonly ApiConfig[]> = TupleToUnion<T>['action'];
  type Apis (line 31) | type Apis<T extends readonly ApiConfig[]> = Record<

FILE: src/components/AIAvatarLoading/index.tsx
  function AIAvatarReadying (line 8) | function AIAvatarReadying() {

FILE: src/components/AiAvatarCard/index.tsx
  type IAiAvatarCardProps (line 12) | interface IAiAvatarCardProps {
  constant THRESHOLD_VOLUME (line 18) | const THRESHOLD_VOLUME = 18;
  function AiAvatarCard (line 20) | function AiAvatarCard(props: IAiAvatarCardProps) {

FILE: src/components/AiChangeCard/CheckScene/index.tsx
  type IProps (line 8) | interface IProps {
  function CheckScene (line 16) | function CheckScene(props: IProps) {

FILE: src/components/AiChangeCard/index.tsx
  function AIChangeCard (line 13) | function AIChangeCard() {

FILE: src/components/DrawerRowItem/index.tsx
  type IDrawerRowItemProps (line 11) | type IDrawerRowItemProps = {
  function DrawerRowItem (line 27) | function DrawerRowItem(props: IDrawerRowItemProps) {

FILE: src/components/FullScreenCard/index.tsx
  function FullScreenCard (line 16) | function FullScreenCard() {

FILE: src/components/Header/index.tsx
  type HeaderProps (line 17) | interface HeaderProps {
  function Header (line 22) | function Header(props: HeaderProps) {

FILE: src/components/Loading/AudioLoading/index.tsx
  type IAudioLoadingProps (line 9) | interface IAudioLoadingProps extends React.HTMLAttributes<HTMLDivElement> {
  function AudioLoading (line 13) | function AudioLoading(props: IAudioLoadingProps) {

FILE: src/components/Loading/HorizonLoading/index.tsx
  type ILoadingProps (line 9) | interface ILoadingProps extends React.HTMLAttributes<HTMLDivElement> {
  function Loading (line 15) | function Loading(props: ILoadingProps) {

FILE: src/components/Loading/VerticalLoading/index.tsx
  function Loading (line 9) | function Loading() {

FILE: src/components/LocalPlayerSet/index.tsx
  function LocalPlayerSet (line 14) | function LocalPlayerSet() {

FILE: src/components/NetworkIndicator/index.tsx
  type INDICATOR_COLORS (line 15) | enum INDICATOR_COLORS {
  constant INDICATOR_TEXT (line 22) | const INDICATOR_TEXT = {
  function NetworkIndicator (line 32) | function NetworkIndicator() {

FILE: src/components/ResizeWrapper/index.tsx
  type IWrapperProps (line 9) | type IWrapperProps = React.PropsWithChildren & {

FILE: src/components/UserTag/index.tsx
  type IUserTagProps (line 9) | interface IUserTagProps {
  function UserTag (line 14) | function UserTag(props: IUserTagProps) {

FILE: src/config/index.ts
  constant AIGC_PROXY_HOST (line 14) | const AIGC_PROXY_HOST = 'http://localhost:3001';
  type IScene (line 16) | interface IScene {

FILE: src/interface.ts
  type DeviceType (line 6) | enum DeviceType {

FILE: src/lib/RtcClient.ts
  type IEventListener (line 33) | interface IEventListener {
  type BasicBody (line 58) | interface BasicBody {
  class RTCClient (line 69) | class RTCClient {
    method checkPermission (line 157) | checkPermission(): Promise<{
    method getDevices (line 171) | async getDevices(props?: { video?: boolean; audio?: boolean }): Promise<{

FILE: src/lib/useCommon.ts
  constant ABORT_VISIBILITY_CHANGE (line 30) | const ABORT_VISIBILITY_CHANGE = 'abortVisibilityChange';
  type FormProps (line 31) | interface FormProps {
  function disPatchJoin (line 190) | async function disPatchJoin(): Promise<boolean | undefined> {

FILE: src/pages/MainPage/MainArea/Antechamber/InvokeButton/index.tsx
  type IInvokeButtonProps (line 11) | interface IInvokeButtonProps extends React.HTMLAttributes<HTMLDivElement> {
  function InvokeButton (line 15) | function InvokeButton(props: IInvokeButtonProps) {

FILE: src/pages/MainPage/MainArea/Antechamber/InvokeButton/loading.tsx
  function Loading (line 8) | function Loading(props: React.HTMLAttributes<HTMLDivElement>) {

FILE: src/pages/MainPage/MainArea/Antechamber/index.tsx
  function Antechamber (line 14) | function Antechamber() {

FILE: src/pages/MainPage/MainArea/Room/AudioController.tsx
  constant THRESHOLD_VOLUME (line 15) | const THRESHOLD_VOLUME = 18;
  function AudioController (line 17) | function AudioController(props: React.HTMLAttributes<HTMLDivElement>) {

FILE: src/pages/MainPage/MainArea/Room/CameraArea.tsx
  function CameraArea (line 26) | function CameraArea(props: React.HTMLAttributes<HTMLDivElement>) {

FILE: src/pages/MainPage/MainArea/Room/Conversation.tsx
  function Conversation (line 19) | function Conversation(props: React.HTMLAttributes<HTMLDivElement> & { sh...

FILE: src/pages/MainPage/MainArea/Room/ToolBar.tsx
  function ToolBar (line 21) | function ToolBar(props: React.HTMLAttributes<HTMLDivElement>) {

FILE: src/pages/MainPage/MainArea/Room/index.tsx
  function Room (line 20) | function Room() {

FILE: src/pages/MainPage/MainArea/index.tsx
  function MainArea (line 10) | function MainArea() {

FILE: src/pages/MainPage/Menu/components/DeviceDrawerButton/index.tsx
  type IDeviceDrawerButtonProps (line 18) | interface IDeviceDrawerButtonProps {
  constant DEVICE_NAME (line 22) | const DEVICE_NAME = {
  function DeviceDrawerButton (line 27) | function DeviceDrawerButton(props: IDeviceDrawerButtonProps) {

FILE: src/pages/MainPage/Menu/components/Operation/index.tsx
  function Operation (line 12) | function Operation() {

FILE: src/pages/MainPage/Menu/components/Subtitle/index.tsx
  function Subtitle (line 13) | function Subtitle() {

FILE: src/pages/MainPage/Menu/index.tsx
  function Menu (line 17) | function Menu() {

FILE: src/pages/Mobile/MobileToolBar/index.tsx
  function MobileToolBar (line 17) | function MobileToolBar(props: React.HTMLAttributes<HTMLDivElement>) {

FILE: src/pages/Mobile/SettingsDrawer/index.tsx
  type SettingsDrawerProps (line 16) | interface SettingsDrawerProps {
  function SettingsDrawer (line 21) | function SettingsDrawer({ visible, onCancel }: SettingsDrawerProps) {

FILE: src/pages/Mobile/components/SettingsItem/index.tsx
  type SettingsItemProps (line 9) | interface SettingsItemProps {
  function SettingsItem (line 17) | function SettingsItem({

FILE: src/store/index.ts
  type RootState (line 10) | interface RootState {

FILE: src/store/slices/device.ts
  type DeviceState (line 16) | interface DeviceState {

FILE: src/store/slices/room.ts
  type IUser (line 15) | interface IUser {
  type LocalUser (line 25) | type LocalUser = Omit<IUser, 'audioStats'> & {
  type Msg (line 30) | interface Msg {
  type SceneConfig (line 39) | interface SceneConfig {
  type RTCConfig (line 52) | interface RTCConfig {
  type RoomState (line 59) | interface RoomState {

FILE: src/utils/handler.ts
  type AnyRecord (line 17) | type AnyRecord = Record<string, any>;
  type MESSAGE_TYPE (line 19) | enum MESSAGE_TYPE {
  type AGENT_BRIEF (line 25) | enum AGENT_BRIEF {
  type COMMAND (line 37) | enum COMMAND {
  type INTERRUPT_PRIORITY (line 54) | enum INTERRUPT_PRIORITY {

FILE: src/utils/logger.ts
  class Logger (line 6) | class Logger {
    method debug (line 7) | public debug(...args: any[]) {
    method log (line 11) | public log(...args: any[]) {
    method error (line 15) | public error(...args: any[]) {
    method warn (line 19) | public warn(...args: any[]) {

FILE: src/utils/utils.ts
  function useIsMobile (line 65) | function useIsMobile() {
Condensed preview — 100 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (209K chars).
[
  {
    "path": ".eslintrc",
    "chars": 4073,
    "preview": "{\n  \"root\": true,\n  \"env\": {\n    \"browser\": true,\n    \"commonjs\": true,\n    \"es6\": true,\n    \"node\": true,\n    \"jest\": t"
  },
  {
    "path": ".gitignore",
    "chars": 417,
    "preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
  },
  {
    "path": ".npmrc",
    "chars": 41,
    "preview": "registry = 'https://registry.npmjs.org/'\n"
  },
  {
    "path": ".prettierrc",
    "chars": 180,
    "preview": "{\n  \"arrowParens\": \"always\",\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"jsxSingleQuote\": false,\n  \"printWidth\": 100,\n  \"u"
  },
  {
    "path": ".stylelintrc",
    "chars": 795,
    "preview": "{\n  \"extends\": [\"stylelint-config-standard\", \"stylelint-config-prettier\"],\n  \"customSyntax\": \"postcss-less\",\n  \"rules\": "
  },
  {
    "path": "LICENSE",
    "chars": 3146,
    "preview": "Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\nRedistribution and use in source and bin"
  },
  {
    "path": "README.md",
    "chars": 4112,
    "preview": "# 交互式AIGC场景 AIGC Demo\n\n此 Demo 为简化版本, 如您有 1.5.x 版本 UI 的诉求, 可切换至 1.5.1 分支。\n跑通阶段时, 无须关心代码实现,仅需按需完成 `Server/scenes/*.json` 的"
  },
  {
    "path": "Server/.npmrc",
    "chars": 40,
    "preview": "registry = 'https://registry.npmjs.org/'"
  },
  {
    "path": "Server/README.md",
    "chars": 940,
    "preview": "# Node Server\n\n## 启动命令\n```\nyarn\n\nyarn dev\n```\n\n## 使用须知\nNode 服务启动时会自动读取 `Server/scenes` 下的所有文件作为可用的场景, 并通过接口 API 返回相关信息。\n"
  },
  {
    "path": "Server/app.js",
    "chars": 4486,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "Server/nodemon.json",
    "chars": 72,
    "preview": "{\n  \"watch\": [\".\"],\n  \"ext\": \"js,json\",\n  \"ignore\": [\"node_modules/*\"]\n}"
  },
  {
    "path": "Server/package.json",
    "chars": 522,
    "preview": "{\n  \"name\": \"AIGCServer\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Server for demo to call open api\",\n  \"main\": \"app.js\","
  },
  {
    "path": "Server/scenes/Custom.json",
    "chars": 1658,
    "preview": "{\n  \"SceneConfig\": {\n    \"icon\": \"https://lf3-rtc-demo.volccdn.com/obj/rtc-aigc-assets/DoubaoAvatar.png\",\n    \"name\": \"自"
  },
  {
    "path": "Server/token.js",
    "chars": 6550,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "Server/util.js",
    "chars": 1798,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "craco.config.js",
    "chars": 517,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "message.js",
    "chars": 457,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "package.json",
    "chars": 1970,
    "preview": "{\n  \"name\": \"aigc\",\n  \"version\": \"1.6.0\",\n  \"license\": \"BSD-3-Clause\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@redu"
  },
  {
    "path": "public/index.html",
    "chars": 1512,
    "preview": "<!--\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-"
  },
  {
    "path": "public/robots.txt",
    "chars": 67,
    "preview": "# https://www.robotstxt.org/robotstxt.html\nUser-agent: *\nDisallow:\n"
  },
  {
    "path": "src/App.tsx",
    "chars": 604,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/app/api.ts",
    "chars": 513,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/app/base.ts",
    "chars": 2832,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/app/index.ts",
    "chars": 342,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/app/type.ts",
    "chars": 992,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/AIAvatarLoading/index.module.less",
    "chars": 365,
    "preview": ".container {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  width: 100%;\n  height: 100%;\n}\n\n.avata"
  },
  {
    "path": "src/components/AIAvatarLoading/index.tsx",
    "chars": 6555,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/AiAvatarCard/index.module.less",
    "chars": 1689,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/AiAvatarCard/index.tsx",
    "chars": 1682,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/AiChangeCard/CheckScene/index.module.less",
    "chars": 1753,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/AiChangeCard/CheckScene/index.tsx",
    "chars": 784,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/AiChangeCard/index.module.less",
    "chars": 1216,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/AiChangeCard/index.tsx",
    "chars": 1509,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/DrawerRowItem/index.module.less",
    "chars": 1559,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/DrawerRowItem/index.tsx",
    "chars": 1930,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/FullScreenCard/index.module.less",
    "chars": 619,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/FullScreenCard/index.tsx",
    "chars": 1307,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/Header/index.module.less",
    "chars": 2196,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/Header/index.tsx",
    "chars": 2781,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/Loading/AudioLoading/index.module.less",
    "chars": 594,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/Loading/AudioLoading/index.tsx",
    "chars": 900,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/Loading/HorizonLoading/index.module.less",
    "chars": 296,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/Loading/HorizonLoading/index.tsx",
    "chars": 1006,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/Loading/VerticalLoading/index.module.less",
    "chars": 898,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/Loading/VerticalLoading/index.tsx",
    "chars": 439,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/LocalPlayerSet/index.module.less",
    "chars": 330,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/LocalPlayerSet/index.tsx",
    "chars": 1202,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/NetworkIndicator/index.module.less",
    "chars": 1161,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/NetworkIndicator/index.tsx",
    "chars": 3558,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/ResizeWrapper/index.module.less",
    "chars": 171,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/ResizeWrapper/index.tsx",
    "chars": 845,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/UserTag/index.module.less",
    "chars": 654,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/components/UserTag/index.tsx",
    "chars": 670,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/config/index.ts",
    "chars": 731,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/index.less",
    "chars": 703,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/index.module.less",
    "chars": 1000,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/index.tsx",
    "chars": 443,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/interface.ts",
    "chars": 210,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/lib/RtcClient.ts",
    "chars": 12508,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/lib/listenerHooks.ts",
    "chars": 7421,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/lib/useCommon.ts",
    "chars": 7238,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/MainArea/Antechamber/InvokeButton/index.module.less",
    "chars": 1020,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/MainArea/Antechamber/InvokeButton/index.tsx",
    "chars": 986,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/MainArea/Antechamber/InvokeButton/loading.tsx",
    "chars": 646,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/MainArea/Antechamber/index.module.less",
    "chars": 1453,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/MainArea/Antechamber/index.tsx",
    "chars": 1314,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/MainArea/Room/AudioController.tsx",
    "chars": 1995,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/MainArea/Room/CameraArea.tsx",
    "chars": 4405,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/MainArea/Room/Conversation.tsx",
    "chars": 3996,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/MainArea/Room/ToolBar.tsx",
    "chars": 2578,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/MainArea/Room/index.module.less",
    "chars": 8743,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/MainArea/Room/index.tsx",
    "chars": 1897,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/MainArea/index.module.less",
    "chars": 5386,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/MainArea/index.tsx",
    "chars": 433,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/Menu/components/DeviceDrawerButton/index.module.less",
    "chars": 687,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/Menu/components/DeviceDrawerButton/index.tsx",
    "chars": 3942,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/Menu/components/Operation/index.module.less",
    "chars": 450,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/Menu/components/Operation/index.tsx",
    "chars": 640,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/Menu/components/Subtitle/index.module.less",
    "chars": 449,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/Menu/components/Subtitle/index.tsx",
    "chars": 1121,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/Menu/index.module.less",
    "chars": 4836,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/Menu/index.tsx",
    "chars": 2548,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/index.module.less",
    "chars": 746,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/MainPage/index.tsx",
    "chars": 2352,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/Mobile/MobileToolBar/index.module.less",
    "chars": 1051,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/Mobile/MobileToolBar/index.tsx",
    "chars": 1956,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/Mobile/SettingsDrawer/index.module.less",
    "chars": 1025,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/Mobile/SettingsDrawer/index.tsx",
    "chars": 2784,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/Mobile/components/SettingsItem/index.module.less",
    "chars": 692,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/pages/Mobile/components/SettingsItem/index.tsx",
    "chars": 875,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/react-app-env.d.ts",
    "chars": 278,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/store/index.ts",
    "chars": 602,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/store/slices/device.ts",
    "chars": 1845,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/store/slices/room.ts",
    "chars": 9322,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/theme.less",
    "chars": 159,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/utils/handler.ts",
    "chars": 3833,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/utils/logger.ts",
    "chars": 437,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/utils/utils.less",
    "chars": 593,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "src/utils/utils.ts",
    "chars": 2259,
    "preview": "/**\n * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.\n * SPDX-license-identifier: BSD-3"
  },
  {
    "path": "tsconfig.json",
    "chars": 570,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"ES6\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"sk"
  }
]

About this extraction

This page contains the full source code of the volcengine/rtc-aigc-demo GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 100 files (184.0 KB), approximately 55.5k tokens, and a symbol index with 90 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!