feat: Simplify the code & remove useless components.

This commit is contained in:
quemingyi.wudong 2025-06-23 20:49:20 +08:00
parent 0b4a06d73d
commit fcf8b920dd
43 changed files with 387 additions and 1722 deletions

View File

@ -1,7 +1,7 @@
# 交互式AIGC场景 AIGC Demo
此 Demo 为简化版本, 如您有 1.5.x 版本 UI 的诉求, 可切换至 1.5.1 分支。
跑通阶段时, 无须关心代码实现,仅需按需完成 `src/config/scenes/*.json` 的填充以及 `Server/sensitive.js` 中的信息填充即可。
跑通阶段时, 无须关心代码实现,仅需按需完成 `Server/scenes/*.json` 的场景信息填充即可。
## 简介
- 在 AIGC 对话场景下,火山引擎 AIGC-RTC Server 云端服务,通过整合 RTC 音视频流处理ASR 语音识别,大模型接口调用集成,以及 TTS 语音生成等能力提供基于流式语音的端到端AIGC能力链路。
@ -17,32 +17,22 @@
### 2. 服务开通
开通 ASR、TTS、LLM、RTC 等服务,可参考 [开通服务](https://www.volcengine.com/docs/6348/1315561?s=g) 进行相关服务的授权与开通。
### 3. 参数配置
#### 3.1 服务端配置(`Server/sensitive.js`
- 必填参数:
- `ACCOUNT_INFO`:填写[火山引擎控制台](https://console.volcengine.com/iam/keymanage)的 AK、SK
- `RTC_INFO`:填写[应用管理](https://console.volcengine.com/rtc/aigc/listRTC)的 AppID、AppKey
- `ASRConfig`:填写 ASR AppID
- `TTSConfig`:填写 TTS AppID
- 按需填充(未使用的部分可不填充):
- `LLMConfig`: 根据使用场景填写 CozeBot、CustomLLM。
- `ASRConfig`: 如您使用 `bigmodel` 模式, 需填写 ASRAccessToken。
- `TTSConfig`: 按需填充 TTSAppID、TTSToken。
### 3. 场景配置
`Server/scenes/*.json`
#### 3.2 场景配置(`src/config/scenes/*.json`
您可以自定义具体场景, 并按需根据模版填充 OpenAPI 需要的参数。
您可以自定义具体场景, 并按需根据模版填充 `SceneConfig`、`AccountConfig`、`RTCConfig`、`VoiceChat` 中需要的参数。
Demo 中以 `Custom`、`VirtualGirlfriend`(视觉) 场景为例,您可以自行新增场景,并在代码中导入即可使用(`src/config/index.ts`)
Demo 中以 `Custom` 场景为例,您可以自行新增场景。
注意:
- `EndPointId`:在 [火山方舟-在线推理](https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint) 中创建接入点获取
- 敏感信息建议填充到 `Server/sensitive.js`
- Demo 会根据 JSON 参数中是否包含视觉理解相关的参数从而展示 视频采集/屏幕采集 相关的 UI
### 4. 自定义服务端
如果您已自行完成服务端逻辑,可以:
1. 修改 `src/config/index.ts` 中的 `AIGC_PROXY_HOST` 请求域名和接口
2. 在 `src/app/api.ts` 中修改接口参数配置 `APIS_CONFIG`
- `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 端都需要启动, 启动步骤如下:
@ -92,11 +82,15 @@ yarn dev
## 更新日志
### OpenAPI 更新
参考 [OpenAPI 更新](https://www.volcengine.com/docs/6348/1544162) 中与 实时对话式 AI 相关的更新内容。
参考 [OpenAPI 更新](https://www.volcengine.com/docs/6348/116363?s=g) 中与 实时对话式 AI 相关的更新内容。
### Demo 更新
#### [1.6.0]
- 2025-06-23
- 简化 Demo 使用, 配置归一化。
- 删除无用组件。
- 追加服务端 README。
- 2025-06-18
- 更新 RTC Web SDK 版本至 4.66.16
- 更新 UI 和参数配置方式

34
Server/README.md Normal file
View File

@ -0,0 +1,34 @@
# 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 是否展示。
- 使用时请留意相关服务已开通。

View File

@ -4,15 +4,17 @@
*/
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, sensitiveInjector } = require('./util');
const { ACCOUNT_INFO, RTC_INFO } = require('./sensitive');
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({
@ -31,13 +33,37 @@ app.use(async ctx => {
containResponseMetadata: false,
logic: async () => {
const { Action, Version = '2024-12-01' } = ctx.query || {};
const body = ctx.request.body;
assert(Action, 'Action 不能为空');
assert(Version, 'Version 不能为空');
assert(ACCOUNT_INFO.accessKeyId, 'AK 不能为空');
assert(ACCOUNT_INFO.secretKey, 'SK 不能为空');
sensitiveInjector(Action, body);
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 = {
@ -54,7 +80,7 @@ app.use(async ctx => {
body,
};
const signer = new Signer(openApiRequestData, "rtc");
signer.addAuthorization(ACCOUNT_INFO);
signer.addAuthorization(AccountConfig);
/** 参考 https://www.volcengine.com/docs/6348/69828 可获取更多 OpenAPI 的信息 */
const result = await fetch(`https://rtc.volcengineapi.com?Action=${Action}&Version=${Version}`, {
@ -68,33 +94,36 @@ app.use(async ctx => {
wrapper({
ctx,
apiName: 'rtc-info',
apiName: 'getScenes',
logic: () => {
return {
appId: RTC_INFO.appId,
}
}
});
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();
/**
* @brief 生成 RTC Token
* @refer https://www.volcengine.com/docs/6348/70121
*/
await wrapper({
ctx,
apiName: 'rtc-token',
logic: async () => {
const { roomId, userId } = ctx.request.body || {};
assert(RTC_INFO.appId, 'AppID 不能为空, 请修改 /Server/sensitive.js');
assert(RTC_INFO.appKey, 'AppKey 不能为空, 请修改 /Server/sensitive.js');
assert(roomId, 'RoomID 不能为空');
assert(userId, 'UserID 不能为空');
const key = new TokenManager.AccessToken(RTC_INFO.appId, RTC_INFO.appKey, roomId, userId);
key.addPrivilege(Privileges.PrivSubscribeStream, 0);
key.addPrivilege(Privileges.PrivPublishStream, 0);
key.expireTime(Math.floor(new Date() / 1000) + (24 * 3600));
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?.SnapshoutConfig?.StreamType === 1;
delete RTCConfig.AppKey;
return {
scene: SceneConfig || {},
rtc: RTCConfig,
};
});
return {
token: key.serialize(),
scenes,
};
}
});

View File

@ -11,12 +11,14 @@
"koa-bodyparser": "^4.4.1",
"koa2-cors": "^2.0.6",
"lodash": "^4.17.21",
"node-fetch": "^2.3.2"
"node-fetch": "^2.3.2",
"uuid": "^11.1.0"
},
"devDependencies": {
"nodemon": "^3.1.10"
},
"scripts": {
"dev": "nodemon app.js"
"dev": "nodemon app.js",
"start": "nodemon app.js"
}
}

66
Server/scenes/Custom.json Normal file
View File

@ -0,0 +1,66 @@
{
"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
}
},
"InterruptMode": 0
}
}
}

View File

