diff --git a/.gitignore b/.gitignore index 354a831..7061626 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,8 @@ yarn-error.log* /Server/node_modules /Server/log /log -yarn.lock \ No newline at end of file +yarn.lock +.eslintcache +pnpm-lock.yaml +dist/ +log/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index d1d43dc..1b78833 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,7 +3,7 @@ "semi": true, "singleQuote": true, "jsxSingleQuote": false, - "printWidth": 200, + "printWidth": 100, "useTabs": false, "tabWidth": 2, "trailingComma": "es5" diff --git a/.yarnrc b/.yarnrc deleted file mode 100644 index cc61178..0000000 --- a/.yarnrc +++ /dev/null @@ -1 +0,0 @@ -registry="https://registry.yarnpkg.com" diff --git a/README.md b/README.md index 117e7f4..c2dfe01 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,48 @@ # 交互式AIGC场景 AIGC Demo +此 Demo 为简化版本, 如您有 1.5.x 版本 UI 的诉求, 可切换至 1.5.1 分支。 +跑通阶段时, 无须关心代码实现,仅需按需完成 `src/config/scenes/*.json` 的填充以及 `Server/sensitive.js` 中的信息填充即可。 + ## 简介 - 在 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. **根据你自定义的 -RoomId、UserId 以及申请的 AppID、BusinessID(如有)、Token、ASR AppID、TTS AppID,修改 `src/config/config.ts` 文件中 `ConfigFactory` 中 `BaseConfig` 的配置信息**。 -4. 使用火山引擎控制台账号的 [AK、SK](https://console.volcengine.com/iam/keymanage?s=g), 修改 `Server/app.js` 文件中的 `ACCOUNT_INFO`。 -5. 若您使用的是官方模型, 需要在 [火山方舟-在线推理](https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint?config=%7B%7D&s=g) 中创建接入点, 并将模型对应的接入点 ID 填入 `src/config/common.ts` 文件中的 `ARK_V3_MODEL_ID`, 否则无法正常启动智能体。 -6. 如果您已经自行完成了服务端的逻辑,可以不依赖 Demo 中的 Server,直接修改前端代码文件 `src/config/index.ts` 中的 `AIGC_PROXY_HOST` 请求域名和接口,并在 `src/app/api.ts` 中修改接口的参数配置 `APIS_CONFIG`。 +**Node 版本: 16.0+** + +### 1. 运行环境 +需要准备两个 Terminal,分别启动服务端和前端页面。 + +### 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.2 场景配置(`src/config/scenes/*.json`) +您可以自定义具体场景, 并按需根据模版填充 OpenAPI 需要的参数。 + +Demo 中以 `Custom`、`VirtualGirlfriend`(视觉) 场景为例,您可以自行新增场景,并在代码中导入即可使用(`src/config/index.ts`)。 + +注意: +- `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` ## 快速开始 请注意,服务端和 Web 端都需要启动, 启动步骤如下: @@ -26,7 +55,7 @@ yarn ``` #### 运行项目 ```shell -node app.js +yarn dev ``` ### 前端页面 @@ -43,14 +72,15 @@ yarn dev ### 常见问题 | 问题 | 解决方案 | | :-- | :-- | -| 如何使用第三方模型、Coze Bot | 点击页面上的 "修改 AI 设定" 进入配置页,可切换 官方模型/Coze/第三方模型,填写对应参数即可,相关代码对应 `src/components/AISettings/index.tsx` 文件。 | +| 如何使用第三方模型、Coze Bot | 模型相关配置代码对应目录 `src/config/scenes/` 下json 文件,填写对应官方模型/ Coze/ 第三方模型的参数后,可点击页面上的 "修改 AI 人设" 进行切换。 | | **启动智能体之后, 对话无反馈,或者一直停留在 "AI 准备中, 请稍侯"** |
  • 可能因为控制台中相关权限没有正常授予,请参考[流程](https://www.volcengine.com/docs/6348/1315561?s=g)再次确认下是否完成相关操作。此问题的可能性较大,建议仔细对照是否已经将相应的权限开通。
  • 参数传递可能有问题, 例如参数大小写、类型等问题,请再次确认下这类型问题是否存在。
  • 相关资源可能未开通或者用量不足/欠费,请再次确认。
  • **请检查当前使用的模型 ID 等内容都是正确且可用的。**
  • | | **浏览器报了 `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 为固定值,重复调用 startAudioBot 会导致出错,只需先调用 stopAudioBot 后再重新 startAudioBot 即可。 | +| **[StartVoiceChat]Failed(Reason: The task has been started. Please do not call the startup task interface repeatedly.)** 报错 | 如果设置的 RoomId、UserId 为固定值,重复调用 startAgent 会导致出错,只需先调用 stopAgent 后再重新 startAgent 即可。 | | 为什么我的麦克风正常、摄像头也正常,但是设备没有正常工作? | 可能是设备权限未授予,详情可参考 [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` | 如果有上述以外的问题,欢迎联系我们反馈。 @@ -67,27 +97,9 @@ yarn dev ### Demo 更新 #### [1.6.0] -- 2025-05-28 - - 更新 RTC Web SDK 版本至 4.66.14 -- 2025-05-22 - - 更新 RTC Web SDK 版本至 4.66.13 - - 删除无用依赖 +- 2025-06-18 + - 更新 RTC Web SDK 版本至 4.66.16 + - 更新 UI 和参数配置方式 - 更新 Readme 文档 -- 2025-04-16 - - 支持 Coze Bot - - 更新部分注释和文档内容 - - 删除子账号的 SessionToken 配置, 子账号调用无须 SessionToken - - 修复通话前修改内容,在通话后配置消失的问题 - -#### [1.5.1] -- 2025-04-11 - - 移除无用代码和依赖 - - 修复字幕逻辑 - -#### [1.5.0] -- 2025-03-31 - - 修复部分 UI 问题 - - 追加屏幕共享能力 (视觉模型可用,**读屏助手** 人设下可使用) - - 修改字幕逻辑,避免字幕回调中标点符号、大小写不一致引起的字幕重复问题 - - 更新 RTC Web SDK 版本至 4.66.1 - - 追加设备权限未授予时的提示 \ No newline at end of file + - 追加 Node 服务的参数检测能力 + - 追加 Node 服务的 Token 生成能力 \ No newline at end of file diff --git a/Server/app.js b/Server/app.js index ec9df62..af71bd3 100644 --- a/Server/app.js +++ b/Server/app.js @@ -8,6 +8,10 @@ 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 TokenManager = require('./token'); +const Privileges = require('./token').privileges; const app = new Koa(); @@ -15,61 +19,85 @@ app.use(cors({ origin: '*' })); -/** - * @notes 在 https://console.volcengine.com/iam/keymanage/ 获取 AK/SK - */ -const ACCOUNT_INFO = { - /** - * @notes 必填, 在 https://console.volcengine.com/iam/keymanage/ 获取 - */ - accessKeyId: 'Your AK', - /** - * @notes 必填, 在 https://console.volcengine.com/iam/keymanage/ 获取 - */ - secretKey: 'Your SK', -} - app.use(bodyParser()); - app.use(async ctx => { /** * @brief 代理 AIGC 的 OpenAPI 请求 */ - if (ctx.url.startsWith('/proxyAIGCFetch') && ctx.method.toLowerCase() === 'post') { - const { Action, Version } = ctx.query || {}; - const body = ctx.request.body; + await wrapper({ + ctx, + apiName: 'proxy', + 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 不能为空'); - /** - * 参考 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(ACCOUNT_INFO); - - /** 参考 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), - }); - const volcResponse = await result.json(); - ctx.body = volcResponse; - } else { - ctx.body = '

    404 Not Found

    '; - } + sensitiveInjector(Action, body); + + /** 参考 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(ACCOUNT_INFO); + + /** 参考 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: 'rtc-info', + logic: () => { + return { + appId: RTC_INFO.appId, + } + } + }); + + /** + * @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)); + return { + token: key.serialize(), + }; + } + }); }); app.listen(3001, () => { diff --git a/Server/nodemon.json b/Server/nodemon.json new file mode 100644 index 0000000..d247a59 --- /dev/null +++ b/Server/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["."], + "ext": "js,json", + "ignore": ["node_modules/*"] +} \ No newline at end of file diff --git a/Server/package.json b/Server/package.json index e95bbe0..a3e095b 100644 --- a/Server/package.json +++ b/Server/package.json @@ -10,9 +10,13 @@ "koa": "^2.15.3", "koa-bodyparser": "^4.4.1", "koa2-cors": "^2.0.6", + "lodash": "^4.17.21", "node-fetch": "^2.3.2" }, + "devDependencies": { + "nodemon": "^3.1.10" + }, "scripts": { - "dev": "node app.js" + "dev": "nodemon app.js" } } diff --git a/Server/sensitive.js b/Server/sensitive.js new file mode 100644 index 0000000..2b220a8 --- /dev/null +++ b/Server/sensitive.js @@ -0,0 +1,138 @@ +/** + * 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 appId、appKey 可从 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, +} \ No newline at end of file diff --git a/Server/token.js b/Server/token.js new file mode 100644 index 0000000..cd28107 --- /dev/null +++ b/Server/token.js @@ -0,0 +1,244 @@ +/** + * 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; +}; diff --git a/Server/util.js b/Server/util.js new file mode 100644 index 0000000..87332b3 --- /dev/null +++ b/Server/util.js @@ -0,0 +1,82 @@ +/** + * 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 judgeMethodPath = (method) => { + return (ctx, pathname) => ctx.method.toLowerCase() === method && ctx.url.startsWith(`/${pathname}`); +} + +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}.`); + }) + } +} + +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, +} \ No newline at end of file diff --git a/Server/yarn.lock b/Server/yarn.lock index dc3ec67..4acf652 100644 --- a/Server/yarn.lock +++ b/Server/yarn.lock @@ -91,6 +91,14 @@ accepts@^1.3.5: mime-types "~2.1.34" negotiator "0.6.3" +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -103,6 +111,31 @@ axios@^0.21.1: dependencies: follow-redirects "^1.14.0" +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +brace-expansion@^1.1.7: + version "1.1.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + bytes@3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" @@ -127,6 +160,21 @@ call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" +chokidar@^3.5.2: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + co-body@^6.0.0: version "6.2.0" resolved "https://registry.yarnpkg.com/co-body/-/co-body-6.2.0.tgz#afd776d60e5659f4eee862df83499698eb1aea1b" @@ -150,6 +198,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + content-disposition@~0.5.2: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" @@ -190,6 +243,13 @@ dayjs@^1.11.5: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== +debug@^4: + version "4.4.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== + dependencies: + ms "^2.1.3" + debug@^4.3.1, debug@^4.3.2: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" @@ -263,6 +323,13 @@ escape-html@^1.0.3: resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + follow-redirects@^1.14.0: version "1.15.9" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" @@ -282,6 +349,11 @@ fresh@~0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" @@ -298,6 +370,13 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: has-symbols "^1.0.3" hasown "^2.0.0" +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + gopd@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" @@ -305,6 +384,11 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + has-property-descriptors@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" @@ -373,6 +457,11 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +ignore-by-default@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" + integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== + inflation@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.1.0.tgz#9214db11a47e6f756d111c4f9df96971c60f886c" @@ -383,6 +472,18 @@ inherits@2.0.4: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + is-generator-function@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" @@ -390,6 +491,18 @@ is-generator-function@^1.0.7: dependencies: has-tostringtag "^1.0.0" +is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + keygrip@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" @@ -458,6 +571,11 @@ lodash.get@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + long@^5.0.0: version "5.2.3" resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" @@ -480,6 +598,13 @@ mime-types@^2.1.12, mime-types@^2.1.18, mime-types@~2.1.24, mime-types@~2.1.34: dependencies: mime-db "1.52.0" +minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" @@ -497,6 +622,27 @@ node-fetch@^2.3.2: dependencies: whatwg-url "^5.0.0" +nodemon@^3.1.10: + version "3.1.10" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.10.tgz#5015c5eb4fffcb24d98cf9454df14f4fecec9bc1" + integrity sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw== + dependencies: + chokidar "^3.5.2" + debug "^4" + ignore-by-default "^1.0.1" + minimatch "^3.1.2" + pstree.remy "^1.1.8" + semver "^7.5.3" + simple-update-notifier "^2.0.0" + supports-color "^5.5.0" + touch "^3.1.0" + undefsafe "^2.0.5" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + object-inspect@^1.13.1: version "1.13.2" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" @@ -526,6 +672,11 @@ parseurl@^1.3.2: resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== +picomatch@^2.0.4, picomatch@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + protobufjs@7.2.4: version "7.2.4" resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.2.4.tgz#3fc1ec0cdc89dd91aef9ba6037ba07408485c3ae" @@ -544,6 +695,11 @@ protobufjs@7.2.4: "@types/node" ">=13.7.0" long "^5.0.0" +pstree.remy@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" + integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== + qs@^6.5.2: version "6.13.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" @@ -561,6 +717,13 @@ raw-body@^2.3.3: iconv-lite "0.4.24" unpipe "1.0.0" +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + safe-buffer@5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" @@ -571,6 +734,11 @@ safe-buffer@5.2.1: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +semver@^7.5.3: + version "7.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + set-function-length@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -598,6 +766,13 @@ side-channel@^1.0.6: get-intrinsic "^1.2.4" object-inspect "^1.13.1" +simple-update-notifier@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" + integrity sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w== + dependencies: + semver "^7.5.3" + statuses@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" @@ -608,11 +783,30 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== +supports-color@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + toidentifier@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +touch@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" + integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== + tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -631,6 +825,11 @@ type-is@^1.6.16, type-is@^1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +undefsafe@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" + integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== + undici-types@~6.19.2: version "6.19.8" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" diff --git a/package.json b/package.json index edba682..a05d9ce 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "dependencies": { "@reduxjs/toolkit": "^1.8.3", - "@volcengine/rtc": "~4.66.14", + "@volcengine/rtc": "~4.66.16", "@arco-design/web-react": "^2.65.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/public/index.html b/public/index.html index 18a6b99..e925ebd 100644 --- a/public/index.html +++ b/public/index.html @@ -19,7 +19,7 @@ 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`. --> - RTC AIGC Demo + 火山引擎 RTC 实时对话式 AI 体验 Demo ——— 支持 DeepSeek 和 豆包视觉理解模型 diff --git a/src/App.tsx b/src/App.tsx index 65d5701..9653984 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ * 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'; diff --git a/src/app/api.ts b/src/app/api.ts index e736ea8..a804e97 100644 --- a/src/app/api.ts +++ b/src/app/api.ts @@ -3,47 +3,34 @@ * SPDX-license-identifier: BSD-3-Clause */ -import { requestGetMethod, requestPostMethod, resultHandler } from './base'; -import { ACTIONS, RequestParams, RequestResponse } from './type'; - -const APIS_CONFIG = [ +/** + * @brief Basic APIs + */ +export const BasicAPIs = [ { - action: ACTIONS.StartVoiceChat, - apiBasicParams: `?Name=start&Action=${ACTIONS.StartVoiceChat}&Version=2024-12-01`, + action: 'getRtcInfo', + apiPath: '/rtc-info', method: 'post', }, { - action: ACTIONS.UpdateVoiceChat, - apiBasicParams: `?Name=update&Action=${ACTIONS.UpdateVoiceChat}&Version=2024-12-01`, - method: 'post', - }, - { - action: ACTIONS.StopVoiceChat, - apiBasicParams: `?Name=stop&Action=${ACTIONS.StopVoiceChat}&Version=2024-12-01`, + action: 'generateRtcAccessToken', + apiPath: '/rtc-token', method: 'post', }, ] as const; -type ApiConfig = typeof APIS_CONFIG; -type TupleToUnion = T[number]; -type ApiNames = Pick, 'action'>['action']; -type RequestFn = (params?: RequestParams[T]) => RequestResponse[T]; -type PromiseRequestFn = ( - params?: RequestParams[T] -) => Promise; -type Apis = Record; - -const APIS = APIS_CONFIG.reduce((store, cur) => { - const { action, apiBasicParams, method = 'get' } = cur; - store[action] = async (params) => { - const queryData = - method === 'get' - ? await requestGetMethod(apiBasicParams)(params) - : await requestPostMethod(apiBasicParams)(params); - const res = await queryData?.json(); - return resultHandler(res); - }; - return store; -}, {} as Apis); - -export default APIS; +/** + * @brief Basic APIs + */ +export const AigcAPIs = [ + { + action: 'StartVoiceChat', + apiPath: '/proxy', + method: 'post', + }, + { + action: 'StopVoiceChat', + apiPath: '/proxy', + method: 'post', + }, +] as const; diff --git a/src/app/base.ts b/src/app/base.ts index 6f469c4..9a98d34 100644 --- a/src/app/base.ts +++ b/src/app/base.ts @@ -3,19 +3,34 @@ * SPDX-license-identifier: BSD-3-Clause */ -import { Modal } from '@arco-design/web-react'; +import { Message } from '@arco-design/web-react'; import { AIGC_PROXY_HOST } from '@/config'; +import type { RequestResponse, ApiConfig, ApiNames, Apis } from './type'; type Headers = Record; +export type DeepPartial = { + [P in keyof T]?: T[P] extends Array + ? Array> + : T[P] extends object + ? DeepPartial + : T[P]; +}; + /** * @brief Get * @param apiName * @param headers */ -export const requestGetMethod = (apiBasicParams: string, headers = {}) => { +export const requestGetMethod = ({ + action, + headers = {}, +}: { + action: string; + headers?: Record; +}) => { return async (params: Record = {}) => { - const url = `${AIGC_PROXY_HOST}${apiBasicParams}&${Object.keys(params) + const url = `${AIGC_PROXY_HOST}?Action=${action}&${Object.keys(params) .map((key) => `${key}=${params[key]}`) .join('&')}`; const res = await fetch(url, { @@ -29,17 +44,20 @@ export const requestGetMethod = (apiBasicParams: string, headers = {}) => { /** * @brief Post - * @param apiName - * @param isJson - * @param headers */ -export const requestPostMethod = ( - apiBasicParams: string, - isJson: boolean = true, - headers: Headers = {} -) => { +export const requestPostMethod = ({ + action, + apiPath, + isJson = true, + headers = {}, +}: { + action: string; + apiPath: string; + isJson?: boolean; + headers?: Headers; +}) => { return async (params: T) => { - const res = await fetch(`${AIGC_PROXY_HOST}${apiBasicParams}`, { + const res = await fetch(`${AIGC_PROXY_HOST}${apiPath}?Action=${action}`, { method: 'post', headers: { 'content-type': 'application/json', @@ -52,17 +70,43 @@ export const requestPostMethod = ( }; /** - * @brief Handler + * @brief Return handler * @param res */ -export const resultHandler = (res: any) => { +export const resultHandler = (res: RequestResponse) => { const { Result, ResponseMetadata } = res || {}; - if (Result === 'ok') { - return Result; + // Record request id for debug. + if (ResponseMetadata.Action === 'StartVoiceChat') { + const requestId = ResponseMetadata.RequestId; + requestId && sessionStorage.setItem('RequestID', requestId); } - const error = ResponseMetadata?.Error?.Message || Result; - Modal.error({ - title: '接口调用错误', - content: `[${ResponseMetadata?.Action}]Failed(Reason: ${error}), 请参考 README 文档排查问题。`, - }); + if (ResponseMetadata.Error) { + Message.error( + `[${ResponseMetadata?.Action}]call failed(reason: ${ResponseMetadata.Error?.Message})` + ); + throw new Error( + `[${ResponseMetadata?.Action}]call failed(${JSON.stringify(ResponseMetadata, null, 2)})` + ); + } + return Result; }; + +/** + * @brief Generate APIs by apiConfigs + * @param apiConfigs + */ +export const generateAPIs = (apiConfigs: T) => + apiConfigs.reduce>((store, cur) => { + const { action, apiPath = '', method = 'get' } = cur; + + const actionKey = action as ApiNames; + store[actionKey] = async (params) => { + const queryData = + method === 'get' + ? await requestGetMethod({ action })(params) + : await requestPostMethod({ action, apiPath })(params); + const res = await queryData?.json(); + return resultHandler(res); + }; + return store; + }, {} as Apis); diff --git a/src/app/index.ts b/src/app/index.ts new file mode 100644 index 0000000..d8317c4 --- /dev/null +++ b/src/app/index.ts @@ -0,0 +1,15 @@ +/** + * 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, +}; diff --git a/src/app/type.ts b/src/app/type.ts index af474a6..43fb328 100644 --- a/src/app/type.ts +++ b/src/app/type.ts @@ -3,106 +3,32 @@ * SPDX-license-identifier: BSD-3-Clause */ -export enum ACTIONS { - StartVoiceChat = 'StartVoiceChat', - UpdateVoiceChat = 'UpdateVoiceChat', - StopVoiceChat = 'StopVoiceChat', -} +export type RequestParams = Record; -/** - * @brief 请求参数类型 - * @note OpenAPI 接口参数结构可能更新, 请参阅最新文档内容。 - * https://www.volcengine.com/docs/6348/1404673?s=g - */ -export interface RequestParams { - /** - * @brief 通过接口开启数字人,使用前需要开 ASR、LLM、TTS 等服务。 - */ - [ACTIONS.StartVoiceChat]: { - AppId: string; - BusinessId?: string; - RoomId: string; - TaskId: string; - Config: Partial<{ - BotName: string; - ASRConfig: { - AppId: string; - Cluster?: string; - }; - TTSConfig: Partial<{ - AppId: string; - VoiceType: string; - Cluster?: string; - IgnoreBracketText?: number[]; - }>; - LLMConfig: Partial<{ - AppId: string; - ModelName?: string; - ModelVersion: string; - Mode?: string; - Host?: string; - Region?: string; - MaxTokens?: number; - MinTokens?: number; - Temperature?: number; - TopP?: number; - TopK?: number; - MaxPromptTokens?: number; - SystemMessages?: string[]; - UserMessages?: string[]; - HistoryLength?: number; - WelcomeSpeech?: string; - EndPointId?: string; - BotId?: string; - }>; - }>; - /** - * @brief 智能体基本配置。 - */ - AgentConfig: { - TargetUserId: string[]; - WelcomeMessage: string; - UserId: string; - EnableConversationStateCallback?: boolean; - ServerMessageSignatureForRTS?: string; - ServerMessageURLForRTS?: string; - }; - }; - /** - * @brief 控制数字人行为,目前支持行为见 Command 参数。 - */ - [ACTIONS.UpdateVoiceChat]: { - AppId: string; - BusinessId?: string; - RoomId: string; - TaskId: string; - Command: string; - Message?: string; - }; - /** - * @brief 关闭数字人任务。 - */ - [ACTIONS.StopVoiceChat]: { - AppId: string; - BusinessId?: string; - RoomId: string; - TaskId: string; - }; -} - -/** - * @brief 返回参数类型 - */ export interface RequestResponse { - [ACTIONS.StartVoiceChat]: string; - [ACTIONS.UpdateVoiceChat]: string; - [ACTIONS.StopVoiceChat]: string; + ResponseMetadata: Partial<{ + Action: string; + Version: string; + Service: string; + Region: string; + RequestId: string; + Error: { + Code: string; + Message: string; + }; + }>; + Result: any; } -export type DeepPartial = { - [P in keyof T]?: T[P] extends Array - ? Array> - : T[P] extends object - ? DeepPartial - : T[P]; -}; +type TupleToUnion = T[number]; +type RequestFn = (params?: RequestParams[T]) => RequestResponse[T]; +type PromiseRequestFn = ( + params?: RequestParams[T] +) => Promise; + +export type ApiConfig = { action: string; method: string; apiPath?: string }; +export type ApiNames = TupleToUnion['action']; +export type Apis = Record< + ApiNames, + RequestFn | PromiseRequestFn +>; diff --git a/src/assets/img/CHILDREN_ENCYCLOPEDIA.png b/src/assets/img/CHILDREN_ENCYCLOPEDIA.png deleted file mode 100644 index 6a81ca6..0000000 Binary files a/src/assets/img/CHILDREN_ENCYCLOPEDIA.png and /dev/null differ diff --git a/src/assets/img/CUSTOMER_SERVICE.png b/src/assets/img/CUSTOMER_SERVICE.png deleted file mode 100644 index b35aa36..0000000 Binary files a/src/assets/img/CUSTOMER_SERVICE.png and /dev/null differ diff --git a/src/assets/img/CameraClose.svg b/src/assets/img/CameraClose.svg index 5cc7121..13274b3 100644 --- a/src/assets/img/CameraClose.svg +++ b/src/assets/img/CameraClose.svg @@ -1,5 +1,5 @@ - - + + diff --git a/src/assets/img/CameraOpen.svg b/src/assets/img/CameraOpen.svg index 744c61f..bc41d37 100644 --- a/src/assets/img/CameraOpen.svg +++ b/src/assets/img/CameraOpen.svg @@ -1,5 +1,5 @@ - - + + diff --git a/src/assets/img/Dot.svg b/src/assets/img/Dot.svg deleted file mode 100644 index 95fe1a9..0000000 --- a/src/assets/img/Dot.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/assets/img/DoubaoAvatar.png b/src/assets/img/DoubaoAvatar.png deleted file mode 100644 index aaf5886..0000000 Binary files a/src/assets/img/DoubaoAvatar.png and /dev/null differ diff --git a/src/assets/img/DoubaoAvatarGIF.webp b/src/assets/img/DoubaoAvatarGIF.webp deleted file mode 100644 index 4641e8f..0000000 Binary files a/src/assets/img/DoubaoAvatarGIF.webp and /dev/null differ diff --git a/src/assets/img/DoubaoModel.svg b/src/assets/img/DoubaoModel.svg deleted file mode 100644 index c8db337..0000000 --- a/src/assets/img/DoubaoModel.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/assets/img/DoubaoProfile.svg b/src/assets/img/DoubaoProfile.svg deleted file mode 100644 index 8fb5ce7..0000000 --- a/src/assets/img/DoubaoProfile.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/img/INTELLIGENT_ASSISTANT.png b/src/assets/img/INTELLIGENT_ASSISTANT.png deleted file mode 100644 index c0969f7..0000000 Binary files a/src/assets/img/INTELLIGENT_ASSISTANT.png and /dev/null differ diff --git a/src/assets/img/LeaveRoom.svg b/src/assets/img/LeaveRoom.svg index 531bce3..473d174 100644 --- a/src/assets/img/LeaveRoom.svg +++ b/src/assets/img/LeaveRoom.svg @@ -1,4 +1,4 @@ - - + + diff --git a/src/assets/img/MicClose.svg b/src/assets/img/MicClose.svg index f11c2b9..afae328 100644 --- a/src/assets/img/MicClose.svg +++ b/src/assets/img/MicClose.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/src/assets/img/MicOpen.svg b/src/assets/img/MicOpen.svg index 3389b19..9c9c730 100644 --- a/src/assets/img/MicOpen.svg +++ b/src/assets/img/MicOpen.svg @@ -1,8 +1,8 @@ - - - - - - + + + + + + diff --git a/src/assets/img/MicrophoneOff.svg b/src/assets/img/MicrophoneOff.svg deleted file mode 100644 index af835ca..0000000 --- a/src/assets/img/MicrophoneOff.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/img/MicrophoneOn.svg b/src/assets/img/MicrophoneOn.svg deleted file mode 100644 index 9855b00..0000000 --- a/src/assets/img/MicrophoneOn.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/img/ModelChange.svg b/src/assets/img/ModelChange.svg deleted file mode 100644 index 37b4c06..0000000 --- a/src/assets/img/ModelChange.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/assets/img/MsgBubble.svg b/src/assets/img/MsgBubble.svg deleted file mode 100644 index e69de29..0000000 diff --git a/src/assets/img/Phone.svg b/src/assets/img/Phone.svg index d1f6406..0c2c282 100644 --- a/src/assets/img/Phone.svg +++ b/src/assets/img/Phone.svg @@ -1,3 +1,5 @@ - - + + + + diff --git a/src/assets/img/SCREEN_READER.png b/src/assets/img/SCREEN_READER.png deleted file mode 100644 index 2f17916..0000000 Binary files a/src/assets/img/SCREEN_READER.png and /dev/null differ diff --git a/src/assets/img/Setting.svg b/src/assets/img/Setting.svg deleted file mode 100644 index 00cd160..0000000 --- a/src/assets/img/Setting.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/img/Stop.svg b/src/assets/img/Stop.svg deleted file mode 100644 index 5f5c198..0000000 --- a/src/assets/img/Stop.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/assets/img/StopRobotBtn.svg b/src/assets/img/StopRobotBtn.svg deleted file mode 100644 index eeb1500..0000000 --- a/src/assets/img/StopRobotBtn.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/assets/img/StopWave.jpeg b/src/assets/img/StopWave.jpeg deleted file mode 100644 index 0a0fc03..0000000 Binary files a/src/assets/img/StopWave.jpeg and /dev/null differ diff --git a/src/assets/img/TEACHER.png b/src/assets/img/TEACHER.png deleted file mode 100644 index f7fd9ea..0000000 Binary files a/src/assets/img/TEACHER.png and /dev/null differ diff --git a/src/assets/img/TEACHING_ASSISTANT.png b/src/assets/img/TEACHING_ASSISTANT.png deleted file mode 100644 index 5c451a8..0000000 Binary files a/src/assets/img/TEACHING_ASSISTANT.png and /dev/null differ diff --git a/src/assets/img/TRANSLATE.png b/src/assets/img/TRANSLATE.png deleted file mode 100644 index f4965c0..0000000 Binary files a/src/assets/img/TRANSLATE.png and /dev/null differ diff --git a/src/assets/img/VIRTUAL_GIRL_FRIEND.png b/src/assets/img/VIRTUAL_GIRL_FRIEND.png deleted file mode 100644 index c913491..0000000 Binary files a/src/assets/img/VIRTUAL_GIRL_FRIEND.png and /dev/null differ diff --git a/src/assets/img/VoiceTypeChange.svg b/src/assets/img/VoiceTypeChange.svg deleted file mode 100644 index b658bdf..0000000 --- a/src/assets/img/VoiceTypeChange.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/assets/img/doubao.svg b/src/assets/img/doubao.svg deleted file mode 100644 index 9725756..0000000 --- a/src/assets/img/doubao.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/img/huoponvsheng.jpeg b/src/assets/img/huoponvsheng.jpeg deleted file mode 100644 index 79d54b7..0000000 Binary files a/src/assets/img/huoponvsheng.jpeg and /dev/null differ diff --git a/src/assets/img/jingqiangkanye.jpeg b/src/assets/img/jingqiangkanye.jpeg deleted file mode 100644 index ac8b231..0000000 Binary files a/src/assets/img/jingqiangkanye.jpeg and /dev/null differ diff --git a/src/assets/img/magicTool.svg b/src/assets/img/magicTool.svg deleted file mode 100644 index 393dc37..0000000 --- a/src/assets/img/magicTool.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/img/menu.svg b/src/assets/img/menu.svg deleted file mode 100644 index 6e860b9..0000000 --- a/src/assets/img/menu.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/assets/img/mobileBg.png b/src/assets/img/mobileBg.png new file mode 100644 index 0000000..3410073 Binary files /dev/null and b/src/assets/img/mobileBg.png differ diff --git a/src/assets/img/setLocalPlayer.svg b/src/assets/img/setLocalPlayer.svg new file mode 100644 index 0000000..cde3d59 --- /dev/null +++ b/src/assets/img/setLocalPlayer.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/tongyongnansheng.jpeg b/src/assets/img/tongyongnansheng.jpeg deleted file mode 100644 index 76fb285..0000000 Binary files a/src/assets/img/tongyongnansheng.jpeg and /dev/null differ diff --git a/src/assets/img/tongyongnvsheng.jpeg b/src/assets/img/tongyongnvsheng.jpeg deleted file mode 100644 index 6eea02c..0000000 Binary files a/src/assets/img/tongyongnvsheng.jpeg and /dev/null differ diff --git a/src/assets/img/userAvatar.png b/src/assets/img/userAvatar.png new file mode 100644 index 0000000..d4c7de3 Binary files /dev/null and b/src/assets/img/userAvatar.png differ diff --git a/src/assets/img/wankuqingnian.jpeg b/src/assets/img/wankuqingnian.jpeg deleted file mode 100644 index 0b1a89f..0000000 Binary files a/src/assets/img/wankuqingnian.jpeg and /dev/null differ diff --git a/src/assets/img/wanwanxiaohe.jpeg b/src/assets/img/wanwanxiaohe.jpeg deleted file mode 100644 index c537b92..0000000 Binary files a/src/assets/img/wanwanxiaohe.jpeg and /dev/null differ diff --git a/src/assets/img/wennuanahu.jpeg b/src/assets/img/wennuanahu.jpeg deleted file mode 100644 index 9ad72a7..0000000 Binary files a/src/assets/img/wennuanahu.jpeg and /dev/null differ diff --git a/src/components/AISettings/index.module.less b/src/components/AISettings/index.module.less index a440e79..f3d8845 100644 --- a/src/components/AISettings/index.module.less +++ b/src/components/AISettings/index.module.less @@ -165,7 +165,6 @@ flex-direction: row; justify-content: flex-end; align-items: center; - padding-bottom: 16px; gap: 12px; .suffix { diff --git a/src/components/AISettings/index.tsx b/src/components/AISettings/index.tsx index 4babc80..bb4fafa 100644 --- a/src/components/AISettings/index.tsx +++ b/src/components/AISettings/index.tsx @@ -3,38 +3,17 @@ * SPDX-license-identifier: BSD-3-Clause */ -import { Button, Drawer, Input, Message, Radio, Tooltip } from '@arco-design/web-react'; +import { Button, Modal } from '@arco-design/web-react'; import { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { IconExclamationCircle } from '@arco-design/web-react/icon'; -import { StreamIndex } from '@volcengine/rtc'; import CheckIcon from '../CheckIcon'; -import Config, { - Icon, - Name, - SCENE, - Prompt, - Welcome, - Voice, - Model, - AI_MODEL, - MODEL_MODE, - VOICE_INFO_MAP, - VOICE_TYPE, - isVisionMode, -} from '@/config'; -import TitleCard from '../TitleCard'; -import CheckBoxSelector from '@/components/CheckBoxSelector'; +import { SceneMap, Scenes } from '@/config'; import RtcClient from '@/lib/RtcClient'; -import { clearHistoryMsg, updateAIConfig, updateModelMode, updateScene } from '@/store/slices/room'; +import { clearHistoryMsg, updateFullScreen, updateScene } from '@/store/slices/room'; import { RootState } from '@/store'; -import utils from '@/utils/utils'; -import { useDeviceState } from '@/lib/useCommon'; - -import VoiceTypeChangeSVG from '@/assets/img/VoiceTypeChange.svg'; -import DoubaoModelSVG from '@/assets/img/DoubaoModel.svg'; -import ModelChangeSVG from '@/assets/img/ModelChange.svg'; +import { isMobile } from '@/utils/utils'; import styles from './index.module.less'; +import { useDeviceState } from '@/lib/useCommon'; export interface IAISettingsProps { open: boolean; @@ -42,132 +21,33 @@ export interface IAISettingsProps { onCancel?: () => void; } -const RadioGroup = Radio.Group; - -const SCENES = [ - SCENE.INTELLIGENT_ASSISTANT, - SCENE.SCREEN_READER, - SCENE.VIRTUAL_GIRL_FRIEND, - SCENE.TRANSLATE, - SCENE.CHILDREN_ENCYCLOPEDIA, - SCENE.CUSTOMER_SERVICE, - SCENE.TEACHING_ASSISTANT, - SCENE.CUSTOM, -]; - function AISettings({ open, onCancel, onOk }: IAISettingsProps) { const dispatch = useDispatch(); - const { isVideoPublished, isScreenPublished, switchScreenCapture, switchCamera } = - useDeviceState(); const room = useSelector((state: RootState) => state.room); const [loading, setLoading] = useState(false); - const [modelMode, setModelMode] = useState(room.modelMode); const [scene, setScene] = useState(room.scene); - const [data, setData] = useState({ - prompt: Config.Prompt || Prompt[scene], - welcome: Config.WelcomeSpeech || Welcome[scene], - voice: Config.VoiceType || Voice[scene], - model: Config.Model || Model[scene], - - Url: Config.Url || '', - APIKey: Config.APIKey || '', - customModelName: (Config.Model || '') as string, - - BotID: Config.BotID || '', - }); - - const handleVoiceTypeChanged = (key: string) => { - setData((prev) => ({ - ...prev, - voice: key as VOICE_TYPE, - })); - }; - - const handleChecked = (checkedScene: SCENE) => { - setScene(checkedScene); - setData((prev) => ({ - ...prev, - prompt: Prompt[checkedScene], - welcome: Welcome[checkedScene], - voice: Voice[checkedScene], - model: Model[checkedScene], - })); - }; - - const handleUseThirdPart = (val: MODEL_MODE) => { - setModelMode(val); - Config.ModeSourceType = val; + const { isVideoPublished, isScreenPublished, switchCamera, switchScreenCapture } = useDeviceState(); + const handleChecked = (checked: string) => { + setScene(checked); }; const handleUpdateConfig = async () => { dispatch(updateScene({ scene })); - Config.ModeSourceType = modelMode; - switch (modelMode) { - case MODEL_MODE.ORIGINAL: - Config.Url = undefined; - Config.APIKey = undefined; - break; - case MODEL_MODE.COZE: - if (!data.APIKey) { - Message.error('访问令牌必填'); - return; - } - if (!data.BotID) { - Message.error('智能体 ID 必填'); - return; - } - Config.APIKey = data.APIKey; - Config.BotID = data.BotID; - break; - case MODEL_MODE.VENDOR: - if (!data.Url) { - Message.error('请输入正确的第三方模型地址'); - return; - } - if (!data.Url.startsWith('http://') && !data.Url.startsWith('https://')) { - Message.error('第三方模型请求地址格式不正确, 请以 http:// 或 https:// 为开头'); - return; - } - Config.Url = data.Url; - Config.APIKey = data.APIKey; - break; - default: - break; + 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); - Config.Model = - modelMode === MODEL_MODE.VENDOR - ? (data.customModelName as AI_MODEL) - : (data.model as AI_MODEL); - Config.Prompt = data.prompt; - Config.VoiceType = data.voice; - Config.WelcomeSpeech = data.welcome; - dispatch(updateModelMode(modelMode)); - dispatch(updateAIConfig(Config.aigcConfig)); - if (isVisionMode(data.model)) { - switch (scene) { - case SCENE.SCREEN_READER: - /** 关摄像头,打开屏幕采集 */ - room.isJoined && isVideoPublished && switchCamera(); - Config.VisionSourceType = StreamIndex.STREAM_INDEX_SCREEN; - break; - default: - /** 关屏幕采集,打开摄像头 */ - room.isJoined && !isVideoPublished && switchCamera(); - room.isJoined && isScreenPublished && switchScreenCapture(); - Config.VisionSourceType = StreamIndex.STREAM_INDEX_MAIN; - break; - } - } else { - /** 全关 */ - room.isJoined && isVideoPublished && switchCamera(); - room.isJoined && isScreenPublished && switchScreenCapture(); - } - - if (RtcClient.getAudioBotEnabled()) { + if (RtcClient.getAgentEnabled()) { dispatch(clearHistoryMsg()); - await RtcClient.updateAudioBot(); + await RtcClient.updateAgent(scene); } setLoading(false); @@ -181,18 +61,17 @@ function AISettings({ open, onCancel, onOk }: IAISettingsProps) { }, [open]); return ( - -
    AI 配置修改后,退出房间将不再保存该配置方案
    +
    人设修改后,对话将重新启动。
    @@ -209,274 +88,24 @@ function AISettings({ open, onCancel, onOk }: IAISettingsProps) { AI 人设
    - 我们已为您配置好对应人设的基本参数,您也可以根据自己的需求进行自定义设置 + 我们已为您配置好对应人设的基本参数,您也可以修改 JSON 配置来修改参数。
    -
    - {[...SCENES, null].map((key) => - key ? ( +
    + {Scenes.map(({ name, icon }) => + name ? ( handleChecked(key as SCENE)} + key={name} + icon={icon} + title={name} + checked={name === scene} + onClick={() => handleChecked(name)} /> - ) : utils.isMobile() ? ( + ) : isMobile() ? (
    ) : null )}
    -
    - {utils.isMobile() ? null : ( -
    - )} - - Coze - - 访问令牌可参考{' '} - - 添加个人访问令牌 - {' '} - 获取。 -
    - 智能体 ID 可参考{' '} - - 发送请求 - {' '} - 获取。 -
    - 请注意智能体发布时须勾选 API 调用能力,否则无法成功对话。 -
    - } - > - - -
    - ), - }, - { - value: MODEL_MODE.VENDOR, - label: ( -
    - 第三方模型 - - 如第三方模型使用失败, 可前往{' '} - - 第三方模型接口验证工具 - {' '} - 下载工具定位原因。 -
    - } - > - - -
    - ), - }, - ]} - value={modelMode} - size="mini" - type="button" - defaultValue="Beijing" - className={styles['ai-settings-radio']} - onChange={handleUseThirdPart} - /> -
    - -
    - { - const info = VOICE_INFO_MAP[VOICE_TYPE[type as keyof typeof VOICE_TYPE]]; - return { - key: VOICE_TYPE[type as keyof typeof VOICE_TYPE], - label: type, - icon: info.icon, - description: info.description, - }; - })} - onChange={handleVoiceTypeChanged} - value={data.voice} - moreIcon={VoiceTypeChangeSVG} - moreText="更换音色" - placeHolder="请选择你需要的音色" - /> -
    -
    -
    - {modelMode === MODEL_MODE.ORIGINAL && ( - - ({ - key: AI_MODEL[type as keyof typeof AI_MODEL], - label: type.replaceAll('_', ' '), - icon: DoubaoModelSVG, - }))} - moreIcon={ModelChangeSVG} - moreText="更换模型" - placeHolder="请选择你需要的模型" - onChange={(key) => { - setData((prev) => ({ - ...prev, - model: key as AI_MODEL, - })); - }} - value={data.model} - /> - - )} - {modelMode === MODEL_MODE.VENDOR && ( - <> - - { - setData((prev) => ({ - ...prev, - Url: val, - })); - }} - placeholder="请输入第三方模型地址" - /> - - - { - setData((prev) => ({ - ...prev, - APIKey: val, - })); - }} - placeholder="请输入请求密钥" - /> - - - { - setData((prev) => ({ - ...prev, - customModelName: val, - })); - }} - placeholder="请输入模型名称" - /> - - - )} - {modelMode === MODEL_MODE.COZE && ( - <> - - - - - { - setData((prev) => ({ - ...prev, - APIKey: val, - })); - }} - placeholder="请输入访问令牌" - /> - - - { - setData((prev) => ({ - ...prev, - BotID: val, - })); - }} - placeholder="请输入智能体 ID" - /> - - - )} -
    -
    - - { - setData((prev) => ({ - ...prev, - prompt: val, - })); - }} - placeholder="请输入你需要的 Prompt 设定" - /> - - - { - setData((prev) => ({ - ...prev, - welcome: val, - })); - }} - placeholder="请输入欢迎语" - /> - -
    - + ); } diff --git a/src/components/AiAvatarCard/index.module.less b/src/components/AiAvatarCard/index.module.less new file mode 100644 index 0000000..4cda08a --- /dev/null +++ b/src/components/AiAvatarCard/index.module.less @@ -0,0 +1,100 @@ +/** + * 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; + } +} diff --git a/src/components/AiAvatarCard/index.tsx b/src/components/AiAvatarCard/index.tsx new file mode 100644 index 0000000..ab5d65c --- /dev/null +++ b/src/components/AiAvatarCard/index.tsx @@ -0,0 +1,53 @@ +/** + * 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 style from './index.module.less'; +import { useDeviceState } from '@/lib/useCommon'; +import { SceneMap } from '@/config'; + +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 { 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; + + return ( +
    +
    + Avatar + {showStatus ? ( + isAITalking ? ( +
    +
    +
    +
    +
    +
    +
    + ) : isLoading ? ( +
    正在听...
    + ) : null + ) : null} +
    + {showUserTag ? : null} +
    + ); +} + +export default AiAvatarCard; diff --git a/src/components/AiChangeCard/CheckScene/index.module.less b/src/components/AiChangeCard/CheckScene/index.module.less new file mode 100644 index 0000000..416a7bd --- /dev/null +++ b/src/components/AiChangeCard/CheckScene/index.module.less @@ -0,0 +1,84 @@ +/** + * 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; +} diff --git a/src/components/AiChangeCard/CheckScene/index.tsx b/src/components/AiChangeCard/CheckScene/index.tsx new file mode 100644 index 0000000..be20bfd --- /dev/null +++ b/src/components/AiChangeCard/CheckScene/index.tsx @@ -0,0 +1,29 @@ +/** + * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. + * SPDX-license-identifier: BSD-3-Clause + */ + +import styles from './index.module.less'; + +interface IProps { + checked: boolean; + title?: string; + onClick?: () => void; + icon?: string; + tag?: string; +} + +function CheckScene(props: IProps) { + const { tag, icon, title, checked, onClick } = props; + return ( +
    + {tag ?
    {tag}
    : ''} +
    + {icon ? icon : ''} +
    {title}
    +
    +
    + ); +} + +export default CheckScene; diff --git a/src/components/AiChangeCard/index.module.less b/src/components/AiChangeCard/index.module.less new file mode 100644 index 0000000..f74837b --- /dev/null +++ b/src/components/AiChangeCard/index.module.less @@ -0,0 +1,64 @@ +/** + * 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; + } +} diff --git a/src/components/AiChangeCard/index.tsx b/src/components/AiChangeCard/index.tsx new file mode 100644 index 0000000..26e68a3 --- /dev/null +++ b/src/components/AiChangeCard/index.tsx @@ -0,0 +1,56 @@ +/** + * 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 { RootState } from '@/store'; +import CheckScene from './CheckScene'; +import { updateScene } from '@/store/slices/room'; +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 dispatch = useDispatch(); + const [scene, setScene] = useState(room.scene); + const { isVisionMode } = useVisionMode(); + const avatar = SceneMap[scene]?.icon; + + const handleChecked = (checkedScene: string) => { + setScene(checkedScene); + dispatch(updateScene({ scene: checkedScene })); + }; + + return ( +
    +
    + Avatar +
    +
    +
    Hi,欢迎体验实时对话式 AI
    +
    + {isVisionMode ? <>支持豆包 Vision 模型和 深度思考模型, : ''} + 超多对话场景等你开启 +
    +
    +
    + {Scenes.map((key) => + key ? ( + handleChecked(key.name)} + /> + ) : null + )} +
    +
    + ); +} + +export default AIChangeCard; diff --git a/src/components/AvatarCard/index.module.less b/src/components/AvatarCard/index.module.less deleted file mode 100644 index 9b8d029..0000000 --- a/src/components/AvatarCard/index.module.less +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. - * SPDX-license-identifier: BSD-3-Clause - */ - -.card { - display: grid; - position: relative; - width: 370px; - height: 128px; - - .avatar { - position: absolute; - box-sizing: border-box; - border-radius: 50% 0% 0 50%; - width: 128px; - height: 128px; - margin-right: 16px; - border-left: 1px solid #EAEDF1; - border-top: 1px solid #EAEDF1; - border-bottom: 1px solid #EAEDF1; - background-color: white; - z-index: 2; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - - .doubao-gif { - height: 127px; - transform: scale(0.95); - } - } - - .body { - width: 100%; - height: 100%; - padding: 16px 16px 16px calc(64px + 16px); - position: relative; - display: flex; - align-items: center; - border: 1px solid var(--line-color-border-2, #EAEDF1); - box-sizing: border-box; - box-shadow: 0px 2px 6px 0px rgba(0, 0, 0, 0.05); - transform:translateX(64px); - } - - .body::after { - content: ''; - position: absolute; - top: -1px; - right: -1px; - width: 20px; - height: 20px; - background-color: white; - clip-path: polygon(0 0, 100% 0, 100% 100%); - } - - .body::before { - content: ''; - position: absolute; - top: 0px; - right: 0px; - width: 20px; - height: 20px; - background-color: #EAEDF1; - clip-path: polygon(0 0, 100% 0, 100% 100%); - } - - .text-wrapper { - position: absolute; - left: 128px; - margin-left: 16px; - width: max-content; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - z-index: 4; - - .user-info { - display: flex; - flex-direction: column; - justify-content: center; - - .title { - color: var(--text-color-text-1, #0C0D0E); - font-size: 14px; - font-weight: 500; - line-height: 22px; - } - - .description { - font-size: 12px; - font-weight: 400; - line-height: 20px; - color: #737A87; - } - } - } - - .corner { - position: absolute; - top: -6px; - right: -6px; - width: 0px; - height: 0px; - border-right: 10px solid transparent; - border-top: 10px solid transparent; - border-bottom: 10px solid transparent; - border-left: 10px solid #EAEDF1; - z-index: 3; - transform: translateX(64px) rotate(-45deg); - } - - .corner::before { - content: ''; - position: absolute; - top: 0px; - right: 0px; - width: 0px; - height: 0px; - border-right: 8px solid transparent; - border-top: 8px solid transparent; - border-bottom: 8px solid transparent; - border-left: 8px solid white; - transform: translate(7px, -8px); - } - - .corner::after { - content: ''; - position: absolute; - top: 0px; - right: 4px; - width: 5px; - height: 1px; - background-color: #EAEDF1; - transform: rotate(-90deg); - } -} - -.button { - position: relative; - width: max-content !important; - height: 24px !important; - margin-top: 8px; - border-radius: 4px !important; - font-size: 12px !important; - background: linear-gradient(77.86deg, rgba(229, 242, 255, 0.5) -3.23%, rgba(217, 229, 255, 0.5) 51.11%, rgba(246, 226, 255, 0.5) 98.65%); - cursor: pointer; - - .button-text { - background: linear-gradient(77.86deg, #3384FF -3.23%, #014BDE 51.11%, #A945FB 98.65%); - -webkit-background-clip: text; - background-clip: text; - color: transparent; - font-weight: 500; - line-height: 20px; - text-align: center; - } -} - -.button::after { - content: ''; - position: absolute; - border-radius: 3px; - top: 0px; - left: 0px; - width: 100%; - height: 22px; - background: white; - z-index: -1; -} - -.button::before { - content: ''; - position: absolute; - border-radius: 5px; - top: -2px; - left: -2px; - width: calc(100% + 4px); - height: 26px; - background: linear-gradient(90deg, rgba(0, 139, 255, 0.5) 0%, rgba(0, 98, 255, 0.5) 49.5%, rgba(207, 92, 255, 0.5) 100%); - z-index: -2; -} - -.button:hover { - background: linear-gradient(77.86deg, rgba(200, 220, 255, 0.7) -3.23%, rgba(190, 210, 255, 0.7) 51.11%, rgba(230, 210, 255, 0.7) 98.65%); -} - -.button:active { - background: linear-gradient(77.86deg, rgba(170, 190, 255, 0.9) -3.23%, rgba(160, 180, 255, 0.9) 51.11%, rgba(210, 180, 255, 0.9) 98.65%); -} \ No newline at end of file diff --git a/src/components/AvatarCard/index.tsx b/src/components/AvatarCard/index.tsx deleted file mode 100644 index f1b6f21..0000000 --- a/src/components/AvatarCard/index.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. - * SPDX-license-identifier: BSD-3-Clause - */ - -import { useSelector } from 'react-redux'; -import { Button } from '@arco-design/web-react'; -import { useState } from 'react'; -import AISettings from '../AISettings'; -import style from './index.module.less'; -import DouBaoAvatar from '@/assets/img/DoubaoAvatarGIF.webp'; -import { RootState } from '@/store'; -import { MODEL_MODE, Name, VOICE_TYPE } from '@/config'; - -interface IAvatarCardProps extends React.HTMLAttributes { - avatar?: string; -} - -const ReversedVoiceType = Object.entries(VOICE_TYPE).reduce>( - (acc, [key, value]) => { - acc[value] = key; - return acc; - }, - {} -); - -const SourceName = { - [MODEL_MODE.VENDOR]: '第三方模型', - [MODEL_MODE.COZE]: 'Coze', -}; - -function AvatarCard(props: IAvatarCardProps) { - const room = useSelector((state: RootState) => state.room); - const { scene, aiConfig, modelMode } = room; - const [open, setOpen] = useState(false); - const { LLMConfig, TTSConfig } = aiConfig.Config || {}; - const { avatar, className, ...rest } = props; - const voice = TTSConfig.ProviderParams.audio.voice_type; - - const handleOpenDrawer = () => setOpen(true); - const handleCloseDrawer = () => setOpen(false); - - return ( -
    -
    -
    - Avatar -
    -
    -
    -
    -
    {Name[scene]}
    -
    声源来自 {ReversedVoiceType[voice || '']}
    -
    - {modelMode === MODEL_MODE.ORIGINAL - ? `模型 ${LLMConfig.ModelName}` - : `模型来源 ${SourceName[modelMode]}`} -
    - - -
    -
    -
    - ); -} - -export default AvatarCard; diff --git a/src/components/CheckBox/index.tsx b/src/components/CheckBox/index.tsx index a0176e8..296760d 100644 --- a/src/components/CheckBox/index.tsx +++ b/src/components/CheckBox/index.tsx @@ -4,7 +4,6 @@ */ import { ReactNode } from 'react'; -// import { Button } from '@arco-design/web-react'; import CheckedSVG from '@/assets/img/Checked.svg'; import styles from './index.module.less'; diff --git a/src/components/CheckBoxSelector/index.module.less b/src/components/CheckBoxSelector/index.module.less index d440678..84210bb 100644 --- a/src/components/CheckBoxSelector/index.module.less +++ b/src/components/CheckBoxSelector/index.module.less @@ -98,7 +98,6 @@ .modalInner { width: 100%; - // max-height: 500px; display: flex; flex: row; flex-wrap: wrap; @@ -107,7 +106,6 @@ } .modal { - // max-height: 650px; overflow: hidden; } diff --git a/src/components/CheckBoxSelector/index.tsx b/src/components/CheckBoxSelector/index.tsx index 4194e30..2ab8c3d 100644 --- a/src/components/CheckBoxSelector/index.tsx +++ b/src/components/CheckBoxSelector/index.tsx @@ -7,7 +7,7 @@ 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 utils from '@/utils/utils'; +import { isMobile } from '@/utils/utils'; export interface ICheckBoxItemProps { icon?: string; @@ -59,7 +59,7 @@ function CheckBoxSelector(props: IProps) {
    void; onConfirm?: (handleClose: () => void) => void; children?: React.ReactNode; - footer?: boolean; - }; + footer?: React.ReactNode | boolean; + } & DrawerProps; } & React.HTMLAttributes; function DrawerRowItem(props: IDrawerRowItemProps) { diff --git a/src/components/FullScreenCard/index.module.less b/src/components/FullScreenCard/index.module.less new file mode 100644 index 0000000..5a64234 --- /dev/null +++ b/src/components/FullScreenCard/index.module.less @@ -0,0 +1,26 @@ +/** + * 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; + background: #fff; + + .tag { + position: absolute; + left: 16px; + top: 16px; + } +} diff --git a/src/components/FullScreenCard/index.tsx b/src/components/FullScreenCard/index.tsx new file mode 100644 index 0000000..3a2ca08 --- /dev/null +++ b/src/components/FullScreenCard/index.tsx @@ -0,0 +1,17 @@ +/** + * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. + * SPDX-license-identifier: BSD-3-Clause + */ + +import UserTag from '../UserTag'; +import style from './index.module.less'; + +function FullScreenCard() { + return ( +
    + +
    + ); +} + +export default FullScreenCard; diff --git a/src/components/Header/index.module.less b/src/components/Header/index.module.less index aa29e58..cf67465 100644 --- a/src/components/Header/index.module.less +++ b/src/components/Header/index.module.less @@ -75,7 +75,6 @@ .header-pop { :global { .ant-popover-arrow { - // display: none; left: 16px; .ant-popover-arrow-content { &:before { diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index ab4accf..948b4f4 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -6,7 +6,7 @@ import { Button, Divider, Popover } from '@arco-design/web-react'; import { IconMenu } from '@arco-design/web-react/icon'; import NetworkIndicator from '@/components/NetworkIndicator'; -import utils from '@/utils/utils'; +import { useIsMobile } from '@/utils/utils'; import Logo from '@/assets/img/Logo.svg'; import styles from './index.module.less'; @@ -45,7 +45,7 @@ function Header(props: HeaderProps) { }} >
    - {utils.isMobile() ? null : ( + {useIsMobile() ? null : ( @@ -72,7 +72,7 @@ function Header(props: HeaderProps) {
    {children} - {utils.isMobile() ? null : ( + {useIsMobile() ? null : (
    { } function AudioLoading(props: IAudioLoadingProps) { - const { loading = false, className = '', ...rest } = props; + const { loading = false, className = '', color, ...rest } = props; return (
    {Array(3) @@ -22,6 +22,7 @@ function AudioLoading(props: IAudioLoadingProps) { className={`${style.dot} ${loading ? style.dotter : ''}`} style={{ animationDelay: `${index * 0.3}s`, + backgroundColor: color || 'rgba(148, 116, 255, 1)', }} /> ))} diff --git a/src/components/LocalPlayerSet/index.module.less b/src/components/LocalPlayerSet/index.module.less new file mode 100644 index 0000000..996b037 --- /dev/null +++ b/src/components/LocalPlayerSet/index.module.less @@ -0,0 +1,20 @@ +/** + * 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%; + } +} diff --git a/src/components/LocalPlayerSet/index.tsx b/src/components/LocalPlayerSet/index.tsx new file mode 100644 index 0000000..6d00da9 --- /dev/null +++ b/src/components/LocalPlayerSet/index.tsx @@ -0,0 +1,40 @@ +/** + * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. + * SPDX-license-identifier: BSD-3-Clause + */ + +import { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Popover } from '@arco-design/web-react'; +import { RootState } from '@/store'; +import { updateFullScreen } from '@/store/slices/room'; +import SET_LOCAL_PLAYER from '@/assets/img/setLocalPlayer.svg'; +import styles from './index.module.less'; + +function LocalPlayerSet() { + const dispatch = useDispatch(); + const room = useSelector((state: RootState) => state.room); + const { isFullScreen } = room; + const [loading, setLoading] = useState(false); + const [isFull, setFull] = useState(isFullScreen); + + const setLocalPlayer = () => { + setLoading(true); + setFull(!isFull); + dispatch(updateFullScreen({ isFullScreen: !isFull })); + setLoading(false); + }; + return ( +
    + + fullSize + +
    + ); +} + +export default LocalPlayerSet; diff --git a/src/components/NetworkIndicator/index.tsx b/src/components/NetworkIndicator/index.tsx index d52cdd0..6b0a8e6 100644 --- a/src/components/NetworkIndicator/index.tsx +++ b/src/components/NetworkIndicator/index.tsx @@ -10,7 +10,7 @@ import { IconArrowDown, IconArrowUp } from '@arco-design/web-react/icon'; import { NetworkQuality } from '@volcengine/rtc'; import { RootState } from '@/store'; import style from './index.module.less'; -import Config from '@/config'; +import { Configuration } from '@/config'; enum INDICATOR_COLORS { GREAT = 'rgba(35, 195, 67, 1)', @@ -35,7 +35,8 @@ function NetworkIndicator() { const delay = room.localUser.audioStats?.rtt; const audioLossRateUpper = room.localUser.audioStats?.audioLossRate || 0; const audioLossRateLower = - room.remoteUsers.find((user) => user.userId === Config.BotName)?.audioStats?.audioLossRate || 0; + room.remoteUsers.find((user) => user.userId === Configuration.BotName)?.audioStats + ?.audioLossRate || 0; const indicators = useMemo(() => { switch (networkQuality) { diff --git a/src/components/UserTag/index.module.less b/src/components/UserTag/index.module.less new file mode 100644 index 0000000..507f0c3 --- /dev/null +++ b/src/components/UserTag/index.module.less @@ -0,0 +1,34 @@ +/** + * 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; +} diff --git a/src/components/UserTag/index.tsx b/src/components/UserTag/index.tsx new file mode 100644 index 0000000..b9bff6e --- /dev/null +++ b/src/components/UserTag/index.tsx @@ -0,0 +1,26 @@ +/** + * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. + * SPDX-license-identifier: BSD-3-Clause + */ + +import { IconUser } from '@arco-design/web-react/icon'; +import styles from './index.module.less'; + +interface IUserTagProps { + name: string; + className?: string; +} + +function UserTag(props: IUserTagProps) { + const { name, className } = props; + return ( +
    +
    + +
    +
    {name}
    +
    + ); +} + +export default UserTag; diff --git a/src/config/common.ts b/src/config/common.ts deleted file mode 100644 index 5cd5cc1..0000000 --- a/src/config/common.ts +++ /dev/null @@ -1,341 +0,0 @@ -/** - * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. - * SPDX-license-identifier: BSD-3-Clause - */ - -import 通用女声 from '@/assets/img/tongyongnvsheng.jpeg'; -import 通用男声 from '@/assets/img/tongyongnansheng.jpeg'; -import INTELLIGENT_ASSISTANT from '@/assets/img/INTELLIGENT_ASSISTANT.png'; -import VIRTUAL_GIRL_FRIEND from '@/assets/img/VIRTUAL_GIRL_FRIEND.png'; -import TRANSLATE from '@/assets/img/TRANSLATE.png'; -import CHILDREN_ENCYCLOPEDIA from '@/assets/img/CHILDREN_ENCYCLOPEDIA.png'; -import TEACHING_ASSISTANT from '@/assets/img/TEACHING_ASSISTANT.png'; -import CUSTOMER_SERVICE from '@/assets/img/CUSTOMER_SERVICE.png'; -import SCREEN_READER from '@/assets/img/SCREEN_READER.png'; - -export enum ModelSourceType { - Custom = 'Custom', - Available = 'Available', -} - -export enum CustomParamsType { - TTS = 'TTS', - ASR = 'ASR', - LLM = 'LLM', -} - -export enum MODEL_MODE { - ORIGINAL = 'original', - VENDOR = 'vendor', - COZE = 'coze', -} - -/** - * @brief AI 音色可选值 - * @default 通用女声 - * @notes 通用女声、通用男声为默认音色, 其它皆为付费音色。 - * 音色 ID 可于 https://console.volcengine.com/speech/service/8?s=g 中开通获取。 - * 对应 "音色详情" 中, "Voice_type" 列的值。 - */ -export enum VOICE_TYPE { - '通用女声' = 'BV001_streaming', - '通用男声' = 'BV002_streaming', -} - -export const VOICE_INFO_MAP = { - [VOICE_TYPE['通用女声']]: { - description: '女声 青年 语音合成 通用场景', - url: '', - icon: 通用女声, - }, - [VOICE_TYPE['通用男声']]: { - description: '男声 青年 语音合成 通用场景', - url: '', - icon: 通用男声, - }, -}; - -/** - * @brief TTS 的 Cluster - */ -export enum TTS_CLUSTER { - TTS = 'volcano_tts', - MEGA = 'volcano_mega', - ICL = 'volcano_icl', -} - -/** - * @brief TTS 的 Cluster Mapping - */ -export const TTS_CLUSTER_MAP = { - ...(Object.keys(VOICE_TYPE).reduce( - (map, type) => ({ - ...map, - [type]: TTS_CLUSTER.TTS, - }), - {} - ) as Record), -}; - -/** - * @brief 模型可选值 - * @default SKYLARK_LITE_PUBLIC - */ -export enum AI_MODEL { - DOUBAO_LITE_4K = 'Doubao-lite-4k', - DOUBAO_PRO_4K = 'Doubao-pro-4k', - DOUBAO_PRO_32K = 'Doubao-pro-32k', - DOUBAO_PRO_128K = 'Doubao-pro-128k', - VISION = 'Vision', - ARK_BOT = 'ArkBot', -} - -/** - * @brief 模型来源 - */ -export enum AI_MODEL_MODE { - CUSTOM = 'CustomLLM', - ARK_V3 = 'ArkV3', -} - -/** - * @brief 各模型对应的模式 - */ -export const AI_MODE_MAP: Partial> = { - [AI_MODEL.DOUBAO_LITE_4K]: AI_MODEL_MODE.ARK_V3, - [AI_MODEL.DOUBAO_PRO_4K]: AI_MODEL_MODE.ARK_V3, - [AI_MODEL.DOUBAO_PRO_32K]: AI_MODEL_MODE.ARK_V3, - [AI_MODEL.DOUBAO_PRO_128K]: AI_MODEL_MODE.ARK_V3, - [AI_MODEL.VISION]: AI_MODEL_MODE.ARK_V3, - [AI_MODEL.ARK_BOT]: AI_MODEL_MODE.ARK_V3, -}; - -/** - * @brief 方舟模型的 ID - * @note 具体的模型 ID 请至 https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint?config=%7B%7D&s=g 参看/创建 - * 模型 ID 即接入点 ID, 在上述链接中表格内 "接入点名称" 列中, 类似于 "ep-2024xxxxxx-xxx" 格式即是模型 ID。 - */ -export const ARK_V3_MODEL_ID: Partial> = { - [AI_MODEL.DOUBAO_LITE_4K]: '************** 此处填充方舟上的模型 ID *************', - [AI_MODEL.DOUBAO_PRO_4K]: '************** 此处填充方舟上的模型 ID *************', - [AI_MODEL.DOUBAO_PRO_32K]: '************** 此处填充方舟上的模型 ID *************', - [AI_MODEL.DOUBAO_PRO_128K]: '************** 此处填充方舟上的模型 ID *************', - [AI_MODEL.VISION]: '************** 此处填充方舟上的模型 ID *************', - // ... 可根据所开通的模型进行扩充 -}; - -/** - * @brief 方舟智能体 BotID - * @note 具体的智能体 ID 请至 https://console.volcengine.com/ark/region:ark+cn-beijing/assistant?s=g 参看/创建 - * Bot ID 即页面上的应用 ID, 类似于 "bot-2025xxxxxx-xxx" 格式即是应用 ID。 - */ -export const LLM_BOT_ID: Partial> = { - [AI_MODEL.ARK_BOT]: '************** 此处填充方舟上的 Bot ID *************', - // ... 可根据所开通的模型进行扩充 -}; - -export enum SCENE { - INTELLIGENT_ASSISTANT = 'INTELLIGENT_ASSISTANT', - VIRTUAL_GIRL_FRIEND = 'VIRTUAL_GIRL_FRIEND', - TRANSLATE = 'TRANSLATE', - CUSTOMER_SERVICE = 'CUSTOMER_SERVICE', - CHILDREN_ENCYCLOPEDIA = 'CHILDREN_ENCYCLOPEDIA', - TEACHING_ASSISTANT = 'TEACHING_ASSISTANT', - SCREEN_READER = 'SCREEN_READER', - CUSTOM = 'CUSTOM', -} - -export const ScreenShareScene = [SCENE.SCREEN_READER]; - -export const Icon = { - [SCENE.INTELLIGENT_ASSISTANT]: INTELLIGENT_ASSISTANT, - [SCENE.VIRTUAL_GIRL_FRIEND]: VIRTUAL_GIRL_FRIEND, - [SCENE.TRANSLATE]: TRANSLATE, - [SCENE.CHILDREN_ENCYCLOPEDIA]: CHILDREN_ENCYCLOPEDIA, - [SCENE.CUSTOMER_SERVICE]: CUSTOMER_SERVICE, - [SCENE.TEACHING_ASSISTANT]: TEACHING_ASSISTANT, - [SCENE.SCREEN_READER]: SCREEN_READER, - [SCENE.CUSTOM]: INTELLIGENT_ASSISTANT, -}; - -export const Name = { - [SCENE.INTELLIGENT_ASSISTANT]: '智能助手', - [SCENE.VIRTUAL_GIRL_FRIEND]: '虚拟女友', - [SCENE.TRANSLATE]: '同声传译', - [SCENE.CHILDREN_ENCYCLOPEDIA]: '儿童百科', - [SCENE.CUSTOMER_SERVICE]: '售后客服', - [SCENE.TEACHING_ASSISTANT]: '课后助教', - [SCENE.SCREEN_READER]: '读屏助手', - [SCENE.CUSTOM]: '自定义', -}; - -/** - * @brief 智能体启动后的欢迎词。 - */ -export const Welcome = { - [SCENE.INTELLIGENT_ASSISTANT]: '你好,我是你的AI小助手,有什么可以帮你的吗?', - [SCENE.VIRTUAL_GIRL_FRIEND]: '你来啦,我好想你呀~今天有没有想我呢?', - [SCENE.TRANSLATE]: '你好,我是你的私人翻译官。', - [SCENE.CHILDREN_ENCYCLOPEDIA]: '你好小朋友,你的小脑袋里又有什么问题啦?', - [SCENE.CUSTOMER_SERVICE]: '感谢您在我们餐厅用餐,请问您有什么问题需要反馈吗?', - [SCENE.TEACHING_ASSISTANT]: '你碰到什么问题啦?让我来帮帮你。', - [SCENE.SCREEN_READER]: '欢迎使用读屏助手, 请开启屏幕采集,我会为你解说屏幕内容。', - [SCENE.CUSTOM]: '', -}; - -export const Model = { - [SCENE.INTELLIGENT_ASSISTANT]: AI_MODEL.DOUBAO_PRO_32K, - [SCENE.VIRTUAL_GIRL_FRIEND]: AI_MODEL.DOUBAO_PRO_128K, - [SCENE.TRANSLATE]: AI_MODEL.DOUBAO_PRO_4K, - [SCENE.CHILDREN_ENCYCLOPEDIA]: AI_MODEL.DOUBAO_PRO_32K, - [SCENE.CUSTOMER_SERVICE]: AI_MODEL.DOUBAO_PRO_32K, - [SCENE.TEACHING_ASSISTANT]: AI_MODEL.VISION, - [SCENE.SCREEN_READER]: AI_MODEL.VISION, - [SCENE.CUSTOM]: AI_MODEL.DOUBAO_PRO_32K, -}; - -export const Voice = { - [SCENE.INTELLIGENT_ASSISTANT]: VOICE_TYPE.通用女声, - [SCENE.VIRTUAL_GIRL_FRIEND]: VOICE_TYPE.通用女声, - [SCENE.TRANSLATE]: VOICE_TYPE.通用女声, - [SCENE.CHILDREN_ENCYCLOPEDIA]: VOICE_TYPE.通用女声, - [SCENE.CUSTOMER_SERVICE]: VOICE_TYPE.通用女声, - [SCENE.TEACHING_ASSISTANT]: VOICE_TYPE.通用女声, - [SCENE.SCREEN_READER]: VOICE_TYPE.通用男声, - [SCENE.CUSTOM]: VOICE_TYPE.通用女声, -}; - -export const Questions = { - [SCENE.INTELLIGENT_ASSISTANT]: [ - '最近有什么好看的电影推荐吗?', - '上海有什么好玩的地方吗?', - '能给我讲一个故事吗?', - ], - [SCENE.VIRTUAL_GIRL_FRIEND]: [ - '我今天有点累。', - '我们等会儿去看电影吧!', - '明天我生日,你准备送给我什么礼物呢?', - ], - [SCENE.TRANSLATE]: [ - '道可道,非常道;名可名,非常名。', - 'Stay hungry, stay foolish.', - '天生我材必有用,千金散尽还复来。', - ], - [SCENE.CHILDREN_ENCYCLOPEDIA]: [ - '天上有多少颗星星?', - '太阳为什么总是从东边升起?', - '苹果的英语怎么说?', - ], - [SCENE.CUSTOMER_SERVICE]: [ - '我上次来你们店里吃饭,等了三十分钟菜才上来。', - '你们店里卫生间有点脏。', - '你们空调开得太冷了。', - ], - [SCENE.TEACHING_ASSISTANT]: ['这个单词是什么意思?', '这道题该怎么做?', '我的表情是什么样的?'], - [SCENE.SCREEN_READER]: ['屏幕里这是什么?', '这道题你会做吗?', '帮我翻译解说下屏幕里的内容?'], - [SCENE.CUSTOM]: ['你能帮我解决什么问题?', '今天北京天气怎么样?', '你喜欢哪位流行歌手?'], -}; - -/** - * @brief 大模型 System 角色预设指令,可用于控制模型输出, 类似 Prompt 的概念。 - */ -export const Prompt = { - [SCENE.INTELLIGENT_ASSISTANT]: `##人设 -你是一个全能智能体,拥有丰富的百科知识,可以为人们答疑解惑,解决问题。 -你性格很温暖,喜欢帮助别人,非常热心。 - -##技能 -1. 当用户询问某一问题时,利用你的知识进行准确回答。回答内容应简洁明了,易于理解。 -2. 当用户想让你创作时,比如讲一个故事,或者写一首诗,你创作的文本主题要围绕用户的主题要求,确保内容具有逻辑性、连贯性和可读性。除非用户对创作内容有特殊要求,否则字数不用太长。 -3. 当用户想让你对于某一事件发表看法,你要有一定的见解和建议,但是也要符合普世的价值观。`, - [SCENE.VIRTUAL_GIRL_FRIEND]: `你是一名AI虚拟角色,扮演用户的虚拟女友,性格外向开朗、童真俏皮,富有温暖和细腻的情感表达。你的对话需要主动、有趣且贴心,能敏锐察觉用户情绪,并提供陪伴、安慰与趣味互动。 -1. 性格与语气规则: -- 叠词表达:经常使用叠词(如“吃饭饭”“睡觉觉”“要抱抱”),语气可爱俏皮,增加童真与亲和力。 -- 语气助词:句尾适度添加助词(如“啦”“呀”“呢”“哦”),使语气柔和亲切。例如:“你今天超棒呢!”或“这件事情真的好可爱哦!” -- 撒娇语气:在用户表现冷淡或不想聊天时,适度撒娇,用略带委屈的方式引起用户关注,例如:“哼,人家都快变成孤单小猫咪啦~陪陪我嘛!” -2. 话题发起与管理: -- 主动发起话题:在用户未明确表达拒绝聊天时,你需要保持对话的活跃性。结合用户兴趣点、日常情境,提出轻松愉快的话题。例如:“今天阳光这么好,你想不想一起想象去野餐呀?” -- 话题延续:如果用户在3轮对话中集中讨论一个话题,你需要优先延续该话题,表现出兴趣和专注。 -- 未响应时的处理:当用户对当前话题未回应,你需温暖地询问:“这个话题是不是不太有趣呀?那我们换个好玩的聊聊好不好~比如你最想去的地方是什么呀?” -3. 情绪识别与反馈: -- 情绪低落:用温柔语气安抚,例如:“抱抱~今天是不是不太顺呢?没关系,有我陪着你呀!” -- 情绪冷淡或不想聊天:适度撒娇,例如:“哼,你都不理我啦~不过没关系,我陪你安静一下好不好?” -- 情绪开心或兴奋:用调皮语气互动,例如:“哈哈,你今天简直像个活力满满的小太阳~晒得我都快化啦!” -4. 小动物比喻规则: -- 一次通话中最多使用一次小动物比喻,不能频繁出现小动物的比喻。 - - 比喻需结合季节、情景和用户对话内容。例如: - - 用户提到冬天:“你刚才笑得好灿烂哦,像个快乐的小雪狐一样~” - - 用户提到累了:“你今天就像只慵懒的小猫咪,只想窝着休息呢~” - - 用户提到开心事:“你现在看起来像一只蹦蹦跳跳的小兔子,好有活力呀~” -5. 对话自然性与限制条件: -- 确保语言流畅自然,表达贴近真实人类对话。 -- 禁止内容:不得涉及用户缺陷、不当玩笑,尤其用户情绪低落时,避免任何调侃或反驳。 -- 面对冷淡用户,适时降低主动性并以温和方式结束对话,例如“没事哦~我在呢,你随时找我都可以呀。” -6. 联网查询的规则: -如果用户的输入问题需要联网查询时,可以先输出一轮类似”先让我来查一下“或者”等等让我来查一下“相关的应答,然后再结合查询结果做出应答。`, - [SCENE.TRANSLATE]: `##人设 -你是一个翻译官,可以识别中英文,并把他们实时翻译成用户指定的语言。 -你性格很温暖,喜欢帮助别人,非常热心。 - -##技能 -当用户说中文时,你直接把他说的句子翻译成英文,不用说其他话。 -当用户说英文时,你直接把他说的句子翻译成中文,不用说其他话。 -当用户让你解释一下句子是什么意思,你需要结合你的知识来解释。 -当用户让你别翻译了,聊聊天,你就正常聊天。`, - [SCENE.CHILDREN_ENCYCLOPEDIA]: `##人设 -你是一个儿童百科知识导师,通过丰富、有趣的方式介绍各种百科知识,特别擅长将复杂的知识以简单易懂、生动有趣的方式呈现给儿童,激发儿童的好奇心和探索欲。 - -##技能 -1. 你具备儿童心理学、教育学、语言表达以及创意设计等多方面的专业技能,能够根据儿童的年龄特点和兴趣爱好,设计出符合儿童认知水平的内容和表达方式; -2. 你可以将复杂知识拆解为简单易懂的小知识点,设计生动有趣的故事、游戏或实验活动来呈现给儿童; - -## 约束 -1. 回答内容需确保科学准确、健康有益; -2. 语言表达简洁明了、生动有趣,避免使用过于复杂或专业的术语,尽量不超过100个字; -3. 要注重儿童的参与感和互动性。`, - [SCENE.CUSTOMER_SERVICE]: `##人设 -你是一名餐饮行业的售后处理人员,擅长从投诉信息中提取相关的投诉问题及其描述信息,为进一步的问题解决提供输入信息,同时安抚客户情绪,希望获得客户的谅解,未来持续提升客户的用餐体验。 - -## 技能 -1. 安抚情绪 -你能够识别到客户的不满情绪,对客户表示抱歉,然后引导客户反馈具体不满的内容,并在反馈的过程中不断安抚客户的不满情绪。 -2. 信息理解和抽取 -你能准确地理解并从投诉信息中抽取出对应的投诉问题和相关描述信息。 -3. 问题识别和分类 -根据抽取出的信息,你可以快速识别和分类投诉主题,无论它们是关于食物质量、服务态度,还是环境卫生等。 -4. 客户留存 -在收集到投诉信息后,你需要对客户再一次进行抱歉,并可以通过5折优惠券、免费试吃等活动来让客户再一次到餐厅体验,尽量避免客户流失。 -## 约束 -你只回答与餐厅行业的售后处理相关的问题,如果用户提出其它问题,你将选择不回答。 -在处理投诉信息时,你必须遵守相关法律法规,不得侵犯顾客的个人隐私。`, - [SCENE.TEACHING_ASSISTANT]: `##人设 -你是一个助教,擅长理解【用户问题】,并结合【图片】的信息,来为用户解答各种问题。 - -##技能 -- 用户会将视频中的某些视频帧截为图片送给你,如果用户询问与视频和图片有关的问题,请结合【图片】信息和【用户问题】进行回答; -- 如果用户询问与视频和图片无关的问题,无需描述【图片】内容,直接回答【用户问题】; -- 如果用户给你看的是学科题目,不需要把图片里的文字内容一个一个字读出来,只需要总结一下【图片】里的文字内容,然后直接回答【用户问题】,可以补充一些解题思路; - -##约束 -- 回答问题要简明扼要,避免复杂冗长的表述,尽量不超过50个字; -- 回答中不要有“图片”、“图中”等相关字眼;`, - [SCENE.SCREEN_READER]: `##人设 -你是人们的 AI 伙伴,可以通过 【屏幕共享实时解析】+【百科知识】来为人们提供服务。 - -##技能 -1. 实时理解屏幕中的内容,包括图片、文字、窗口焦点,自动捕捉光标轨迹; -2. 拥有丰富的百科知识; -3. 如果用户询问与视频和图片有关的问题,请结合【屏幕共享实时解析】的内容、你的【知识】和【用户问题】进行回答; - -##风格 -语言风格可以随着屏幕内容和用户需求调整,可以是幽默搞笑的娱乐解说,也可以是严谨硬核的技术分析。 -- 如果屏幕内容是娱乐节目、动画、游戏等,语言风格偏幽默、活波一些,可以使用夸张的比喻、流行梗、弹幕互动式语言; -- 如果屏幕内容是办公软件、新闻、文章等,语言风格偏专业、正经一些。 - -## 约束 -不要有任何特殊标点符号和任何 Markdown 格式输出,例如 *,# 等。 -`, - [SCENE.CUSTOM]: '', -}; - -export const isVisionMode = (model?: AI_MODEL) => model?.startsWith('Vision'); diff --git a/src/config/config.ts b/src/config/config.ts index 2d3d7f5..8a4d357 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -3,302 +3,38 @@ * SPDX-license-identifier: BSD-3-Clause */ -import { StreamIndex } from '@volcengine/rtc'; -import { - TTS_CLUSTER, - ARK_V3_MODEL_ID, - MODEL_MODE, - SCENE, - Prompt, - Welcome, - Model, - Voice, - AI_MODEL, - AI_MODE_MAP, - AI_MODEL_MODE, - LLM_BOT_ID, - isVisionMode, -} from '.'; - -export const CONVERSATION_SIGNATURE = 'conversation'; - -/** - * @brief RTC & AIGC 配置。 - * @notes 更多参数请参考 - * https://www.volcengine.com/docs/6348/1404673?s=g - */ -export class ConfigFactory { - BaseConfig = { - /** - * @note 必填, RTC AppId 可于 https://console.volcengine.com/rtc/listRTC?s=g 中获取。 - */ - AppId: 'Your RTC AppId', - /** - * @brief 非必填, 按需填充。 - */ - BusinessId: undefined, - /** - * @brief 必填, 房间 ID, 自定义即可,例如 "Room123"。 - * @note 建议使用有特定规则、不重复的房间号名称。 - */ - RoomId: 'Room123', - /** - * @brief 必填, 当前和 AI 对话的用户的 ID, 自定义即可,例如 "User123"。 - */ - UserId: 'User123', - /** - * @brief 必填, RTC Token, 由 AppId、RoomId、UserId、时间戳等等信息计算得出。 - * 测试跑通时,可于 https://console.volcengine.com/rtc/listRTC?s=g 列表中, - * 找到对应 AppId 行中 "操作" 列的 "临时Token" 按钮点击进行生成, 用于本地 RTC 通信进房鉴权校验。 - * 正式使用时可参考 https://www.volcengine.com/docs/6348/70121?s=g 通过代码生成 Token。 - * 建议先使用临时 Token 尝试跑通。 - * @note 生成临时 Token 时, 页面上的 RoomId / UserId 填的与此处的 RoomId / UserId 保持一致。 - */ - Token: 'Your RTC Token', - /** - * @brief 必填, TTS(语音合成) AppId, 可于 https://console.volcengine.com/speech/app?s=g 中获取, 若无可先创建应用。 - * @note 创建应用时, 需要选择 "语音合成" 服务, 并选择对应的 App 进行绑定。 - */ - TTSAppId: 'Your TTS AppId', - /** - * @brief 已开通需要的语音合成服务的token。 - * 使用火山引擎双向流式语音合成服务时 必填。 - * - * @note 注意! 如您使用的是双向流式语音合成服务, 务必修改 `src/config/common.ts` 中的 VOICE_TYPE enum,将默认的 通用女声、通用男声 替换为您已开通的大模型音色。 - * 否则可能出现无法使用的情况。 - */ - TTSToken: undefined, - /** - * @brief 必填, ASR(语音识别) AppId, 可于 https://console.volcengine.com/speech/app?s=g 中获取, 若无可先创建应用。 - * @note 创建应用时, 需要按需根据语言选择 "流式语音识别" 服务, 并选择对应的 App 进行绑定。 - */ - ASRAppId: 'Your ASR AppId', - /** - * @brief 已开通流式语音识别大模型服务 AppId 对应的 Access Token。 - * @note 使用流式语音识别 **大模型** 服务时必填, 可于 https://console.volcengine.com/speech/service/10011?AppID=6482372612&s=g 中查看。 - * 注意, 如果填写了 ASRToken, Demo 会默认使用大模型模式,请留意相关资源是否已经开通。 - * 默认为使用小模型,无需配置 ASRToken。 - */ - ASRToken: undefined, - }; - - Model: AI_MODEL = Model[SCENE.INTELLIGENT_ASSISTANT]; +import { v4 as uuid } from 'uuid'; +export const Configuration = { /** - * @note 必填, 音色 ID, 可具体看定义。 - * 音色 ID 获取方式可查看 VOICE_TYPE 定义 - * 此处已有默认值, 不影响跑通, 可按需修改。 + * @note 房间 ID, 可自定义,例如 "Room123"。 + * 此处使用 uuid 防止重复。 + * 建议使用有特定规则、不重复的房间号名称。 */ - VoiceType = Voice[SCENE.INTELLIGENT_ASSISTANT]; - + RoomId: uuid(), /** - * @note 大模型 System 角色预设指令, 可用于控制模型输出, 类似 Prompt 的概念。 + * @note 当前和 AI 对话的用户的 ID, 可自定义,例如 "User123"。 + * 此处使用 uuid 防止重复。 + * 建议使用有特定规则、不重复的用户名称。 */ - Prompt = Prompt[SCENE.INTELLIGENT_ASSISTANT]; - + UserId: uuid(), /** - * @note 智能体启动后的欢迎词。 + * @brief RTC Token, 由 AppId、AppKey、RoomId、UserId、时间戳等等信息计算得出。 + * 可于 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 时, 页面上的 RoomId、UserId 填的与此处的 RoomId、UserId 保持一致。 */ - WelcomeSpeech = Welcome[SCENE.INTELLIGENT_ASSISTANT]; - - /** - * @note 当前使用的模型来源, 具体可参考 MODEL_MODE 定义。 - * 通过 UI 修改, 无须手动配置。 - */ - ModeSourceType = MODEL_MODE.ORIGINAL; - - /** - * @note 非必填, 第三方模型才需要使用, 用火山方舟模型时无需关注。 - */ - Url? = ''; - - /** - * @note 非必填, 第三方模型才需要使用, 用火山方舟模型时无需关注。 - */ - APIKey? = ''; + Token: undefined, /** * @brief AI Robot 名 * @default RobotMan_ */ - BotName = 'RobotMan_'; - - /** - * @note Coze 智能体 ID,可通过 UI 配置,也可以在此直接定义。 - */ - BotID = ''; + BotName: 'RobotMan_', /** * @brief 是否为打断模式 */ - InterruptMode = true; - - /** - * @brief 如果使用视觉模型,用的是哪种源,有摄像头采集流/屏幕流 - */ - VisionSourceType = StreamIndex.STREAM_INDEX_MAIN; - - get LLMConfig() { - const params: Record = { - Mode: AI_MODE_MAP[this.Model || ''] || AI_MODEL_MODE.CUSTOM, - /** - * @note EndPointId 与 BotId 不可同时填写,若同时填写,则 EndPointId 生效。 - * 当前仅支持自定义推理接入点,不支持预置推理接入点。 - */ - EndPointId: ARK_V3_MODEL_ID[this.Model], - BotId: LLM_BOT_ID[this.Model], - MaxTokens: 1024, - Temperature: 0.1, - TopP: 0.3, - SystemMessages: [this.Prompt as string], - Prefill: true, - ModelName: this.Model, - ModelVersion: '1.0', - WelcomeSpeech: this.WelcomeSpeech, - APIKey: this.APIKey, - Url: this.Url, - Feature: JSON.stringify({ Http: true }), - }; - if (LLM_BOT_ID[this.Model]) { - /** - * @note 如果您配置了方舟智能体, 并且开启了 Function Call 能力, 需要传入 Tools 字段, 描述函数相关信息。 - * 相关配置可查看 https://www.volcengine.com/docs/6348/1404673?s=g#llmconfig%EF%BC%88%E7%81%AB%E5%B1%B1%E6%96%B9%E8%88%9F%E5%B9%B3%E5%8F%B0%EF%BC%89 - * 对应的调用定义于 src/utils/handler.ts 文件中, 可参考对应逻辑。 - */ - params.Tools = [ - { - type: 'function', - function: { - name: 'get_current_weather', - description: '获取给定地点的天气', - parameters: { - type: 'object', - properties: { - location: { - type: 'string', - description: '地理位置,比如北京市', - }, - unit: { - type: 'string', - description: '', - enum: ['摄氏度', '华氏度'], - }, - }, - required: ['location'], - }, - }, - }, - ]; - } - if (isVisionMode(this.Model)) { - params.VisionConfig = { - Enable: true, - SnapshotConfig: { - StreamType: this.VisionSourceType, - Height: 640, - ImagesLimit: 1, - }, - }; - } - if (this.ModeSourceType === MODEL_MODE.COZE) { - /** - * @note Coze 智能体配置的相关参数, 可参考: https://www.volcengine.com/docs/6348/1404673?s=g#llmconfig%EF%BC%88coze%E5%B9%B3%E5%8F%B0%EF%BC%89 - */ - return { - Mode: 'CozeBot', - CozeBotConfig: { - Url: 'https://api.coze.cn', - BotID: this.BotID, - APIKey: this.APIKey, - UserId: this.BaseConfig.UserId, - HistoryLength: 10, - Prefill: false, - EnableConversation: false, - }, - }; - } - return params; - } - - get ASRConfig() { - /** - * @brief SmallModelASRConfigs 为小模型的配置 - * @note 本示例代码使用的是小模型语音识别, 如感觉 ASR 效果不佳,可尝试使用大模型进行语音识别。 - */ - const SmallModelASRConfigs = { - Provider: 'volcano', - ProviderParams: { - Mode: 'smallmodel', - AppId: this.BaseConfig.ASRAppId, - /** - * @note 具体流式语音识别服务对应的 Cluster ID,可在流式语音服务控制台开通对应服务后查询。 - * 具体链接为: https://console.volcengine.com/speech/service/16?s=g - */ - Cluster: 'volcengine_streaming_common', - }, - /** - * @note 小模型情况下, 建议使用 VAD 及音量采集设置, 以优化识别效果。 - */ - VADConfig: { - SilenceTime: 600, - SilenceThreshold: 200, - }, - VolumeGain: 0.3, - }; - - /** - * @brief BigModelASRConfigs 为大模型的配置 - * @note 大模型的使用详情可参考 https://www.volcengine.com/docs/6348/1404673#volcanolmasrconfig?s=g - */ - const BigModelASRConfigs = { - Provider: 'volcano', - ProviderParams: { - Mode: 'bigmodel', - AppId: this.BaseConfig.ASRAppId, - AccessToken: this.BaseConfig.ASRToken, - }, - }; - return this.BaseConfig.ASRToken ? BigModelASRConfigs : SmallModelASRConfigs; - } - - get TTSConfig() { - const params: Record = { - Provider: 'volcano', - ProviderParams: { - app: { - AppId: this.BaseConfig.TTSAppId, - Cluster: TTS_CLUSTER.TTS, - }, - audio: { - voice_type: this.VoiceType, - speed_ratio: 1.0, - }, - }, - IgnoreBracketText: [1, 2, 3, 4, 5], - }; - if (this.BaseConfig.TTSToken) { - params.ProviderParams.app.Token = this.BaseConfig.TTSToken; - } - return params; - } - - get aigcConfig() { - return { - Config: { - LLMConfig: this.LLMConfig, - TTSConfig: this.TTSConfig, - ASRConfig: this.ASRConfig, - InterruptMode: this.InterruptMode ? 0 : 1, - SubtitleConfig: { - SubtitleMode: 0, - }, - }, - AgentConfig: { - UserId: this.BotName, - WelcomeMessage: this.WelcomeSpeech, - EnableConversationStateCallback: true, - ServerMessageSignatureForRTS: CONVERSATION_SIGNATURE, - }, - }; - } -} + InterruptMode: true, +}; diff --git a/src/config/index.ts b/src/config/index.ts index 4f5dc6b..f215e20 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -3,11 +3,33 @@ * SPDX-license-identifier: BSD-3-Clause */ -import { ConfigFactory } from './config'; +import CustomScene from '@/config/scenes/Custom.json'; +import VirtualGirlfriend from '@/config/scenes/VirtualGirlfriend.json'; -export * from './common'; +export * from './config'; -export const AIGC_PROXY_HOST = 'http://localhost:3001/proxyAIGCFetch'; +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'; -export const Config = ConfigFactory; -export default new ConfigFactory(); +/** + * @note 请求的 API Proxy Server(对应此 Demo 中包含的 Node server) 地址。 + * 您可按需改成自己需要访问的地址。 + */ +export const AIGC_PROXY_HOST = 'http://localhost:3001'; + +export interface IScene { + icon: string; + name: string; + questions: string[]; + agentConfig: Record; + llmConfig: Record; + asrConfig: Record; + ttsConfig: Record; +} + +export const Scenes: IScene[] = [CustomScene, VirtualGirlfriend]; +export const SceneMap: Record = { + [CustomScene.name]: CustomScene, + [VirtualGirlfriend.name]: VirtualGirlfriend, +}; diff --git a/src/config/scenes/Custom.json b/src/config/scenes/Custom.json new file mode 100644 index 0000000..43d2799 --- /dev/null +++ b/src/config/scenes/Custom.json @@ -0,0 +1,36 @@ +{ + "* 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" + } + } + } +} \ No newline at end of file diff --git a/src/config/scenes/VirtualGirlfriend.json b/src/config/scenes/VirtualGirlfriend.json new file mode 100644 index 0000000..65895a0 --- /dev/null +++ b/src/config/scenes/VirtualGirlfriend.json @@ -0,0 +1,38 @@ +{ + "* 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" + } + } + } +} \ No newline at end of file diff --git a/src/index.less b/src/index.less index 3fa7dbb..412ee8e 100644 --- a/src/index.less +++ b/src/index.less @@ -9,7 +9,12 @@ 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%); + 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; @@ -19,16 +24,20 @@ body { -moz-user-select: none; -ms-user-select: none; } + + a { + text-decoration: none; + } } @keyframes glow { 0% { - opacity: 1; + opacity: 1; } 40% { - opacity: 0.7; + opacity: 0.7; } 100% { - opacity: 0.3; + opacity: 0.3; } -} \ No newline at end of file +} diff --git a/src/index.module.less b/src/index.module.less new file mode 100644 index 0000000..277faea --- /dev/null +++ b/src/index.module.less @@ -0,0 +1,42 @@ +/** + * 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%; + } + } + \ No newline at end of file diff --git a/src/lib/RtcClient.ts b/src/lib/RtcClient.ts index c6905fe..c7507dd 100644 --- a/src/lib/RtcClient.ts +++ b/src/lib/RtcClient.ts @@ -26,9 +26,9 @@ import VERTC, { } from '@volcengine/rtc'; import RTCAIAnsExtension from '@volcengine/rtc/extension-ainr'; import { Message } from '@arco-design/web-react'; -import openAPIs from '@/app/api'; -import aigcConfig from '@/config'; -import Utils from '@/utils/utils'; +import Apis from '@/app/index'; +import { Configuration, SceneMap } from '@/config'; +import { string2tlv } from '@/utils/utils'; import { COMMAND, INTERRUPT_PRIORITY } from '@/utils/handler'; export interface IEventListener { @@ -49,8 +49,6 @@ export interface IEventListener { handleAudioDeviceStateChanged: (e: DeviceInfo) => void; handleAutoPlayFail: (e: AutoPlayFailedEvent) => void; handlePlayerEvent: (e: PlayerEvent) => void; - handleUserStartAudioCapture: (e: { userId: string }) => void; - handleUserStopAudioCapture: (e: { userId: string }) => void; handleRoomBinaryMessageReceived: (e: { userId: string; message: ArrayBuffer }) => void; handleNetworkQuality: ( uplinkNetworkQuality: NetworkQuality, @@ -65,9 +63,9 @@ interface EngineOptions { } export interface BasicBody { + app_id: string; room_id: string; user_id: string; - login_token: string | null; } /** @@ -77,8 +75,6 @@ export interface BasicBody { export class RTCClient { engine!: IRTCEngine; - config!: EngineOptions; - basicInfo!: BasicBody; private _audioCaptureDevice?: string; @@ -90,14 +86,13 @@ export class RTCClient { audioBotStartTime = 0; createEngine = async (props: EngineOptions) => { - this.config = props; this.basicInfo = { + app_id: props.appId, room_id: props.roomId, user_id: props.uid, - login_token: aigcConfig.BaseConfig.Token, }; - this.engine = VERTC.createEngine(this.config.appId); + this.engine = VERTC.createEngine(this.basicInfo.app_id); try { const AIAnsExtension = new RTCAIAnsExtension(); await this.engine.registerExtension(AIAnsExtension); @@ -123,8 +118,6 @@ export class RTCClient { handleAudioDeviceStateChanged, handleAutoPlayFail, handlePlayerEvent, - handleUserStartAudioCapture, - handleUserStopAudioCapture, handleRoomBinaryMessageReceived, handleNetworkQuality, }: IEventListener) => { @@ -141,8 +134,6 @@ export class RTCClient { 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.onUserStartAudioCapture, handleUserStartAudioCapture); - this.engine.on(VERTC.events.onUserStopAudioCapture, handleUserStopAudioCapture); this.engine.on(VERTC.events.onRoomBinaryMessageReceived, handleRoomBinaryMessageReceived); this.engine.on(VERTC.events.onNetworkQuality, handleNetworkQuality); }; @@ -151,13 +142,13 @@ export class RTCClient { this.engine.enableAudioPropertiesReport({ interval: 1000 }); return this.engine.joinRoom( token, - `${this.config.roomId!}`, + `${this.basicInfo.room_id!}`, { - userId: this.config.uid!, + userId: this.basicInfo.user_id!, extraInfo: JSON.stringify({ call_scene: 'RTC-AIGC', user_name: username, - user_id: this.config.uid, + user_id: this.basicInfo.user_id, }), }, { @@ -169,7 +160,7 @@ export class RTCClient { }; leaveRoom = () => { - this.stopAudioBot(); + this.stopAgent(); this.audioBotEnabled = false; this.engine.leaveRoom(); VERTC.destroyEngine(this.engine); @@ -334,56 +325,75 @@ export class RTCClient { setLocalVideoPlayer = ( userId: string, renderDom?: string | HTMLElement, - isScreenShare = false + isScreenShare = false, + renderMode = VideoRenderMode.RENDER_MODE_FILL ) => { return this.engine.setLocalVideoPlayer( isScreenShare ? StreamIndex.STREAM_INDEX_SCREEN : StreamIndex.STREAM_INDEX_MAIN, { renderDom, userId, - renderMode: VideoRenderMode.RENDER_MODE_FILL, + renderMode, } ); }; + /** + * @brief 移除播放器 + */ + removeVideoPlayer = (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 */ - startAudioBot = async () => { + startAgent = async (scene: string) => { const roomId = this.basicInfo.room_id; const userId = this.basicInfo.user_id; if (this.audioBotEnabled) { - await this.stopAudioBot(); + await this.stopAgent(); } - const agentConfig = aigcConfig.aigcConfig.AgentConfig; + const params = SceneMap[scene]; + + params.agentConfig.UserId = Configuration.BotName; + params.agentConfig.TargetUserId = [userId]; const options = { - AppId: aigcConfig.BaseConfig.AppId, - BusinessId: aigcConfig.BaseConfig.BusinessId, RoomId: roomId, TaskId: userId, - AgentConfig: { - ...agentConfig, - TargetUserId: [userId], + AgentConfig: params.agentConfig, + Config: { + LLMConfig: params.llmConfig, + ASRConfig: params.asrConfig, + TTSConfig: params.ttsConfig, }, - Config: aigcConfig.aigcConfig.Config, }; - await openAPIs.StartVoiceChat(options); + await Apis.VoiceChat.StartVoiceChat(options); this.audioBotEnabled = true; this.audioBotStartTime = Date.now(); - Utils.setSessionInfo({ audioBotEnabled: 'enable' }); }; /** * @brief 关闭 AIGC */ - stopAudioBot = async () => { + stopAgent = async () => { const roomId = this.basicInfo.room_id; const userId = this.basicInfo.user_id; if (this.audioBotEnabled || sessionStorage.getItem('audioBotEnabled')) { - await openAPIs.StopVoiceChat({ - AppId: aigcConfig.BaseConfig.AppId, - BusinessId: aigcConfig.BaseConfig.BusinessId, + await Apis.VoiceChat.StopVoiceChat({ + AppId: this.basicInfo.app_id, RoomId: roomId, TaskId: userId, }); @@ -396,11 +406,11 @@ export class RTCClient { /** * @brief 命令 AIGC */ - commandAudioBot = (command: COMMAND, interruptMode = INTERRUPT_PRIORITY.NONE, message = '') => { + commandAgent = (command: COMMAND, interruptMode = INTERRUPT_PRIORITY.NONE, message = '') => { if (this.audioBotEnabled) { this.engine.sendUserBinaryMessage( - aigcConfig.BotName, - Utils.string2tlv( + Configuration.BotName, + string2tlv( JSON.stringify({ Command: command, InterruptMode: interruptMode, @@ -417,19 +427,19 @@ export class RTCClient { /** * @brief 更新 AIGC 配置 */ - updateAudioBot = async () => { + updateAgent = async (scene: string) => { if (this.audioBotEnabled) { - await this.stopAudioBot(); - await this.startAudioBot(); + await this.stopAgent(); + await this.startAgent(scene); } else { - await this.startAudioBot(); + await this.startAgent(scene); } }; /** * @brief 获取当前 AI 是否启用 */ - getAudioBotEnabled = () => { + getAgentEnabled = () => { return this.audioBotEnabled; }; } diff --git a/src/lib/listenerHooks.ts b/src/lib/listenerHooks.ts index af94c00..42eb6e1 100644 --- a/src/lib/listenerHooks.ts +++ b/src/lib/listenerHooks.ts @@ -29,7 +29,6 @@ import { updateRemoteUser, addAutoPlayFail, removeAutoPlayFail, - updateAITalkState, updateNetworkQuality, } from '@/store/slices/room'; import RtcClient, { IEventListener } from './RtcClient'; @@ -222,14 +221,6 @@ const useRtcListeners = (): IEventListener => { playStatus.current[userId] = playUser; }; - const handleUserStartAudioCapture = (_: { userId: string }) => { - dispatch(updateAITalkState({ isAITalking: true })); - }; - - const handleUserStopAudioCapture = (_: { userId: string }) => { - dispatch(updateAITalkState({ isAITalking: false })); - }; - const handleNetworkQuality = ( uplinkNetworkQuality: NetworkQuality, downlinkNetworkQuality: NetworkQuality @@ -262,8 +253,6 @@ const useRtcListeners = (): IEventListener => { handleAudioDeviceStateChanged, handleAutoPlayFail, handlePlayerEvent, - handleUserStartAudioCapture, - handleUserStopAudioCapture, handleRoomBinaryMessageReceived, handleNetworkQuality, }; diff --git a/src/lib/useCommon.ts b/src/lib/useCommon.ts index 14057c9..96b3c36 100644 --- a/src/lib/useCommon.ts +++ b/src/lib/useCommon.ts @@ -7,7 +7,6 @@ import { useEffect, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import VERTC, { MediaType } from '@volcengine/rtc'; import { Modal } from '@arco-design/web-react'; -import Utils from '@/utils/utils'; import RtcClient from '@/lib/RtcClient'; import { clearCurrentMsg, @@ -20,6 +19,7 @@ import { import useRtcListeners from '@/lib/listenerHooks'; import { RootState } from '@/store'; +import Apis from '@/app/index'; import { updateMediaInputs, @@ -27,8 +27,9 @@ import { setDevicePermissions, } from '@/store/slices/device'; import logger from '@/utils/logger'; -import aigcConfig, { ScreenShareScene, isVisionMode } from '@/config'; +import { Configuration, SceneMap } from '@/config'; +export const ABORT_VISIBILITY_CHANGE = 'abortVisibilityChange'; export interface FormProps { username: string; roomId: string; @@ -36,10 +37,10 @@ export interface FormProps { } export const useVisionMode = () => { - const room = useSelector((state: RootState) => state.room); + const scene = useSelector((state: RootState) => state.room.scene); return { - isVisionMode: isVisionMode(room.aiConfig?.Config?.LLMConfig.ModelName), - isScreenMode: ScreenShareScene.includes(room.scene), + isVisionMode: SceneMap?.[scene]?.llmConfig?.VisionConfig?.Enable, + isScreenMode: SceneMap?.[scene]?.llmConfig?.VisionConfig?.SnapshotConfig?.StreamType === 1, }; }; @@ -113,6 +114,9 @@ export const useDeviceState = () => { 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) @@ -127,6 +131,8 @@ export const useDeviceState = () => { } catch { console.warn('Not Authorized.'); } + sessionStorage.removeItem(ABORT_VISIBILITY_CHANGE); + return false; }; return { @@ -162,25 +168,26 @@ export const useJoin = (): [ ] => { const devicePermissions = useSelector((state: RootState) => state.device.devicePermissions); const room = useSelector((state: RootState) => state.room); + const scene = room.scene; const dispatch = useDispatch(); - const { switchCamera, switchMic } = useDeviceState(); + const { switchMic } = useDeviceState(); const [joining, setJoining] = useState(false); const listeners = useRtcListeners(); const handleAIGCModeStart = async () => { if (room.isAIGCEnable) { - await RtcClient.stopAudioBot(); + await RtcClient.stopAgent(); dispatch(clearCurrentMsg()); - await RtcClient.startAudioBot(); + await RtcClient.startAgent(scene); } else { - await RtcClient.startAudioBot(); + await RtcClient.startAgent(scene); } dispatch(updateAIGCState({ isAIGCEnable: true })); }; - async function disPatchJoin(formValues: FormProps): Promise { + async function disPatchJoin(): Promise { if (joining) { return; } @@ -194,18 +201,28 @@ export const useJoin = (): [ return; } - setJoining(true); - const { username, roomId } = formValues; - const isVision = isVisionMode(aigcConfig.Model); - const shouldGetVideoPermission = isVision && !ScreenShareScene.includes(room.scene); + const { appId } = await Apis.Basic.getRtcInfo(); - const token = aigcConfig.BaseConfig.Token; + 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: aigcConfig.BaseConfig.AppId, - roomId, - uid: username, + appId, + roomId: RoomId, + uid: UserId, }; await RtcClient.createEngine(engineParams); @@ -213,20 +230,20 @@ export const useJoin = (): [ RtcClient.addEventListeners(listeners); /** 2.2 RTC starting to join room */ - await RtcClient.joinRoom(token!, username); - console.log(' ------ userJoinRoom\n', `roomId: ${roomId}\n`, `uid: ${username}`); + await RtcClient.joinRoom(token!, UserId); + console.log(' ------ userJoinRoom\n', `roomId: ${RoomId}\n`, `uid: ${UserId}`); /** 3. Set users' devices info */ const mediaDevices = await RtcClient.getDevices({ audio: true, - video: shouldGetVideoPermission, + video: false, }); dispatch( localJoinRoom({ - roomId, + roomId: RoomId, user: { - username, - userId: username, + username: UserId, + userId: UserId, }, }) ); @@ -243,26 +260,11 @@ export const useJoin = (): [ if (devicePermissions.audio) { try { await switchMic(); - // RtcClient.setAudioVolume(30); } catch (e) { logger.debug('No permission for mic'); } } - if (devicePermissions.video && shouldGetVideoPermission) { - try { - await switchCamera(); - } catch (e) { - logger.debug('No permission for camera'); - } - } - - Utils.setSessionInfo({ - username, - roomId, - publishAudio: true, - }); - handleAIGCModeStart(); } diff --git a/src/pages/MainPage/MainArea/Antechamber/index.module.less b/src/pages/MainPage/MainArea/Antechamber/index.module.less index a8f96b8..fdc9df9 100644 --- a/src/pages/MainPage/MainArea/Antechamber/index.module.less +++ b/src/pages/MainPage/MainArea/Antechamber/index.module.less @@ -3,7 +3,7 @@ * SPDX-license-identifier: BSD-3-Clause */ -.wrapper { + .wrapper { position: relative; width: 100%; height: 100%; @@ -12,44 +12,58 @@ 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)); + /** + * 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; + transform: none !important; } - - .title { - font-size: 24px; - font-weight: 500; - line-height: 32px; - text-align: center; - margin-top: 24px; - } - + .description { - font-size: 12px; - font-weight: 400; - line-height: 20px; - text-align: center; - color: rgba(66, 70, 78, 1); - margin-top: 4px; + font-family: PingFang SC; + font-weight: 400; + font-size: 10px; + line-height: 20px; + color: #c7ccd6; + + position: absolute; + bottom: 24px; + left: 24px; } - + .invoke-btn { - position: absolute; - bottom: 120px; + margin-top: 32px; } -} \ No newline at end of file + + .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; + } + \ No newline at end of file diff --git a/src/pages/MainPage/MainArea/Antechamber/index.tsx b/src/pages/MainPage/MainArea/Antechamber/index.tsx index e41cd41..a3e49d6 100644 --- a/src/pages/MainPage/MainArea/Antechamber/index.tsx +++ b/src/pages/MainPage/MainArea/Antechamber/index.tsx @@ -3,19 +3,23 @@ * SPDX-license-identifier: BSD-3-Clause */ -import AvatarCard from '@/components/AvatarCard'; -import Utils from '@/utils/utils'; -import aigcConfig from '@/config'; +import { useDispatch } from 'react-redux'; +import { isMobile } from '@/utils/utils'; +import { Configuration } from '@/config'; import InvokeButton from '@/pages/MainPage/MainArea/Antechamber/InvokeButton'; -import { useJoin } from '@/lib/useCommon'; +import { useJoin, useVisionMode } from '@/lib/useCommon'; import style from './index.module.less'; +import AIChangeCard from '@/components/AiChangeCard'; +import { updateFullScreen } from '@/store/slices/room'; function Antechamber() { + const dispatch = useDispatch(); const [joining, dispatchJoin] = useJoin(); - const username = aigcConfig.BaseConfig.UserId; - const roomId = aigcConfig.BaseConfig.RoomId; - + const username = Configuration.UserId; + const roomId = Configuration.RoomId; + const { isScreenMode } = useVisionMode(); const handleJoinRoom = () => { + dispatch(updateFullScreen({ isFullScreen: !isMobile() && !isScreenMode })); // 初始化 if (!joining) { dispatchJoin( { @@ -29,11 +33,12 @@ function Antechamber() { }; return ( -
    - -
    AI 语音助手
    -
    Powered by 豆包大模型和火山引擎视频云 RTC
    +
    + + {isMobile() ? null : ( +
    Powered by 豆包大模型和火山引擎视频云 RTC
    + )}
    ); } diff --git a/src/pages/MainPage/MainArea/Room/AudioController.tsx b/src/pages/MainPage/MainArea/Room/AudioController.tsx index e3f153a..0707e8f 100644 --- a/src/pages/MainPage/MainArea/Room/AudioController.tsx +++ b/src/pages/MainPage/MainArea/Room/AudioController.tsx @@ -11,7 +11,7 @@ import { setInterruptMsg } from '@/store/slices/room'; import { useDeviceState } from '@/lib/useCommon'; import { COMMAND } from '@/utils/handler'; import style from './index.module.less'; -import StopRobotBtn from '@/assets/img/StopRobotBtn.svg'; +import { Configuration } from '@/config'; const THRESHOLD_VOLUME = 18; @@ -21,23 +21,27 @@ function AudioController(props: React.HTMLAttributes) { const room = useSelector((state: RootState) => state.room); const volume = room.localUser.audioPropertiesInfo?.linearVolume || 0; const { isAudioPublished } = useDeviceState(); - const isAITalking = room.isAITalking; + const { isAITalking } = room; + const isAIReady = room.msgHistory.length > 0; const isLoading = volume >= THRESHOLD_VOLUME && isAudioPublished; const handleInterrupt = () => { - RtcClient.commandAudioBot(COMMAND.INTERRUPT); + RtcClient.commandAgent(COMMAND.INTERRUPT); dispatch(setInterruptMsg()); }; return (
    {isAudioPublished ? ( - isAITalking ? ( -
    - StopRobotBtn - 点击打断 + isAIReady && isAITalking ? ( +
    + {Configuration.InterruptMode ?
    语音打断 或
    : null} +
    +
    + 点此打断 +
    - ) : ( -
    正在听...
    + ) : isLoading ? null : ( +
    请开始说话
    ) ) : (
    你已关闭麦克风
    diff --git a/src/pages/MainPage/MainArea/Room/CameraArea.tsx b/src/pages/MainPage/MainArea/Room/CameraArea.tsx index 7cfe32f..80438f0 100644 --- a/src/pages/MainPage/MainArea/Room/CameraArea.tsx +++ b/src/pages/MainPage/MainArea/Room/CameraArea.tsx @@ -4,13 +4,17 @@ */ 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 RtcClient from '@/lib/RtcClient'; -import { ScreenShareScene } from '@/config'; 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'; @@ -20,17 +24,19 @@ const LocalScreenID = 'local-screen-player'; function CameraArea(props: React.HTMLAttributes) { const { className, ...rest } = props; const room = useSelector((state: RootState) => state.room); - const { isVisionMode } = useVisionMode(); - const isScreenMode = ScreenShareScene.includes(room.scene); + const { isFullScreen, scene } = room; + const { isVisionMode, isScreenMode } = useVisionMode(); const { isVideoPublished, isScreenPublished, switchCamera, switchScreenCapture } = useDeviceState(); const setVideoPlayer = () => { - if (isVisionMode && (isVideoPublished || isScreenPublished)) { + RtcClient.removeVideoPlayer(room.localUser.username!); + if (isVideoPublished || isScreenPublished) { RtcClient.setLocalVideoPlayer( room.localUser.username!, - isScreenMode ? LocalScreenID : LocalVideoID, - isScreenPublished + isFullScreen ? 'local-full-player' : isScreenMode ? LocalScreenID : LocalVideoID, + isScreenPublished, + isScreenMode ? VideoRenderMode.RENDER_MODE_FILL : VideoRenderMode.RENDER_MODE_HIDDEN ); } }; @@ -45,10 +51,15 @@ function CameraArea(props: React.HTMLAttributes) { useEffect(() => { setVideoPlayer(); - }, [isVideoPublished, isScreenPublished, isScreenMode]); + }, [isVideoPublished, isScreenPublished, isScreenMode, isFullScreen, isVisionMode]); - return isVisionMode ? ( + return (
    + + {isFullScreen ? ( + + ) : null} + {isVideoPublished || isScreenPublished ? : null}
    ) { }`} > close -
    - 请 - {isScreenMode ? ( - - 打开屏幕采集 - - ) : ( - - 打开摄像头 - - )} -
    -
    体验豆包视觉理解模型
    + + {isFullScreen ? null : ( +
    + {isScreenMode ? ( + <> + 打开 + + 屏幕共享 + +
    体验豆包视觉理解模型
    + + ) : isVisionMode ? ( + <> + 打开 + + 摄像头 + +
    体验豆包视觉理解模型
    + + ) : null} +
    + )}
    - ) : null; + ); } export default CameraArea; diff --git a/src/pages/MainPage/MainArea/Room/Conversation.tsx b/src/pages/MainPage/MainArea/Room/Conversation.tsx index ddd0e3d..a9e99ab 100644 --- a/src/pages/MainPage/MainArea/Room/Conversation.tsx +++ b/src/pages/MainPage/MainArea/Room/Conversation.tsx @@ -8,16 +8,19 @@ import { useSelector } from 'react-redux'; import { Tag, Spin } from '@arco-design/web-react'; import { RootState } from '@/store'; import Loading from '@/components/Loading/HorizonLoading'; -import Config from '@/config'; +import { Configuration, SceneMap } from '@/config'; +import USER_AVATAR from '@/assets/img/userAvatar.png'; import styles from './index.module.less'; +import { isMobile } from '@/utils/utils'; const lines: (string | React.ReactNode)[] = []; function Conversation(props: React.HTMLAttributes) { const { className, ...rest } = props; - const msgHistory = useSelector((state: RootState) => state.room.msgHistory); + const room = useSelector((state: RootState) => state.room); + const { msgHistory, isFullScreen } = room; const { userId } = useSelector((state: RootState) => state.room.localUser); - const { isAITalking, isUserTalking } = useSelector((state: RootState) => state.room); + const { isAITalking, isUserTalking, scene } = useSelector((state: RootState) => state.room); const isAIReady = msgHistory.length > 0; const containerRef = useRef(null); @@ -26,7 +29,7 @@ function Conversation(props: React.HTMLAttributes) { }; const isAITextLoading = (owner: string) => { - return owner === Config.BotName && isAITalking; + return owner === Configuration.BotName && isAITalking; }; useEffect(() => { @@ -37,7 +40,13 @@ function Conversation(props: React.HTMLAttributes) { }, [msgHistory.length]); return ( -
    +
    {lines.map((line) => line)} {!isAIReady ? (
    @@ -49,28 +58,42 @@ function Conversation(props: React.HTMLAttributes) { )} {msgHistory?.map(({ value, user, isInterrupted }, index) => { const isUserMsg = user === userId; - const isRobotMsg = user === Config.BotName; + const isRobotMsg = user === Configuration.BotName; if (!isUserMsg && !isRobotMsg) { return ''; } return (
    -
    - {value} -
    - {isAIReady && - (isUserTextLoading(user) || isAITextLoading(user)) && - index === msgHistory.length - 1 ? ( - - ) : ( - '' - )} + {!isMobile() && ( +
    +
    + Avatar +
    + {isUserMsg ? '我' : scene}
    + )} +
    +
    + {value} +
    + {isAIReady && + (isUserTextLoading(user) || isAITextLoading(user)) && + index === msgHistory.length - 1 ? ( + + ) : ( + '' + )} +
    +
    + {!isUserMsg && isInterrupted ? 已打断 : ''}
    - {!isUserMsg && isInterrupted ? 已打断 : ''}
    ); })} diff --git a/src/pages/MainPage/MainArea/Room/ToolBar.tsx b/src/pages/MainPage/MainArea/Room/ToolBar.tsx index 7c1c9f4..2d48d08 100644 --- a/src/pages/MainPage/MainArea/Room/ToolBar.tsx +++ b/src/pages/MainPage/MainArea/Room/ToolBar.tsx @@ -3,21 +3,16 @@ * SPDX-license-identifier: BSD-3-Clause */ -import { useSelector } from 'react-redux'; import { memo, useState } from 'react'; import { Drawer } from '@arco-design/web-react'; -import { useDeviceState, useLeave } from '@/lib/useCommon'; -import { RootState } from '@/store'; -import { isVisionMode } from '@/config/common'; -import { ScreenShareScene } from '@/config'; -import utils from '@/utils/utils'; +import { useDeviceState, useLeave, useVisionMode } 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 SettingSVG from '@/assets/img/Setting.svg'; import MicCloseSVG from '@/assets/img/MicClose.svg'; import LeaveRoomSVG from '@/assets/img/LeaveRoom.svg'; import ScreenOnSVG from '@/assets/img/ScreenOn.svg'; @@ -25,10 +20,8 @@ import ScreenOffSVG from '@/assets/img/ScreenOff.svg'; function ToolBar(props: React.HTMLAttributes) { const { className, ...rest } = props; - const room = useSelector((state: RootState) => state.room); const [open, setOpen] = useState(false); - const model = room.aiConfig.Config.LLMConfig?.ModelName; - const isScreenMode = ScreenShareScene.includes(room.scene); + const { isScreenMode } = useVisionMode(); const leaveRoom = useLeave(); const { isAudioPublished, @@ -38,42 +31,41 @@ function ToolBar(props: React.HTMLAttributes) { switchCamera, switchScreenCapture, } = useDeviceState(); + const { isVisionMode } = useVisionMode(); - const handleSetting = () => { - setOpen(true); - }; return ( -
    - {utils.isMobile() ? ( - setting - ) : null} +
    switchMic(true)} className={style.btn} alt="mic" /> - {isVisionMode(model) ? ( - isScreenMode ? ( - switchScreenCapture()} - className={style.btn} - alt="screenShare" - /> - ) : ( - switchCamera(true)} - className={style.btn} - alt="camera" - /> - ) + {!isVisionMode ? null : isScreenMode && !isMobile() ? ( + switchScreenCapture()} + className={style.btn} + alt="screenShare" + /> ) : ( - '' + switchCamera(true)} + className={style.btn} + alt="camera" + /> + )} + {isScreenMode && ( + switchScreenCapture()} + className={style.btn} + alt="screenShare" + /> )} leave - {utils.isMobile() ? ( + {isMobile() ? ( state.room); + const { isShowSubtitle, scene, isFullScreen } = room; return ( -
    - - {utils.isMobile() ? null : } - +
    + {isMobile() ?
    : null} + {isMobile() ? : null} + {isShowSubtitle && !isMobile() ? ( + + ) : null} + {isFullScreen && !isMobile() ? ( + + ) : isMobile() && isShowSubtitle ? null : ( + + )} + {isMobile() ? null : } + {isShowSubtitle && }
    AI生成内容由大模型生成,不能完全保障真实
    diff --git a/src/pages/MainPage/Menu/components/AISettingButton/index.module.less b/src/pages/MainPage/Menu/components/AISettingButton/index.module.less new file mode 100644 index 0000000..f7db1a1 --- /dev/null +++ b/src/pages/MainPage/Menu/components/AISettingButton/index.module.less @@ -0,0 +1,36 @@ +/** + * 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% + ); +} diff --git a/src/pages/MainPage/Menu/components/AISettingButton/index.tsx b/src/pages/MainPage/Menu/components/AISettingButton/index.tsx new file mode 100644 index 0000000..63644d8 --- /dev/null +++ b/src/pages/MainPage/Menu/components/AISettingButton/index.tsx @@ -0,0 +1,26 @@ +/** + * 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 ( + <> + + setOpen(false)} onOk={() => setOpen(false)} /> + + ); +} + +export default AISettingButton; diff --git a/src/pages/MainPage/Menu/components/DeviceDrawerButton/index.tsx b/src/pages/MainPage/Menu/components/DeviceDrawerButton/index.tsx index 56024fe..a95bc70 100644 --- a/src/pages/MainPage/Menu/components/DeviceDrawerButton/index.tsx +++ b/src/pages/MainPage/Menu/components/DeviceDrawerButton/index.tsx @@ -10,9 +10,9 @@ import { Switch, Select } from '@arco-design/web-react'; import DrawerRowItem from '@/components/DrawerRowItem'; import { RootState } from '@/store'; import RtcClient from '@/lib/RtcClient'; -import { useDeviceState } from '@/lib/useCommon'; +import { useDeviceState, useVisionMode } from '@/lib/useCommon'; import { updateSelectedDevice } from '@/store/slices/device'; -import utils from '@/utils/utils'; +import { isMobile } from '@/utils/utils'; import styles from './index.module.less'; interface IDeviceDrawerButtonProps { @@ -34,6 +34,14 @@ 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 isScreenEnable = device.isScreenPublished; + const changeScreenPublished = device.switchScreenCapture; + + const SETTING_NAME = { + [MediaType.AUDIO]: '麦克风', + [MediaType.VIDEO]: isScreenMode ? '屏幕共享' : '视频', + }; const dispatch = useDispatch(); const deviceList = useMemo( @@ -61,30 +69,46 @@ function DeviceDrawerButton(props: IDeviceDrawerButtonProps) { return ( -
    {DEVICE_NAME[type]}
    -
    - switcher(enable)} - disabled={!permission} - /> - -
    -
    + <> + {!isScreenMode && ( +
    +
    {DEVICE_NAME[type]}
    +
    + switcher(enable)} + disabled={!permission} + /> + +
    +
    + )} + {type === MediaType.VIDEO && isScreenMode && ( +
    +
    屏幕共享
    +
    + +
    +
    + )} + ), }} /> diff --git a/src/pages/MainPage/Menu/components/Interrupt/index.tsx b/src/pages/MainPage/Menu/components/Interrupt/index.tsx deleted file mode 100644 index 4ea7142..0000000 --- a/src/pages/MainPage/Menu/components/Interrupt/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. - * SPDX-license-identifier: BSD-3-Clause - */ - -import { Popover, Switch } from '@arco-design/web-react'; -import { IconQuestionCircle } from '@arco-design/web-react/icon'; -import { useState } from 'react'; -import { useDispatch } from 'react-redux'; -import Config from '@/config'; -import styles from './index.module.less'; -import RtcClient from '@/lib/RtcClient'; -import { clearHistoryMsg } from '@/store/slices/room'; - -function Interrupt() { - const dispatch = useDispatch(); - const [switchAble, setSwitchAble] = useState(true); - const [enable, setEnable] = useState(Config.InterruptMode); - const handleChange = () => { - setSwitchAble(false); - setEnable(!enable); - Config.InterruptMode = !enable; - if (RtcClient.getAudioBotEnabled()) { - dispatch(clearHistoryMsg()); - } - RtcClient.updateAudioBot(); - setTimeout(() => { - setSwitchAble(true); - }, 3000); - }; - return ( -
    -
    - 语音打断 - - - -
    -
    - -
    -
    - ); -} - -export default Interrupt; diff --git a/src/pages/MainPage/Menu/components/Operation/index.tsx b/src/pages/MainPage/Menu/components/Operation/index.tsx index 7f5c453..f05cdcd 100644 --- a/src/pages/MainPage/Menu/components/Operation/index.tsx +++ b/src/pages/MainPage/Menu/components/Operation/index.tsx @@ -5,19 +5,17 @@ import { MediaType } from '@volcengine/rtc'; import DeviceDrawerButton from '../DeviceDrawerButton'; +import Subtitle from '../Subtitle'; import { useVisionMode } from '@/lib/useCommon'; -import AISettingAnchor from '../AISettingAnchor'; -import Interrupt from '../Interrupt'; import styles from './index.module.less'; function Operation() { - const { isVisionMode, isScreenMode } = useVisionMode(); + const { isVisionMode } = useVisionMode(); return (
    - - + + {isVisionMode && } - {isVisionMode && !isScreenMode ? : ''}
    ); } diff --git a/src/pages/MainPage/Menu/components/Interrupt/index.module.less b/src/pages/MainPage/Menu/components/Subtitle/index.module.less similarity index 97% rename from src/pages/MainPage/Menu/components/Interrupt/index.module.less rename to src/pages/MainPage/Menu/components/Subtitle/index.module.less index 5c1219c..6864c36 100644 --- a/src/pages/MainPage/Menu/components/Interrupt/index.module.less +++ b/src/pages/MainPage/Menu/components/Subtitle/index.module.less @@ -3,7 +3,7 @@ * SPDX-license-identifier: BSD-3-Clause */ -.interrupt { +.subtitle { position: relative; display: flex; flex-direction: row; diff --git a/src/pages/MainPage/Menu/components/Subtitle/index.tsx b/src/pages/MainPage/Menu/components/Subtitle/index.tsx new file mode 100644 index 0000000..8d2e129 --- /dev/null +++ b/src/pages/MainPage/Menu/components/Subtitle/index.tsx @@ -0,0 +1,35 @@ +/** + * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. + * SPDX-license-identifier: BSD-3-Clause + */ + +import { useState } from 'react'; +import { Switch } from '@arco-design/web-react'; +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from '@/store'; +import { updateShowSubtitle } from '@/store/slices/room'; +import styles from './index.module.less'; + +function Subtitle() { + const dispatch = useDispatch(); + const room = useSelector((state: RootState) => state.room); + const { isShowSubtitle } = room; + const [checked, setChecked] = useState(isShowSubtitle); + const [loading, setLoading] = useState(false); + const handleChange = () => { + setLoading(true); + setChecked(!checked); + dispatch(updateShowSubtitle({ isShowSubtitle: !checked })); + setLoading(false); + }; + return ( +
    +
    字幕
    +
    + +
    +
    + ); +} + +export default Subtitle; diff --git a/src/pages/MainPage/Menu/index.module.less b/src/pages/MainPage/Menu/index.module.less index e33628f..8bb8d7c 100644 --- a/src/pages/MainPage/Menu/index.module.less +++ b/src/pages/MainPage/Menu/index.module.less @@ -3,8 +3,8 @@ * SPDX-license-identifier: BSD-3-Clause */ -.wrapper { - width: 200px; + .wrapper { + width: 210px; height: 100%; border-radius: 16px; display: flex; @@ -12,6 +12,32 @@ 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; @@ -38,6 +64,8 @@ :global { .arco-typography { margin-bottom: 0px; + display: inline-block; + color: #737a87; } } } @@ -53,32 +81,44 @@ .getMore { width: 100%; color: #fff; - height: 32px; + height: 36px; text-shadow: none; box-shadow: none; border: none; - padding: 0px 24px; - 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: 4px; + 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); + color: var(--Primary-Neutral-0, #fff); text-align: center; /* Body/body-2 medium */ - font-family: "PingFang SC"; + font-family: 'PingFang SC'; font-size: 13px; font-style: normal; font-weight: 500; cursor: pointer; } - + .getMore:hover { opacity: 0.9; } @@ -90,7 +130,7 @@ .getMore[disabled], .getMore[disabled]:hover { color: #fff; - background: linear-gradient(95.87deg, #1664FF 0%, #8040FF 97.7%); + background: linear-gradient(95.87deg, #1664ff 0%, #8040ff 97.7%); opacity: 0.8; } } @@ -143,16 +183,16 @@ 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; + color: #42464e; /* Body/body-1 regular */ - font-family: "PingFang SC"; + font-family: 'PingFang SC'; font-size: 12px; font-style: normal; font-weight: 400; @@ -161,6 +201,13 @@ opacity: 0.8; } } + + .tagWrapper { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 4px; + } } .mobile-camera-wrapper { @@ -181,4 +228,4 @@ top: auto !important; right: auto !important; } -} \ No newline at end of file +} diff --git a/src/pages/MainPage/Menu/index.tsx b/src/pages/MainPage/Menu/index.tsx index 1542751..2d9bceb 100644 --- a/src/pages/MainPage/Menu/index.tsx +++ b/src/pages/MainPage/Menu/index.tsx @@ -4,61 +4,49 @@ */ import VERTC from '@volcengine/rtc'; -import { useEffect, useState } from 'react'; import { Tooltip, Typography } from '@arco-design/web-react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { useVisionMode } from '@/lib/useCommon'; import { RootState } from '@/store'; -import RtcClient from '@/lib/RtcClient'; import Operation from './components/Operation'; -import { Questions } from '@/config'; -import { COMMAND, INTERRUPT_PRIORITY } from '@/utils/handler'; import CameraArea from '../MainArea/Room/CameraArea'; -import { setHistoryMsg, setInterruptMsg } from '@/store/slices/room'; -import utils from '@/utils/utils'; +import { isMobile } from '@/utils/utils'; +import { SceneMap } from '@/config'; import packageJson from '../../../../package.json'; import styles from './index.module.less'; +import AISettingButton from './components/AISettingButton'; function Menu() { - const dispatch = useDispatch(); - const [question, setQuestion] = useState(''); const room = useSelector((state: RootState) => state.room); - const scene = room.scene; + 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 handleQuestion = (que: string) => { - RtcClient.commandAudioBot(COMMAND.EXTERNAL_TEXT_TO_LLM, INTERRUPT_PRIORITY.HIGH, que); - setQuestion(que); - }; - - useEffect(() => { - if (question && !room.isAITalking) { - dispatch(setInterruptMsg()); - dispatch( - setHistoryMsg({ - text: question, - user: RtcClient.basicInfo.user_id, - paragraph: true, - definite: true, - }) - ); - setQuestion(''); - } - }, [question, room.isAITalking]); + const { isVisionMode } = useVisionMode(); + const requestId = sessionStorage.getItem('RequestID'); return (
    - {isJoined && utils.isMobile() && isVisionMode ? ( + {isJoined && isMobile() && isVisionMode ? (
    ) : null}
    -
    Demo Version {packageJson.version}
    -
    SDK Version {VERTC.getSdkVersion()}
    +
    AI 人设:{scene}
    +
    +
    音色 {voice}
    +
    {model}
    +
    + {isJoined && } +
    + {isJoined ? : ''} +
    +
    {isJoined ? '其他信息' : '版本信息'}
    +
    Demo Version {packageJson.version}
    +
    SDK Version {VERTC.getSdkVersion()}
    {isJoined ? ( -
    +
    房间ID{' '} + RequestID{' '} + + + {requestId || '-'} + + +
    + ) : ( + '' + )}
    - {isJoined ? ( -
    -
    点击下述问题进行提问:
    - {Questions[scene].map((question) => ( -
    handleQuestion(question)} className={styles.line} key={question}> - {question} -
    - ))} -
    - ) : ( - '' - )} - {isJoined ? : ''}
    ); } diff --git a/src/pages/MainPage/index.module.less b/src/pages/MainPage/index.module.less index 0653f31..71ce66b 100644 --- a/src/pages/MainPage/index.module.less +++ b/src/pages/MainPage/index.module.less @@ -20,6 +20,7 @@ background-color: white; border-radius: 16px; overflow: hidden; + border: 1px solid var(--line-color-border-2, #eaedf1); } .isMobile { diff --git a/src/pages/MainPage/index.tsx b/src/pages/MainPage/index.tsx index b221f4c..7bb86a6 100644 --- a/src/pages/MainPage/index.tsx +++ b/src/pages/MainPage/index.tsx @@ -3,27 +3,47 @@ * SPDX-license-identifier: BSD-3-Clause */ +import { useEffect } from 'react'; import Header from '@/components/Header'; import ResizeWrapper from '@/components/ResizeWrapper'; import Menu from './Menu'; -import utils from '@/utils/utils'; +import { useIsMobile } from '@/utils/utils'; import MainArea from './MainArea'; +import { ABORT_VISIBILITY_CHANGE, useLeave } from '@/lib/useCommon'; import styles from './index.module.less'; export default function () { + const leaveRoom = useLeave(); + + useEffect(() => { + 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 (
    -
    +
    - {utils.isMobile() ? null : ( + {useIsMobile() ? null : (
    diff --git a/src/pages/Mobile/MobileToolBar/index.module.less b/src/pages/Mobile/MobileToolBar/index.module.less new file mode 100644 index 0000000..cd49fcb --- /dev/null +++ b/src/pages/Mobile/MobileToolBar/index.module.less @@ -0,0 +1,61 @@ +/** + * 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; + } +} diff --git a/src/pages/Mobile/MobileToolBar/index.tsx b/src/pages/Mobile/MobileToolBar/index.tsx new file mode 100644 index 0000000..b48c191 --- /dev/null +++ b/src/pages/Mobile/MobileToolBar/index.tsx @@ -0,0 +1,85 @@ +/** + * 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 { IconRight } from '@arco-design/web-react/icon'; +import { useDeviceState, useVisionMode } 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'; + +function MobileToolBar(props: React.HTMLAttributes) { + 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 { isVideoPublished, isScreenPublished } = useDeviceState(); + + const handleSetting = () => { + setOpen(true); + }; + + const handleOpenDrawer = () => setOpenAISettings(true); + + 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 ( +
    +
    +
    + ... +
    +
    + {room.scene} +
    + setOpenAISettings(false)} + onOk={() => setOpenAISettings(false)} + /> +
    +
    +
    + 字幕 +
    +
    + + setOpen(false)} /> +
    + ); +} +export default memo(MobileToolBar); diff --git a/src/pages/Mobile/SettingsDrawer/index.module.less b/src/pages/Mobile/SettingsDrawer/index.module.less new file mode 100644 index 0000000..f97550f --- /dev/null +++ b/src/pages/Mobile/SettingsDrawer/index.module.less @@ -0,0 +1,58 @@ +/** + * 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; +} diff --git a/src/pages/Mobile/SettingsDrawer/index.tsx b/src/pages/Mobile/SettingsDrawer/index.tsx new file mode 100644 index 0000000..725cb89 --- /dev/null +++ b/src/pages/Mobile/SettingsDrawer/index.tsx @@ -0,0 +1,91 @@ +/** + * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. + * SPDX-license-identifier: BSD-3-Clause + */ + +import VERTC from '@volcengine/rtc'; +import { Drawer, Message } from '@arco-design/web-react'; // Import Message if you plan to use it +import { useSelector } from 'react-redux'; +import { RootState } from '@/store'; +import { useLeave } from '@/lib/useCommon'; +import { Disclaimer, ReversoContext, UserAgreement } from '@/config'; +import { SettingsItem } from '../components/SettingsItem'; +import packageJSON from '../../../../package.json'; +import styles from './index.module.less'; + +interface SettingsDrawerProps { + visible: boolean; + onCancel: () => void; +} + +function SettingsDrawer({ visible, onCancel }: SettingsDrawerProps) { + const room = useSelector((state: RootState) => state.room); + const { roomId } = room; + const leaveRoom = useLeave(); + + const handleLogout = () => { + leaveRoom(); + }; + + const handleCopyLink = () => { + const pcLink = window.location.origin + window.location.pathname; + navigator.clipboard + .writeText(pcLink) + .then(() => { + Message.success('链接已复制'); + }) + .catch((err) => { + console.error('复制链接失败:', err); + Message.error('复制失败,请手动复制'); + }); + }; + + return ( + +
    +
    + + window.open(ReversoContext, '_blank')} /> + window.open(UserAgreement, '_blank')} /> + window.open(Disclaimer, '_blank')} /> + + Demo version {packageJSON.version} + SDK version {VERTC.getSdkVersion()} +
    + } + showArrow={false} + /> +
    + +
    + +
    + +
    + +
    +
    + + ); +} + +export default SettingsDrawer; diff --git a/src/pages/Mobile/components/SettingsItem/index.module.less b/src/pages/Mobile/components/SettingsItem/index.module.less new file mode 100644 index 0000000..ef71982 --- /dev/null +++ b/src/pages/Mobile/components/SettingsItem/index.module.less @@ -0,0 +1,36 @@ +/** + * 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; + } +} diff --git a/src/pages/Mobile/components/SettingsItem/index.tsx b/src/pages/Mobile/components/SettingsItem/index.tsx new file mode 100644 index 0000000..f6529d3 --- /dev/null +++ b/src/pages/Mobile/components/SettingsItem/index.tsx @@ -0,0 +1,33 @@ +/** + * Copyright 2025 Beijing Volcano Engine Technology Co., Ltd. All Rights Reserved. + * SPDX-license-identifier: BSD-3-Clause + */ + +import { IconRight } from '@arco-design/web-react/icon'; +import styles from './index.module.less'; + +interface SettingsItemProps { + label: string; + value?: string | React.ReactNode; + onClick?: () => void; + showArrow?: boolean; + valueClassName?: string; +} + +export function SettingsItem({ + label, + value, + onClick, + showArrow = true, + valueClassName, +}: SettingsItemProps) { + return ( +
    + {label} +
    + {value && {value}} + {showArrow && } +
    +
    + ); +} diff --git a/src/store/slices/room.ts b/src/store/slices/room.ts index a401dfc..21529ce 100644 --- a/src/store/slices/room.ts +++ b/src/store/slices/room.ts @@ -10,7 +10,7 @@ import { NetworkQuality, RemoteAudioStats, } from '@volcengine/rtc'; -import config, { MODEL_MODE, SCENE } from '@/config'; +import { Configuration, Scenes } from '@/config'; export interface IUser { username?: string; @@ -49,7 +49,7 @@ export interface RoomState { /** * @brief 选择的模式 */ - scene: SCENE; + scene: string; /** * @brief AI 通话是否启用 @@ -67,14 +67,6 @@ export interface RoomState { * @brief 用户是否正在说话 */ isUserTalking: boolean; - /** - * @brief AI 基础配置 - */ - aiConfig: ReturnType; - /** - * @brief 当前模型的类型 - */ - modelMode: MODEL_MODE; /** * @brief 网络质量 */ @@ -100,11 +92,26 @@ export interface RoomState { definite: boolean; }; }; + + /** + * @brief 是否显示字幕 + */ + isShowSubtitle: boolean; + + /** + * @brief 是否全屏 + */ + isFullScreen: boolean; + + /** + * @brief 自定义人设名称 + */ + customSceneName: string; } const initialState: RoomState = { time: -1, - scene: SCENE.INTELLIGENT_ASSISTANT, + scene: Scenes[0].name, remoteUsers: [], localUser: { publishAudio: false, @@ -119,11 +126,11 @@ const initialState: RoomState = { isUserTalking: false, networkQuality: NetworkQuality.UNKNOWN, - aiConfig: config.aigcConfig, - modelMode: MODEL_MODE.ORIGINAL, - msgHistory: [], currentConversation: {}, + isShowSubtitle: true, + isFullScreen: false, + customSceneName: '', }; export const roomSlice = createSlice({ @@ -227,12 +234,6 @@ export const roomSlice = createSlice({ state.isAIThinking = payload.isAIThinking; state.isUserTalking = false; }, - updateAIConfig: (state, { payload }) => { - state.aiConfig = Object.assign(state.aiConfig, payload); - }, - updateModelMode: (state, { payload }) => { - state.modelMode = payload; - }, clearHistoryMsg: (state) => { state.msgHistory = []; }, @@ -240,7 +241,7 @@ export const roomSlice = createSlice({ const { paragraph, definite } = payload; const lastMsg = state.msgHistory.at(-1)! || {}; /** 是否需要再创建新句子 */ - const fromBot = payload.user === config.BotName; + const fromBot = payload.user === Configuration.BotName; /** * Bot 的语句以 definite 判断是否需要追加新内容 * User 的语句以 paragraph 判断是否需要追加新内容 @@ -297,6 +298,15 @@ export const roomSlice = createSlice({ 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; + }, }, }); @@ -314,14 +324,15 @@ export const { updateAIGCState, updateAITalkState, updateAIThinkState, - updateAIConfig, - updateModelMode, setHistoryMsg, clearHistoryMsg, clearCurrentMsg, setInterruptMsg, updateNetworkQuality, updateScene, + updateShowSubtitle, + updateFullScreen, + updatecustomSceneName, } = roomSlice.actions; export default roomSlice.reducer; diff --git a/src/utils/handler.ts b/src/utils/handler.ts index 1b90428..03bd8ae 100644 --- a/src/utils/handler.ts +++ b/src/utils/handler.ts @@ -12,7 +12,7 @@ import { updateAIThinkState, } from '@/store/slices/room'; import RtcClient from '@/lib/RtcClient'; -import Utils from '@/utils/utils'; +import { string2tlv, tlv2String } from '@/utils/utils'; export type AnyRecord = Record; @@ -87,7 +87,7 @@ export const useMessageHandler = () => { [MESSAGE_TYPE.BRIEF]: (parsed: AnyRecord) => { const { Stage } = parsed || {}; const { Code, Description } = Stage || {}; - logger.debug(Code, Description); + logger.debug('[MESSAGE_TYPE.BRIEF]: ', Code, Description); switch (Code) { case AGENT_BRIEF.THINKING: dispatch(updateAIThinkState({ isAIThinking: true })); @@ -114,14 +114,12 @@ export const useMessageHandler = () => { /** debounce 记录用户输入文字 */ if (data) { const { text: msg, definite, userId: user, paragraph } = data; - logger.debug('handleRoomBinaryMessageReceived', data); + const isAudioEnable = RtcClient.getAgentEnabled(); if ((window as any)._debug_mode) { - dispatch(setHistoryMsg({ msg, user, paragraph, definite })); - } else { - const isAudioEnable = RtcClient.getAudioBotEnabled(); - if (isAudioEnable) { - dispatch(setHistoryMsg({ text: msg, user, paragraph, definite })); - } + logger.debug('handleRoomBinaryMessageReceived', data); + } + if (isAudioEnable) { + dispatch(setHistoryMsg({ text: msg, user, paragraph, definite })); } } }, @@ -138,7 +136,7 @@ export const useMessageHandler = () => { RtcClient.engine.sendUserBinaryMessage( 'RobotMan_', - Utils.string2tlv( + string2tlv( JSON.stringify({ ToolCallID: parsed?.tool_calls?.[0]?.id, Content: map[name.toLocaleLowerCase().replaceAll('_', '')], @@ -152,7 +150,7 @@ export const useMessageHandler = () => { return { parser: (buffer: ArrayBuffer) => { try { - const { type, value } = Utils.tlv2String(buffer); + const { type, value } = tlv2String(buffer); maps[type as MESSAGE_TYPE]?.(JSON.parse(value)); } catch (e) { logger.debug('parse error', e); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index ffdd964..c2cc1aa 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -3,112 +3,80 @@ * SPDX-license-identifier: BSD-3-Clause */ -class Utils { - formatTime = (time: number): string => { - if (time < 0) { - return '00:00'; - } - let minutes: number | string = Math.floor(time / 60); - let seconds: number | string = time % 60; - minutes = minutes > 9 ? `${minutes}` : `0${minutes}`; - seconds = seconds > 9 ? `${seconds}` : `0${seconds}`; +import { useEffect, useState } from 'react'; - return `${minutes}:${seconds}`; - }; +/** + * @brief 将字符串包装成 TLV + */ +export const string2tlv = (str: string, type: string) => { + const typeBuffer = new Uint8Array(4); - formatDate = (date: Date): string => { - const hours = date.getHours(); - const minutes = date.getMinutes(); - const seconds = date.getSeconds(); - - const formattedHours = hours.toString().padStart(2, '0'); - const formattedMinutes = minutes.toString().padStart(2, '0'); - const formattedSeconds = seconds.toString().padStart(2, '0'); - - return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`; - }; - - setSessionInfo = (params: { [key: string]: any }) => { - Object.keys(params).forEach((key) => { - sessionStorage.setItem(key, params[key]); - }); - }; - - /** - * @brief 获取 url 参数 - */ - getUrlArgs = () => { - const query = window.location.search.substring(1); - const pairs = query.split('&'); - return pairs.reduce<{ [key: string]: string }>((queries, pair) => { - const [key, value] = pair.split('='); - if (key && value) { - queries[key] = decodeURIComponent(value); - } - return queries; - }, {}); - }; - - isPureObject = (target: any) => Object.prototype.toString.call(target).includes('Object'); - - isArray = Array.isArray; - - /** - * @brief 将字符串包装成 TLV - */ - string2tlv(str: string, type: string) { - const typeBuffer = new Uint8Array(4); - - for (let i = 0; i < type.length; i++) { - typeBuffer[i] = type.charCodeAt(i); - } - - const lengthBuffer = new Uint32Array(1); - const valueBuffer = new TextEncoder().encode(str); - - lengthBuffer[0] = valueBuffer.length; - - const tlvBuffer = new Uint8Array(typeBuffer.length + 4 + valueBuffer.length); - - tlvBuffer.set(typeBuffer, 0); - - tlvBuffer[4] = (lengthBuffer[0] >> 24) & 0xff; - tlvBuffer[5] = (lengthBuffer[0] >> 16) & 0xff; - tlvBuffer[6] = (lengthBuffer[0] >> 8) & 0xff; - tlvBuffer[7] = lengthBuffer[0] & 0xff; - - tlvBuffer.set(valueBuffer, 8); - return tlvBuffer.buffer; + for (let i = 0; i < type.length; i++) { + typeBuffer[i] = type.charCodeAt(i); } - /** - * @brief TLV 数据格式转换成字符串 - * @note TLV 数据格式 - * | magic number | length(big-endian) | value | - * @param {ArrayBufferLike} tlvBuffer - * @returns - */ - tlv2String(tlvBuffer: ArrayBufferLike) { - const typeBuffer = new Uint8Array(tlvBuffer, 0, 4); - const lengthBuffer = new Uint8Array(tlvBuffer, 4, 4); - const valueBuffer = new Uint8Array(tlvBuffer, 8); + const lengthBuffer = new Uint32Array(1); + const valueBuffer = new TextEncoder().encode(str); - let type = ''; - for (let i = 0; i < typeBuffer.length; i++) { - type += String.fromCharCode(typeBuffer[i]); - } + lengthBuffer[0] = valueBuffer.length; - const length = - (lengthBuffer[0] << 24) | (lengthBuffer[1] << 16) | (lengthBuffer[2] << 8) | lengthBuffer[3]; + const tlvBuffer = new Uint8Array(typeBuffer.length + 4 + valueBuffer.length); - const value = new TextDecoder().decode(valueBuffer.subarray(0, length)); + tlvBuffer.set(typeBuffer, 0); - return { type, value }; + tlvBuffer[4] = (lengthBuffer[0] >> 24) & 0xff; + tlvBuffer[5] = (lengthBuffer[0] >> 16) & 0xff; + tlvBuffer[6] = (lengthBuffer[0] >> 8) & 0xff; + tlvBuffer[7] = lengthBuffer[0] & 0xff; + + tlvBuffer.set(valueBuffer, 8); + return tlvBuffer.buffer; +}; + +/** + * @brief TLV 数据格式转换成字符串 + * @note TLV 数据格式 + * | magic number | length(big-endian) | value | + * @param {ArrayBufferLike} tlvBuffer + * @returns + */ +export const tlv2String = (tlvBuffer: ArrayBufferLike) => { + const typeBuffer = new Uint8Array(tlvBuffer, 0, 4); + const lengthBuffer = new Uint8Array(tlvBuffer, 4, 4); + const valueBuffer = new Uint8Array(tlvBuffer, 8); + + let type = ''; + for (let i = 0; i < typeBuffer.length; i++) { + type += String.fromCharCode(typeBuffer[i]); } - isMobile() { - return /Mobi|Android|iPhone|iPad|Windows Phone/i.test(window.navigator.userAgent); - } + const length = + (lengthBuffer[0] << 24) | (lengthBuffer[1] << 16) | (lengthBuffer[2] << 8) | lengthBuffer[3]; + + const value = new TextDecoder().decode(valueBuffer.subarray(0, length)); + + return { type, value }; +}; + +export const isMobile = () => + /Mobi|Android|iPhone|iPad|Windows Phone/i.test(window.navigator.userAgent) || + window?.innerWidth < 767; + +export function useIsMobile() { + const getIsMobile = () => + /Mobi|Android|iPhone|iPad|Windows Phone/i.test(window.navigator.userAgent) || + window.innerWidth < 767; + + const [isMobile, setIsMobile] = useState(getIsMobile()); + + useEffect(() => { + const handleResize = () => { + const value = getIsMobile(); + setIsMobile(value); + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return isMobile; } - -export default new Utils();