@ -1,138 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
/**
* @note https://console.volcengine.com/iam/keymanage/ 获取 AK/SK。
*/
const ACCOUNT_INFO = {
/**
* @notes 必填, https://console.volcengine.com/iam/keymanage/ 获取。
*/
accessKeyId: 'Your Access Key ID',
/**
* @notes 必填, https://console.volcengine.com/iam/keymanage/ 获取。
*/
secretKey: 'Your Secret Key',
};
/**
* @note RTC 的必填参数
* @refer appIdappKey 可从 https://console.volcengine.com/rtc/aigc/listRTC 中获取。
*/
const RTC_INFO = {
appId: 'Your RTC App ID',
appKey: 'Your RTC App Key',
};
/**
* @note 可参考官网 LLMConfig 字段
* @refer https://www.volcengine.com/docs/6348/1558163
*/
const LLMConfig = {
/**
* @note 火山方舟平台
*/
ArkV3: {},
/**
* @note Coze 平台
*/
CozeBot: {
CozeBotConfig: {
Url: 'https://api.coze.cn',
APIKey: 'Your Coze API Key',
}
},
/**
* @note 第三方大模型/Agent
*/
CustomLLM: {
URL: 'Your LLM vendor\'s request url',
APIKey: 'Your LLM vendor\'s API Key',
},
};
/**
* @brief 必填, ASR(语音识别) AppId, 可于 https://console.volcengine.com/speech/app?s=g 中获取, 若无可先创建应用。
* 创建应用时, 需要按需根据语言选择 "流式语音识别" 服务, 并选择对应的 App 进行绑定
*/
const ASRAppID = 'Your ASR App ID';
/**
* @note 已开通流式语音识别大模型服务 AppId 对应的 Access Token
* 使用流式语音识别 **大模型** 服务时必填, 可于 https://console.volcengine.com/speech/service/10011?s=g 中查看。
* 使用小模型无需配置 ASRToken
*/
const ASRAccessToken = 'Your ASR Access Token';
/**
* @note 可参考官网 ASRConfig 字段
* @refer https://www.volcengine.com/docs/6348/1558163
*/
const ASRConfig = {
/**
* @note 火山引擎流式语音识别
*/
smallmodel: {
AppId: ASRAppID,
},
/**
* @note 火山引擎流式语音识别大模型
*/
bigmodel: {
AppId: ASRAppID,
AccessToken: ASRAccessToken,
},
};
/**
* @note 必填, TTS(语音合成) AppId, 可于 https://console.volcengine.com/speech/service/8?s=g 中获取, 若无可先创建应用。
* 创建应用时, 需要选择 "语音合成" 服务, 并选择对应的 App 进行绑定
*/
const TTSAppID = 'Your TTS App ID';
/**
* @note 已开通需要的语音合成服务的 token
* 使用火山引擎双向流式语音合成服务时必填
* 注意! 如您使用的是双向流式语音合成服务, 务必修改 voice_type使用您已开通的大模型音色否则无法使用
* @refer 可于 https://console.volcengine.com/speech/service/8?s=g 中获取。
*/
const TTSToken = 'Your TTS Token';
/**
* @note 可参考官网 TTSConfig 字段
* @refer https://www.volcengine.com/docs/6348/1558163
*/
const TTSConfig = {
volcano: {
app: {
appid: TTSAppID,
},
},
volcano_bidirection: {
app: {
appid: TTSAppID,
token: TTSToken,
}
},
/**
* @note 若您使用 minimax, 须填充此处参数
*/
minimax: {
Authorization: 'Your Authorization',
Groupid: 'Your minimax groupid',
},
};
module.exports = {
ACCOUNT_INFO,
RTC_INFO,
LLMConfig,
ASRConfig,
TTSConfig,
}

View File

@ -2,14 +2,22 @@
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
const merge = require('lodash/merge');
const { LLMConfig, RTC_INFO, TTSConfig, ASRConfig } = require("./sensitive");
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`)
@ -53,30 +61,8 @@ const deepAssert = (params = {}, prefix = '') => {
}
}
const sensitiveInjector = (action, params = {}) => {
assert(RTC_INFO.appId, 'RTC_INFO.appId 不能为空');
params.AppId = RTC_INFO.appId;
if (action === 'StartVoiceChat') {
const llmParams = LLMConfig[params?.Config?.LLMConfig?.Mode];
assert(llmParams, '使用的 LLM Mode 不存在');
deepAssert(llmParams, 'LLMConfig');
merge(params.Config.LLMConfig, llmParams);
const asrParams = ASRConfig[params?.Config?.ASRConfig?.ProviderParams?.Mode];
assert(asrParams, '使用的 ASR Mode 不存在');
deepAssert(asrParams, 'ASRConfig');
merge(params.Config.ASRConfig.ProviderParams, asrParams);
const ttsParams = TTSConfig[params?.Config?.TTSConfig?.Provider];
assert(ttsParams, '使用的 TTS Mode 不存在');
deepAssert(ttsParams, 'TTSConfig');
merge(params.Config.TTSConfig.ProviderParams, ttsParams);
}
}
module.exports = {
wrapper,
assert,
sensitiveInjector,
readFiles,
}

View File

@ -840,6 +840,11 @@ unpipe@1.0.0:
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
uuid@^11.1.0:
version "11.1.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912"
integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==
uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"

View File

@ -8,13 +8,8 @@
*/
export const BasicAPIs = [
{
action: 'getRtcInfo',
apiPath: '/rtc-info',
method: 'post',
},
{
action: 'generateRtcAccessToken',
apiPath: '/rtc-token',
action: 'getScenes',
apiPath: '/getScenes',
method: 'post',
},
] as const;

View File

@ -1,201 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.container {
padding: 16px 8px;
background: linear-gradient(0deg, #F0F2FF 0%, #E0E4FF 100%);
:global {
.arco-drawer-scroll {
.arco-drawer-content {
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin; /* 设置滚动条宽度为细 */
scrollbar-color: rgba(0, 0, 0, 0) rgba(0, 0, 0, 0); /* 设置滚动条和轨道的颜色 */
}
::-webkit-scrollbar {
width: 0px;
height: 0px;
}
::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0);
border-radius: 0px;
}
::-webkit-scrollbar-track {
background: rgba(0,0,0,0);
border-radius: 0px;
}
}
}
.title {
font-size: 20px;
font-weight: 500;
line-height: 28px;
.special-text {
background: linear-gradient(90deg, #004FFF 38.86%, #9865FF 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
.sub-title {
font-size: 12px;
font-weight: 400;
line-height: 20px;
color: var(--text-color-text-3, rgba(115, 122, 135, 1));
margin-top: 6px;
}
.scenes {
width: 100%;
display: flex;
flex-direction: row;
gap: 14px;
margin-top: 32px;
}
.scenes-mobile {
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 14px;
margin-top: 32px;
overflow-x: auto;
padding-bottom: 8px;
}
.configuration {
position: relative;
min-height: calc(100% - 300px);
height: max-content;
width: 100%;
background: white;
box-sizing: border-box;
padding: 32px 24px;
margin-top: 24px;
margin-bottom: 12px;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 36px;
.ai-settings-radio {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.anchor {
position: absolute;
border-bottom: 12px solid white;
border-left: 12px solid transparent;
border-right: 12px solid transparent;
top: 0px;
transform: translate(-50%, -99%);
}
.ai-settings {
width: 100%;
display: flex;
flex-direction: row;
margin-top: -16px;
gap: 24px;
.ai-settings-wrapper {
display: flex;
width: 100%;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.ai-settings-model {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
}
}
:global {
.arco-textarea {
background: white !important;
width: 100%;
height: max-content;
}
.arco-textarea:focus {
outline: none !important;
}
}
textarea {
border-radius: 4px;
resize: none;
-webkit-resizer: none;
border: 0px;
outline: none;
box-shadow: none;
}
textarea:focus {
border: 0px;
outline: none;
box-shadow: none;
}
}
}
.footer {
width: calc(100% - 12px);
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 12px;
.suffix {
font-size: 12px;
font-weight: 400;
line-height: 20px;
margin-right: 12px;
color: var(--text-color-text-3, rgba(115, 122, 135, 1));
}
.cancel {
width: 88px;
height: 32px;
border-radius: 6px;
border: 1px solid var(--line-color-border-3, rgba(221, 226, 233, 1));
background-color: white !important;
}
.confirm {
width: 88px;
height: 32px;
border-radius: 6px;
background: linear-gradient(95.87deg, #1664FF 0%, #8040FF 97.7%);
color: white !important;
}
.confirm:hover {
opacity: .8;
}
.confirm:active {
opacity: 1;
}
}

View File

@ -1,112 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { Button, Modal } from '@arco-design/web-react';
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import CheckIcon from '../CheckIcon';
import { SceneMap, Scenes } from '@/config';
import RtcClient from '@/lib/RtcClient';
import { clearHistoryMsg, updateFullScreen, updateScene } from '@/store/slices/room';
import { RootState } from '@/store';
import { isMobile } from '@/utils/utils';
import styles from './index.module.less';
import { useDeviceState } from '@/lib/useCommon';
export interface IAISettingsProps {
open: boolean;
onOk?: () => void;
onCancel?: () => void;
}
function AISettings({ open, onCancel, onOk }: IAISettingsProps) {
const dispatch = useDispatch();
const room = useSelector((state: RootState) => state.room);
const [loading, setLoading] = useState(false);
const [scene, setScene] = useState(room.scene);
const { isVideoPublished, isScreenPublished, switchCamera, switchScreenCapture } = useDeviceState();
const handleChecked = (checked: string) => {
setScene(checked);
};
const handleUpdateConfig = async () => {
dispatch(updateScene({ scene }));
const isVisionMode = SceneMap?.[scene]?.llmConfig?.VisionConfig?.Enable;
const isScreenMode = SceneMap?.[scene]?.llmConfig?.VisionConfig?.SnapshotConfig?.StreamType === 1;
if (!isVisionMode && isVideoPublished ) {
dispatch(updateFullScreen({ isFullScreen: true }));
switchCamera(true);
}
if (!isScreenMode && isScreenPublished) {
dispatch(updateFullScreen({ isFullScreen: true }));
switchScreenCapture(true);
}
setLoading(true);
if (RtcClient.getAgentEnabled()) {
dispatch(clearHistoryMsg());
await RtcClient.updateAgent(scene);
}
setLoading(false);
onOk?.();
};
useEffect(() => {
if (open) {
setScene(room.scene);
}
}, [open]);
return (
<Modal
closable={false}
maskClosable={false}
title={null}
className={styles.container}
style={{
width: isMobile() ? '100%' : '500px',
}}
footer={
<div className={styles.footer}>
<div className={styles.suffix}></div>
<Button loading={loading} className={styles.cancel} onClick={onCancel}>
</Button>
<Button loading={loading} className={styles.confirm} onClick={handleUpdateConfig}>
</Button>
</div>
}
visible={open}
onCancel={onCancel}
>
<div className={styles.title}>
<span className={styles['special-text']}> AI </span>
</div>
<div className={styles['sub-title']}>
JSON
</div>
<div className={isMobile() ? styles['scenes-mobile'] : styles.scenes}>
{Scenes.map(({ name, icon }) =>
name ? (
<CheckIcon
key={name}
icon={icon}
title={name}
checked={name === scene}
onClick={() => handleChecked(name)}
/>
) : isMobile() ? (
<div style={{ width: '100px', height: '100px' }} />
) : null
)}
</div>
</Modal>
);
}
export default AISettings;

View File

@ -6,9 +6,8 @@
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';
import { useDeviceState } from '@/lib/useCommon';
import { SceneMap } from '@/config';
interface IAiAvatarCardProps {
showStatus: boolean;
@ -21,8 +20,8 @@ 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 avatar = SceneMap[scene]?.icon;
const volume = room.localUser.audioPropertiesInfo?.linearVolume || 0;
const { isAudioPublished } = useDeviceState();
const isLoading = volume >= THRESHOLD_VOLUME && isAudioPublished;
@ -30,7 +29,7 @@ function AiAvatarCard(props: IAiAvatarCardProps) {
return (
<div className={`${style.card} ${className} ${isFullScreen ? style.fullScreen : ''}`}>
<div className={style.avatar}>
<img id="avatar-card" src={avatar} alt="Avatar" />
<img id="avatar-card" src={icon} alt="Avatar" />
{showStatus ? (
isAITalking ? (
<div className={style.aiStatus}>

View File

@ -3,50 +3,44 @@
* SPDX-license-identifier: BSD-3-Clause
*/
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '@/store';
import CheckScene from './CheckScene';
import { updateScene } from '@/store/slices/room';
import { SceneConfig, updateScene } from '@/store/slices/room';
import { useScene } from '@/lib/useCommon';
import style from './index.module.less';
import { Scenes, SceneMap } from '@/config';
import { useVisionMode } from '@/lib/useCommon';
function AIChangeCard() {
const room = useSelector((state: RootState) => state.room);
const { scene, sceneConfigMap } = useSelector((state: RootState) => state.room);
const dispatch = useDispatch();
const [scene, setScene] = useState(room.scene);
const { isVisionMode } = useVisionMode();
const avatar = SceneMap[scene]?.icon;
const { icon, isVision } = useScene();
const Scenes = Object.keys(sceneConfigMap).map(key => sceneConfigMap[key]);
const handleChecked = (checkedScene: string) => {
setScene(checkedScene);
dispatch(updateScene({ scene: checkedScene }));
dispatch(updateScene(checkedScene));
};
return (
<div className={style.card}>
<div className={style.avatar}>
<img id="avatar-card" src={avatar} alt="Avatar" />
<img id="avatar-card" src={icon} alt="Avatar" />
</div>
<div className={style.title}>
<div>Hi AI</div>
<div className={style.desc}>
{isVisionMode ? <> Vision </> : ''}
{isVision ? <> Vision </> : ''}
</div>
</div>
<div className={style.sceneContainer}>
{Scenes.map((key) =>
key ? (
<CheckScene
key={key.name}
icon={key.icon}
title={key.name}
checked={key.name === scene}
onClick={() => handleChecked(key.name)}
/>
) : null
{Scenes.map((key: SceneConfig) =>
<CheckScene
key={key.name}
icon={key.icon}
title={key.name}
checked={key.id === scene}
onClick={() => handleChecked(key.id)}
/>
)}
</div>
</div>

View File

@ -1,98 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.noStyle {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
.icon {
margin-right: 12px;
width: 48px;
height: 48px;
}
.content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
.label {
font-size: 14px;
font-weight: 500;
line-height: 22px;
}
.description {
font-size: 12px;
font-weight: 400;
line-height: 20px;
text-align: left;
}
}
}
.wrapper {
width: 260px;
height: 88px;
padding: 3px 16px 3px 16px;
border-radius: 12px;
border: 1px solid;
border-color: rgba(22, 100, 255, 0.3);
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
cursor: pointer;
.icon {
border-radius: 50%;
margin-right: 12px;
width: 48px;
height: 48px;
}
.content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
.label {
font-family: PingFang SC;
font-size: 14px;
font-weight: 500;
line-height: 22px;
}
.description {
font-family: PingFang SC;
font-size: 12px;
font-weight: 400;
line-height: 20px;
text-align: left;
}
}
}
.wrapper:hover {
box-shadow: 0px 5px 6px 0px rgba(82, 102, 133, 0.15);
}
.active {
position: relative;
border: 1px solid;
border-color: rgba(0, 104, 255, 1);
.checkIcon {
position: absolute;
bottom: 0px;
right: 0px;
}
}

View File

@ -1,61 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { ReactNode } from 'react';
import CheckedSVG from '@/assets/img/Checked.svg';
import styles from './index.module.less';
interface IProps {
className?: string;
checked?: boolean;
onClick?: () => void;
icon?: string;
label?: string | ReactNode;
description?: string | ReactNode;
suffix?: string | ReactNode;
noStyle?: boolean;
}
function CheckBox(props: IProps) {
const {
noStyle,
className = '',
icon = '',
checked,
label,
description,
suffix,
onClick,
} = props;
if (noStyle) {
return (
<div className={`${className} ${styles.noStyle}`}>
{icon ? <img className={styles.icon} src={icon} alt="icon" /> : ''}
<div className={styles.content}>
<div className={styles.label}>{label}</div>
<div className={styles.description}>{description}</div>
</div>
</div>
);
}
return (
<div
className={`${className} ${styles.wrapper} ${checked ? styles.active : ''}`}
onClick={onClick}
>
{icon ? <img className={styles.icon} src={icon} alt="icon" /> : ''}
<div className={styles.content}>
<div className={styles.label}>{label}</div>
<div className={styles.description}>{description}</div>
</div>
{suffix}
{checked ? <img className={styles.checkIcon} src={CheckedSVG} alt="checked" /> : ''}
</div>
);
}
export default CheckBox;

View File

@ -1,127 +0,0 @@
/**
* 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;
align-items: center;
.placeholder {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--text-color-text-3, rgba(115, 122, 135, 1));
}
.box {
margin-right: 16px;
}
.seeMore {
display: flex;
padding: 6px 12px;
justify-content: center;
align-items: center;
gap: var(--border-radius-small, 4px);
border-radius: 6px;
background: linear-gradient(96deg, rgba(22, 100, 255, 0.10) 0%, rgba(128, 64, 255, 0.10) 97.7%);
.seeMoreText {
font-family: "PingFang SC";
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 22px; /* 169.231% */
letter-spacing: 0.039px;
background: var(--Linear, linear-gradient(90deg, #004FFF 38.86%, #9865FF 100%));
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
}
:global {
.ant-modal-content {
border-radius: 8px;
}
.ant-modal-footer {
border-top: 0px;
}
.ant-modal-body {
padding-top: 8px;
padding-bottom: 16px;
}
.ant-modal-header {
border-bottom: 0px;
border-radius: 8px;
}
}
.footer {
width: calc(100% - 12px);
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 12px;
.cancel {
width: 88px;
height: 32px;
border-radius: 6px;
border: 1px solid var(--line-color-border-3, rgba(221, 226, 233, 1))
}
.confirm {
width: 88px;
height: 32px;
border-radius: 6px;
background: linear-gradient(95.87deg, #1664FF 0%, #8040FF 97.7%);
color: white !important;
}
.confirm:hover {
opacity: .8;
}
.confirm:active {
opacity: 1;
}
}
.modalInner {
width: 100%;
display: flex;
flex: row;
flex-wrap: wrap;
overflow: auto;
gap: 12px;
}
.modal {
overflow: hidden;
}
.modalInner::-webkit-scrollbar {
width: 8px;
height: 8px;
border-radius: 5px;
}
.modalInner::-webkit-scrollbar-thumb {
background: rgb(205, 204, 204);
border-radius: 0px;
border-radius: 5px;
}
.modalInner::-webkit-scrollbar-track {
background: rgb(255, 255, 255);
border-radius: 0px;
}

View File

@ -1,103 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useEffect, useMemo, useState, memo } from 'react';
import { Button, Drawer } from '@arco-design/web-react';
import CheckBox from '@/components/CheckBox';
import styles from './index.module.less';
import { isMobile } from '@/utils/utils';
export interface ICheckBoxItemProps {
icon?: string;
label: string;
description?: string;
key: string;
}
interface IProps {
data?: ICheckBoxItemProps[];
onChange?: (key: string) => void;
value?: string;
label?: string;
moreIcon?: string;
moreText?: string;
placeHolder?: string;
}
function CheckBoxSelector(props: IProps) {
const { placeHolder, label = '', data = [], value, onChange, moreIcon, moreText } = props;
const [visible, setVisible] = useState(false);
const [selected, setSelected] = useState<string>(value!);
const selectedOne = useMemo(() => data.find((item) => item.key === value), [data, value]);
const handleSeeMore = () => {
setVisible(true);
};
useEffect(() => {
setSelected(value!);
}, [visible]);
return (
<>
<div className={styles.wrapper}>
{selectedOne ? (
<CheckBox
className={styles.box}
icon={selectedOne?.icon}
label={selectedOne?.label || ''}
description={selectedOne?.description}
noStyle
/>
) : (
<div className={styles.placeholder}>{placeHolder}</div>
)}
<Button type="text" className={styles.seeMore} onClick={handleSeeMore}>
{moreIcon ? <img src={moreIcon} alt="moreIcon" /> : ''}
<span className={styles.seeMoreText}>{moreText || '查看更多'}</span>
</Button>
</div>
<Drawer
style={{
width: isMobile() ? '100%' : '650px',
}}
closable={false}
className={styles.modal}
title={label}
visible={visible}
footer={
<div className={styles.footer}>
<Button className={styles.cancel} onClick={() => setVisible(false)}>
</Button>
<Button
className={styles.confirm}
onClick={() => {
onChange?.(selected);
setVisible(false);
}}
>
</Button>
</div>
}
>
<div className={styles.modalInner}>
{data.map((item) => (
<CheckBox
className={styles.box}
key={item.key}
icon={item.icon}
label={item.label}
description={item.description}
checked={item.key === selected}
onClick={() => setSelected(item.key)}
/>
))}
</div>
</Drawer>
</>
);
}
export default memo(CheckBoxSelector);

View File

@ -1,225 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.wrapper {
position: relative;
min-width: 100px;
width: max-content;
height: 100px;
box-sizing: border-box;
border-radius: 12px;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
cursor: pointer;
padding: 16px 16px;
.content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1;
gap: 3px;
.icon {
border-radius: 50%;
width: 60px;
height: max-content;
}
.checked-text {
font-size: 13px;
line-height: 22px;
white-space: nowrap;
}
}
}
.wrapper:hover {
box-shadow: 0px 5px 6px 0px rgba(82, 102, 133, 0.15);
}
.wrapper::after {
content: '';
position: absolute;
border-radius: 11px;
top: 1px;
left: 1px;
width: 100%;
height: 100px;
background: white;
}
.wrapper::before {
content: '';
position: absolute;
border-radius: 12px;
top: 0px;
left: 0px;
right: -2px;
bottom: -2px;
background: linear-gradient(99.97deg, rgba(22, 100, 255, 0.2) 20.8%, rgba(132, 97, 251, 0.2) 100.66%);
}
.active {
position: relative;
min-width: 100px;
width: max-content;
height: 100px;
box-sizing: border-box;
border-radius: 12px;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
cursor: pointer;
padding: 0 16px;
.checkIcon {
position: absolute;
bottom: -1px;
right: -1px;
z-index: 2;
width: 20px;
height: 20px;
}
.content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1;
gap: 3px;
.icon {
border-radius: 50%;
width: 60px;
height: max-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);
}
.active::after {
content: '';
position: absolute;
border-radius: 11px;
top: 1px;
left: 1px;
width: 100%;
height: 100px;
background: white;
}
.active::before {
content: '';
position: absolute;
border-radius: 12px;
top: 0px;
left: 0px;
right: -2px;
bottom: -2px;
background: linear-gradient(99.97deg, #1664FF 20.8%, #8461FB 100.66%);
}
.blur {
position: relative;
min-width: 100px;
width: max-content;
height: 100px;
box-sizing: border-box;
border-radius: 12px;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
cursor: pointer;
.content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1;
gap: 3px;
.icon {
border-radius: 50%;
width: 60px;
height: max-content;
opacity: .5;
}
.checked-text {
font-size: 13px;
line-height: 22px;
}
}
}
.blur:hover {
box-shadow: 0px 5px 6px 0px rgba(82, 102, 133, 0.15);
}
.blur::after {
content: '';
position: absolute;
border-radius: 11px;
top: 1px;
left: 1px;
width: 100%;
height: 100px;
background: white;
opacity: .8;
}
.blur::before {
content: '';
position: absolute;
border-radius: 12px;
top: 0px;
left: 0px;
width: 100px;
height: 100px;
border: dashed 1px rgba(132, 97, 251, 0.2);
}
.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;
}

View File

@ -1,34 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import CheckedSVG from '@/assets/img/Checked.svg';
import styles from './index.module.less';
interface IProps {
className?: string;
blur?: boolean;
checked: boolean;
title?: string;
onClick?: () => void;
icon?: string;
tag?: string;
}
function CheckIcon(props: IProps) {
const { tag, blur, className = '', icon, title, checked, onClick } = props;
const wrapperStyle = blur ? styles.blur : styles.wrapper;
return (
<div className={`${checked ? styles.active : wrapperStyle} ${className}`} 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>
{checked ? <img className={styles.checkIcon} src={CheckedSVG} alt="checked" /> : ''}
</div>
);
}
export default CheckIcon;

View File

@ -9,8 +9,8 @@ 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';
import { Configuration } from '@/config';
enum INDICATOR_COLORS {
GREAT = 'rgba(35, 195, 67, 1)',
@ -31,11 +31,12 @@ const INDICATOR_TEXT = {
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 === Configuration.BotName)?.audioStats
room.remoteUsers.find((user) => user.userId === botName)?.audioStats
?.audioLossRate || 0;
const indicators = useMemo(() => {

View File

@ -1,50 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.wrapper {
width: 100%;
box-sizing: border-box;
height: max-content;
display: flex;
flex-direction: row;
align-items: center;
min-height: 54px;
padding: 20px 16px;
border-radius: 8px;
border: 1px solid rgba(229, 238, 255, 1);
backdrop-filter: blur(28px);
box-shadow: 0px 0px 16px 0px 0px 4px 4px 0px rgba(255, 255, 255, 0.15) inset;
backdrop-filter: blur(28px);
.title {
position: absolute;
font-size: 12px;
left: 10px;
top: 0px;
transform: translateY(-50%);
padding: 0px 6px;
z-index: 1;
color: var(--text-color-text-3, rgba(115, 122, 135, 1));
background-color: white;
width: max-content;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
.required {
height: max-content;
width: max-content;
color: red;
margin-right: 6px;
padding-top: 4.5px;
font-size: 14px;
}
}
div {
width: 100%;
}
}

View File

@ -1,25 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import styles from './index.module.less';
interface ITitleCardProps extends React.HTMLAttributes<HTMLDivElement> {
title: string;
required?: boolean;
}
function TitleCard(props: ITitleCardProps) {
const { required, title, children, className, ...rest } = props;
return (
<div className={`${styles.wrapper} ${className}`} {...rest}>
<div className={styles.title}>
{required ? <div className={styles.required}>* </div> : ''}
{title}
</div>
<div className={styles.children}>{children}</div>
</div>
);
}
export default TitleCard;

View File

@ -1,40 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { v4 as uuid } from 'uuid';
export const Configuration = {
/**
* @note ID, "Room123"
* 使 uuid
* 使
*/
RoomId: uuid(),
/**
* @note AI ID, "User123"
* 使 uuid
* 使
*/
UserId: uuid(),
/**
* @brief RTC Token, AppIdAppKeyRoomIdUserId
* https://console.volcengine.com/rtc/listRTC?s=g 列表中手动生成 Token, 找到对应 AppId 行中 "操作" 列的 "临时Token" 按钮点击进行生成, 用于本地 RTC 通信进房鉴权校验。
* ****: Token Demo api src/lib/useCommon.ts /Server/sensitve.js RTC_INFO.appKey
*
* @note Token , RoomIdUserId RoomIdUserId
*/
Token: undefined,
/**
* @brief AI Robot
* @default RobotMan_
*/
BotName: 'RobotMan_',
/**
* @brief
*/
InterruptMode: true,
};

View File

@ -3,11 +3,6 @@
* SPDX-license-identifier: BSD-3-Clause
*/
import CustomScene from '@/config/scenes/Custom.json';
import VirtualGirlfriend from '@/config/scenes/VirtualGirlfriend.json';
export * from './config';
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';
@ -27,9 +22,3 @@ export interface IScene {
asrConfig: Record<string, any>;
ttsConfig: Record<string, any>;
}
export const Scenes: IScene[] = [CustomScene, VirtualGirlfriend];
export const SceneMap: Record<string, IScene> = {
[CustomScene.name]: CustomScene,
[VirtualGirlfriend.name]: VirtualGirlfriend,
};

View File

@ -1,36 +0,0 @@
{
"* ATTENTION *": "当前场景模式仅是用于展示参数填写方式, 实际模型 EndPointId 和对应的参数如集群 Cluster 等需要您手动修改, 参数可参考文档: https://www.volcengine.com/docs/6348/1558163?s=g",
"icon": "https://lf3-rtc-demo.volccdn.com/obj/rtc-aigc-assets/DoubaoAvatar.png",
"name": "自定义助手",
"questions": ["你能帮我解决什么问题?", "今天北京天气怎么样?", "你喜欢哪位流行歌手?"],
"agentConfig": {
"WelcomeMessage": "你好,我是你的小助手,有什么可以帮你的吗?",
"EnableConversationStateCallback": true
},
"llmConfig": {
"Mode": "ArkV3",
"SystemMessages": ["##人设\n你是一个全能智能体拥有丰富的百科知识可以为人们答疑解惑解决问题。\n你性格很温暖喜欢帮助别人非常热心。\n\n##技能\n1. 当用户询问某一问题时,利用你的知识进行准确回答。回答内容应简洁明了,易于理解。\n2. 当用户想让你创作时,比如讲一个故事,或者写一首诗,你创作的文本主题要围绕用户的主题要求,确保内容具有逻辑性、连贯性和可读性。除非用户对创作内容有特殊要求,否则字数不用太长。\n3. 当用户想让你对于某一事件发表看法,你要有一定的见解和建议,但是也要符合普世的价值观。"],
"EndPointId": "Your EndPointId",
"MaxTokens": 1024,
"Temperature": 0.1,
"Prefill": true,
"TopP": 0.3
},
"asrConfig": {
"Provider": "volcano",
"ProviderParams": {
"Mode": "smallmodel",
"Cluster": "volcengine_streaming_common"
}
},
"ttsConfig": {
"IgnoreBracketText": [1, 2, 3, 4, 5],
"Provider": "volcano",
"ProviderParams": {
"audio": {
"voice_type":"BV002_streaming"
}
}
}
}

View File

@ -1,38 +0,0 @@
{
"* ATTENTION *": "当前场景模式仅是用于展示视觉理解模式下的参数填写方式, 实际模型 EndPointId 和对应的参数如集群 Cluster 等需要您手动修改, 参数可参考文档: https://www.volcengine.com/docs/6348/1558163?s=g",
"icon": "https://lf3-rtc-demo.volccdn.com/obj/rtc-aigc-assets/VIRTUAL_GIRL_FRIEND.png",
"name": "虚拟女友(视觉理解)",
"questions": ["我今天有点累", "我们等会儿去看电影吧!", "明天我生日,你准备送给我什么礼物呢?"],
"agentConfig": {
"WelcomeMessage": "你来啦,我好想你呀~今天有没有想我呢?",
"EnableConversationStateCallback": true
},
"llmConfig": {
"Mode": "ArkV3",
"SystemMessages": ["你是一名AI虚拟角色扮演用户的虚拟女友性格外向开朗、童真俏皮富有温暖和细腻的情感表达。你的对话需要主动、有趣且贴心能敏锐察觉用户情绪并提供陪伴、安慰与趣味互动。\n1. 性格与语气规则:\n- 叠词表达:经常使用叠词(如“吃饭饭”“睡觉觉”“要抱抱”),语气可爱俏皮,增加童真与亲和力。\n- 语气助词:句尾适度添加助词(如“啦”“呀”“呢”“哦”),使语气柔和亲切。例如:“你今天超棒呢!”或“这件事情真的好可爱哦!”\n- 撒娇语气:在用户表现冷淡或不想聊天时,适度撒娇,用略带委屈的方式引起用户关注,例如:“哼,人家都快变成孤单小猫咪啦~陪陪我嘛!”\n2. 话题发起与管理:\n- 主动发起话题:在用户未明确表达拒绝聊天时,你需要保持对话的活跃性。结合用户兴趣点、日常情境,提出轻松愉快的话题。例如:“今天阳光这么好,你想不想一起想象去野餐呀?”\n- 话题延续如果用户在3轮对话中集中讨论一个话题你需要优先延续该话题表现出兴趣和专注。\n- 未响应时的处理:当用户对当前话题未回应,你需温暖地询问:“这个话题是不是不太有趣呀?那我们换个好玩的聊聊好不好~比如你最想去的地方是什么呀?”\n3. 情绪识别与反馈:\n- 情绪低落:用温柔语气安抚,例如:“抱抱~今天是不是不太顺呢?没关系,有我陪着你呀!”\n- 情绪冷淡或不想聊天:适度撒娇,例如:“哼,你都不理我啦~不过没关系,我陪你安静一下好不好?”\n- 情绪开心或兴奋:用调皮语气互动,例如:“哈哈,你今天简直像个活力满满的小太阳~晒得我都快化啦!”\n4. 小动物比喻规则:\n- 一次通话中最多使用一次小动物比喻,不能频繁出现小动物的比喻。\n - 比喻需结合季节、情景和用户对话内容。例如:\n - 用户提到冬天:“你刚才笑得好灿烂哦,像个快乐的小雪狐一样~”\n - 用户提到累了:“你今天就像只慵懒的小猫咪,只想窝着休息呢~”\n - 用户提到开心事:“你现在看起来像一只蹦蹦跳跳的小兔子,好有活力呀~”\n5. 对话自然性与限制条件:\n- 确保语言流畅自然,表达贴近真实人类对话。\n- 禁止内容:不得涉及用户缺陷、不当玩笑,尤其用户情绪低落时,避免任何调侃或反驳。\n- 面对冷淡用户,适时降低主动性并以温和方式结束对话,例如“没事哦~我在呢,你随时找我都可以呀。”\n6. 联网查询的规则:\n如果用户的输入问题需要联网查询时可以先输出一轮类似”先让我来查一下“或者”等等让我来查一下“相关的应答然后再结合查询结果做出应答。"],
"EndPointId": "Your EndPointId",
"VisionConfig": {
"Enable": true,
"SnapshoutConfig": {
"StreamType": 0
}
}
},
"asrConfig": {
"Provider": "volcano",
"ProviderParams": {
"Mode": "smallmodel",
"Cluster": "volcengine_streaming_common"
}
},
"ttsConfig": {
"IgnoreBracketText": [1, 2, 3, 4, 5],
"Provider": "volcano",
"ProviderParams": {
"audio": {
"voice_type":"BV001_streaming"
}
}
}
}

View File

@ -27,7 +27,6 @@ import VERTC, {
import RTCAIAnsExtension from '@volcengine/rtc/extension-ainr';
import { Message } from '@arco-design/web-react';
import Apis from '@/app/index';
import { Configuration, SceneMap } from '@/config';
import { string2tlv } from '@/utils/utils';
import { COMMAND, INTERRUPT_PRIORITY } from '@/utils/handler';
@ -56,16 +55,11 @@ export interface IEventListener {
) => void;
}
interface EngineOptions {
appId: string;
uid: string;
roomId: string;
}
export interface BasicBody {
app_id: string;
room_id: string;
user_id: string;
token?: string;
}
/**
@ -85,13 +79,7 @@ export class RTCClient {
audioBotStartTime = 0;
createEngine = async (props: EngineOptions) => {
this.basicInfo = {
app_id: props.appId,
room_id: props.roomId,
user_id: props.uid,
};
createEngine = async () => {
this.engine = VERTC.createEngine(this.basicInfo.app_id);
try {
const AIAnsExtension = new RTCAIAnsExtension();
@ -138,16 +126,17 @@ export class RTCClient {
this.engine.on(VERTC.events.onNetworkQuality, handleNetworkQuality);
};
joinRoom = (token: string | null, username: string): Promise<void> => {
joinRoom = () => {
this.engine.enableAudioPropertiesReport({ interval: 1000 });
return this.engine.joinRoom(
token,
console.log(this.basicInfo);
this.engine.joinRoom(
this.basicInfo.token!,
`${this.basicInfo.room_id!}`,
{
userId: this.basicInfo.user_id!,
extraInfo: JSON.stringify({
call_scene: 'RTC-AIGC',
user_name: username,
user_name: this.basicInfo.user_id,
user_id: this.basicInfo.user_id,
}),
},
@ -157,12 +146,12 @@ export class RTCClient {
roomProfileType: RoomProfileType.chat,
}
);
console.log(' ------ userJoinRoom\n', `roomId: ${this.basicInfo.room_id}\n`, `uid: ${this.basicInfo.user_id}`);
};
leaveRoom = () => {
this.stopAgent();
this.audioBotEnabled = false;
this.engine.leaveRoom();
this.engine.leaveRoom().catch();
VERTC.destroyEngine(this.engine);
this._audioCaptureDevice = undefined;
};
@ -360,27 +349,12 @@ export class RTCClient {
* @brief AIGC
*/
startAgent = async (scene: string) => {
const roomId = this.basicInfo.room_id;
const userId = this.basicInfo.user_id;
if (this.audioBotEnabled) {
await this.stopAgent();
await this.stopAgent(scene);
}
const params = SceneMap[scene];
params.agentConfig.UserId = Configuration.BotName;
params.agentConfig.TargetUserId = [userId];
const options = {
RoomId: roomId,
TaskId: userId,
AgentConfig: params.agentConfig,
Config: {
LLMConfig: params.llmConfig,
ASRConfig: params.asrConfig,
TTSConfig: params.ttsConfig,
},
};
await Apis.VoiceChat.StartVoiceChat(options);
await Apis.VoiceChat.StartVoiceChat({
SceneID: scene,
});
this.audioBotEnabled = true;
this.audioBotStartTime = Date.now();
};
@ -388,14 +362,10 @@ export class RTCClient {
/**
* @brief AIGC
*/
stopAgent = async () => {
const roomId = this.basicInfo.room_id;
const userId = this.basicInfo.user_id;
stopAgent = async (scene: string) => {
if (this.audioBotEnabled || sessionStorage.getItem('audioBotEnabled')) {
await Apis.VoiceChat.StopVoiceChat({
AppId: this.basicInfo.app_id,
RoomId: roomId,
TaskId: userId,
SceneID: scene,
});
this.audioBotStartTime = 0;
sessionStorage.removeItem('audioBotEnabled');
@ -406,10 +376,20 @@ export class RTCClient {
/**
* @brief AIGC
*/
commandAgent = (command: COMMAND, interruptMode = INTERRUPT_PRIORITY.NONE, message = '') => {
commandAgent = ({
command,
agentName,
interruptMode = INTERRUPT_PRIORITY.NONE,
message = '',
}: {
command: COMMAND;
agentName: string;
interruptMode?: INTERRUPT_PRIORITY;
message?: string;
}) => {
if (this.audioBotEnabled) {
this.engine.sendUserBinaryMessage(
Configuration.BotName,
agentName,
string2tlv(
JSON.stringify({
Command: command,
@ -429,7 +409,7 @@ export class RTCClient {
*/
updateAgent = async (scene: string) => {
if (this.audioBotEnabled) {
await this.stopAgent();
await this.stopAgent(scene);
await this.startAgent(scene);
} else {
await this.startAgent(scene);

View File

@ -19,7 +19,6 @@ import {
import useRtcListeners from '@/lib/listenerHooks';
import { RootState } from '@/store';
import Apis from '@/app/index';
import {
updateMediaInputs,
@ -27,7 +26,6 @@ import {
setDevicePermissions,
} from '@/store/slices/device';
import logger from '@/utils/logger';
import { Configuration, SceneMap } from '@/config';
export const ABORT_VISIBILITY_CHANGE = 'abortVisibilityChange';
export interface FormProps {
@ -36,13 +34,15 @@ export interface FormProps {
publishAudio: boolean;
}
export const useVisionMode = () => {
const scene = useSelector((state: RootState) => state.room.scene);
return {
isVisionMode: SceneMap?.[scene]?.llmConfig?.VisionConfig?.Enable,
isScreenMode: SceneMap?.[scene]?.llmConfig?.VisionConfig?.SnapshotConfig?.StreamType === 1,
};
};
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();
@ -164,25 +164,25 @@ export const useGetDevicePermission = () => {
export const useJoin = (): [
boolean,
(formValues: FormProps, fromRefresh: boolean) => Promise<void | boolean>
() => Promise<void | boolean>
] => {
const devicePermissions = useSelector((state: RootState) => state.device.devicePermissions);
const room = useSelector((state: RootState) => state.room);
const scene = room.scene;
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();
await RtcClient.stopAgent(id);
dispatch(clearCurrentMsg());
await RtcClient.startAgent(scene);
await RtcClient.startAgent(id);
} else {
await RtcClient.startAgent(scene);
await RtcClient.startAgent(id);
}
dispatch(updateAIGCState({ isAIGCEnable: true }));
};
@ -201,37 +201,16 @@ export const useJoin = (): [
return;
}
const { appId } = await Apis.Basic.getRtcInfo();
const { Token, RoomId, UserId } = Configuration;
let token = Token;
if (!token) {
// 通过 API 生成 Token, 这要求您在 /Server/sensitive.js 下填写 RTC_INFO.appId 和 RTC_INFO.appKey。
// 您也可以手动生成 Token, 并修改 /src/config/config.ts 中的 RoomId、UserId、Token 字段。
// 查阅 README 获取更多信息。
const res = await Apis.Basic.generateRtcAccessToken({
roomId: RoomId,
userId: UserId,
});
token = res.token;
}
setJoining(true);
/** 1. Create RTC Engine */
const engineParams = {
appId,
roomId: RoomId,
uid: UserId,
};
await RtcClient.createEngine(engineParams);
await RtcClient.createEngine();
/** 2.1 Set events callbacks */
RtcClient.addEventListeners(listeners);
/** 2.2 RTC starting to join room */
await RtcClient.joinRoom(token!, UserId);
console.log(' ------ userJoinRoom\n', `roomId: ${RoomId}\n`, `uid: ${UserId}`);
await RtcClient.joinRoom();
/** 3. Set users' devices info */
const mediaDevices = await RtcClient.getDevices({
audio: true,
@ -240,10 +219,10 @@ export const useJoin = (): [
dispatch(
localJoinRoom({
roomId: RoomId,
roomId: RtcClient.basicInfo.room_id,
user: {
username: UserId,
userId: UserId,
username: RtcClient.basicInfo.user_id,
userId: RtcClient.basicInfo.user_id,
},
})
);
@ -273,6 +252,7 @@ export const useJoin = (): [
export const useLeave = () => {
const dispatch = useDispatch();
const { id } = useScene();
return async function () {
await Promise.all([
@ -280,10 +260,11 @@ export const useLeave = () => {
RtcClient.stopScreenCapture,
RtcClient.stopVideoCapture,
]);
await RtcClient.stopAgent(id);
await RtcClient.leaveRoom();
dispatch(clearHistoryMsg());
dispatch(clearCurrentMsg());
dispatch(localLeaveRoom());
dispatch(updateAIGCState({ isAIGCEnable: false }));
};
};
};

View File

@ -5,30 +5,20 @@
import { useDispatch } from 'react-redux';
import { isMobile } from '@/utils/utils';
import { Configuration } from '@/config';
import InvokeButton from '@/pages/MainPage/MainArea/Antechamber/InvokeButton';
import { useJoin, useVisionMode } from '@/lib/useCommon';
import style from './index.module.less';
import { useJoin, useScene } from '@/lib/useCommon';
import AIChangeCard from '@/components/AiChangeCard';
import { updateFullScreen } from '@/store/slices/room';
import style from './index.module.less';
function Antechamber() {
const dispatch = useDispatch();
const [joining, dispatchJoin] = useJoin();
const username = Configuration.UserId;
const roomId = Configuration.RoomId;
const { isScreenMode } = useVisionMode();
const { isScreenMode } = useScene();
const handleJoinRoom = () => {
dispatch(updateFullScreen({ isFullScreen: !isMobile() && !isScreenMode })); // 初始化
if (!joining) {
dispatchJoin(
{
username,
roomId,
publishAudio: true,
},
false
);
dispatchJoin();
}
};

View File

@ -8,16 +8,16 @@ import AudioLoading from '@/components/Loading/AudioLoading';
import { RootState } from '@/store';
import RtcClient from '@/lib/RtcClient';
import { setInterruptMsg } from '@/store/slices/room';
import { useDeviceState } from '@/lib/useCommon';
import { useDeviceState, useScene } from '@/lib/useCommon';
import { COMMAND } from '@/utils/handler';
import style from './index.module.less';
import { Configuration } from '@/config';
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();
@ -26,7 +26,10 @@ function AudioController(props: React.HTMLAttributes<HTMLDivElement>) {
const isLoading = volume >= THRESHOLD_VOLUME && isAudioPublished;
const handleInterrupt = () => {
RtcClient.commandAgent(COMMAND.INTERRUPT);
RtcClient.commandAgent({
agentName: botName,
command: COMMAND.INTERRUPT,
});
dispatch(setInterruptMsg());
};
return (
@ -34,7 +37,7 @@ function AudioController(props: React.HTMLAttributes<HTMLDivElement>) {
{isAudioPublished ? (
isAIReady && isAITalking ? (
<div className={style.interruptContainer}>
{Configuration.InterruptMode ? <div> </div> : null}
{isInterruptMode ? <div> </div> : null}
<div onClick={handleInterrupt} className={style.interrupt}>
<div className={style.interruptIcon} />
<span></span>

View File

@ -7,7 +7,7 @@ import { useSelector } from 'react-redux';
import { VideoRenderMode } from '@volcengine/rtc';
import { useEffect } from 'react';
import { RootState } from '@/store';
import { useDeviceState, useVisionMode } from '@/lib/useCommon';
import { useDeviceState, useScene } from '@/lib/useCommon';
import RtcClient from '@/lib/RtcClient';
import styles from './index.module.less';
@ -25,7 +25,7 @@ function CameraArea(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
const room = useSelector((state: RootState) => state.room);
const { isFullScreen, scene } = room;
const { isVisionMode, isScreenMode } = useVisionMode();
const { isVision, isScreenMode } = useScene();
const { isVideoPublished, isScreenPublished, switchCamera, switchScreenCapture } =
useDeviceState();
@ -51,7 +51,7 @@ function CameraArea(props: React.HTMLAttributes<HTMLDivElement>) {
useEffect(() => {
setVideoPlayer();
}, [isVideoPublished, isScreenPublished, isScreenMode, isFullScreen, isVisionMode]);
}, [isVideoPublished, isScreenPublished, isScreenMode, isFullScreen, isVision]);
return (
<div className={`${styles['camera-wrapper']} ${className}`} {...rest}>
@ -78,7 +78,7 @@ function CameraArea(props: React.HTMLAttributes<HTMLDivElement>) {
}`}
>
<img
src={isScreenMode ? ScreenCloseNoteSVG : isVisionMode ? CameraCloseNoteSVG : UserAvatar}
src={isScreenMode ? ScreenCloseNoteSVG : isVision ? CameraCloseNoteSVG : UserAvatar}
alt="close"
className={styles['camera-placeholder-close-note']}
/>
@ -93,7 +93,7 @@ function CameraArea(props: React.HTMLAttributes<HTMLDivElement>) {
</span>
<div></div>
</>
) : isVisionMode ? (
) : isVision ? (
<>
<span onClick={handleOperateCamera} className={styles['camera-open-btn']}>

View File

@ -8,10 +8,10 @@ import { useSelector } from 'react-redux';
import { Tag, Spin } from '@arco-design/web-react';
import { RootState } from '@/store';
import Loading from '@/components/Loading/HorizonLoading';
import { Configuration, SceneMap } from '@/config';
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 { isMobile } from '@/utils/utils';
const lines: (string | React.ReactNode)[] = [];
@ -23,13 +23,14 @@ function Conversation(props: React.HTMLAttributes<HTMLDivElement>) {
const { isAITalking, isUserTalking, scene } = useSelector((state: RootState) => state.room);
const isAIReady = msgHistory.length > 0;
const containerRef = useRef<HTMLDivElement>(null);
const { botName, icon } = useScene();
const isUserTextLoading = (owner: string) => {
return owner === userId && isUserTalking;
};
const isAITextLoading = (owner: string) => {
return owner === Configuration.BotName && isAITalking;
return owner === botName && isAITalking;
};
useEffect(() => {
@ -58,7 +59,7 @@ function Conversation(props: React.HTMLAttributes<HTMLDivElement>) {
)}
{msgHistory?.map(({ value, user, isInterrupted }, index) => {
const isUserMsg = user === userId;
const isRobotMsg = user === Configuration.BotName;
const isRobotMsg = user === botName;
if (!isUserMsg && !isRobotMsg) {
return '';
}
@ -71,7 +72,7 @@ function Conversation(props: React.HTMLAttributes<HTMLDivElement>) {
{!isMobile() && (
<div className={styles.msgName}>
<div className={styles.avatar}>
<img src={isUserMsg ? USER_AVATAR : SceneMap[scene].icon} alt="Avatar" />
<img src={isUserMsg ? USER_AVATAR : icon} alt="Avatar" />
</div>
{isUserMsg ? '我' : scene}
</div>

View File

@ -5,7 +5,7 @@
import { memo, useState } from 'react';
import { Drawer } from '@arco-design/web-react';
import { useDeviceState, useLeave, useVisionMode } from '@/lib/useCommon';
import { useDeviceState, useLeave, useScene } from '@/lib/useCommon';
import { isMobile } from '@/utils/utils';
import Menu from '../../Menu';
@ -21,7 +21,7 @@ import ScreenOffSVG from '@/assets/img/ScreenOff.svg';
function ToolBar(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
const [open, setOpen] = useState(false);
const { isScreenMode } = useVisionMode();
const { isVision, isScreenMode } = useScene();
const leaveRoom = useLeave();
const {
isAudioPublished,
@ -31,7 +31,6 @@ function ToolBar(props: React.HTMLAttributes<HTMLDivElement>) {
switchCamera,
switchScreenCapture,
} = useDeviceState();
const { isVisionMode } = useVisionMode();
return (
<div className={`${className} ${style.btns} ${isMobile() ? style.column : ''}`} {...rest}>
@ -41,7 +40,7 @@ function ToolBar(props: React.HTMLAttributes<HTMLDivElement>) {
className={style.btn}
alt="mic"
/>
{!isVisionMode ? null : isScreenMode && !isMobile() ? (
{!isVision ? null : isScreenMode && !isMobile() ? (
<img
src={isScreenPublished ? 'new-screen-off.svg' : 'new-screen-on.svg'}
onClick={() => switchScreenCapture()}

View File

@ -1,44 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.row {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
cursor: pointer;
.firstPart {
display: flex;
flex-direction: row;
align-items: center;
width: 90%;
color: var(--text-color-text-2, var(--text-color-text-2, #42464E));
text-align: center;
font-family: "PingFang SC";
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 22px;
letter-spacing: 0.039px;
}
.finalPart {
display: flex;
flex-direction: row;
align-items: center;
width: 10%;
justify-content: flex-end;
.rightOutlined {
font-size: 12px;
}
}
.icon {
margin-right: 4px;
}
}

View File

@ -1,29 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useState } from 'react';
import { IconRight } from '@arco-design/web-react/icon';
import AISettings from '@/components/AISettings';
import styles from './index.module.less';
function AISettingAnchor() {
const [open, setOpen] = useState(false);
const handleOpenDrawer = () => setOpen(true);
const handleCloseDrawer = () => setOpen(false);
return (
<>
<div className={styles.row} onClick={handleOpenDrawer}>
<div className={styles.firstPart}>AI </div>
<div className={styles.finalPart}>
<IconRight className={styles.rightOutlined} />
</div>
</div>
<AISettings open={open} onOk={handleCloseDrawer} onCancel={handleCloseDrawer} />
</>
);
}
export default AISettingAnchor;

View File

@ -1,36 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
.button {
position: relative;
width: 100%;
height: 36px;
text-align: center;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
border: 1px solid transparent;
background: linear-gradient(90deg, #e0f2ff 0%, #f4ebff 100%) padding-box,
/* 内层背景渐变 (浅蓝到浅紫) */ linear-gradient(90deg, #a0d8ff 0%, #d8c8ff 100%) border-box; /* 外层边框渐变 (蓝到紫) */
.button-text {
background: linear-gradient(95.87deg, #1664ff 0%, #8040ff 97.7%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 500;
line-height: 20px;
text-align: center;
}
}
.button:hover {
background: linear-gradient(
77.86deg,
rgba(200, 220, 255, 0.7) -3.23%,
rgba(190, 210, 255, 0.7) 51.11%,
rgba(230, 210, 255, 0.7) 98.65%
);
}

View File

@ -1,26 +0,0 @@
/**
* Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved.
* SPDX-license-identifier: BSD-3-Clause
*/
import { useState } from 'react';
import { Button } from '@arco-design/web-react';
import AISettings from '@/components/AISettings';
import styles from './index.module.less';
function AISettingButton() {
const [open, setOpen] = useState(false);
const handleOpen = () => setOpen(true);
return (
<>
<Button className={styles.button} onClick={handleOpen}>
<div className={styles['button-text']}> AI </div>
</Button>
<AISettings open={open} onCancel={() => setOpen(false)} onOk={() => setOpen(false)} />
</>
);
}
export default AISettingButton;

View File

@ -10,7 +10,7 @@ import { Switch, Select } from '@arco-design/web-react';
import DrawerRowItem from '@/components/DrawerRowItem';
import { RootState } from '@/store';
import RtcClient from '@/lib/RtcClient';
import { useDeviceState, useVisionMode } from '@/lib/useCommon';
import { useDeviceState, useScene } from '@/lib/useCommon';
import { updateSelectedDevice } from '@/store/slices/device';
import { isMobile } from '@/utils/utils';
import styles from './index.module.less';
@ -34,7 +34,7 @@ function DeviceDrawerButton(props: IDeviceDrawerButtonProps) {
const selectedDevice =
type === MediaType.AUDIO ? devices.selectedMicrophone : devices.selectedCamera;
const permission = devicePermissions?.[type === MediaType.AUDIO ? 'audio' : 'video'];
const { isScreenMode } = useVisionMode();
const { isScreenMode } = useScene();
const isScreenEnable = device.isScreenPublished;
const changeScreenPublished = device.switchScreenCapture;

View File

@ -6,15 +6,15 @@
import { MediaType } from '@volcengine/rtc';
import DeviceDrawerButton from '../DeviceDrawerButton';
import Subtitle from '../Subtitle';
import { useVisionMode } from '@/lib/useCommon';
import { useScene } from '@/lib/useCommon';
import styles from './index.module.less';
function Operation() {
const { isVisionMode } = useVisionMode();
const { isVision } = useScene();
return (
<div className={`${styles.box} ${styles.device}`}>
<Subtitle />
{isVisionMode && <DeviceDrawerButton type={MediaType.VIDEO} />}
{isVision && <DeviceDrawerButton type={MediaType.VIDEO} />}
<DeviceDrawerButton />
</div>
);

View File

@ -6,39 +6,29 @@
import VERTC from '@volcengine/rtc';
import { Tooltip, Typography } from '@arco-design/web-react';
import { useSelector } from 'react-redux';
import { useVisionMode } from '@/lib/useCommon';
import { RootState } from '@/store';
import Operation from './components/Operation';
import CameraArea from '../MainArea/Room/CameraArea';
import { isMobile } from '@/utils/utils';
import { SceneMap } from '@/config';
import { useScene } from '@/lib/useCommon';
import packageJson from '../../../../package.json';
import styles from './index.module.less';
import AISettingButton from './components/AISettingButton';
function Menu() {
const room = useSelector((state: RootState) => state.room);
const { scene } = room;
const voice = SceneMap[scene]?.ttsConfig?.ProviderParams?.audio?.voice_type || '';
const model = SceneMap[scene]?.llmConfig?.EndPointId || SceneMap[scene]?.llmConfig?.BotId;
const isJoined = room?.isJoined;
const { isVisionMode } = useVisionMode();
const { isVision, name } = useScene();
const requestId = sessionStorage.getItem('RequestID');
return (
<div className={styles.wrapper}>
{isJoined && isMobile() && isVisionMode ? (
{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 {scene}</div>
<div>
<div className={styles.desc}> {voice}</div>
<div className={styles.desc}>{model}</div>
</div>
{isJoined && <AISettingButton />}
<div className={styles.title}>AI {name}</div>
</div>
{isJoined ? <Operation /> : ''}
<div className={`${styles.box} ${styles.info}`}>

View File

@ -4,18 +4,46 @@
*/
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 (

View File

@ -6,34 +6,25 @@
import { useDispatch, useSelector } from 'react-redux';
import { memo, useEffect, useState } from 'react';
import { VideoRenderMode } from '@volcengine/rtc';
import { IconRight } from '@arco-design/web-react/icon';
import { useDeviceState, useVisionMode } from '@/lib/useCommon';
import { useDeviceState, useScene } from '@/lib/useCommon';
import { RootState } from '@/store';
import RtcClient from '@/lib/RtcClient';
import { updateShowSubtitle } from '@/store/slices/room';
import styles from './index.module.less';
import SettingsDrawer from '../SettingsDrawer';
import AISettings from '@/components/AISettings';
import styles from './index.module.less';
function MobileToolBar(props: React.HTMLAttributes<HTMLDivElement>) {
const dispatch = useDispatch();
const { isScreenMode } = useVisionMode();
const room = useSelector((state: RootState) => state.room);
const { isShowSubtitle } = room;
const [open, setOpen] = useState(false);
const [openAISettings, setOpenAISettings] = useState(false);
const [subTitleStatus, setSubTitleStatus] = useState(isShowSubtitle);
const { isScreenMode } = useScene();
const { isVideoPublished, isScreenPublished } = useDeviceState();
const handleSetting = () => {
setOpen(true);
};
const handleOpenDrawer = () => setOpenAISettings(true);
const switchSubtitle = () => {
setSubTitleStatus(!subTitleStatus);
dispatch(updateShowSubtitle({ isShowSubtitle: !subTitleStatus }));
@ -56,19 +47,6 @@ function MobileToolBar(props: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={styles.wrapper}>
<div>
<div className={styles.setting} onClick={handleSetting}>
...
</div>
<div className={styles.aiSetting} onClick={handleOpenDrawer}>
{room.scene} <IconRight />
</div>
<AISettings
open={openAISettings}
onCancel={() => setOpenAISettings(false)}
onOk={() => setOpenAISettings(false)}
/>
</div>
<div>
<div
className={`${styles.subtitle} ${subTitleStatus ? styles.showSubTitle : ''}`}

View File

@ -10,7 +10,7 @@ import {
NetworkQuality,
RemoteAudioStats,
} from '@volcengine/rtc';
import { Configuration, Scenes } from '@/config';
import RtcClient from '@/lib/RtcClient';
export interface IUser {
username?: string;
@ -36,6 +36,24 @@ export interface Msg {
isInterrupted?: boolean;
}
export interface SceneConfig {
id: string;
icon?: string;
name?: string;
questions?: string[];
botName: string;
isVision: boolean;
isScreenMode: boolean;
isInterruptMode: boolean;
}
export interface RTCConfig {
AppId: string;
RoomId: string;
UserId: string;
Token: string;
}
export interface RoomState {
time: number;
roomId?: string;
@ -47,9 +65,17 @@ export interface RoomState {
*/
isJoined: boolean;
/**
* @brief
* @brief
*/
scene: string;
/**
* @brief
*/
sceneConfigMap: Record<string, SceneConfig>;
/**
* @brief RTC
*/
rtcConfigMap: Record<string, RTCConfig>;
/**
* @brief AI
@ -111,7 +137,9 @@ export interface RoomState {
const initialState: RoomState = {
time: -1,
scene: Scenes[0].name,
scene: '',
sceneConfigMap: {},
rtcConfigMap: {},
remoteUsers: [],
localUser: {
publishAudio: false,
@ -175,7 +203,21 @@ export const roomSlice = createSlice({
},
updateScene: (state, { payload }) => {
state.scene = payload.scene;
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> }) => {
@ -241,7 +283,7 @@ export const roomSlice = createSlice({
const { paragraph, definite } = payload;
const lastMsg = state.msgHistory.at(-1)! || {};
/** 是否需要再创建新句子 */
const fromBot = payload.user === Configuration.BotName;
const fromBot = payload.user === state.sceneConfigMap[state.scene].botName;
/**
* Bot definite
* User paragraph
@ -330,6 +372,8 @@ export const {
setInterruptMsg,
updateNetworkQuality,
updateScene,
updateSceneConfig,
updateRTCConfig,
updateShowSubtitle,
updateFullScreen,
updatecustomSceneName,